Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/release-gate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Release gate

on:
pull_request:
push:
branches: [main]
workflow_dispatch:
schedule:
- cron: '23 8 * * *'

permissions:
contents: read

jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: backend/go.mod
cache-dependency-path: backend/go.sum
- name: Install pinned build and scan tools
shell: bash
run: |
set -euo pipefail
curl --fail --location --silent --show-error \
https://github.com/protocolbuffers/protobuf/releases/download/v36.1/protoc-36.1-linux-x86_64.zip \
-o "$RUNNER_TEMP/protoc.zip"
echo "c4bc672d9d49214dc8cafdceadf4df92182d6ca8e3ec65a56b2d7de5602669b4 $RUNNER_TEMP/protoc.zip" | sha256sum -c -
unzip -q "$RUNNER_TEMP/protoc.zip" -d "$RUNNER_TEMP/protoc"
echo "$RUNNER_TEMP/protoc/bin" >> "$GITHUB_PATH"
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.12
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.2
go install golang.org/x/vuln/cmd/govulncheck@v1.9.0
cd backend
go mod download
- name: Verify source and committed executable
run: bash backend/verify-release.sh
- name: Ensure the gate did not rewrite tracked inputs
run: git diff --exit-code
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ usage, availability, dish alerts, and stow/unstow/reboot controls.
omarchy plugin add https://github.com/nulluserid/omarchy-dishy.git --enable
```

Then run `./install.sh` once (from this directory) to install the `starlinkd`
Then run `./install.sh` once from
`~/.config/omarchy/plugins/io.github.nulluserid.dishy/` to install the `starlinkd`
backend service. The bar widget shows live data within ~2 seconds.

After a plugin update (`omarchy plugin update`), restart the shell once to
Expand Down Expand Up @@ -84,16 +85,24 @@ pick up dashboard changes — the shell caches QML until restart.
dependencies, deterministically built with `CGO_ENABLED=0 -trimpath
-buildvcs=false` and version-stamped to the release tag — verify with
`go version -m backend/bin/starlinkd` and `backend/bin/starlinkd -version`).
To rebuild it yourself at a tag: `backend/build.sh` needs `go` (≥ 1.27, matching backend/go.mod)
and `protoc`; the protobuf descriptors are captured from the dish itself via
To rebuild it yourself at a tag: `backend/build.sh` needs the exact Go version
in `backend/go.mod`, `jq`, `protoc`, `protoc-gen-go v1.36.12`, and
`protoc-gen-go-grpc v1.6.2`; the protobuf descriptors are captured from the dish itself via
gRPC reflection (`backend/proto/dish.pb`) — nothing hand-maintained.
The build refuses a different compiler or a version stamp that disagrees with
the manifest. Source and bundled-runtime vulnerability scans, tests, and a
byte-identical rebuild are required before release (see [RELEASING.md](RELEASING.md)).

## Install

1. `omarchy plugin add https://github.com/nulluserid/omarchy-dishy.git --enable`
2. From the plugin directory: `./install.sh`
2. `cd ~/.config/omarchy/plugins/io.github.nulluserid.dishy && ./install.sh`
(installs + enables the `starlinkd` systemd user service)

The installer refuses other checkouts: the service always runs the canonical
plugin path above, so running it in a development clone must not silently
restart a different binary.

Optional keybind — toggle the panel with `omarchy-shell dishy toggle`.

The dish address is a widget setting (default `192.168.100.1:9200`; changing
Expand Down
71 changes: 63 additions & 8 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Releasing Dishy

The short loop: PR → merge → release commit → tag → deploy → marketplace
The short loop: release tree → PR → verified merge → tag → deploy → marketplace
update. Anything pushed to `main` is what the marketplace's install command
gives users (it clones mutable HEAD), so `main` stays shippable.

Expand All @@ -11,14 +11,63 @@ directly on `main`.

## 2. Release commit

On `main`:
Prepare the release tree on the feature branch before merge:

1. Bump `version` in `manifest.json`.
2. If `backend/` changed: rebuild the committed binary, version-stamped —
`VERSION=vX.Y.Z backend/build.sh` (needs `go` + `protoc`). QML/docs-only
releases leave the binary alone.
3. Commit as `release: vX.Y.Z`, tag annotated `vX.Y.Z`, push `main` + tag,
publish the GitHub release with notes.
2. Rebuild the committed binary for **every versioned release**, including
QML-only releases: `backend/build.sh`. The default stamp is the manifest
version, not the previous Git tag. The exact compiler in `backend/go.mod`
is required; if needed select it with `GOTOOLCHAIN=go1.27.3` (use the
version in go.mod when it changes). Builds no longer run `go mod tidy` or
silently change dependencies. Commit deliberate dependency changes separately
from running the build.
3. Run the gate below on the final release tree, pass exact-diff review, then
commit/push and merge the PR only after its CI gate passes.
4. On `main`, run the gate again at the merged SHA, tag annotated `vX.Y.Z`,
push the tag, and publish the GitHub release with notes. The manifest,
executable stamp, and tag must identify the same version.

### Mandatory pre-submission gate

Build tools: exact Go from `backend/go.mod`, `jq`, `protoc` (CI pins 36.1),
`protoc-gen-go@v1.36.12`, `protoc-gen-go-grpc@v1.6.2`, and
`govulncheck@v1.9.0`. Install Go tools explicitly with `go install` at those
versions. Run:

```bash
bash backend/verify-release.sh
omarchy plugin validate .
```

The script regenerates ignored protobuf stubs, verifies module checksums and
tidiness, checks formatting, runs race tests/vet, compares a clean deterministic
rebuild to the **committed** executable, checks its compiler/static-build metadata
and manifest stamp, and scans **both source and binary** against the live Go
vulnerability database. Scanner errors and findings fail the gate; text mode is
intentional because JSON/SARIF output does not fail on findings. An offline
`GOVULNDB=file:///...` snapshot is for isolated local verification only, not a
substitute for CI's live advisory check before submission.

The Release gate workflow runs on PRs, main pushes, manual requests, and a daily
schedule. Watch scheduled failures: a previously clean binary can acquire newly
published vulnerabilities without any source change. A clean scan covers known
Go advisories, not all possible bugs.

Before asking a maintainer to check again, record these checks in the release/PR:

- Source boundaries: hostile dish arrays/floats/maps, control inputs, egress
responses, disk/permission upgrades, installer/service destination, QML consumers.
- Artifact boundaries: actual linked compiler and module versions, current
advisories, build settings, reproducibility, and manifest/binary/tag consistency.
- Real surface: isolated daemon smoke test; plugin validation and local UI smoke
when QML changes. Name anything not exercised rather than calling it covered.
- Evidence identity: **full final main SHA**, binary SHA-256, tool/scanner versions,
successful CI URL, and an independent review of the exact outgoing diff.

Why these are separate: the v1.5.1 source audit missed GO-2026-6809 in the
statically linked Go runtime, along with dependency advisories, and its release
left the manifest at 1.5.0. Reviewing source and printing build metadata did not
check that metadata against current advisories or the shipped release identity.

The binary is deterministic (`CGO_ENABLED=0 -trimpath -buildvcs=false`);
verify a checkout with `go version -m backend/bin/starlinkd` and
Expand All @@ -29,7 +78,8 @@ verify a checkout with `go version -m backend/bin/starlinkd` and

```
omarchy plugin update --yes
./install.sh # restarts starlinkd (needed whenever backend/ changed)
cd ~/.config/omarchy/plugins/io.github.nulluserid.dishy
./install.sh # restarts the canonical plugin binary
```

If any file other than the entry point changed — `Panel.qml`, `Model.js`,
Expand All @@ -54,6 +104,11 @@ it promptly:
3. A bot runs the security baseline against that exact commit; a maintainer
applies `approved-and-verified` and the listing snapshot advances.

While the initial submission is still under review, reply on that issue with
the same evidence and the final SHA instead of filing a duplicate submission.
Do not name a source-only fix commit as the shipped release: the rebuilt binary
must already be in the SHA being submitted.

Keep `main` clean between releases — every commit on it is what
`omarchy plugin add` hands users until the next verification lands.

Expand Down
Binary file modified backend/bin/starlinkd
Binary file not shown.
27 changes: 20 additions & 7 deletions backend/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,28 @@
#
# Proto codegen: the descriptors come from the dish itself (proto/dish.pb,
# captured via gRPC server reflection) — no hand-maintained .proto drift.
# Runtime: the built binary is static; only protoc + a Go toolchain are needed
# at build time (go via mise if not on PATH).
# Runtime: the built binary is static. Build tools: the exact Go in go.mod,
# protoc, protoc-gen-go v1.36.12, protoc-gen-go-grpc v1.6.2, and jq.
set -euo pipefail
cd "$(dirname "$0")"

# Go toolchain: prefer PATH, fall back to mise.
if command -v go >/dev/null 2>&1; then GO=go; else GO="mise x go -- go"; fi
export PATH="$HOME/.local/bin:$PATH"
export GOTOOLCHAIN="${GOTOOLCHAIN:-local}"
WANT_GO="go$(awk '$1 == "go" {print $2}' go.mod)"
if [ "$($GO env GOVERSION)" != "$WANT_GO" ]; then
echo "Build requires $WANT_GO; select it explicitly (GOTOOLCHAIN=$WANT_GO)." >&2
exit 1
fi
VERSION="${VERSION:-v$(jq -er .version ../manifest.json)}"
if [ "$VERSION" != "v$(jq -er .version ../manifest.json)" ]; then
echo "VERSION must match manifest.json" >&2
exit 1
fi
export GOOS=linux GOARCH=amd64 GOAMD64=v1 GOEXPERIMENT= GOFLAGS=-mod=readonly
test "$(protoc-gen-go --version)" = 'protoc-gen-go v1.36.12'
test "$(protoc-gen-go-grpc --version)" = 'protoc-gen-go-grpc 1.6.2'

GEN=gen
OUT=$(mktemp -d)
Expand Down Expand Up @@ -42,9 +56,8 @@ rm -rf "$GEN"
mkdir -p "$GEN"
mv "$OUT/starlinkd/$GEN"/* "$GEN"/

$GO mod tidy
# Deterministic: CGO off (fully static), no VCS stamping (amend-proof);
# version comes from the tag via -X main.version.
VERSION="${VERSION:-$(git describe --tags --always 2>/dev/null || echo dev)}"
CGO_ENABLED=0 $GO build -trimpath -buildvcs=false -ldflags="-s -w -X main.version=$VERSION" -o bin/starlinkd .
echo "built bin/starlinkd ($VERSION)"
# version comes from the manifest, not the previous tag on a release branch.
OUTPUT="${OUTPUT:-bin/starlinkd}"
CGO_ENABLED=0 $GO build -trimpath -buildvcs=false -ldflags="-s -w -X main.version=$VERSION" -o "$OUTPUT" .
echo "built $OUTPUT ($VERSION)" >&2
2 changes: 1 addition & 1 deletion backend/control.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ func normalizeTarget(t string) (string, error) {
return "", fmt.Errorf("target too long (%d)", len(t))
}
for _, r := range t {
if r <= ' ' || r == 0x7f {
if r <= ' ' || r == 0x7f || strings.ContainsRune("/@?#%\\", r) {
return "", fmt.Errorf("invalid character in target")
}
}
Expand Down
8 changes: 4 additions & 4 deletions backend/go.mod
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
module starlinkd

go 1.27.2
go 1.27.3

require (
google.golang.org/grpc v1.84.0
google.golang.org/protobuf v1.36.12
)

require (
golang.org/x/net v0.57.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/net v0.62.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.43.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect
)
12 changes: 6 additions & 6 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 h1:qEHAMpSaUhtD0p3NbEEI83HwNGFxEwaSJ1G9PLnCBZE=
Expand Down
24 changes: 21 additions & 3 deletions backend/link.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
Expand All @@ -36,6 +37,7 @@ import (
)

const geoEndpoint = "https://ifconfig.co/json"
const maxGeoBytes = 1 << 20

// updateRoute refreshes the default-route fingerprint. A change starts the
// one-shot probe burst, demands a fresh egress lookup, and anchors
Expand Down Expand Up @@ -225,8 +227,17 @@ func (d *daemon) applyGeo(g geoInfo) {
// launch), so a lookup in flight during a route change can never masquerade
// as a post-change answer.
func fetchGeo(at time.Time) (geoInfo, error) {
client := &http.Client{Timeout: 5 * time.Second}
req, err := http.NewRequest(http.MethodGet, geoEndpoint, nil)
return fetchGeoURL(at, geoEndpoint)
}

func fetchGeoURL(at time.Time, endpoint string) (geoInfo, error) {
client := &http.Client{
Timeout: 5 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return geoInfo{}, err
}
Expand All @@ -239,6 +250,13 @@ func fetchGeo(at time.Time) (geoInfo, error) {
if resp.StatusCode != http.StatusOK {
return geoInfo{}, fmt.Errorf("http %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxGeoBytes+1))
if err != nil {
return geoInfo{}, err
}
if len(body) > maxGeoBytes {
return geoInfo{}, fmt.Errorf("geo response exceeds %d bytes", maxGeoBytes)
}
var raw struct {
IP string `json:"ip"`
City string `json:"city"`
Expand All @@ -247,7 +265,7 @@ func fetchGeo(at time.Time) (geoInfo, error) {
ASN string `json:"asn"`
ASNOrg string `json:"asn_org"`
}
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
if err := json.Unmarshal(body, &raw); err != nil {
return geoInfo{}, err
}
return geoInfo{
Expand Down
Loading