diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml new file mode 100644 index 0000000..9965712 --- /dev/null +++ b/.github/workflows/release-gate.yml @@ -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 diff --git a/README.md b/README.md index 33de910..34ccffa 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/RELEASING.md b/RELEASING.md index 536ff7b..974d8ea 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -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. @@ -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 @@ -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`, @@ -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. diff --git a/backend/bin/starlinkd b/backend/bin/starlinkd index 53d0272..5f1f39e 100755 Binary files a/backend/bin/starlinkd and b/backend/bin/starlinkd differ diff --git a/backend/build.sh b/backend/build.sh index d6595b6..492990e 100755 --- a/backend/build.sh +++ b/backend/build.sh @@ -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) @@ -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 diff --git a/backend/control.go b/backend/control.go index 6ba4b05..05b558c 100644 --- a/backend/control.go +++ b/backend/control.go @@ -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") } } diff --git a/backend/go.mod b/backend/go.mod index ae111a5..f254aea 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,6 @@ module starlinkd -go 1.27.2 +go 1.27.3 require ( google.golang.org/grpc v1.84.0 @@ -8,8 +8,8 @@ require ( ) 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 ) diff --git a/backend/go.sum b/backend/go.sum index 4f06a88..45ca2d2 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/link.go b/backend/link.go index c25656d..5765a64 100644 --- a/backend/link.go +++ b/backend/link.go @@ -26,6 +26,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net" "net/http" "os" @@ -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 @@ -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 } @@ -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"` @@ -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{ diff --git a/backend/link_test.go b/backend/link_test.go index 5448afc..9e4bab7 100644 --- a/backend/link_test.go +++ b/backend/link_test.go @@ -4,9 +4,12 @@ import ( "context" "encoding/json" "errors" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" + "sync/atomic" "testing" "time" @@ -17,6 +20,79 @@ import ( dish "starlinkd/gen/spacex_api/device/dish" ) +func TestFetchGeoHTTPBoundary(t *testing.T) { + var redirected atomic.Bool + destination := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + redirected.Store(true) + _, _ = w.Write([]byte(`{"asn":"AS14593"}`)) + })) + defer destination.Close() + for _, tc := range []struct { + name string + status int + body string + wantOK bool + }{ + {"normal", 200, `{"ip":"203.0.113.1","asn":"AS14593","asn_org":"Starlink"}`, true}, + {"oversize", 200, `{"city":"` + strings.Repeat("x", maxGeoBytes) + `"}`, false}, + {"trailing oversize", 200, `{}` + strings.Repeat(" ", maxGeoBytes), false}, + {"invalid", 200, `{`, false}, + {"redirect", 302, "", false}, + {"error", 503, `{}`, false}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", destination.URL) + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })) + defer srv.Close() + g, err := fetchGeoURL(t0, srv.URL) + if (err == nil) != tc.wantOK { + t.Fatalf("fetch = %+v, %v", g, err) + } + if tc.wantOK { + if !g.IsStarlink || g.IP != "203.0.113.1" || g.Ts != t0.Unix() { + t.Fatalf("unexpected geo: %+v", g) + } + } else { + d := newDaemon(config{}, nil) + before := geoInfo{Ts: t0.Unix(), IP: "198.51.100.1"} + d.geo = before + d.applyGeo(g) + if d.geo != before { + t.Fatal("failed lookup replaced last good geo") + } + } + }) + } + if redirected.Load() { + t.Fatal("redirect destination was contacted") + } +} + +func TestDialRejectsURLTargets(t *testing.T) { + for _, target := range []string{"dns:///dish.local:9200", "unix:///tmp/dish:9200", "dish.local/path:9200", "user@dish.local:9200", "dish.local?x:9200", "dish.local#x:9200", "dish%2elocal:9200", `dish\local:9200`} { + if _, err := normalizeTarget(target); err == nil { + t.Errorf("normalize accepted %q", target) + } + if conn, err := dial(target); err == nil { + conn.Close() + t.Errorf("dial accepted %q", target) + } + } + for _, target := range []string{"192.168.100.1:9200", "dish.local:9200", "[::1]:9200", "unix:9200", "unix-abstract:9200", "dns:9200", "passthrough:9200"} { + conn, err := dial(target) + if err != nil { + t.Fatalf("dial rejected %q: %v", target, err) + } + if got, want := conn.CanonicalTarget(), "dns:///"+target; got != want { + t.Errorf("dial(%q) canonical target = %q, want %q", target, got, want) + } + conn.Close() + } +} + // stubClient routes Handle by request kind so full goodTick/failTick cycles // run without a dish. type stubClient struct { diff --git a/backend/persist.go b/backend/persist.go index f19d598..2db039e 100644 --- a/backend/persist.go +++ b/backend/persist.go @@ -20,7 +20,12 @@ func stateDir() (string, error) { base = filepath.Join(home, ".local", "state") } dir := filepath.Join(base, "starlinkd") - return dir, os.MkdirAll(dir, 0o700) + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + // MkdirAll leaves pre-upgrade permissions unchanged. Tighten the root + // boundary without recursively rewriting historical files. + return dir, os.Chmod(dir, 0o700) } // atomicWrite writes via tmp+rename so consumers never see partial files. @@ -31,6 +36,11 @@ func atomicWrite(path string, data []byte) error { if err != nil { return err } + if err := f.Chmod(0o600); err != nil { + f.Close() + os.Remove(tmp) + return err + } if _, err := f.Write(data); err != nil { f.Close() os.Remove(tmp) @@ -145,6 +155,10 @@ func (d *daemon) rollDaily(now time.Time) { // when the file exceeds 512KB. func appendEvent(dir string, ev event) error { p := filepath.Join(dir, "events.jsonl") + // Tighten a legacy event file before it can become the rotated file. + if err := os.Chmod(p, 0o600); err != nil && !os.IsNotExist(err) { + return err + } if fi, err := os.Stat(p); err == nil && fi.Size() > maxEventBytes { if err := os.Rename(p, filepath.Join(dir, "events.old.jsonl")); err != nil { return fmt.Errorf("rotate: %w", err) @@ -154,11 +168,14 @@ func appendEvent(dir string, ev event) error { if err != nil { return err } - f, err := os.OpenFile(p, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + f, err := os.OpenFile(p, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) if err != nil { return err } defer f.Close() + if err := f.Chmod(0o600); err != nil { + return err + } _, err = f.Write(append(b, '\n')) return err } diff --git a/backend/persist_test.go b/backend/persist_test.go index 338e71e..489ee13 100644 --- a/backend/persist_test.go +++ b/backend/persist_test.go @@ -67,6 +67,49 @@ func TestAtomicWrite(t *testing.T) { } } +func assertMode(t *testing.T, path string, want os.FileMode) { + t.Helper() + fi, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != want { + t.Fatalf("%s mode = %o, want %o", path, fi.Mode().Perm(), want) + } +} + +func TestUpgradeStatePermissions(t *testing.T) { + base := t.TempDir() + t.Setenv("XDG_STATE_HOME", base) + dir := filepath.Join(base, "starlinkd") + if err := os.Mkdir(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(dir, 0o755); err != nil { + t.Fatal(err) + } + if got, err := stateDir(); err != nil || got != dir { + t.Fatalf("stateDir = %q, %v", got, err) + } + assertMode(t, dir, 0o700) + p := filepath.Join(dir, "status.json") + if err := os.WriteFile(p+".tmp", []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + if err := atomicWrite(p, []byte("new")); err != nil { + t.Fatal(err) + } + assertMode(t, p, 0o600) + p = filepath.Join(dir, "events.jsonl") + if err := os.WriteFile(p, []byte("{}\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := appendEvent(dir, event{Type: "state_change"}); err != nil { + t.Fatal(err) + } + assertMode(t, p, 0o600) +} + func TestAppendEventRotation(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "events.jsonl") @@ -85,6 +128,8 @@ func TestAppendEventRotation(t *testing.T) { if old.Size() <= maxEventBytes { t.Errorf("old file size = %d, want > %d", old.Size(), maxEventBytes) } + assertMode(t, filepath.Join(dir, "events.old.jsonl"), 0o600) + assertMode(t, p, 0o600) cur, err := os.ReadFile(p) if err != nil { t.Fatal(err) diff --git a/backend/poller.go b/backend/poller.go index 5d8e2c4..2e9296b 100644 --- a/backend/poller.go +++ b/backend/poller.go @@ -18,7 +18,14 @@ import ( // dial connects to the dish. The connection is lazy; failures surface per RPC. func dial(target string) (*grpc.ClientConn, error) { - return grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials())) + var err error + target, err = normalizeTarget(target) + if err != nil { + return nil, err + } + // Treat even resolver-named hosts ("unix:9200", "dns:9200") as TCP + // host:port, not opaque gRPC resolver URIs. + return grpc.NewClient("dns:///"+target, grpc.WithTransportCredentials(insecure.NewCredentials())) } func rpcErr(err error) string { diff --git a/backend/test-install.sh b/backend/test-install.sh new file mode 100644 index 0000000..4af9beb --- /dev/null +++ b/backend/test-install.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Exercise install.sh without ever touching the real user service/config. +set -euo pipefail +cd "$(dirname "$0")/.." +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/home" "$work/tools" "$work/clone" +cp install.sh "$work/clone/" +cat > "$work/tools/systemctl" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$HOME/systemctl-calls" +EOF +chmod +x "$work/tools/systemctl" +export HOME="$work/home" PATH="$work/tools:$PATH" +if bash "$work/clone/install.sh" > "$work/rejected.log" 2>&1; then + echo "Installer accepted a non-canonical checkout" >&2 + exit 1 +fi +test ! -e "$HOME/systemctl-calls" +test ! -e "$HOME/.config/systemd/user" +plugin="$HOME/.config/omarchy/plugins/io.github.nulluserid.dishy" +mkdir -p "$plugin/backend/bin" "$plugin/deploy" +cp install.sh "$plugin/" +cp deploy/starlinkd.service "$plugin/deploy/" +printf '#!/usr/bin/env bash\nexit 0\n' > "$plugin/backend/bin/starlinkd" +chmod +x "$plugin/backend/bin/starlinkd" +# A symlink to the canonical checkout is still the same physical directory. +ln -s "$plugin" "$work/plugin-link" +bash "$work/plugin-link/install.sh" > "$work/accepted.log" 2>&1 +cmp "$plugin/deploy/starlinkd.service" "$HOME/.config/systemd/user/starlinkd.service" +test "$(wc -l < "$HOME/systemctl-calls")" -eq 4 +grep -Fxq -- '--user restart starlinkd.service' "$HOME/systemctl-calls" +echo 'Installer location/side-effect tests passed' diff --git a/backend/verify-release.sh b/backend/verify-release.sh new file mode 100644 index 0000000..5c8c4a3 --- /dev/null +++ b/backend/verify-release.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Fail-closed source AND shipped-artifact gate. Run from any directory. +set -euo pipefail +cd "$(dirname "$0")" +export GOTOOLCHAIN="${GOTOOLCHAIN:-local}" +export GOOS=linux GOARCH=amd64 GOAMD64=v1 GOEXPERIMENT= GOFLAGS=-mod=readonly +want_go="go$(awk '$1 == "go" {print $2}' go.mod)" +test "$(go env GOVERSION)" = "$want_go" || { echo "Use $want_go" >&2; exit 1; } +version="v$(jq -er .version ../manifest.json)" +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +go mod verify +# Fresh code generation + rebuild, without replacing the committed artifact. +OUTPUT="$work/starlinkd" VERSION="$version" bash ./build.sh +cmp bin/starlinkd "$work/starlinkd" || { echo "Bundled binary does not match source/toolchain/manifest; rebuild it." >&2; exit 1; } +test "$(bin/starlinkd -version)" = "starlinkd $version" +metadata=$(go version -m bin/starlinkd) +printf '%s\n' "$metadata" +test "$(printf '%s\n' "$metadata" | head -n 1)" = "bin/starlinkd: $want_go" +for setting in 'CGO_ENABLED=0' 'GOOS=linux' 'GOARCH=amd64' 'GOAMD64=v1' '-trimpath=true'; do + grep -Fq -- "$setting" <<< "$metadata" +done +test -z "$(gofmt -l . | grep -v '^gen/' || true)" +go mod tidy -diff +CGO_ENABLED=1 go test -race -count=1 ./... +CGO_ENABLED=0 go vet ./... + +# Text mode is intentional: JSON/SARIF modes do not fail on findings. +# The override supports a frozen official DB snapshot in an offline sandbox. +db="${GOVULNDB:-https://vuln.go.dev}" +CGO_ENABLED=0 govulncheck -db "$db" ./... +govulncheck -db "$db" -mode=binary bin/starlinkd +bash ./test-install.sh +echo "Release gate passed: $version ($want_go)" diff --git a/deploy/starlinkd.service b/deploy/starlinkd.service index a23a4ba..d2ca0e6 100644 --- a/deploy/starlinkd.service +++ b/deploy/starlinkd.service @@ -11,6 +11,7 @@ ProtectSystem=full PrivateTmp=true RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 MemoryMax=256M +UMask=0077 ExecStart=%h/.config/omarchy/plugins/io.github.nulluserid.dishy/backend/bin/starlinkd Restart=on-failure RestartSec=5 diff --git a/install.sh b/install.sh index bda1484..abcae4a 100755 --- a/install.sh +++ b/install.sh @@ -4,6 +4,14 @@ set -euo pipefail cd "$(dirname "$0")" +# The unit deliberately runs the marketplace checkout, not an arbitrary clone. +# Refuse before changing any service/config if that is not what we checked. +PLUGIN_DIR="$HOME/.config/omarchy/plugins/io.github.nulluserid.dishy" +if [ ! -d "$PLUGIN_DIR" ] || [ "$(pwd -P)" != "$(cd "$PLUGIN_DIR" && pwd -P)" ]; then + echo "Run install.sh from $PLUGIN_DIR (install/update the Omarchy plugin first)." >&2 + exit 1 +fi + UNIT_DST="$HOME/.config/systemd/user/starlinkd.service" if [ ! -x backend/bin/starlinkd ]; then diff --git a/manifest.json b/manifest.json index 8eb145e..165a7b5 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "io.github.nulluserid.dishy", "name": "Dishy", - "version": "1.5.0", + "version": "1.5.2", "author": "nulluserid", "description": "Starlink dish status in the bar with a live dashboard popup", "kinds": [