diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a31b903..e1186d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: '1.23' + go-version-file: agent/go.mod cache-dependency-path: agent/go.sum - name: Regenerate the API client @@ -54,3 +54,10 @@ jobs: - name: Test working-directory: agent run: go test -race ./... + + - name: Shell syntax + working-directory: agent + run: | + for f in setup.sh uninstall.sh packaging/*.sh packaging/lib/*.sh; do + bash -n "$f" + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d1c1a93 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,97 @@ +name: Release + +# Push a tag to publish a release: `git tag 0.3.0 && git push origin 0.3.0`. +# The tag is the version (a leading "v" is dropped), so it must be +# MAJOR.MINOR.PATCH: agent packages refuse anything else. +# +# Needs, in the repository settings: +# - variable MB_RELEASE_PUBLIC_KEYS: comma-separated base64 release public +# keys (from `mbpack keygen`), compiled into the agent and mbpack +# - secret MB_RELEASE_SIGNING_KEY: the base64 seed of one of those keys, +# used to sign the agent packages +# docs/architecture.md sections 4 and 5; README.md, "Release signing". + +on: + push: + tags: ['*'] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + env: + RELEASE_KEYS: ${{ vars.MB_RELEASE_PUBLIC_KEYS }} + MB_RELEASE_SIGNING_KEY: ${{ secrets.MB_RELEASE_SIGNING_KEY }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Version from the tag + run: | + tag="${GITHUB_REF_NAME}" + version="${tag#v}" + if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Tag '$tag' is not MAJOR.MINOR.PATCH (optionally with a leading v)." + exit 1 + fi + echo "VERSION=$version" >> "$GITHUB_ENV" + echo "RELEASE_TAG=$tag" >> "$GITHUB_ENV" + + # A release without a compiled-in key could never update itself, and an + # unsigned package is refused by every board. Fail before building. + - name: Check signing configuration + run: | + if [[ -z "$RELEASE_KEYS" ]]; then + echo "::error::Repository variable MB_RELEASE_PUBLIC_KEYS is not set." + exit 1 + fi + if [[ -z "$MB_RELEASE_SIGNING_KEY" ]]; then + echo "::error::Repository secret MB_RELEASE_SIGNING_KEY is not set." + exit 1 + fi + + - name: Test + run: go test ./... + + - name: Build release tarballs + run: | + make package + make package-amd64 + + - name: Build and sign agent packages + run: | + make package-mbu + make package-mbu-amd64 + + # mbpack was built with RELEASE_KEYS, so verify checks the packages + # against exactly the keys the new agent trusts. + - name: Verify agent packages + run: | + for f in build/musallahboard-agent-"$VERSION"-*.mbu; do + build/mbpack verify "$f" + done + + - name: Build mbpush + run: make mbpush + + - name: Publish + uses: softprops/action-gh-release@v2 + with: + fail_on_unmatched_files: true + files: | + build/musallahboard-agent-${{ env.VERSION }}-arm64.tar.gz + build/musallahboard-agent-${{ env.VERSION }}-amd64.tar.gz + build/musallahboard-agent-${{ env.VERSION }}-arm64.mbu + build/musallahboard-agent-${{ env.VERSION }}-amd64.mbu + build/agent-channel-arm64.json + build/agent-channel-amd64.json + build/musallahboard-agent-arm64 + build/musallahboard-agent-amd64 + build/mbpush-windows-amd64.exe + build/mbpush-darwin-arm64 + build/mbpush-linux-amd64 diff --git a/Makefile b/Makefile index 5d83744..c5174f5 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,25 @@ VERSION ?= 0.1.0 +# The GitHub release tag the packages are published under. Usually VERSION; +# CI sets it separately when the tag has a prefix. +RELEASE_TAG ?= $(VERSION) +# Release public keys compiled into the agent (docs/architecture.md 5): +# comma-separated base64, from `mbpack keygen`. Empty is a dev build, which +# accepts only release keys listed in the board's trust.json. CI passes the +# repository variable MB_RELEASE_PUBLIC_KEYS. +RELEASE_KEYS ?= # Must match the module path in go.mod exactly, case included: the linker # silently ignores -X for a symbol it cannot resolve, so a wrong-case path -# here ships a binary that reports "dev" forever. +# here ships a binary that reports "dev" forever (or trusts no release key). PKG := github.com/LensBridge/agent -LDFLAGS := -s -w -X $(PKG)/internal/version.Version=$(VERSION) +LDFLAGS := -s -w -X $(PKG)/internal/version.Version=$(VERSION) \ + -X $(PKG)/internal/trust.BuiltinReleaseKeys=$(RELEASE_KEYS) +# Where the release channel points boards at (docs/architecture.md 9.4). +RELEASE_DOWNLOAD := https://github.com/LensBridge/agent/releases/download/$(RELEASE_TAG) .PHONY: build build-arm64 build-amd64 tidy test clean \ deploy install-remote install-remote-x86 package package-amd64 \ - mbpush mbpush-windows mbpush-macos mbpush-linux + mbpush mbpush-windows mbpush-macos mbpush-linux \ + mbpack package-mbu package-mbu-amd64 build: CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o build/musallahboard-agent ./cmd/agent @@ -20,8 +32,9 @@ build-amd64: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ go build -trimpath -ldflags "$(LDFLAGS)" -o build/musallahboard-agent-amd64 ./cmd/agent -# mbpush: the admin-laptop side of offline mode (docs/offline.md). Pure Go, -# shells out to the system ssh/scp, so a plain cross-compile is enough. +# mbpush: the laptop tool for the service port (docs/architecture.md 9.5). +# Pure Go (it only shells out to the system ssh for --ssh), so a plain +# cross-compile is enough. mbpush: mbpush-windows mbpush-macos mbpush-linux mbpush-windows: @@ -36,6 +49,35 @@ mbpush-linux: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ go build -trimpath -ldflags "$(LDFLAGS)" -o build/mbpush-linux-amd64 ./cmd/mbpush +# mbpack: builds, signs, verifies and inspects .mbu packages (section 4.4). +# Host build: it runs where the packages are made, not on a board. `verify` +# also trusts the RELEASE_KEYS compiled in here. +mbpack: + CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o build/mbpack ./cmd/mbpack + +# Signed agent packages plus the channel file boards poll (sections 4 and +# 9.4). The signing seed comes from the environment, never the command line: +# MB_RELEASE_SIGNING_KEY= make package-mbu VERSION=0.3.0 RELEASE_KEYS= +# $(1) is the architecture. +define agent_mbu + @test -n "$$MB_RELEASE_SIGNING_KEY" || { echo "MB_RELEASE_SIGNING_KEY is not set (base64 Ed25519 seed from mbpack keygen)"; exit 1; } + build/mbpack agent --version $(VERSION) --arch $(1) --key-env MB_RELEASE_SIGNING_KEY \ + build/musallahboard-agent-$(1) -o build/musallahboard-agent-$(VERSION)-$(1).mbu + @f=build/musallahboard-agent-$(VERSION)-$(1).mbu; \ + sha=$$(sha256sum "$$f" | cut -d' ' -f1); \ + bytes=$$(wc -c < "$$f" | tr -d ' '); \ + printf '{"version":"%s","url":"%s","sha256":"%s","bytes":%s}\n' \ + "$(VERSION)" "$(RELEASE_DOWNLOAD)/musallahboard-agent-$(VERSION)-$(1).mbu" "$$sha" "$$bytes" \ + > build/agent-channel-$(1).json; \ + echo "Wrote $$f and build/agent-channel-$(1).json" +endef + +package-mbu: build-arm64 mbpack + $(call agent_mbu,arm64) + +package-mbu-amd64: build-amd64 mbpack + $(call agent_mbu,amd64) + tidy: go mod tidy @@ -72,8 +114,9 @@ install-remote-x86: package-amd64 # Build a self-contained release tarball ready to ship to a Pi: # musallahboard-agent-$(VERSION)-arm64.tar.gz -# containing the binary, install/uninstall/update scripts, the systemd unit, -# and the sudoers allow-list. On the Pi: +# containing the binary, install/uninstall/update scripts, the systemd units +# (agent, kiosk, self-updater, USB import), the udev rules and the sudoers +# allow-list. On the Pi: # tar xzf musallahboard-agent-*.tar.gz # sudo bash musallahboard-agent-*/packaging/install.sh --token=X --backend=Y package: build-arm64 @@ -81,7 +124,10 @@ package: build-arm64 @cp build/musallahboard-agent-arm64 \ build/pkg/musallahboard-agent-$(VERSION)-arm64/musallahboard-agent @cp -r packaging build/pkg/musallahboard-agent-$(VERSION)-arm64/ - @sed -i 's/\r//' build/pkg/musallahboard-agent-$(VERSION)-arm64/packaging/*.sh + @sed -i 's/\r//' build/pkg/musallahboard-agent-$(VERSION)-arm64/packaging/*.sh \ + build/pkg/musallahboard-agent-$(VERSION)-arm64/packaging/*.service \ + build/pkg/musallahboard-agent-$(VERSION)-arm64/packaging/*.path \ + build/pkg/musallahboard-agent-$(VERSION)-arm64/packaging/udev/*.rules @chmod +x build/pkg/musallahboard-agent-$(VERSION)-arm64/packaging/*.sh # setup.sh is the whole point of the arm64 tarball: the one-line installer # fetches this archive and runs it. Not shipped in the amd64 one — it is a @@ -103,7 +149,10 @@ package-amd64: build-amd64 @cp build/musallahboard-agent-amd64 \ build/pkg/musallahboard-agent-$(VERSION)-amd64/musallahboard-agent @cp -r packaging build/pkg/musallahboard-agent-$(VERSION)-amd64/ - @sed -i 's/\r//' build/pkg/musallahboard-agent-$(VERSION)-amd64/packaging/*.sh + @sed -i 's/\r//' build/pkg/musallahboard-agent-$(VERSION)-amd64/packaging/*.sh \ + build/pkg/musallahboard-agent-$(VERSION)-amd64/packaging/*.service \ + build/pkg/musallahboard-agent-$(VERSION)-amd64/packaging/*.path \ + build/pkg/musallahboard-agent-$(VERSION)-amd64/packaging/udev/*.rules @chmod +x build/pkg/musallahboard-agent-$(VERSION)-amd64/packaging/*.sh @cp README.md build/pkg/musallahboard-agent-$(VERSION)-amd64/ 2>/dev/null || true @tar czf build/musallahboard-agent-$(VERSION)-amd64.tar.gz \ diff --git a/README.md b/README.md index a5c4212..db88efc 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ sudo musallahboard-agent enroll \ --token [--rtc] +bash setup.sh --service-port [--rtc] # --rtc if a DS3231 clock module is fitted +``` + +That turns the ethernet port into a service port: a laptop or phone plugged straight into it gets an address from the board. Turn it off before plugging the board into a real network: `sudo musallahboard-agent service-port off`. + +To update the board, get the packages first, while you have internet: + +- content: LensBridge admin, Devices, the board, Download offline bundle (`musallahboard-content--.mbu`, 14 days) +- board app and agent: `mbpush fetch` (saves the latest `musallahboard-app-*.mbu` and `musallahboard-agent-*-arm64.mbu`) + +Then use any of: -# On the laptop (build with: make mbpush): -mbpush musallahboard-3f2a1b4c-2026-09-24.zip # set clock, install bundle, show status -mbpush status # check only +1. **USB stick.** Copy the `.mbu` files to the stick (its root, or a `MusallahBoard/` folder) and plug it into the board. The screen shows progress; remove the stick when it says "Update complete". One stick can carry content for several boards: each board takes only its own. +2. **Laptop.** Plug into the board's ethernet port and open `http://10.77.0.1/` in a browser, or run `mbpush ...`. `mbpush status` shows what is installed and how far the board's clock is off; an upload also sets the clock from the laptop's. For a board reachable only over SSH: `mbpush --ssh admin@host ...`. +3. **Android app.** Downloads the packages while it has signal, then uploads them over a USB ethernet adapter. -# On the board: -sudo musallahboard-agent status -sudo musallahboard-agent mode online # switch back; before joining a real network +On the board itself: `sudo musallahboard-agent import ...` and `sudo musallahboard-agent status`. + +### Release signing + +Software (board app and agent) is signed with a release key that the backend never holds; content is signed by the backend's own key. To set up release signing once: + +```bash +make mbpack && build/mbpack keygen # prints a private seed, a public key and its key id ``` -[docs/offline.md](docs/offline.md) is the full design and the contract between the backend exporter, this agent and the frontend, including how to convert an existing online board. +- The **private seed** goes in the repository secret `MB_RELEASE_SIGNING_KEY` (and the frontend repo's, for app packages). Keep an offline copy; never commit it. +- The **public key** goes in the repository variable `MB_RELEASE_PUBLIC_KEYS` (comma-separated, to allow a second key during a rotation). CI compiles it into the agent (`RELEASE_KEYS`), so every board trusts it without any configuration. + +Pushing a tag (`0.3.0`) runs `.github/workflows/release.yml`: tests, builds, signs `musallahboard-agent--.mbu`, and publishes them with `agent-channel-.json`, the release tarballs and the `mbpush` binaries. Locally: `MB_RELEASE_SIGNING_KEY= make package-mbu VERSION=0.3.0 RELEASE_KEYS=`. + +### Trust management + +Keys live in `/etc/musallahboard/trust.json` (root-owned). Content keys are pinned at enrollment; `sudo musallahboard-agent trust fetch` re-reads them from the backend (for example after a key rotation). Release keys are compiled in and can be extended there. + +```bash +sudo musallahboard-agent trust show +sudo musallahboard-agent trust add content|release +sudo musallahboard-agent trust remove +sudo musallahboard-agent trust fetch +``` ## Roadmap diff --git a/cmd/agent/board.go b/cmd/agent/board.go new file mode 100644 index 0000000..71e4552 --- /dev/null +++ b/cmd/agent/board.go @@ -0,0 +1,220 @@ +package main + +import ( + "context" + "encoding/json" + "log/slog" + "net" + "os" + "runtime" + "strings" + "sync" + "time" + + "github.com/coreos/go-systemd/v22/daemon" + + "github.com/LensBridge/agent/internal/boardsync" + "github.com/LensBridge/agent/internal/cdp" + "github.com/LensBridge/agent/internal/clock" + "github.com/LensBridge/agent/internal/commands" + "github.com/LensBridge/agent/internal/config" + "github.com/LensBridge/agent/internal/events" + "github.com/LensBridge/agent/internal/importer" + "github.com/LensBridge/agent/internal/keystore" + "github.com/LensBridge/agent/internal/kioskurl" + "github.com/LensBridge/agent/internal/localserver" + "github.com/LensBridge/agent/internal/store" + "github.com/LensBridge/agent/internal/trust" + "github.com/LensBridge/agent/internal/updatescreen" + "github.com/LensBridge/agent/internal/uploadserver" + "github.com/LensBridge/agent/internal/version" + "github.com/LensBridge/agent/internal/wsclient" +) + +// kioskWatchInterval is how often the daemon checks that Chromium is not +// sitting on its own error page. The kiosk is ordered after the agent, so +// this is a backstop, not the mechanism. +const kioskWatchInterval = 30 * time.Second + +// startBoard starts everything an enrolled board runs (docs/architecture.md, +// section 3). The local server comes first and is the only part whose failure +// is fatal: without it the board has nothing to show. Everything that needs +// the network or the device key degrades to "not running" instead, because a +// board that cannot reach LensBridge must still show what it has. +func startBoard(ctx context.Context, logger *slog.Logger, cfg *config.Config, safeMode bool, wg *sync.WaitGroup) error { + layout := store.Default() + if err := os.MkdirAll(layout.Inbox(), 0o770); err != nil { + logger.Warn("could not create inbox", "err", err) + } + + hub := &events.Hub{} + cdpClient := cdp.New("") + screen := updatescreen.New(cdpClient, strings.TrimSuffix(localserver.BaseURL, "/"), logger) + imp := importer.New(importer.Deps{ + Layout: layout, + DeviceID: cfg.DeviceID, + AgentVersion: version.Version, + Ring: func() (*trust.Ring, error) { return trust.LoadRing(trust.DefaultPath) }, + Screen: screen, + Events: hub, + Logger: logger, + }) + keeper := clock.New(layout, logger) + + priv, keyErr := keystore.Load(cfg.KeyPath) + if keyErr != nil { + logger.Error("device key unreadable: serving installed content only (no sync, no remote commands)", + "err", keyErr, "keyPath", cfg.KeyPath) + } + var syncer *boardsync.Syncer + if keyErr == nil { + syncer = boardsync.New(boardsync.Deps{ + Cfg: cfg, Key: priv, Layout: layout, Importer: imp, + AgentVersion: version.Version, Logger: logger, + }) + } + + local := localserver.New(localserver.Deps{ + Layout: layout, DeviceID: cfg.DeviceID, AgentVersion: version.Version, Hub: hub, + Sync: func() any { + if syncer == nil { + return map[string]any{"enabled": false, "lastError": "device key unreadable"} + } + return syncer.Status() + }, + Weather: func() (json.RawMessage, bool) { + if syncer == nil { + return nil, false + } + return syncer.Weather() + }, + UpdateActive: screen.Active, + Logger: logger, + }) + ln, err := net.Listen("tcp", localserver.ListenAddr) + if err != nil { + return err + } + goRun(wg, func() { + if err := local.Serve(ctx, ln); err != nil { + // Without the server the board shows nothing; let systemd restart + // us rather than idle with the watchdog still happy. + logger.Error("local server stopped", "err", err) + os.Exit(1) + } + }) + + // Only now can the kiosk find something at the URL it is given. + if err := kioskurl.WriteLocal(kioskurl.DefaultOutPath); err != nil { + logger.Warn("could not write kiosk url", "err", err) + } + _, _ = daemon.SdNotify(false, daemon.SdNotifyReady) + _, _ = daemon.SdNotify(false, "STATUS=serving the board on "+localserver.ListenAddr) + + logInstalled(logger, layout, cfg) + goRun(wg, func() { screen.Resume(ctx) }) + goRun(wg, func() { watchKiosk(ctx, logger, cdpClient) }) + goRun(wg, func() { imp.RunInbox(ctx) }) + goRun(wg, func() { keeper.Run(ctx) }) + + if cfg.ServicePort() { + startUploadServer(ctx, logger, cfg, layout, imp, keeper, screen, wg) + } + + if syncer != nil { + goRun(wg, func() { syncer.Run(ctx) }) + startCommandChannel(ctx, logger, cfg, priv, safeMode, cdpClient, syncer, wg) + } + return nil +} + +func startUploadServer(ctx context.Context, logger *slog.Logger, cfg *config.Config, layout store.Layout, + imp *importer.Importer, keeper *clock.Keeper, screen *updatescreen.Screen, wg *sync.WaitGroup) { + ln, err := uploadserver.Listen() + if err != nil { + logger.Error("could not start the upload server; USB sticks still work", "addr", uploadserver.ListenAddr, "err", err) + return + } + srv := uploadserver.New(uploadserver.Deps{ + Layout: layout, DeviceID: cfg.DeviceID, AgentVersion: version.Version, Importer: imp, + ApplyClientTime: func(unix int64, b importer.Batch) uploadserver.ClockReport { + r := keeper.ApplyClientTime(unix, b) + return uploadserver.ClockReport{DriftSeconds: r.DriftSeconds, Adjusted: r.Adjusted, Note: r.Note} + }, + RTCPresent: keeper.RTCPresent, + UpdateActive: screen.Active, + Logger: logger, + }) + logger.Info("upload server listening", "addr", uploadserver.ListenAddr) + goRun(wg, func() { + if err := srv.Serve(ctx, ln); err != nil { + logger.Error("upload server stopped", "err", err) + } + }) +} + +// startCommandChannel holds the backend WebSocket (telemetry and remote +// commands) open. config.refresh also asks the content syncer for a sync now. +func startCommandChannel(ctx context.Context, logger *slog.Logger, cfg *config.Config, priv []byte, + safeMode bool, cdpClient *cdp.Client, syncer *boardsync.Syncer, wg *sync.WaitGroup) { + wsClient := wsclient.New(cfg, priv, logger, version.Version, safeMode) + wsClient.SetPageProber(cdpClient) + registry := commands.NewRegistry() + registry.Register(&commands.ChromeReload{CDP: cdpClient}) + registry.Register(&commands.ChromeScreenshot{CDP: cdpClient}) + registry.Register(&commands.ConfigRefresh{CDP: cdpClient, Before: syncer.Trigger}) + registry.Register(&commands.KioskRestart{}) + registry.Register(&commands.SystemReboot{}) + registry.Register(&commands.LogsTail{}) + logger.Info("command handlers registered", "kinds", registry.Kinds()) + wsClient.SetCommandHandler(commands.NewDispatcher(registry, logger).Handle) + goRun(wg, func() { wsClient.Run(ctx) }) +} + +// watchKiosk navigates Chromium back to the board if it lands on its own +// error page (the server restarting under it, for instance). +func watchKiosk(ctx context.Context, logger *slog.Logger, c *cdp.Client) { + t := time.NewTicker(kioskWatchInterval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + } + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) + target, err := c.FirstPageTarget(cctx) + if err == nil && strings.HasPrefix(target.URL, "chrome-error:") { + logger.Info("kiosk is on an error page; returning it to the board") + _ = c.Call(cctx, target, "Page.navigate", map[string]any{"url": localserver.BaseURL}, nil) + } + cancel() + } +} + +func logInstalled(logger *slog.Logger, l store.Layout, cfg *config.Config) { + st := localserver.BuildStatus(l, cfg.DeviceID, version.Version, time.Now()) + attrs := []any{"deviceId", cfg.DeviceID, "arch", runtime.GOARCH, "servicePort", cfg.ServicePort()} + if st.App != nil { + attrs = append(attrs, "app", st.App.Version) + } else { + attrs = append(attrs, "app", "none") + } + if st.Content != nil { + attrs = append(attrs, "content", st.Content.FirstDay+".."+st.Content.LastDay, "staleDays", *st.StaleDays) + } else { + attrs = append(attrs, "content", "none") + } + if st.Error != "" { + attrs = append(attrs, "error", st.Error) + } + logger.Info("board state", attrs...) +} + +func goRun(wg *sync.WaitGroup, f func()) { + wg.Add(1) + go func() { + defer wg.Done() + f() + }() +} diff --git a/cmd/agent/gate.go b/cmd/agent/gate.go deleted file mode 100644 index b594d95..0000000 --- a/cmd/agent/gate.go +++ /dev/null @@ -1,190 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "io" - "os" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/LensBridge/agent/internal/offline" -) - -// ── gate ────────────────────────────────────────────────────────────────────── -// -// The gate is the only thing the restricted push account (setup.sh -// --offline, "mbpush") can run. sshd forces every session for that account to -// `sudo -n /usr/bin/musallahboard-agent gate`, whatever the client asked for, -// and passes the client's request in SSH_ORIGINAL_COMMAND. The gate accepts a -// short, fixed list of requests and refuses everything else, so a leaked push -// key can update content and fix the clock but cannot open a shell, read -// files or run anything else as root. See docs/offline.md, "Push account". -// -// The request is split on whitespace and matched token for token; it is never -// handed to a shell. -// -// Clients send every request as `sudo -n musallahboard-agent gate `. -// For the push account sshd replaces that with the forced command, and the -// gate strips the prefix back off SSH_ORIGINAL_COMMAND. For the admin account -// the same text simply runs, and the request arrives as arguments. So one -// client works against either account without knowing which it has. - -const ( - // The compressed app build a client may stream in. InstallApp separately - // caps the unpacked size. - gateMaxAppBytes = 200 << 20 - - // clock-set refuses times outside this range. No real board clock is set - // before this software existed or after 2100; a request for one is a bug - // in the client or someone trying to break certificate or log timestamps. - gateClockFloor = 1767225600 // 2026-01-01T00:00:00Z - gateClockCeil = 4102444800 // 2100-01-01T00:00:00Z - - gateAllowed = "status, status --json, clock, clock-set , bundle-install, app-install" - - // binaryPath is where setup.sh installs the agent; the sudoers rule and - // sshd ForceCommand name it literally. - binaryPath = "/usr/bin/musallahboard-agent" -) - -type gateRequest struct { - verb string // status | clock | clock-set | bundle-install | app-install - json bool // status --json - epoch int64 // clock-set -} - -// parseGateRequest turns a client's request into a gateRequest, or explains -// why it is refused. -func parseGateRequest(raw string) (gateRequest, error) { - f := strings.Fields(raw) - if len(f) > 0 && f[0] == "sudo" { - if len(f) < 4 || f[1] != "-n" || (f[2] != "musallahboard-agent" && f[2] != binaryPath) || f[3] != "gate" { - return gateRequest{}, fmt.Errorf("%q is not a gate request", raw) - } - f = f[4:] - } - if len(f) == 0 { - return gateRequest{}, errors.New("no request given (this account can only run the MusallahBoard gate)") - } - - switch { - case len(f) == 1 && f[0] == "status": - return gateRequest{verb: "status"}, nil - case len(f) == 2 && f[0] == "status" && f[1] == "--json": - return gateRequest{verb: "status", json: true}, nil - case len(f) == 1 && f[0] == "clock": - return gateRequest{verb: "clock"}, nil - case len(f) == 2 && f[0] == "clock-set": - epoch, err := parseGateEpoch(f[1]) - if err != nil { - return gateRequest{}, err - } - return gateRequest{verb: "clock-set", epoch: epoch}, nil - case len(f) == 1 && (f[0] == "bundle-install" || f[0] == "app-install"): - return gateRequest{verb: f[0]}, nil - } - return gateRequest{}, fmt.Errorf("%q is not an allowed request", strings.Join(f, " ")) -} - -// parseGateEpoch accepts plain decimal seconds only: no sign, no spaces, no -// hex, and within [gateClockFloor, gateClockCeil). -func parseGateEpoch(s string) (int64, error) { - for _, c := range s { - if c < '0' || c > '9' { - return 0, fmt.Errorf("clock-set needs whole seconds since 1970, not %q", s) - } - } - n, err := strconv.ParseInt(s, 10, 64) - if err != nil || n < gateClockFloor || n >= gateClockCeil { - return 0, fmt.Errorf("clock-set %s is outside 2026–2100; refusing to set the clock to that", s) - } - return n, nil -} - -func runGate(args []string) { - raw := strings.Join(args, " ") - if raw == "" { - raw = os.Getenv("SSH_ORIGINAL_COMMAND") - } - req, err := parseGateRequest(raw) - if err != nil { - fmt.Fprintf(os.Stderr, "refused: %v\nAllowed: %s\n", err, gateAllowed) - os.Exit(126) - } - requireRoot("gate") - - switch req.verb { - case "status": - if req.json { - runStatus([]string{"--json"}) - } else { - runStatus(nil) - } - case "clock": - fmt.Println(time.Now().Unix()) - case "clock-set": - gateClockSet(req.epoch) - case "bundle-install": - cfg := loadConfigCLI() - gateInstallFromStdin("bundle.zip", offline.MaxBundleBytes, func(path string) error { - return installBundleAndReport(cfg, path) - }) - case "app-install": - gateInstallFromStdin("app.tar.gz", gateMaxAppBytes, installAppAndReport) - } -} - -func gateClockSet(epoch int64) { - if out, err := runCmd("date", "-s", "@"+strconv.FormatInt(epoch, 10)); err != nil { - fail("could not set the clock: %v\n%s", err, out) - } - fmt.Printf("Set the clock to %s.\n", time.Unix(epoch, 0).Format("Mon 2 Jan 2006 15:04:05 MST")) - if _, err := runCmd("hwclock", "-w"); err != nil { - fmt.Println("Warning: could not save the time to an RTC (none fitted?). The clock is right now, but a power cut will lose it.") - return - } - fmt.Println("Saved it to the RTC.") -} - -// gateInstallFromStdin saves stdin to a root-only temp file named name, hands -// it to install, and removes it whatever happens. -func gateInstallFromStdin(name string, max int64, install func(path string) error) { - dir, err := os.MkdirTemp("", "musallahboard-gate-") - if err != nil { - fail("could not create a temporary directory: %v", err) - } - path := filepath.Join(dir, name) - err = saveLimited(path, os.Stdin, max) - if err == nil { - err = install(path) - } - os.RemoveAll(dir) - if err != nil { - fail("%v", err) - } -} - -// saveLimited copies at most max bytes from r to path, failing if there is -// more or nothing at all. -func saveLimited(path string, r io.Reader, max int64) error { - f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if err != nil { - return err - } - n, err := io.Copy(f, io.LimitReader(r, max+1)) - if cerr := f.Close(); err == nil { - err = cerr - } - switch { - case err != nil: - return fmt.Errorf("receiving the upload failed: %w\n Nothing on this board has changed.", err) - case n == 0: - return errors.New("nothing was sent. Stream the file on stdin.\n Nothing on this board has changed.") - case n > max: - return fmt.Errorf("the upload is larger than %d MB, so it was refused.\n Nothing on this board has changed.", max>>20) - } - return nil -} diff --git a/cmd/agent/gate_test.go b/cmd/agent/gate_test.go deleted file mode 100644 index 2735908..0000000 --- a/cmd/agent/gate_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestParseGateRequest(t *testing.T) { - ok := []struct { - raw string - want gateRequest - }{ - {"status", gateRequest{verb: "status"}}, - {"status --json", gateRequest{verb: "status", json: true}}, - {" clock ", gateRequest{verb: "clock"}}, - {"clock-set 1790000000", gateRequest{verb: "clock-set", epoch: 1790000000}}, - {"bundle-install", gateRequest{verb: "bundle-install"}}, - {"app-install", gateRequest{verb: "app-install"}}, - // What clients actually send; the forced command hands it over verbatim. - {"sudo -n musallahboard-agent gate bundle-install", gateRequest{verb: "bundle-install"}}, - {"sudo -n /usr/bin/musallahboard-agent gate status --json", gateRequest{verb: "status", json: true}}, - } - for _, tc := range ok { - got, err := parseGateRequest(tc.raw) - if err != nil || got != tc.want { - t.Errorf("parseGateRequest(%q) = %+v, %v; want %+v", tc.raw, got, err, tc.want) - } - } - - refused := []string{ - "", - " ", - "bash", - "sh -c id", - "status; id", - "status --json; id", - "status --json extra", - "clock && reboot", - "clock-set", - "clock-set 1790000000 1790000000", - "clock-set -1790000000", - "clock-set +1790000000", - "clock-set 0x6AB0B380", - "clock-set 1790000000.5", - "clock-set 0", - "clock-set 1000000000", // 2001: before the floor - "clock-set 4102444800", // 2100: at the ceiling - "clock-set 99999999999999999999", - "bundle-install /etc/shadow", - "app-install ../../x", - "bundle install /tmp/x.zip", // the admin CLI form, not a gate request - "mode online", - "internal-sftp", - "scp -t /tmp/x.zip", - "sudo -n musallahboard-agent mode online", - "sudo -n musallahboard-agent gate", - "sudo musallahboard-agent gate status", - "sudo -n /tmp/musallahboard-agent gate status", - "sudo -n musallahboard-agent gate sudo -n musallahboard-agent gate status", - } - for _, raw := range refused { - if got, err := parseGateRequest(raw); err == nil { - t.Errorf("parseGateRequest(%q) = %+v, want refused", raw, got) - } - } -} - -func TestSaveLimited(t *testing.T) { - dir := t.TempDir() - - p := filepath.Join(dir, "ok.zip") - if err := saveLimited(p, strings.NewReader("12345"), 5); err != nil { - t.Fatalf("exactly at the limit: %v", err) - } - if b, _ := os.ReadFile(p); string(b) != "12345" { - t.Errorf("saved %q", b) - } - - if err := saveLimited(filepath.Join(dir, "big.zip"), strings.NewReader("123456"), 5); err == nil { - t.Error("over the limit: want error") - } - if err := saveLimited(filepath.Join(dir, "empty.zip"), strings.NewReader(""), 5); err == nil { - t.Error("empty upload: want error") - } - // O_EXCL: never write through something already at the path. - if err := saveLimited(p, strings.NewReader("x"), 5); err == nil { - t.Error("existing file: want error") - } -} diff --git a/cmd/agent/import_cli.go b/cmd/agent/import_cli.go new file mode 100644 index 0000000..031bc7a --- /dev/null +++ b/cmd/agent/import_cli.go @@ -0,0 +1,137 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/LensBridge/agent/internal/fsutil" + "github.com/LensBridge/agent/internal/importer" + "github.com/LensBridge/agent/internal/mbu" + "github.com/LensBridge/agent/internal/store" +) + +// importWait is how long `import` waits for the daemon's verdict. An agent +// update restarts the daemon mid-batch, and a large content package takes a +// while to hash on a Pi. +const importWait = 15 * time.Minute + +// runImport is `import ... | -` (docs/architecture.md, section 9.7). +// It copies the packages into the inbox and prints the daemon's results. The +// daemon does the verifying and installing, exactly as for a USB stick or an +// upload; this command only delivers. +func runImport(args []string) { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "usage: musallahboard-agent import ... | - (- reads one package from stdin)") + os.Exit(2) + } + requireRoot("import") + loadConfigCLI() + l := store.Default() + inbox := l.Inbox() + if err := os.MkdirAll(inbox, 0o770); err != nil { + fail("could not create %s: %v", inbox, err) + } + + stamp := time.Now().UTC().Format("20060102T150405") + var names []string + for i, a := range args { + base := "stdin.mbu" + var r io.Reader = os.Stdin + if a != "-" { + f, err := os.Open(a) + if err != nil { + fail("%v", err) + } + defer f.Close() + r, base = f, filepath.Base(a) + } + base = strings.TrimSuffix(base, filepath.Ext(base)) + name := fmt.Sprintf("cli-%s-%d-%s.mbu", stamp, i+1, safeName(base)) + part := filepath.Join(inbox, "."+name+".part") + n, err := fsutil.CopyFileSync(part, r, mbu.MaxPackageBytes+1, 0o640) + if err != nil { + os.Remove(part) + fail("could not copy %s into the inbox: %v", a, err) + } + if n > mbu.MaxPackageBytes { + os.Remove(part) + fail("%s is larger than the %d MiB limit", a, mbu.MaxPackageBytes>>20) + } + chownLike(part, inbox) + if err := os.Rename(part, filepath.Join(inbox, name)); err != nil { + fail("%v", err) + } + names = append(names, name) + } + fmt.Printf("Handed %d package(s) to the agent; waiting for it to check and install them ...\n", len(names)) + + rejected := false + deadline := time.Now().Add(importWait) + hinted := false + for _, name := range names { + res, ok := waitResult(l.InboxResults(), name, deadline, &hinted) + if !ok { + fail("no result for %s after %s. Is musallahboard-agent running? (systemctl status musallahboard-agent)", name, importWait) + } + mark := "OK " + if res.Action == importer.ActionRejected { + mark, rejected = "NO ", true + } + fmt.Printf(" %s %s: %s\n", mark, res.Action, res.Message) + } + if rejected { + fmt.Println("Anything refused was not installed; the board keeps what it had.") + os.Exit(1) + } +} + +func waitResult(dir, name string, deadline time.Time, hinted *bool) (importer.InboxResult, bool) { + path := filepath.Join(dir, name+".json") + start := time.Now() + for time.Now().Before(deadline) { + if raw, err := os.ReadFile(path); err == nil { + var r importer.InboxResult + if json.Unmarshal(raw, &r) == nil { + return r, true + } + } + if !*hinted && time.Since(start) > 30*time.Second { + fmt.Println(" (still waiting; an agent update restarts the agent, which takes up to two minutes)") + *hinted = true + } + time.Sleep(time.Second) + } + return importer.InboxResult{}, false +} + +func safeName(s string) string { + var b strings.Builder + for _, c := range s { + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '.', c == '-', c == '_': + b.WriteRune(c) + default: + b.WriteByte('_') + } + } + if b.Len() == 0 { + return "package" + } + return b.String() +} + +// chownLike gives path the owner of dir, so the unprivileged daemon can +// delete the file once it has processed it. +func chownLike(path, dir string) { + if fi, err := os.Stat(dir); err == nil { + if st, ok := fi.Sys().(*syscall.Stat_t); ok { + _ = os.Chown(path, int(st.Uid), int(st.Gid)) + } + } +} diff --git a/cmd/agent/main.go b/cmd/agent/main.go index cfffc53..9351604 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -15,16 +15,12 @@ import ( "github.com/coreos/go-systemd/v22/daemon" "github.com/LensBridge/agent/internal/cdp" - "github.com/LensBridge/agent/internal/commands" "github.com/LensBridge/agent/internal/config" "github.com/LensBridge/agent/internal/enroll" - "github.com/LensBridge/agent/internal/keystore" - "github.com/LensBridge/agent/internal/kioskurl" "github.com/LensBridge/agent/internal/netinfo" "github.com/LensBridge/agent/internal/safemode" "github.com/LensBridge/agent/internal/splash" "github.com/LensBridge/agent/internal/version" - "github.com/LensBridge/agent/internal/wsclient" ) const ( @@ -55,16 +51,18 @@ func main() { runDaemon() case "enroll": runEnroll(os.Args[2:]) - case "bundle": - runBundle(os.Args[2:]) case "status": runStatus(os.Args[2:]) - case "mode": - runMode(os.Args[2:]) - case "app": - runApp(os.Args[2:]) - case "gate": - runGate(os.Args[2:]) + case "import": + runImport(os.Args[2:]) + case "service-port": + runServicePort(os.Args[2:]) + case "trust": + runTrust(os.Args[2:]) + case "selfupdate": + runSelfUpdate(os.Args[2:]) + case "usb-import": + runUSBImport(os.Args[2:]) case "version", "-v", "--version": fmt.Printf("musallahboard-agent %s\n", version.Version) case "-h", "--help", "help": @@ -77,21 +75,22 @@ func main() { } func printHelp() { - fmt.Print(`musallahboard-agent — device agent for MusallahBoard kiosk Pis + fmt.Print(`musallahboard-agent: device agent for MusallahBoard kiosk Pis (docs/architecture.md) Usage: musallahboard-agent run Run the daemon (default) musallahboard-agent enroll --token=X --backend=Y Register this device - musallahboard-agent status [--json] Show mode, content bundle and clock + musallahboard-agent status [--json] Show versions, content, sync, trust and clock musallahboard-agent version Print version -Offline mode (see docs/offline.md; these need sudo): - musallahboard-agent bundle install Check and install a content bundle - musallahboard-agent mode online|offline Switch mode (restarts the agent) - musallahboard-agent app install Install a board app build for offline use - musallahboard-agent gate Restricted entry point for the push account: - status [--json] | clock | clock-set | - bundle-install | app-install (file on stdin) +Administration (need sudo): + musallahboard-agent import ... | - Install signed update packages + musallahboard-agent service-port on|off eth0 service port + upload server at http://10.77.0.1/ + musallahboard-agent trust show|add|remove|fetch Keys this board accepts packages from + +Run by systemd (root): + musallahboard-agent selfupdate apply Install a staged agent update, with rollback + musallahboard-agent usb-import Copy update packages from a USB stick Enroll flags: --token One-time enrollment token from the admin portal @@ -160,15 +159,9 @@ func runDaemon() { logger.Warn("agent starting in safe mode (repeated crashes detected)") } - if cfg.Mode == config.ModeOffline { - // No backend WebSocket, telemetry or remote commands: offline mode - // makes no network calls at all. See docs/offline.md, Contract 2. - if err := startOffline(ctx, logger, cfg, &wg); err != nil { - logger.Error("could not start offline mode", "err", err) - os.Exit(1) - } - } else { - startOnline(ctx, logger, cfg, safeMode, &wg) + if err := startBoard(ctx, logger, cfg, safeMode, &wg); err != nil { + logger.Error("could not start", "err", err) + os.Exit(1) } wg.Add(1) @@ -189,62 +182,6 @@ func runDaemon() { logger.Info("shutdown complete") } -// startOnline is the original run mode: load the device key, point the kiosk -// at the provisioned board URL, and hold the backend WebSocket open with the -// command dispatcher behind it. It returns once everything is started; the -// goroutines it adds to wg stop when ctx is cancelled. -func startOnline(ctx context.Context, logger *slog.Logger, cfg *config.Config, safeMode bool, wg *sync.WaitGroup) { - priv, err := keystore.Load(cfg.KeyPath) - if err != nil { - logger.Error("failed to load device key", "err", err, "keyPath", cfg.KeyPath) - os.Exit(1) - } - logger.Info("agent identity loaded", - "deviceId", cfg.DeviceID, - "websocketUrl", cfg.WebSocketURL, - "safeMode", safeMode, - ) - - // Re-compose the kiosk URL on every startup. Covers the case where enroll - // ran before the operator provisioned the base board URL, and picks up a - // changed base URL after an agent restart. The kiosk unit waits on this - // file, so a successful write here is what unblocks the board on first boot. - if err := kioskurl.Write(kioskurl.DefaultBoardURLPath, kioskurl.DefaultOutPath, cfg.DeviceID); err != nil { - logger.Warn("could not compose kiosk url (kiosk will wait)", "err", err) - } else { - logger.Info("kiosk url written", "path", kioskurl.DefaultOutPath) - } - - wsClient := wsclient.New(cfg, priv, logger, version.Version, safeMode) - - cdpClient := cdp.New("") - // Heartbeats ask the browser what it's showing rather than trusting the - // kiosk unit's state, which Restart=always keeps "active" through a crash - // loop or a stuck splash. - wsClient.SetPageProber(cdpClient) - registry := commands.NewRegistry() - registry.Register(&commands.ChromeReload{CDP: cdpClient}) - registry.Register(&commands.ChromeScreenshot{CDP: cdpClient}) - registry.Register(&commands.ConfigRefresh{CDP: cdpClient, DeviceID: cfg.DeviceID}) - registry.Register(&commands.KioskRestart{}) - registry.Register(&commands.SystemReboot{}) - registry.Register(&commands.LogsTail{}) - logger.Info("command handlers registered", "kinds", registry.Kinds()) - - dispatcher := commands.NewDispatcher(registry, logger) - wsClient.SetCommandHandler(dispatcher.Handle) - - // Re-sent, and harmless, when awaitEnrollment already reported ready. - _, _ = daemon.SdNotify(false, daemon.SdNotifyReady) - _, _ = daemon.SdNotify(false, "STATUS=enrolled") - - wg.Add(1) - go func() { - defer wg.Done() - wsClient.Run(ctx) - }() -} - // awaitEnrollment blocks until a usable config appears at defaultConfigPath, // painting this device's IP address onto the kiosk splash while it waits. // Returns nil if ctx is cancelled first. diff --git a/cmd/agent/offline.go b/cmd/agent/offline.go deleted file mode 100644 index 846823c..0000000 --- a/cmd/agent/offline.go +++ /dev/null @@ -1,126 +0,0 @@ -package main - -import ( - "context" - "fmt" - "log/slog" - "net" - "os" - "strings" - "sync" - "time" - - "github.com/coreos/go-systemd/v22/daemon" - - "github.com/LensBridge/agent/internal/cdp" - "github.com/LensBridge/agent/internal/config" - "github.com/LensBridge/agent/internal/kioskurl" - "github.com/LensBridge/agent/internal/offline" -) - -const ( - // kioskRecoverWindow is how long after startup the agent watches for the - // kiosk sitting on Chromium's own error page. On boot the kiosk and the - // agent start in parallel, and a kiosk that wins the race finds nothing on - // 127.0.0.1:8080 yet. Unlike the online board there is no later network - // event to nudge it, so it would stay on the error page until someone - // intervened. - kioskRecoverWindow = 2 * time.Minute - kioskRecoverInterval = 5 * time.Second -) - -// startOffline runs offline mode: the local HTTP server for the kiosk and the -// offline kiosk URL. It makes no outbound network calls; the only connections -// it makes are to Chromium's DevTools port on loopback. -// -// The listener is bound before kiosk-url is written, so a kiosk restarted by -// that write always finds the server already accepting. -func startOffline(ctx context.Context, logger *slog.Logger, cfg *config.Config, wg *sync.WaitGroup) error { - paths := offline.DefaultPaths() - - ln, err := net.Listen("tcp", offline.ListenAddr) - if err != nil { - return fmt.Errorf("listen on %s: %w", offline.ListenAddr, err) - } - srv := offline.NewServer(paths, cfg.DeviceID, logger) - wg.Add(1) - go func() { - defer wg.Done() - if err := srv.Serve(ctx, ln); err != nil { - // Without the server the board has nothing to show; let systemd - // restart us rather than idle with the watchdog still happy. - logger.Error("offline server stopped", "err", err) - os.Exit(1) - } - }() - - st := offline.ReadStatus(paths, config.ModeOffline, cfg.DeviceID, time.Now()) - switch { - case st.Error != "": - logger.Error("installed offline bundle is unreadable", "err", st.Error) - case st.Bundle == nil: - logger.Warn("offline mode with no bundle installed; the board will show an error until one is pushed") - default: - logger.Info("offline mode", - "listen", offline.ListenAddr, - "deviceId", cfg.DeviceID, - "firstDay", st.Bundle.FirstDay, - "lastDay", st.Bundle.LastDay, - "servingDay", *st.ServingDay, - "daysRemaining", *st.DaysRemaining, - "staleDays", *st.StaleDays, - ) - } - - if err := kioskurl.WriteOffline(kioskurl.DefaultOutPath, cfg.DeviceID); err != nil { - logger.Warn("could not write offline kiosk url (kiosk will wait)", "err", err) - } else { - logger.Info("kiosk url written", "path", kioskurl.DefaultOutPath, "mode", config.ModeOffline) - } - - _, _ = daemon.SdNotify(false, daemon.SdNotifyReady) - _, _ = daemon.SdNotify(false, "STATUS=offline mode") - - wg.Add(1) - go func() { - defer wg.Done() - recoverKioskErrorPage(ctx, logger, cdp.New(""), kioskurl.OfflineURL(cfg.DeviceID)) - }() - return nil -} - -// recoverKioskErrorPage watches the kiosk for a short while after startup and, -// if it is showing Chromium's network-error page, navigates it to url. It -// stops as soon as the board has loaded from the local server. -func recoverKioskErrorPage(ctx context.Context, logger *slog.Logger, c *cdp.Client, url string) { - deadline := time.NewTimer(kioskRecoverWindow) - defer deadline.Stop() - tick := time.NewTicker(kioskRecoverInterval) - defer tick.Stop() - - for { - callCtx, cancel := context.WithTimeout(ctx, kioskRecoverInterval) - target, err := c.FirstPageTarget(callCtx) - if err == nil { - switch { - case strings.HasPrefix(target.URL, kioskurl.OfflineBaseURL): - cancel() - return - case strings.HasPrefix(target.URL, "chrome-error:"): - logger.Info("kiosk is on an error page; pointing it at the local server") - if err := c.Call(callCtx, target, "Page.navigate", map[string]any{"url": url}, nil); err != nil { - logger.Debug("kiosk navigate failed", "err", err) - } - } - } - cancel() - - select { - case <-tick.C: - case <-deadline.C: - return - case <-ctx.Done(): - return - } - } -} diff --git a/cmd/agent/offline_cli.go b/cmd/agent/offline_cli.go deleted file mode 100644 index 5c69f9e..0000000 --- a/cmd/agent/offline_cli.go +++ /dev/null @@ -1,479 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "flag" - "fmt" - "io/fs" - "os" - "os/exec" - "os/signal" - "path/filepath" - "strings" - "syscall" - "time" - - "github.com/LensBridge/agent/internal/cdp" - "github.com/LensBridge/agent/internal/config" - "github.com/LensBridge/agent/internal/offline" -) - -// Names shared with setup.sh --offline. Change them together. -const ( - serviceConn = "musallahboard-service-port" - serviceIface = "eth0" - serviceIP = "10.77.0.1" - agentUnit = "musallahboard-agent.service" - rtcDevice = "/dev/rtc0" -) - -// The CLI subcommands below are run by a person at a terminal (or by mbpush -// on their behalf), so everything they print is plain English, errors -// included. Anything that changes the system checks for root up front rather -// than failing half-way through. - -func fail(format string, args ...any) { - fmt.Fprintf(os.Stderr, "Error: "+format+"\n", args...) - os.Exit(1) -} - -func warnf(format string, args ...any) { - fmt.Fprintf(os.Stderr, "Warning: "+format+"\n", args...) -} - -func requireRoot(cmd string) { - if os.Geteuid() != 0 { - fail("`musallahboard-agent %s` changes the system and must run as root.\n Try: sudo musallahboard-agent %s", cmd, strings.Join(os.Args[1:], " ")) - } -} - -// loadConfigCLI loads the enrolled config, explaining the usual failures. -func loadConfigCLI() *config.Config { - cfg, err := config.Load(defaultConfigPath) - switch { - case err == nil: - return cfg - case errors.Is(err, fs.ErrPermission): - fail("cannot read %s (permission denied). Run this with sudo.", defaultConfigPath) - case errors.Is(err, fs.ErrNotExist): - fail("this board is not enrolled yet (%s does not exist). Enroll it online first:\n sudo musallahboard-agent enroll --token= --backend=", defaultConfigPath) - default: - fail("%v", err) - } - return nil -} - -// ── bundle install ──────────────────────────────────────────────────────────── - -func runBundle(args []string) { - if len(args) != 2 || args[0] != "install" { - fmt.Fprintln(os.Stderr, "usage: musallahboard-agent bundle install ") - os.Exit(2) - } - requireRoot("bundle install") - cfg := loadConfigCLI() - if err := installBundleAndReport(cfg, args[1]); err != nil { - fail("%v", err) - } -} - -// installBundleAndReport installs zipPath and prints the outcome. It returns -// rather than exiting on failure so the gate can remove its upload first. -func installBundleAndReport(cfg *config.Config, zipPath string) error { - paths := offline.DefaultPaths() - - fmt.Printf("Checking %s ...\n", filepath.Base(zipPath)) - res, err := offline.InstallBundle(paths, zipPath, cfg.DeviceID) - if res == nil { - return fmt.Errorf("the bundle was NOT installed: %v\n Nothing on this board has changed; it is still showing its previous content.", err) - } - m := res.Manifest - fmt.Printf("Installed content for %s to %s (%d days, generated %s).\n", - m.FirstDay, m.LastDay, len(m.Days()), m.GeneratedAt) - if res.Previous != "" { - fmt.Printf("The previous bundle is kept as a fallback.\n") - } - if err != nil { - warnf("%v", err) - } - - if cfg.Mode == config.ModeOffline { - if err := reloadKiosk(); err != nil { - warnf("could not reload the screen (%v).\n The board picks up the new content by itself within 10 minutes.", err) - } else { - fmt.Println("Reloaded the screen.") - } - } else { - fmt.Println("This board is in online mode, so the bundle is stored but not shown.") - fmt.Println("It will be served after: sudo musallahboard-agent mode offline") - } - - fmt.Println() - printStatus(os.Stdout, buildCLIStatus(cfg, paths)) - return nil -} - -// reloadKiosk asks the kiosk Chromium to reload over CDP. -func reloadKiosk() error { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - return cdp.New("").PageReload(ctx) -} - -// ── status ──────────────────────────────────────────────────────────────────── - -// cliStatus is /api/local/status plus what only the CLI reports. -type cliStatus struct { - offline.Status - Clock clockInfo `json:"clock"` - RTC bool `json:"rtc"` -} - -type clockInfo struct { - Now string `json:"now"` // RFC 3339, system zone - Unix int64 `json:"unix"` // seconds since the epoch - Timezone string `json:"timezone"` // IANA name where it can be found -} - -func runStatus(args []string) { - fs := flag.NewFlagSet("status", flag.ExitOnError) - asJSON := fs.Bool("json", false, "print the status object as JSON") - if err := fs.Parse(args); err != nil || fs.NArg() != 0 { - fmt.Fprintln(os.Stderr, "usage: musallahboard-agent status [--json]") - os.Exit(2) - } - cfg := loadConfigCLI() - st := buildCLIStatus(cfg, offline.DefaultPaths()) - if *asJSON { - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - _ = enc.Encode(st) - return - } - printStatus(os.Stdout, st) -} - -func buildCLIStatus(cfg *config.Config, paths offline.Paths) cliStatus { - now := time.Now() - _, rtcErr := os.Stat(rtcDevice) - return cliStatus{ - Status: offline.ReadStatus(paths, cfg.Mode, cfg.DeviceID, now), - Clock: clockInfo{Now: now.Format(time.RFC3339), Unix: now.Unix(), Timezone: systemZone()}, - RTC: rtcErr == nil, - } -} - -// systemZone names the system timezone. Go reports time.Local as "Local", -// which tells a person nothing. -func systemZone() string { - if tz := strings.TrimPrefix(os.Getenv("TZ"), ":"); tz != "" { - return tz - } - if target, err := os.Readlink("/etc/localtime"); err == nil { - if _, name, ok := strings.Cut(target, "zoneinfo/"); ok { - return name - } - } - if b, err := os.ReadFile("/etc/timezone"); err == nil && strings.TrimSpace(string(b)) != "" { - return strings.TrimSpace(string(b)) - } - name, _ := time.Now().Zone() - return name -} - -func printStatus(w *os.File, st cliStatus) { - row := func(k, format string, args ...any) { - fmt.Fprintf(w, " %-13s %s\n", k+":", fmt.Sprintf(format, args...)) - } - - row("Mode", "%s", st.Mode) - row("Device", "%s", st.DeviceID) - - switch { - case st.Error != "": - row("Bundle", "installed but unreadable: %s", st.Error) - case st.Bundle == nil: - row("Bundle", "none installed") - if st.Mode == config.ModeOffline { - row("", "The board has nothing to show. Push a bundle with mbpush.") - } - default: - b := st.Bundle - row("Bundle", "%s to %s (generated %s, %s)", b.FirstDay, b.LastDay, b.GeneratedAt, b.Timezone) - switch { - case *st.StaleDays > 0: - row("Today", "%s — content ran out %d day(s) ago; repeating %s", st.Today, *st.StaleDays, *st.ServingDay) - row("", "Push a new bundle.") - case st.Today < b.FirstDay: - row("Today", "%s — bundle starts %s; showing its first day", st.Today, b.FirstDay) - row("Remaining", "%d day(s) of content", *st.DaysRemaining) - case *st.DaysRemaining == 0: - row("Today", "%s — the last day in this bundle", st.Today) - row("Remaining", "none after today. Push a new bundle.") - default: - row("Today", "%s", st.Today) - note := "" - if *st.DaysRemaining <= 3 { - note = " — plan a visit soon" - } - row("Remaining", "%d more day(s) after today%s", *st.DaysRemaining, note) - } - } - - t, _ := time.Parse(time.RFC3339, st.Clock.Now) - row("Clock", "%s (%s)", t.Format("Mon 2 Jan 2006 15:04:05 MST"), st.Clock.Timezone) - if st.RTC { - row("RTC", "present (%s)", rtcDevice) - } else { - row("RTC", "not found — the clock is only as good as the last mbpush or network time sync") - } -} - -// ── mode ────────────────────────────────────────────────────────────────────── - -func runMode(args []string) { - force := false - var rest []string - for _, a := range args { - if a == "--force" { - force = true - continue - } - rest = append(rest, a) - } - if len(rest) != 1 || config.ValidateMode(rest[0]) != nil || (force && rest[0] != config.ModeOffline) { - fmt.Fprintln(os.Stderr, "usage: musallahboard-agent mode online|offline [--force]") - fmt.Fprintln(os.Stderr, " --force (offline only) start the service port even if eth0 is connected to a network") - os.Exit(2) - } - target := rest[0] - requireRoot("mode " + target) - loadConfigCLI() // must be enrolled; SetMode rewrites this file - - // Switching to online over the service port kills this SSH session part - // way through. Keep going when the terminal hangs up; every step left is - // one we want finished. - signal.Ignore(syscall.SIGHUP, syscall.SIGPIPE) - - switch target { - case config.ModeOffline: - modeOffline(force) - case config.ModeOnline: - modeOnline() - } -} - -// NetworkManager arrangement for eth0 (created by setup.sh --offline): -// -// - lanConn, a plain DHCP client profile at the default priority, tried -// first. On a real network it gets an address and the board is just a -// client there. Offline mode gives it a single autoconnect retry, so when -// a laptop (which runs no DHCP server) is plugged in it fails once and -// NetworkManager moves on. -// - serviceConn, the shared/DHCP-server profile, at the lowest priority -// NetworkManager allows, so it is only ever autoconnected after every -// other eth0 profile has failed. -// -// So a board booted plugged into a building's network comes up as a DHCP -// client, not a DHCP server, unless that network's DHCP server fails to answer -// within the 30 s DHCP timeout setup.sh gives lanConn. See docs/offline.md. -const ( - lanConn = "musallahboard-lan" - servicePortPriority = "-999" // NM_SETTING_CONNECTION_AUTOCONNECT_PRIORITY_MIN -) - -func modeOffline(force bool) { - if !nmConnectionExists(serviceConn) { - fail("the NetworkManager connection %q does not exist, so a laptop could not reach this board.\n Provision it first (while the board still has internet): bash setup.sh --offline", serviceConn) - } - if out, err := runCmd("nmcli", "connection", "modify", serviceConn, - "connection.autoconnect", "yes", "connection.autoconnect-priority", servicePortPriority); err != nil { - fail("could not enable the service port: %v\n%s", err, out) - } - if nmConnectionExists(lanConn) { - if out, err := runCmd("nmcli", "connection", "modify", lanConn, "connection.autoconnect-retries", "1"); err != nil { - fail("could not configure %s: %v\n%s", lanConn, err, out) - } - } - if err := config.SetMode(defaultConfigPath, config.ModeOffline); err != nil { - fail("could not write %s: %v", defaultConfigPath, err) - } - fmt.Printf("Set mode = offline in %s.\n", defaultConfigPath) - - devices, _ := runCmd("nmcli", "-t", "-f", "DEVICE,CONNECTION", "device") - routes, _ := runCmd("ip", "-4", "route", "show", "default") - inUse, why := ifaceInUse(serviceIface, serviceConn, devices, routes) - switch { - case inUse && !force: - fmt.Printf("%s is connected to a network right now (%s), so the service port was not started:\n"+ - "that would hand out addresses on that network. It starts by itself when a laptop is plugged\n"+ - "into %s instead, or run `sudo musallahboard-agent mode offline` again once %s is unplugged\n"+ - "from this network.\n", serviceIface, why, serviceIface, serviceIface) - case connectionOnDevice(devices, serviceIface) == serviceConn: - fmt.Printf("The service port is already up on %s (%s).\n", serviceIface, serviceIP) - default: - if inUse { - warnf("--force: starting the service port although %s is in use (%s).", serviceIface, why) - } - if out, err := runCmd("nmcli", "connection", "up", serviceConn); err != nil { - // Normal with no cable plugged in. - fmt.Printf("The service port is enabled and starts when a laptop is plugged into %s (now: %s).\n", serviceIface, firstLine(out)) - } else { - fmt.Printf("The service port is up: plug a laptop into %s and connect to %s.\n", serviceIface, serviceIP) - } - } - - if _, _, err := offline.CurrentBundle(offline.DefaultPaths()); errors.Is(err, offline.ErrNoBundle) { - warnf("no content bundle is installed, so the board will have nothing to show.\n Push one with: mbpush ") - } - - restartAgent() - fmt.Println("Done. This board is now offline: it serves the installed bundle and makes no network calls.") -} - -func modeOnline() { - exists := nmConnectionExists(serviceConn) - if sshViaServicePort() { - warnf("you are connected through the service port (%s). Switching to online turns it off,\n"+ - " so this SSH session will drop in a few seconds. The switch still completes.", serviceIP) - time.Sleep(3 * time.Second) - } - - if err := config.SetMode(defaultConfigPath, config.ModeOnline); err != nil { - fail("could not write %s: %v", defaultConfigPath, err) - } - fmt.Printf("Set mode = online in %s.\n", defaultConfigPath) - - if exists { - if out, err := runCmd("nmcli", "connection", "modify", serviceConn, "connection.autoconnect", "no"); err != nil { - fail("could not disable the service port's autoconnect: %v\n%s\n"+ - " Do not plug this board into a network until it is fixed: it could hand out addresses there.", err, out) - } - } - if nmConnectionExists(lanConn) { - // Back to NetworkManager's default number of DHCP attempts. - if out, err := runCmd("nmcli", "connection", "modify", lanConn, "connection.autoconnect-retries", "-1"); err != nil { - warnf("could not reset %s's retries: %v\n%s", lanConn, err, out) - } - } - - // Before taking the port down: once it is down this session may be gone. - restartAgent() - - devices, _ := runCmd("nmcli", "-t", "-f", "DEVICE,CONNECTION", "device") - if exists && connectionOnDevice(devices, serviceIface) == serviceConn { - if out, err := runCmd("nmcli", "connection", "down", serviceConn); err != nil { - fail("could not bring the service port down: %v\n%s\n"+ - " Do not plug this board into a network until it is fixed: it could hand out addresses there.", err, out) - } - } - fmt.Println("Done. The service port is off, so it is now safe to connect eth0 to the musallah's network.") - fmt.Println("The board goes back to its online content once it can reach LensBridge.") -} - -// restartAgent restarts the daemon so it picks up the new mode. On startup it -// rewrites kiosk-url, and the kiosk .path watcher restarts the browser. -func restartAgent() { - if out, err := runCmd("systemctl", "restart", agentUnit); err != nil { - fail("could not restart %s: %v\n%s", agentUnit, err, out) - } - fmt.Printf("Restarted %s; the screen reloads in a few seconds.\n", agentUnit) -} - -func nmConnectionExists(name string) bool { - out, err := runCmd("nmcli", "-g", "NAME", "connection", "show") - if err != nil { - return false - } - for _, line := range strings.Split(out, "\n") { - if unescapeTerse(strings.TrimSpace(line)) == name { - return true - } - } - return false -} - -func runCmd(name string, args ...string) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - out, err := exec.CommandContext(ctx, name, args...).CombinedOutput() - return strings.TrimSpace(string(out)), err -} - -func firstLine(s string) string { - s, _, _ = strings.Cut(s, "\n") - return s -} - -// sshViaServicePort reports whether an SSH session is connected through the -// service-port address. sudo drops SSH_CONNECTION by default, so the kernel's -// socket table is the reliable source; the variable is a cheap first check. -func sshViaServicePort() bool { - if f := strings.Fields(os.Getenv("SSH_CONNECTION")); len(f) == 4 && f[2] == serviceIP { - return true - } - data, err := os.ReadFile("/proc/net/tcp") - return err == nil && hasEstablishedTCP(string(data), serviceIP, 22) -} - -// hasEstablishedTCP scans /proc/net/tcp content for an ESTABLISHED socket -// whose local end is ip:port. Addresses there are hex, in host byte order -// for the IP (little-endian on every Pi) and big-endian for the port. -func hasEstablishedTCP(procNetTCP, ip string, port int) bool { - var a, b, c, d byte - if n, _ := fmt.Sscanf(ip, "%d.%d.%d.%d", &a, &b, &c, &d); n != 4 { - return false - } - want := fmt.Sprintf("%02X%02X%02X%02X:%04X", d, c, b, a, port) - for _, line := range strings.Split(procNetTCP, "\n")[1:] { - f := strings.Fields(line) - // sl local_address rem_address st ...; st 01 is ESTABLISHED. - if len(f) > 3 && strings.EqualFold(f[1], want) && f[3] == "01" { - return true - } - } - return false -} - -// ── app install ─────────────────────────────────────────────────────────────── - -func runApp(args []string) { - if len(args) != 2 || args[0] != "install" { - fmt.Fprintln(os.Stderr, "usage: musallahboard-agent app install ") - os.Exit(2) - } - requireRoot("app install") - if err := installAppAndReport(args[1]); err != nil { - fail("%v", err) - } -} - -// installAppAndReport installs the build at src and prints the outcome, -// returning on failure like installBundleAndReport. -func installAppAndReport(src string) error { - paths := offline.DefaultPaths() - - fmt.Printf("Installing the board app from %s ...\n", src) - res, err := offline.InstallApp(paths, src) - if res == nil { - return fmt.Errorf("the app was NOT installed: %v\n Nothing on this board has changed.", err) - } - fmt.Printf("Installed the board app into %s.\n", paths.SPA) - if err != nil { - warnf("%v", err) - } - - // The app is only served locally in offline mode; online, the kiosk loads - // the hosted board and there is nothing to reload. An unenrolled board - // (setup.sh --offline before enrollment) has no mode yet either. - if cfg, err := config.Load(defaultConfigPath); err == nil && cfg.Mode == config.ModeOffline { - if err := reloadKiosk(); err != nil { - warnf("could not reload the screen (%v); it picks up the new app on its next reload.", err) - } else { - fmt.Println("Reloaded the screen.") - } - } - return nil -} diff --git a/cmd/agent/offline_cli_test.go b/cmd/agent/offline_cli_test.go deleted file mode 100644 index cebe61a..0000000 --- a/cmd/agent/offline_cli_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package main - -import ( - "testing" - - "github.com/LensBridge/agent/internal/kioskurl" - "github.com/LensBridge/agent/internal/offline" -) - -// A trimmed /proc/net/tcp. 10.77.0.1 is 01004D0A in host (little-endian) -// order; port 22 is 0016. -const procNetTCP = ` sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode - 0: 00000000:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1 0000000000000000 100 0 0 10 0 - 1: 0100007F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 999 0 2 1 0000000000000000 100 0 0 10 0 - 2: 0A00A8C0:0016 6400A8C0:D431 01 00000000:00000000 02:0004E1B2 00000000 0 0 3 4 0000000000000000 20 4 29 10 -1 -` - -func TestHasEstablishedTCP(t *testing.T) { - cases := []struct { - name string - data string - ip string - want bool - }{ - {"only a LAN session", procNetTCP, "10.77.0.1", false}, - {"LAN session found on its own address", procNetTCP, "192.168.0.10", true}, - {"service-port session", procNetTCP + " 3: 01004D0A:0016 3A4D000A:E1F2 01 00000000:00000000 02:0004E1B2 00000000 0 0 4 4 0 20 4 29 10 -1\n", "10.77.0.1", true}, - {"service-port socket closing, not established", procNetTCP + " 3: 01004D0A:0016 3A4D000A:E1F2 06 00000000:00000000 00:00000000 00000000 0 0 0 3 0\n", "10.77.0.1", false}, - {"other port on service address", procNetTCP + " 3: 01004D0A:1F90 3A4D000A:E1F2 01 0 0 0 0 0 0 0\n", "10.77.0.1", false}, - {"empty", "", "10.77.0.1", false}, - {"bad ip", procNetTCP, "not-an-ip", false}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if got := hasEstablishedTCP(tc.data, tc.ip, 22); got != tc.want { - t.Errorf("got %v, want %v", got, tc.want) - } - }) - } -} - -// The kiosk is pointed at the URL kioskurl composes; the server listens where -// internal/offline says. The two packages must not drift apart. -func TestOfflineURLMatchesListenAddr(t *testing.T) { - if want := "http://" + offline.ListenAddr + "/"; kioskurl.OfflineBaseURL != want { - t.Errorf("kioskurl.OfflineBaseURL = %q, want %q", kioskurl.OfflineBaseURL, want) - } -} diff --git a/cmd/agent/selfupdate.go b/cmd/agent/selfupdate.go new file mode 100644 index 0000000..fe5b108 --- /dev/null +++ b/cmd/agent/selfupdate.go @@ -0,0 +1,47 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/LensBridge/agent/internal/selfupdate" + "github.com/LensBridge/agent/internal/version" +) + +// runSelfUpdate is `selfupdate apply` (docs/architecture.md, section 12), +// started as root by musallahboard-agent-update.service when the daemon has +// staged an agent package. Its output goes to the journal. It exits non-zero +// when the update was refused or rolled back, so `systemctl status` shows +// that something needs a look; the outcome is also in last-update.json. +func runSelfUpdate(args []string) { + if len(args) != 1 || args[0] != "apply" { + fmt.Fprintln(os.Stderr, "usage: musallahboard-agent selfupdate apply") + fmt.Fprintln(os.Stderr, " Installs the agent update the daemon staged, and rolls it back if the new agent does not start.") + fmt.Fprintln(os.Stderr, " It normally runs by itself; there is rarely a reason to run it by hand.") + os.Exit(2) + } + requireRoot("selfupdate apply") + + // Once the binary is swapped the rest must finish (health check, and + // rollback if needed), so a hangup of an interactive terminal is ignored + // and SIGTERM only cancels the waits. + signal.Ignore(syscall.SIGHUP, syscall.SIGPIPE) + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) + defer cancel() + + u := selfupdate.New(version.Version) + out, err := u.Apply(ctx) + switch { + case errors.Is(err, selfupdate.ErrNothingStaged): + fmt.Println("No agent update is staged; nothing to do.") + case err != nil: + fmt.Printf("Agent update %s: %s\n", out.Status, out.Message) + os.Exit(1) + default: + fmt.Println(out.Message) + } +} diff --git a/cmd/agent/serviceport_cli.go b/cmd/agent/serviceport_cli.go new file mode 100644 index 0000000..f1a40d9 --- /dev/null +++ b/cmd/agent/serviceport_cli.go @@ -0,0 +1,262 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/LensBridge/agent/internal/config" +) + +// Names shared with setup.sh --service-port. Change them together. +const ( + serviceConn = "musallahboard-service-port" + serviceIface = "eth0" + serviceIP = "10.77.0.1" + agentUnit = "musallahboard-agent.service" + rtcDevice = "/dev/rtc0" +) + +// The CLI subcommands below are run by a person at a terminal (or by mbpush +// on their behalf), so everything they print is plain English, errors +// included. Anything that changes the system checks for root up front rather +// than failing half-way through. + +func fail(format string, args ...any) { + fmt.Fprintf(os.Stderr, "Error: "+format+"\n", args...) + os.Exit(1) +} + +func warnf(format string, args ...any) { + fmt.Fprintf(os.Stderr, "Warning: "+format+"\n", args...) +} + +func requireRoot(cmd string) { + if os.Geteuid() != 0 { + fail("`musallahboard-agent %s` changes the system and must run as root.\n Try: sudo musallahboard-agent %s", cmd, strings.Join(os.Args[1:], " ")) + } +} + +// loadConfigCLI loads the enrolled config, explaining the usual failures. +func loadConfigCLI() *config.Config { + cfg, err := config.Load(defaultConfigPath) + switch { + case err == nil: + return cfg + case errors.Is(err, fs.ErrPermission): + fail("cannot read %s (permission denied). Run this with sudo.", defaultConfigPath) + case errors.Is(err, fs.ErrNotExist): + fail("this board is not enrolled yet (%s does not exist). Enroll it online first:\n sudo musallahboard-agent enroll --token= --backend=", defaultConfigPath) + default: + fail("%v", err) + } + return nil +} + +// ── service-port ────────────────────────────────────────────────────────────── + +// runServicePort is `service-port on|off [--force]` (docs/architecture.md, +// section 13): whether eth0 offers the service port and the upload server. +func runServicePort(args []string) { + force := false + var rest []string + for _, a := range args { + if a == "--force" { + force = true + continue + } + rest = append(rest, a) + } + if len(rest) != 1 || (rest[0] != "on" && rest[0] != "off") || (force && rest[0] != "on") { + fmt.Fprintln(os.Stderr, "usage: musallahboard-agent service-port on|off [--force]") + fmt.Fprintln(os.Stderr, " --force (on only) start the service port even if eth0 is connected to a network") + os.Exit(2) + } + requireRoot("service-port " + rest[0]) + loadConfigCLI() // must be enrolled; SetServicePort rewrites this file + + // Turning it off over the service port kills this SSH session part way + // through. Keep going when the terminal hangs up; every step left is one + // we want finished. + signal.Ignore(syscall.SIGHUP, syscall.SIGPIPE) + + if rest[0] == "on" { + servicePortOn(force) + } else { + servicePortOff() + } +} + +// NetworkManager arrangement for eth0 (created by setup.sh --service-port): +// +// - lanConn, a plain DHCP client profile at the default priority, tried +// first. On a real network it gets an address and the board is just a +// client there. `service-port on` gives it a single autoconnect retry, so when +// a laptop (which runs no DHCP server) is plugged in it fails once and +// NetworkManager moves on. +// - serviceConn, the shared/DHCP-server profile, at the lowest priority +// NetworkManager allows, so it is only ever autoconnected after every +// other eth0 profile has failed. +// +// So a board booted plugged into a building's network comes up as a DHCP +// client, not a DHCP server, unless that network's DHCP server fails to answer +// within the 30 s DHCP timeout setup.sh gives lanConn. See docs/architecture.md, section 13. +const ( + lanConn = "musallahboard-lan" + servicePortPriority = "-999" // NM_SETTING_CONNECTION_AUTOCONNECT_PRIORITY_MIN +) + +func servicePortOn(force bool) { + if !nmConnectionExists(serviceConn) { + fail("the NetworkManager connection %q does not exist, so a laptop could not reach this board.\n Provision it first: bash setup.sh --service-port", serviceConn) + } + if out, err := runCmd("nmcli", "connection", "modify", serviceConn, + "connection.autoconnect", "yes", "connection.autoconnect-priority", servicePortPriority); err != nil { + fail("could not enable the service port: %v\n%s", err, out) + } + if nmConnectionExists(lanConn) { + if out, err := runCmd("nmcli", "connection", "modify", lanConn, "connection.autoconnect-retries", "1"); err != nil { + fail("could not configure %s: %v\n%s", lanConn, err, out) + } + } + if err := config.SetServicePort(defaultConfigPath, true); err != nil { + fail("could not write %s: %v", defaultConfigPath, err) + } + fmt.Printf("Set service_port = true in %s.\n", defaultConfigPath) + + devices, _ := runCmd("nmcli", "-t", "-f", "DEVICE,CONNECTION", "device") + routes, _ := runCmd("ip", "-4", "route", "show", "default") + inUse, why := ifaceInUse(serviceIface, serviceConn, devices, routes) + switch { + case inUse && !force: + fmt.Printf("%s is connected to a network right now (%s), so the service port was not started:\n"+ + "that would hand out addresses on that network. It starts by itself when a laptop is plugged\n"+ + "into %s instead, or run `sudo musallahboard-agent service-port on` again once %s is unplugged\n"+ + "from this network.\n", serviceIface, why, serviceIface, serviceIface) + case connectionOnDevice(devices, serviceIface) == serviceConn: + fmt.Printf("The service port is already up on %s (%s).\n", serviceIface, serviceIP) + default: + if inUse { + warnf("--force: starting the service port although %s is in use (%s).", serviceIface, why) + } + if out, err := runCmd("nmcli", "connection", "up", serviceConn); err != nil { + // Normal with no cable plugged in. + fmt.Printf("The service port is enabled and starts when a laptop is plugged into %s (now: %s).\n", serviceIface, firstLine(out)) + } else { + fmt.Printf("The service port is up: plug a laptop into %s and connect to %s.\n", serviceIface, serviceIP) + } + } + + restartAgent() + fmt.Printf("Done. Plug a laptop or phone into %s and open http://%s/ to send updates.\n", serviceIface, serviceIP) +} + +func servicePortOff() { + exists := nmConnectionExists(serviceConn) + if sshViaServicePort() { + warnf("you are connected through the service port (%s). Switching to online turns it off,\n"+ + " so this SSH session will drop in a few seconds. The switch still completes.", serviceIP) + time.Sleep(3 * time.Second) + } + + if err := config.SetServicePort(defaultConfigPath, false); err != nil { + fail("could not write %s: %v", defaultConfigPath, err) + } + fmt.Printf("Set service_port = false in %s.\n", defaultConfigPath) + + if exists { + if out, err := runCmd("nmcli", "connection", "modify", serviceConn, "connection.autoconnect", "no"); err != nil { + fail("could not disable the service port's autoconnect: %v\n%s\n"+ + " Do not plug this board into a network until it is fixed: it could hand out addresses there.", err, out) + } + } + if nmConnectionExists(lanConn) { + // Back to NetworkManager's default number of DHCP attempts. + if out, err := runCmd("nmcli", "connection", "modify", lanConn, "connection.autoconnect-retries", "-1"); err != nil { + warnf("could not reset %s's retries: %v\n%s", lanConn, err, out) + } + } + + // Before taking the port down: once it is down this session may be gone. + restartAgent() + + devices, _ := runCmd("nmcli", "-t", "-f", "DEVICE,CONNECTION", "device") + if exists && connectionOnDevice(devices, serviceIface) == serviceConn { + if out, err := runCmd("nmcli", "connection", "down", serviceConn); err != nil { + fail("could not bring the service port down: %v\n%s\n"+ + " Do not plug this board into a network until it is fixed: it could hand out addresses there.", err, out) + } + } + fmt.Println("Done. The service port is off, so it is now safe to connect eth0 to the musallah's network.") + fmt.Println("Content keeps syncing from LensBridge whenever the board can reach it.") +} + +// restartAgent restarts the daemon so it starts or stops the upload server. +func restartAgent() { + if out, err := runCmd("systemctl", "restart", agentUnit); err != nil { + fail("could not restart %s: %v\n%s", agentUnit, err, out) + } + fmt.Printf("Restarted %s.\n", agentUnit) +} + +func nmConnectionExists(name string) bool { + out, err := runCmd("nmcli", "-g", "NAME", "connection", "show") + if err != nil { + return false + } + for _, line := range strings.Split(out, "\n") { + if unescapeTerse(strings.TrimSpace(line)) == name { + return true + } + } + return false +} + +func runCmd(name string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, name, args...).CombinedOutput() + return strings.TrimSpace(string(out)), err +} + +func firstLine(s string) string { + s, _, _ = strings.Cut(s, "\n") + return s +} + +// sshViaServicePort reports whether an SSH session is connected through the +// service-port address. sudo drops SSH_CONNECTION by default, so the kernel's +// socket table is the reliable source; the variable is a cheap first check. +func sshViaServicePort() bool { + if f := strings.Fields(os.Getenv("SSH_CONNECTION")); len(f) == 4 && f[2] == serviceIP { + return true + } + data, err := os.ReadFile("/proc/net/tcp") + return err == nil && hasEstablishedTCP(string(data), serviceIP, 22) +} + +// hasEstablishedTCP scans /proc/net/tcp content for an ESTABLISHED socket +// whose local end is ip:port. Addresses there are hex, in host byte order +// for the IP (little-endian on every Pi) and big-endian for the port. +func hasEstablishedTCP(procNetTCP, ip string, port int) bool { + var a, b, c, d byte + if n, _ := fmt.Sscanf(ip, "%d.%d.%d.%d", &a, &b, &c, &d); n != 4 { + return false + } + want := fmt.Sprintf("%02X%02X%02X%02X:%04X", d, c, b, a, port) + for _, line := range strings.Split(procNetTCP, "\n")[1:] { + f := strings.Fields(line) + // sl local_address rem_address st ...; st 01 is ESTABLISHED. + if len(f) > 3 && strings.EqualFold(f[1], want) && f[3] == "01" { + return true + } + } + return false +} diff --git a/cmd/agent/status_cli.go b/cmd/agent/status_cli.go new file mode 100644 index 0000000..ba6713f --- /dev/null +++ b/cmd/agent/status_cli.go @@ -0,0 +1,177 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/LensBridge/agent/internal/localserver" + "github.com/LensBridge/agent/internal/store" + "github.com/LensBridge/agent/internal/trust" + "github.com/LensBridge/agent/internal/version" +) + +// cliStatus is /api/local/status plus what only the CLI reports. +type cliStatus struct { + localserver.Status + Clock clockInfo `json:"clock"` + RTC bool `json:"rtc"` + Trust trustInfo `json:"trust"` + Daemon bool `json:"daemonRunning"` +} + +type clockInfo struct { + Now string `json:"now"` + Unix int64 `json:"unix"` + Timezone string `json:"timezone"` +} + +type trustInfo struct { + ContentKeys int `json:"contentKeys"` + ReleaseKeys int `json:"releaseKeys"` +} + +func runStatus(args []string) { + fs := flag.NewFlagSet("status", flag.ExitOnError) + asJSON := fs.Bool("json", false, "print the status object as JSON") + if err := fs.Parse(args); err != nil || fs.NArg() != 0 { + fmt.Fprintln(os.Stderr, "usage: musallahboard-agent status [--json]") + os.Exit(2) + } + st := buildCLIStatus() + if *asJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + _ = enc.Encode(st) + return + } + printStatus(st) +} + +// buildCLIStatus asks the running daemon first: only it knows the sync state +// and whether the update screen is up, and asking needs no privileges. If it +// is not running, the installed state is read from disk (which needs root). +func buildCLIStatus() cliStatus { + var st cliStatus + if s, ok := daemonStatus(); ok { + st.Status, st.Daemon = s, true + } else { + cfg := loadConfigCLI() + st.Status = localserver.BuildStatus(store.Default(), cfg.DeviceID, version.Version, time.Now()) + } + now := time.Now() + st.Clock = clockInfo{Now: now.Format(time.RFC3339), Unix: now.Unix(), Timezone: systemZone()} + _, err := os.Stat(rtcDevice) + st.RTC = err == nil + if ts, err := trust.Load(trust.DefaultPath); err == nil { + st.Trust.ContentKeys = len(ts.Content) + st.Trust.ReleaseKeys = len(ts.Release) + } + if b, err := trust.Builtin(); err == nil { + st.Trust.ReleaseKeys += len(b) + } + return st +} + +func daemonStatus() (localserver.Status, bool) { + var s localserver.Status + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, localserver.BaseURL+"api/local/status", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return s, false + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil || resp.StatusCode != http.StatusOK || json.Unmarshal(raw, &s) != nil { + return s, false + } + return s, true +} + +// systemZone names the system timezone. Go reports time.Local as "Local", +// which tells a person nothing. +func systemZone() string { + if tz := strings.TrimPrefix(os.Getenv("TZ"), ":"); tz != "" { + return tz + } + if target, err := os.Readlink("/etc/localtime"); err == nil { + if _, name, ok := strings.Cut(target, "zoneinfo/"); ok { + return name + } + } + if b, err := os.ReadFile("/etc/timezone"); err == nil && strings.TrimSpace(string(b)) != "" { + return strings.TrimSpace(string(b)) + } + name, _ := time.Now().Zone() + return name +} + +func printStatus(st cliStatus) { + row := func(k, format string, args ...any) { + fmt.Printf(" %-13s %s\n", k+":", fmt.Sprintf(format, args...)) + } + row("Device", "%s", st.DeviceID) + if st.Daemon { + row("Agent", "%s (running)", st.AgentVersion) + } else { + row("Agent", "%s (NOT running: systemctl status musallahboard-agent)", version.Version) + } + if st.App != nil { + row("Board app", "%s", st.App.Version) + } else { + row("Board app", "none installed: the screen asks for an update") + } + + switch { + case st.Error != "": + row("Content", "installed but unreadable: %s", st.Error) + case st.Content == nil: + row("Content", "none installed") + default: + c := st.Content + src := "" + if c.Source != "" { + src = ", from " + c.Source + } + row("Content", "%s to %s (created %s%s)", c.FirstDay, c.LastDay, c.CreatedAt, src) + switch { + case *st.StaleDays > 0: + row("Today", "%s: content ran out %d day(s) ago; repeating %s", st.Today, *st.StaleDays, *st.ServingDay) + case *st.DaysRemaining == 0: + row("Today", "%s: the last day of this content", st.Today) + default: + row("Today", "%s, %d more day(s) of content", st.Today, *st.DaysRemaining) + } + } + if m, ok := st.Sync.(map[string]any); ok { + switch { + case m["enabled"] == false: + row("Sync", "off") + case m["lastError"] != nil: + row("Sync", "failing: %v", m["lastError"]) + case m["lastSuccessAt"] != nil: + row("Sync", "last succeeded %v", m["lastSuccessAt"]) + default: + row("Sync", "not yet attempted") + } + } + row("Trust", "%d content key(s), %d release key(s)", st.Trust.ContentKeys, st.Trust.ReleaseKeys) + if st.Trust.ContentKeys == 0 { + row("", "No content key: run `sudo musallahboard-agent trust fetch` while online.") + } + t, _ := time.Parse(time.RFC3339, st.Clock.Now) + row("Clock", "%s (%s)", t.Format("Mon 2 Jan 2006 15:04:05 MST"), st.Clock.Timezone) + if st.RTC { + row("RTC", "present (%s)", rtcDevice) + } else { + row("RTC", "not found: the clock only survives power cuts through the last update's time") + } +} diff --git a/cmd/agent/trust_cli.go b/cmd/agent/trust_cli.go new file mode 100644 index 0000000..0ee49fb --- /dev/null +++ b/cmd/agent/trust_cli.go @@ -0,0 +1,193 @@ +package main + +import ( + "context" + "encoding/base64" + "flag" + "fmt" + "os" + "strings" + "time" + + "github.com/LensBridge/agent/internal/config" + "github.com/LensBridge/agent/internal/enroll" + "github.com/LensBridge/agent/internal/trust" +) + +// runTrust is `trust show|add|remove|fetch` (docs/architecture.md, section +// 5). trust.json is root-owned and the daemon only reads it; it reloads the +// keys for every import, so changes here take effect without a restart. +func runTrust(args []string) { + if len(args) == 0 { + trustUsage() + } + switch args[0] { + case "show": + if len(args) != 1 { + trustUsage() + } + trustShow() + case "add": + if len(args) < 3 || len(args) > 4 { + trustUsage() + } + requireRoot("trust add") + note := "" + if len(args) == 4 { + note = args[3] + } + trustAdd(args[1], args[2], note) + case "remove": + if len(args) != 2 { + trustUsage() + } + requireRoot("trust remove") + trustRemove(args[1]) + case "fetch": + trustFetch(args[1:]) + default: + trustUsage() + } +} + +func trustUsage() { + fmt.Fprint(os.Stderr, `usage: + musallahboard-agent trust show List the keys this board accepts packages from + musallahboard-agent trust add content|release [note] Trust another public key (root) + musallahboard-agent trust remove Stop trusting a key (root) + musallahboard-agent trust fetch [--backend URL] Pin the backend's content keys (root) +`) + os.Exit(2) +} + +func loadTrustCLI() *trust.Store { + s, err := trust.LoadForWrite(trust.DefaultPath) + if err != nil { + fail("cannot read the trust store: %v", err) + } + return s +} + +func trustShow() { + s := loadTrustCLI() + fmt.Printf("Trust store: %s\n\n", trust.DefaultPath) + + fmt.Println("Content keys (may sign content only; they belong to the LensBridge backend):") + if len(s.Content) == 0 { + fmt.Println(" none. This board cannot verify any content package until one is added.") + fmt.Println(" While online, run: sudo musallahboard-agent trust fetch") + } + for _, k := range s.Content { + printTrustKey(k.KeyID, k.PublicKey, k.Note) + } + + fmt.Println() + fmt.Println("Release keys (may sign the board app and the agent):") + builtin, err := trust.Builtin() + if err != nil { + warnf("the release keys compiled into this agent are broken: %v", err) + } + for _, pub := range builtin { + printTrustKey(trust.KeyID(pub), base64.StdEncoding.EncodeToString(pub), "compiled into this agent") + } + for _, k := range s.Release { + printTrustKey(k.KeyID, k.PublicKey, k.Note) + } + if len(builtin) == 0 && len(s.Release) == 0 { + fmt.Println(" none. This is a development build with no release key compiled in, so board app") + fmt.Println(" and agent updates are refused until one is added with `trust add release`.") + } +} + +func printTrustKey(id, pub, note string) { + if note != "" { + note = " (" + note + ")" + } + fmt.Printf(" %s %s%s\n", id, pub, note) +} + +func trustAdd(roleArg, b64, note string) { + role, err := trust.ParseRole(roleArg) + if err != nil { + fail("%v", err) + } + pub, err := trust.ParsePublicKey(b64) + if err != nil { + fail("%v", err) + } + s := loadTrustCLI() + if !s.Add(role, pub, note) { + fmt.Printf("Key %s is already trusted; nothing changed.\n", trust.KeyID(pub)) + return + } + if err := s.Save(trust.DefaultPath); err != nil { + fail("could not write %s: %v", trust.DefaultPath, err) + } + fmt.Printf("Now trusting %s key %s.\n", role, trust.KeyID(pub)) + if role == trust.RoleRelease { + fmt.Println("Packages signed by it can install software on this board. Only add keys you are sure of.") + } +} + +func trustRemove(keyID string) { + s := loadTrustCLI() + if !s.Remove(keyID) { + builtin, _ := trust.Builtin() + for _, pub := range builtin { + if trust.KeyID(pub) == keyID { + fail("key %s is compiled into this agent and cannot be removed from trust.json", keyID) + } + } + fail("no key %s in %s (see `musallahboard-agent trust show`)", keyID, trust.DefaultPath) + } + if err := s.Save(trust.DefaultPath); err != nil { + fail("could not write %s: %v", trust.DefaultPath, err) + } + fmt.Printf("Removed key %s.\n", keyID) + if len(s.Content) == 0 { + warnf("no content key is left, so this board will refuse all content until one is added\n (sudo musallahboard-agent trust fetch).") + } +} + +func trustFetch(args []string) { + fs := flag.NewFlagSet("trust fetch", flag.ExitOnError) + backend := fs.String("backend", "", "backend base URL, for a board that is not enrolled (default: backend_url from "+defaultConfigPath+")") + if err := fs.Parse(args); err != nil || fs.NArg() != 0 { + trustUsage() + } + requireRoot("trust fetch") + url := *backend + if url == "" { + cfg, err := config.Load(defaultConfigPath) + if err != nil { + fail("cannot tell which backend to ask: %v\n Pass it explicitly: sudo musallahboard-agent trust fetch --backend https://", err) + } + url = cfg.BackendURL + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + fmt.Printf("Asking %s for its content signing keys ...\n", strings.TrimRight(url, "/")) + keys, err := enroll.FetchSigningKeys(ctx, url, nil) + if err != nil { + fail("%v\n Nothing has changed. Check the board's internet connection and try again.", err) + } + if len(keys) == 0 { + fail("the backend has no content signing key configured yet, so there is nothing to pin.\n Nothing has changed.") + } + added, err := enroll.PinContentKeys(trust.DefaultPath, keys, "lensbridge (fetched "+time.Now().UTC().Format("2006-01-02")+")") + if err != nil { + fail("%v\n Nothing has changed.", err) + } + if len(added) == 0 { + fmt.Printf("All %d key(s) the backend publishes are already trusted; nothing changed.\n", len(keys)) + return + } + for _, id := range added { + fmt.Printf("Now trusting content key %s.\n", id) + } + if n := len(keys) - len(added); n > 0 { + fmt.Printf("%d other key(s) were already trusted.\n", n) + } + fmt.Println("The board uses them for its next import; no restart is needed.") +} diff --git a/cmd/agent/usbimport.go b/cmd/agent/usbimport.go new file mode 100644 index 0000000..74ba690 --- /dev/null +++ b/cmd/agent/usbimport.go @@ -0,0 +1,47 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/LensBridge/agent/internal/config" + "github.com/LensBridge/agent/internal/usbimport" +) + +// runUSBImport is `usb-import ` (docs/architecture.md, section +// 9.6), started as root by musallahboard-usb-import@.service when a +// USB stick is plugged in. Everything it prints lands in the journal. +func runUSBImport(args []string) { + if len(args) != 1 { + fmt.Fprintln(os.Stderr, "usage: musallahboard-agent usb-import ") + os.Exit(2) + } + requireRoot("usb-import") + + cfg, err := config.Load(defaultConfigPath) + if err != nil { + // Not enrolled (or config unreadable): there is no daemon importing + // yet, so there is nothing to hand the stick to. Not an error for + // the unit; the stick is simply ignored. + fmt.Printf("USB import: ignoring %s: this board is not set up yet (%v)\n", args[0], err) + return + } + if !cfg.USBImport() { + return // usb_import = false: quietly ignore every stick + } + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) + defer cancel() + err = usbimport.New().Run(ctx, args[0]) + switch { + case errors.Is(err, usbimport.ErrUnsupported): + // Already explained; a stick we do not read is not a failure. + case err != nil: + fmt.Printf("USB import from %s failed: %v\n", args[0], err) + os.Exit(1) + } +} diff --git a/cmd/mbpack/main.go b/cmd/mbpack/main.go new file mode 100644 index 0000000..feaa88f --- /dev/null +++ b/cmd/mbpack/main.go @@ -0,0 +1,276 @@ +// Command mbpack builds, signs, verifies and inspects MusallahBoard update +// packages (.mbu, docs/architecture.md section 4). +// +// mbpack keygen +// mbpack app --version 2.1.0 --local-api 2 (--key-file K | --key-env NAME)... -o out.mbu +// mbpack agent --version 0.3.0 --arch arm64 (--key-file K | --key-env NAME)... -o out.mbu +// mbpack verify [--content-key B64]... [--release-key B64]... +// mbpack inspect +// +// Content packages are built by the LensBridge backend, not here. +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "flag" + "fmt" + iofs "io/fs" + "os" + "path/filepath" + "strings" + _ "time/tzdata" // content timezones must resolve on any OS + + "github.com/LensBridge/agent/internal/mbu" + "github.com/LensBridge/agent/internal/trust" +) + +func main() { + if len(os.Args) < 2 { + usage() + } + var err error + switch os.Args[1] { + case "keygen": + err = keygen() + case "app": + err = buildApp(os.Args[2:]) + case "agent": + err = buildAgent(os.Args[2:]) + case "verify": + err = verify(os.Args[2:]) + case "inspect": + err = inspect(os.Args[2:]) + default: + usage() + } + if err != nil { + fmt.Fprintln(os.Stderr, "mbpack:", err) + os.Exit(1) + } +} + +func usage() { + fmt.Fprint(os.Stderr, `usage: + mbpack keygen + mbpack app --version X.Y.Z --local-api N (--key-file F | --key-env NAME)... -o out.mbu + mbpack agent --version X.Y.Z --arch arm64|amd64 (--key-file F | --key-env NAME)... -o out.mbu + mbpack verify [--content-key B64]... [--release-key B64]... + mbpack inspect +`) + os.Exit(2) +} + +type multi []string + +func (m *multi) String() string { return strings.Join(*m, ",") } +func (m *multi) Set(v string) error { *m = append(*m, v); return nil } + +func keygen() error { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return err + } + fmt.Printf("private seed (keep secret): %s\n", base64.StdEncoding.EncodeToString(priv.Seed())) + fmt.Printf("public key: %s\n", base64.StdEncoding.EncodeToString(pub)) + fmt.Printf("key id: %s\n", trust.KeyID(pub)) + return nil +} + +// signingFlags registers --key-file and --key-env and returns a loader. +func signingFlags(fs *flag.FlagSet) func() ([]ed25519.PrivateKey, error) { + var files, envs multi + fs.Var(&files, "key-file", "file holding a base64 Ed25519 seed (repeatable)") + fs.Var(&envs, "key-env", "environment variable holding a base64 Ed25519 seed (repeatable)") + return func() ([]ed25519.PrivateKey, error) { + var keys []ed25519.PrivateKey + for _, f := range files { + raw, err := os.ReadFile(f) + if err != nil { + return nil, err + } + k, err := trust.ParsePrivateSeed(string(raw)) + if err != nil { + return nil, fmt.Errorf("%s: %w", f, err) + } + keys = append(keys, k) + } + for _, e := range envs { + v := os.Getenv(e) + if v == "" { + return nil, fmt.Errorf("environment variable %s is empty", e) + } + k, err := trust.ParsePrivateSeed(v) + if err != nil { + return nil, fmt.Errorf("%s: %w", e, err) + } + keys = append(keys, k) + } + if len(keys) == 0 { + return nil, errors.New("no signing key: pass --key-file or --key-env") + } + return keys, nil + } +} + +// parseArgs lets flags follow the positional argument. +func parseArgs(fs *flag.FlagSet, args []string) ([]string, error) { + var pos []string + for len(args) > 0 { + if err := fs.Parse(args); err != nil { + return nil, err + } + args = fs.Args() + if len(args) > 0 { + pos = append(pos, args[0]) + args = args[1:] + } + } + return pos, nil +} + +func buildApp(args []string) error { + fs := flag.NewFlagSet("app", flag.ExitOnError) + version := fs.String("version", "", "app version, MAJOR.MINOR.PATCH") + localAPI := fs.Int("local-api", 2, "local kiosk API version the build needs") + out := fs.String("o", "", "output file") + keys := signingFlags(fs) + pos, err := parseArgs(fs, args) + if err != nil || len(pos) != 1 || *out == "" { + usage() + } + signers, err := keys() + if err != nil { + return err + } + root := pos[0] + var src []mbu.Source + err = filepath.WalkDir(root, func(p string, d iofs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + if !d.Type().IsRegular() { + return fmt.Errorf("%s is not a regular file", p) + } + rel, err := filepath.Rel(root, p) + if err != nil { + return err + } + src = append(src, mbu.Source{Path: filepath.ToSlash(rel), FromFile: p}) + return nil + }) + if err != nil { + return err + } + m := mbu.Manifest{Type: mbu.TypeApp, Version: *version, App: &mbu.AppInfo{LocalAPI: *localAPI}} + return writePackage(*out, m, src, signers) +} + +func buildAgent(args []string) error { + fs := flag.NewFlagSet("agent", flag.ExitOnError) + version := fs.String("version", "", "agent version, MAJOR.MINOR.PATCH") + arch := fs.String("arch", "arm64", "arm64 or amd64") + out := fs.String("o", "", "output file") + keys := signingFlags(fs) + pos, err := parseArgs(fs, args) + if err != nil || len(pos) != 1 || *out == "" { + usage() + } + signers, err := keys() + if err != nil { + return err + } + const name = "musallahboard-agent" + m := mbu.Manifest{Type: mbu.TypeAgent, Version: *version, Agent: &mbu.AgentInfo{Arch: *arch, Binary: name}} + return writePackage(*out, m, []mbu.Source{{Path: name, FromFile: pos[0]}}, signers) +} + +func writePackage(out string, m mbu.Manifest, src []mbu.Source, signers []ed25519.PrivateKey) error { + f, err := os.Create(out + ".part") + if err != nil { + return err + } + if err := mbu.Build(f, m, src, signers); err != nil { + f.Close() + os.Remove(out + ".part") + return err + } + if err := f.Close(); err != nil { + return err + } + if err := os.Rename(out+".part", out); err != nil { + return err + } + fmt.Printf("wrote %s (%s, %d files)\n", out, m.Type, len(src)) + return nil +} + +func verify(args []string) error { + fs := flag.NewFlagSet("verify", flag.ExitOnError) + var ck, rk multi + fs.Var(&ck, "content-key", "trusted content public key, base64 (repeatable)") + fs.Var(&rk, "release-key", "trusted release public key, base64 (repeatable)") + pos, err := parseArgs(fs, args) + if err != nil || len(pos) != 1 { + usage() + } + parse := func(list []string) ([]ed25519.PublicKey, error) { + var out []ed25519.PublicKey + for _, b := range list { + k, err := trust.ParsePublicKey(b) + if err != nil { + return nil, err + } + out = append(out, k) + } + return out, nil + } + c, err := parse(ck) + if err != nil { + return err + } + r, err := parse(rk) + if err != nil { + return err + } + builtin, err := trust.Builtin() + if err != nil { + return err + } + pkg, err := mbu.Open(pos[0], trust.NewRing(c, append(r, builtin...)), mbu.OpenOptions{}) + if err != nil { + return err + } + defer pkg.Close() + for _, f := range pkg.Manifest.Files { + if err := pkg.CopyFile(f.Path, discard{}); err != nil { + return err + } + } + fmt.Printf("OK: %s, signed by %s, %d files verified\n", pkg.Manifest.Describe(), pkg.SignedBy, len(pkg.Manifest.Files)) + return nil +} + +type discard struct{} + +func (discard) Write(p []byte) (int, error) { return len(p), nil } + +func inspect(args []string) error { + if len(args) != 1 { + usage() + } + m, err := mbu.Peek(args[0]) + if err != nil { + return err + } + out, _ := json.MarshalIndent(m, "", " ") + fmt.Println("(unverified)") + fmt.Println(string(out)) + return nil +} diff --git a/cmd/mbpush/board.go b/cmd/mbpush/board.go new file mode 100644 index 0000000..861ca44 --- /dev/null +++ b/cmd/mbpush/board.go @@ -0,0 +1,417 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// Limits the board enforces (docs/architecture.md, 9.5). Checked here too, so +// a file that could never be accepted is not sent across first. +const ( + maxPackageBytes = 512 << 20 + maxUploadBytes = 1 << 30 +) + +// boardClient talks to the upload server on the service port. +type boardClient struct { + base string // http://10.77.0.1 + hc *http.Client + now func() time.Time +} + +func newBoardClient(host string) *boardClient { + base := host + if !strings.Contains(base, "://") { + base = "http://" + base + } + return &boardClient{ + base: strings.TrimRight(base, "/"), + hc: &http.Client{ + Transport: &http.Transport{ + // Never through a proxy: the board is at the other end of a + // cable, and a laptop's corporate proxy settings would send + // 10.77.0.1 somewhere else entirely. + Proxy: nil, + DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext, + }, + // A full 1 GiB upload over a slow adapter, then the board + // verifying and installing it, all inside one request. + Timeout: 60 * time.Minute, + }, + now: time.Now, + } +} + +// ── Import ─────────────────────────────────────────────────────────────────── + +// importResult is one package's outcome (docs/architecture.md, section 8). +type importResult struct { + File string `json:"file"` + Type string `json:"type"` + Version string `json:"version"` + Action string `json:"action"` + Message string `json:"message"` +} + +type importResponse struct { + Results []importResult `json:"results"` + Clock *struct { + // Board minus uploader, before any correction: positive means the + // board was ahead. + DriftSeconds int64 `json:"driftSeconds"` + Adjusted bool `json:"adjusted"` + Note string `json:"note"` + } `json:"clock"` +} + +// runPush uploads files as one batch and prints what the board did with each. +// It fails if the upload failed or any package was rejected. +func runPush(c *boardClient, files []string, out io.Writer) error { + var total int64 + sizes := make([]int64, len(files)) + for i, f := range files { + fi, err := os.Stat(f) + if err != nil { + return fmt.Errorf("cannot read %s: %v", f, err) + } + if !fi.Mode().IsRegular() { + return fmt.Errorf("%s is not a file", f) + } + if fi.Size() > maxPackageBytes { + return fmt.Errorf("%s is %s; the board accepts packages up to %s", f, humanBytes(fi.Size()), humanBytes(maxPackageBytes)) + } + sizes[i] = fi.Size() + total += fi.Size() + } + if total > maxUploadBytes { + return fmt.Errorf("these packages add up to %s; the board accepts up to %s at a time. Send them in two goes", humanBytes(total), humanBytes(maxUploadBytes)) + } + + step(out, "Sending %d package(s), %s, to the board at %s", len(files), humanBytes(total), c.hostName()) + + // Streamed: a 500 MB package is read from disk as it is sent, never held + // in memory. + pr, pw := io.Pipe() + mw := multipart.NewWriter(pw) + writeErr := make(chan error, 1) + go func() { + err := writeParts(mw, files, sizes, out) + if err == nil { + err = mw.Close() + } + pw.CloseWithError(err) // nil closes normally + writeErr <- err + }() + + req, err := http.NewRequest(http.MethodPost, c.base+"/api/import", pr) + if err != nil { + return err + } + req.Header.Set("Content-Type", mw.FormDataContentType()) + // The board sets its clock from this, but only once a package in this + // request has verified, and only within limits (section 10). + req.Header.Set("X-MB-Client-Time", strconv.FormatInt(c.now().Unix(), 10)) + + resp, err := c.hc.Do(req) + pr.Close() // unblocks the writer if the board answered before reading it all + werr := <-writeErr + if err != nil { + if werr != nil && !errors.Is(werr, io.ErrClosedPipe) { + return fmt.Errorf("could not read a package: %v\nNothing was installed", werr) + } + return c.connectError(err, true) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return c.statusError(resp) + } + var ir importResponse + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&ir); err != nil { + return fmt.Errorf("the board sent an answer mbpush does not understand (%v).\nRun `mbpush status` to see what is installed", err) + } + return printImport(out, ir) +} + +func writeParts(mw *multipart.Writer, files []string, sizes []int64, out io.Writer) error { + for i, f := range files { + fmt.Fprintf(out, " %s (%s)\n", filepath.Base(f), humanBytes(sizes[i])) + src, err := os.Open(f) + if err != nil { + return err + } + part, err := mw.CreateFormFile("package", filepath.Base(f)) + if err == nil { + _, err = io.Copy(part, src) + } + src.Close() + if err != nil { + return err + } + } + return nil +} + +// actionLabel puts a result's action in words for the results table. +func actionLabel(action string) string { + switch action { + case "installed": + return "Installed" + case "unchanged": + return "Already installed" + case "staged": + return "Installing" + case "skipped": + return "Skipped" + case "rejected": + return "NOT INSTALLED" + default: + return action + } +} + +func printImport(out io.Writer, ir importResponse) error { + step(out, "Results") + rejected, staged := 0, 0 + if len(ir.Results) == 0 { + fmt.Fprintln(out, " The board reported no results.") + } + for _, r := range ir.Results { + fmt.Fprintf(out, " %-18s %s\n", actionLabel(r.Action), r.File) + if r.Message != "" { + fmt.Fprintf(out, " %-18s %s\n", "", r.Message) + } + switch r.Action { + case "rejected": + rejected++ + case "staged": + staged++ + } + } + + if ir.Clock != nil { + step(out, "Clock") + fmt.Fprintf(out, " %s\n", describeClockReport(ir.Clock.DriftSeconds, ir.Clock.Adjusted)) + if ir.Clock.Note != "" { + fmt.Fprintf(out, " (%s)\n", ir.Clock.Note) + } + } + + fmt.Fprintln(out) + if staged > 0 { + fmt.Fprintln(out, "The board is updating its own software and restarts it by itself. Leave it powered") + fmt.Fprintln(out, "on; any other packages in this upload are installed after the restart.") + } + if rejected > 0 { + return fmt.Errorf("%d package(s) were not installed (see above). The board keeps what it had", rejected) + } + fmt.Fprintln(out, "Done. Wait for the board's screen to say \"Update complete\", then unplug the cable.") + return nil +} + +// describeClockReport puts the board's clock report in words. +func describeClockReport(driftSeconds int64, adjusted bool) string { + drift := time.Duration(driftSeconds) * time.Second + switch { + case adjusted: + return fmt.Sprintf("The board's clock was %s. It has been set to this laptop's time.", describeDrift(drift)) + case drift.Abs() <= clockTolerance: + return "The board's clock is " + describeDrift(drift) + "." + default: + return fmt.Sprintf("The board's clock is %s, and was not changed.", describeDrift(drift)) + } +} + +// ── Status ─────────────────────────────────────────────────────────────────── + +type boardStatus struct { + DeviceID string `json:"deviceId"` + AgentVersion string `json:"agentVersion"` + App *struct { + Version string `json:"version"` + } `json:"app"` + Content *struct { + FirstDay string `json:"firstDay"` + LastDay string `json:"lastDay"` + Timezone string `json:"timezone"` + Source string `json:"source"` + InstalledAt string `json:"installedAt"` + } `json:"content"` + Today string `json:"today"` + DaysRemaining *int `json:"daysRemaining"` + StaleDays *int `json:"staleDays"` + Clock struct { + Unix int64 `json:"unix"` + Timezone string `json:"timezone"` + } `json:"clock"` + RTC bool `json:"rtc"` + Update struct { + Active bool `json:"active"` + } `json:"update"` +} + +func runStatus(c *boardClient, out io.Writer) error { + sent := c.now() + resp, err := c.hc.Get(c.base + "/api/status") + got := c.now() + if err != nil { + return c.connectError(err, false) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return c.statusError(resp) + } + var st boardStatus + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&st); err != nil { + return fmt.Errorf("the board sent a status mbpush does not understand: %v", err) + } + printStatus(out, c.hostName(), st, sent, got) + return nil +} + +func printStatus(out io.Writer, host string, st boardStatus, sent, got time.Time) { + row := func(label, format string, args ...any) { + fmt.Fprintf(out, " %-11s %s\n", label, fmt.Sprintf(format, args...)) + } + step(out, "Board at %s", host) + row("Board", "%s", orUnknown(st.DeviceID)) + row("Agent", "%s", orUnknown(st.AgentVersion)) + if st.App != nil { + row("Board app", "%s", st.App.Version) + } else { + row("Board app", "not installed (send musallahboard-app-.mbu)") + } + + if st.Content == nil { + row("Content", "none installed (send the board's content package from LensBridge)") + } else { + c := st.Content + row("Content", "%s to %s (%s), %s", c.FirstDay, c.LastDay, c.Timezone, describeSource(c.Source)) + switch { + case st.StaleDays != nil && *st.StaleDays > 0: + row("", "OUT OF DATE: it ran out %s ago, so the board is repeating %s.", plural(*st.StaleDays, "day"), c.LastDay) + case st.DaysRemaining != nil && *st.DaysRemaining == 0: + row("", "Today (%s) is its last day.", st.Today) + case st.DaysRemaining != nil: + row("", "Today is %s: %s of content left after today.", st.Today, plural(*st.DaysRemaining, "day")) + } + } + + if st.Clock.Unix > 0 { + cc := compareClocks(sent, got, st.Clock.Unix) + zone := "" + if st.Clock.Timezone != "" { + zone = " (time zone " + st.Clock.Timezone + ")" + } + row("Clock", "%s%s", describeDrift(cc.Drift), zone) + if cc.NeedsSet { + row("", "Sending a package with mbpush sets it to this laptop's time.") + } + } + if st.RTC { + row("RTC", "fitted: the board keeps time while unplugged") + } else { + row("RTC", "none: the board loses time while unplugged") + } + if st.Update.Active { + row("Update", "in progress") + } +} + +func describeSource(s string) string { + switch s { + case "sync": + return "synced from LensBridge" + case "usb": + return "from a USB stick" + case "upload": + return "uploaded from a laptop or phone" + case "cli": + return "installed by an admin" + case "": + return "source unknown" + default: + return "from " + s + } +} + +func plural(n int, word string) string { + if n == 1 { + return "1 " + word + } + return fmt.Sprintf("%d %ss", n, word) +} + +func orUnknown(s string) string { + if s == "" { + return "unknown" + } + return s +} + +// ── Errors ─────────────────────────────────────────────────────────────────── + +func (c *boardClient) hostName() string { + if i := strings.Index(c.base, "://"); i >= 0 { + return c.base[i+3:] + } + return c.base +} + +// connectError explains a failed request. duringUpload says whether the board +// may have received part of an upload. +func (c *boardClient) connectError(err error, duringUpload bool) error { + var op *net.OpError + if errors.As(err, &op) && op.Op == "dial" { + return fmt.Errorf("could not reach the board at %s (%v).\n"+ + " - Is the cable plugged into the board's ethernet port?\n"+ + " - Is this laptop's wired adapter set to get an address automatically (DHCP)?\n"+ + " It gets a 10.77.0.x address within about a minute of plugging in.\n"+ + " - Is the board's service port on? (on the board: sudo musallahboard-agent service-port on)\n"+ + "Nothing on the board has changed", c.hostName(), op.Err) + } + if duringUpload { + return fmt.Errorf("lost the connection to the board during the upload (%v).\n"+ + "Run `mbpush status` to see what the board has installed, then send the packages again", err) + } + return fmt.Errorf("lost the connection to the board (%v)", err) +} + +// statusError turns a non-200 answer into plain English. +func (c *boardClient) statusError(resp *http.Response) error { + var body struct { + Message string `json:"message"` + } + _ = json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&body) + switch resp.StatusCode { + case http.StatusConflict: + return errors.New("the board is already installing another upload (from a browser, a phone or another mbpush).\n" + + "Wait until its screen says \"Update complete\", then try again. Nothing from this upload was installed") + case http.StatusMisdirectedRequest: + return fmt.Errorf("the board refused a request addressed to %q. It only answers to %s or musallahboard.local,\n"+ + "so leave out --host, or use one of those", c.hostName(), defaultHost) + case http.StatusRequestEntityTooLarge: + return fmt.Errorf("the board says the upload is too large: %s", orDefault(body.Message, resp.Status)) + case http.StatusNotFound, http.StatusMethodNotAllowed: + return fmt.Errorf("%s answered %s. Is that a MusallahBoard on the service port, with agent v2 or later?", c.hostName(), resp.Status) + default: + return fmt.Errorf("the board answered %s: %s", resp.Status, orDefault(body.Message, "no details")) + } +} + +func orDefault(s, d string) string { + if s == "" { + return d + } + return s +} diff --git a/cmd/mbpush/clock.go b/cmd/mbpush/clock.go index a96aee0..6a663d3 100644 --- a/cmd/mbpush/clock.go +++ b/cmd/mbpush/clock.go @@ -2,15 +2,13 @@ package main import ( "fmt" - "strconv" - "strings" "time" ) -// clockTolerance is how far the board's clock may be from the laptop's -// before mbpush corrects it (docs/offline.md, "mbpush"). `date +%s` has -// one-second resolution and the round trip adds a little more, so anything -// much tighter would "fix" clocks that are already right. +// clockTolerance is how far apart the board's clock and the laptop's may be +// and still count as in step. It matches the board, which ignores an +// uploader's time closer than this (docs/architecture.md, section 10): +// the clocks are compared to the second, over a link with some latency. const clockTolerance = 5 * time.Second // clockCheck is the outcome of comparing the two clocks. @@ -21,27 +19,13 @@ type clockCheck struct { NeedsSet bool } -// compareClocks decides whether the board's clock needs setting. sentAt and -// gotAt bracket the remote `date +%s` call; the laptop's time is taken as -// their midpoint, so a slow link does not read as drift. +// compareClocks compares the board's clock, read by a request sent at sentAt +// and answered at gotAt, with the laptop's. The laptop's time is taken as the +// midpoint of the request, so a slow link does not read as drift. func compareClocks(sentAt, gotAt time.Time, boardEpoch int64) clockCheck { laptop := sentAt.Add(gotAt.Sub(sentAt) / 2) drift := time.Unix(boardEpoch, 0).Sub(laptop.Truncate(time.Second)) - abs := drift - if abs < 0 { - abs = -abs - } - return clockCheck{Drift: drift, NeedsSet: abs > clockTolerance} -} - -// parseEpoch reads the output of `date +%s`. -func parseEpoch(out string) (int64, error) { - s := strings.TrimSpace(out) - n, err := strconv.ParseInt(s, 10, 64) - if err != nil || n <= 0 { - return 0, fmt.Errorf("the board answered %q instead of a time", s) - } - return n, nil + return clockCheck{Drift: drift, NeedsSet: drift.Abs() > clockTolerance} } // describeDrift puts a drift in words: "3 s behind this laptop". diff --git a/cmd/mbpush/fetch.go b/cmd/mbpush/fetch.go new file mode 100644 index 0000000..559399f --- /dev/null +++ b/cmd/mbpush/fetch.go @@ -0,0 +1,188 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "regexp" + "time" +) + +// channel is a release channel file (docs/architecture.md, 9.4): a pointer to +// the latest package. It is only a pointer. What makes a package safe is its +// signature, which the board checks; the sha256 here just catches a broken +// download before it is carried to a board. +type channel struct { + Version string `json:"version"` + URL string `json:"url"` + SHA256 string `json:"sha256"` + Bytes int64 `json:"bytes"` +} + +var ( + versionRE = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`) + sha256RE = regexp.MustCompile(`^[0-9a-f]{64}$`) + fileNameRE = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9._-]*\.mbu$`) +) + +func newDownloadClient() *http.Client { + return &http.Client{ + Transport: &http.Transport{ + // The laptop is online here, so its proxy settings apply. + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{Timeout: 15 * time.Second}).DialContext, + TLSHandshakeTimeout: 15 * time.Second, + }, + Timeout: 30 * time.Minute, + } +} + +// runFetch downloads the latest board app and agent packages into o.dir, so +// they can be carried to boards without internet. Both are tried even if one +// fails. +func runFetch(hc *http.Client, o options, out io.Writer) error { + if err := os.MkdirAll(o.dir, 0o755); err != nil { + return err + } + step(out, "Downloading the latest packages into %s", o.dir) + var failed int + for _, ch := range []struct{ what, url string }{ + {"Board app", o.appChannel}, + {"Agent (" + o.arch + ")", o.agentChannel}, + } { + msg, err := fetchChannel(hc, ch.url, o.dir) + if err != nil { + failed++ + fmt.Fprintf(out, " %-14s FAILED: %v\n", ch.what, err) + continue + } + fmt.Fprintf(out, " %-14s %s\n", ch.what, msg) + } + if failed > 0 { + return fmt.Errorf("%d download(s) failed (see above)", failed) + } + fmt.Fprintln(out, "\nDone. Send them with `mbpush ...`, or copy them to a USB stick.") + return nil +} + +// fetchChannel reads one channel file and downloads the package it points +// at, unless an identical file is already there. It returns what it did. +func fetchChannel(hc *http.Client, channelURL, dir string) (string, error) { + base, err := url.Parse(channelURL) + if err != nil { + return "", fmt.Errorf("bad channel URL %q: %v", channelURL, err) + } + resp, err := hc.Get(channelURL) + if err != nil { + return "", fmt.Errorf("could not read the release channel: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("could not read the release channel %s: %s", channelURL, resp.Status) + } + var ch channel + if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&ch); err != nil { + return "", fmt.Errorf("the release channel is not valid: %v", err) + } + switch { + case !versionRE.MatchString(ch.Version): + return "", fmt.Errorf("the release channel has an invalid version %q", ch.Version) + case !sha256RE.MatchString(ch.SHA256): + return "", errors.New("the release channel has an invalid sha256") + case ch.Bytes <= 0 || ch.Bytes > maxPackageBytes: + return "", fmt.Errorf("the release channel gives an impossible size (%d bytes)", ch.Bytes) + } + pkgURL, err := base.Parse(ch.URL) // relative URLs resolve against the channel + if err != nil || (pkgURL.Scheme != "https" && pkgURL.Scheme != "http") { + return "", fmt.Errorf("the release channel has an invalid package URL %q", ch.URL) + } + name := path.Base(pkgURL.Path) + if !fileNameRE.MatchString(name) { + return "", fmt.Errorf("the release channel points at %q, which is not a .mbu file", name) + } + dest := filepath.Join(dir, name) + + if ok, _ := fileMatches(dest, ch.Bytes, ch.SHA256); ok { + return fmt.Sprintf("%s already downloaded (%s)", name, ch.Version), nil + } + if err := download(hc, pkgURL.String(), dest, ch.Bytes, ch.SHA256); err != nil { + return "", err + } + return fmt.Sprintf("%s (%s, %s)", name, ch.Version, humanBytes(ch.Bytes)), nil +} + +// download saves url to dest via a temporary file, and only renames it into +// place once its size and sha256 match. +func download(hc *http.Client, url, dest string, size int64, sum string) error { + resp, err := hc.Get(url) + if err != nil { + return fmt.Errorf("download failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download failed: %s", resp.Status) + } + + tmp, err := os.CreateTemp(filepath.Dir(dest), "."+filepath.Base(dest)+".*.part") + if err != nil { + return err + } + keep := false + defer func() { + if !keep { + tmp.Close() + os.Remove(tmp.Name()) + } + }() + + h := sha256.New() + // One byte over the declared size is enough to know it is wrong. + n, err := io.Copy(io.MultiWriter(tmp, h), io.LimitReader(resp.Body, size+1)) + if err != nil { + return fmt.Errorf("download failed: %v", err) + } + if n != size { + return fmt.Errorf("download is %d bytes, the release channel says %d; not saved", n, size) + } + if got := hex.EncodeToString(h.Sum(nil)); got != sum { + return errors.New("download does not match the release channel's sha256; not saved") + } + if err := tmp.Sync(); err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmp.Name(), dest); err != nil { + return err + } + keep = true + return nil +} + +// fileMatches reports whether p exists with exactly this size and sha256. +func fileMatches(p string, size int64, sum string) (bool, error) { + f, err := os.Open(p) + if err != nil { + return false, err + } + defer f.Close() + fi, err := f.Stat() + if err != nil || fi.Size() != size { + return false, err + } + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return false, err + } + return hex.EncodeToString(h.Sum(nil)) == sum, nil +} diff --git a/cmd/mbpush/main.go b/cmd/mbpush/main.go index 693d856..c4641e8 100644 --- a/cmd/mbpush/main.go +++ b/cmd/mbpush/main.go @@ -1,77 +1,104 @@ -// Command mbpush updates an offline MusallahBoard from the admin's laptop. +// Command mbpush updates a MusallahBoard from the admin's laptop. // -// Plug the laptop into the board's ethernet port, then: +// Plug the laptop into the board's ethernet port (the service port), then: // -// mbpush musallahboard-3f2a1b4c-2026-09-24.zip # clock sync, copy, install, status -// mbpush --app dist/ # update the board app -// mbpush status # clock check + status, changes nothing +// mbpush musallahboard-content-3f2a1b4c-2026-09-24.mbu musallahboard-app-2.1.0.mbu +// mbpush status // -// It shells out to the system ssh (Windows 10+ ships it), so the user's -// existing keys and ssh-agent work as they do everywhere else. There is no -// upload endpoint on the board: everything goes over SSH to the restricted -// push account setup.sh --offline creates, whose only command is the agent's -// gate. Files are streamed to it on stdin. See docs/offline.md, "mbpush" and -// "Push account". +// and, the day before a visit, while the laptop has internet: +// +// mbpush fetch +// +// Packages are uploaded over plain HTTP to the board's upload server at +// 10.77.0.1 (docs/architecture.md, section 9.5). That needs no account or key: +// every package is signed and checked by the board, which is what makes it +// safe. The same page is at http://10.77.0.1/ in any browser, so mbpush is a +// convenience, not a requirement. +// +// For a board that can only be reached over SSH, --ssh user@host streams each +// package to `sudo musallahboard-agent import -` through the system ssh. package main import ( - "archive/tar" - "compress/gzip" "errors" "flag" "fmt" "io" - "io/fs" "os" - "os/exec" "path/filepath" "strings" - "time" ) const ( - // defaultHost is the board's address on the service-port cable. + // defaultHost is the board's address on the service-port cable. The + // upload server only answers to this name (or musallahboard.local). defaultHost = "10.77.0.1" - // defaultUser is the restricted push account setup.sh --offline creates. - // The admin account works too: requests are sent in a form both accept. - defaultUser = "mbpush" + + defaultAppChannel = "https://github.com/LensBridge/MusallahBoard/releases/latest/download/app-channel.json" + defaultAgentChannel = "https://github.com/LensBridge/agent/releases/latest/download/agent-channel-%s.json" ) type options struct { - host, user, identity string - app string - checkHostKey bool - args []string + host string + ssh string // user@host; empty means HTTP upload + identity string + checkHostKey bool + + // fetch + dir string + arch string + appChannel string + agentChannel string + + command string // "push", "status" or "fetch" + files []string // for push } func usage(w io.Writer) { - fmt.Fprintf(w, `mbpush — update an offline MusallahBoard over its ethernet port + fmt.Fprintf(w, `mbpush: update a MusallahBoard from this laptop Usage: - mbpush Set the board's clock, send and install the bundle, show status - mbpush --app Install a new board app build - mbpush status Check the clock and show status; changes nothing + mbpush ... Send update packages to the board and install them + mbpush status Show what the board has installed and its clock + mbpush fetch Download the latest board app and agent packages + (do this before a visit, while you have internet) + +The laptop must be plugged into the board's ethernet port. The board gives it +an address within about a minute. You can also open http://%s/ in a browser. Flags (anywhere on the line): - --host Board address (default %s) - --user Account on the board (default %s, the push account) - -i SSH identity file (default: your ssh config / agent) - --check-host-key Check the board's SSH host key as usual (off by default - on %s, where every board shares one address) -`, defaultHost, defaultUser, defaultHost) + --host Board address (default %s) + --ssh Send over SSH instead, to 'sudo musallahboard-agent import -' + -i SSH identity file (with --ssh) + --check-host-key Check the board's SSH host key (with --ssh; off by default + on %s, where every board shares one address) + +Fetch flags: + --dir Where to save the packages (default: current directory) + --arch arm64|amd64 The boards' architecture (default arm64, a Raspberry Pi) + --app-channel Board app release channel (default: MusallahBoard releases) + --agent-channel Agent release channel (default: agent releases, for --arch) + +Content packages (musallahboard-content-*.mbu) come from LensBridge: +Devices > the board > Download offline bundle. +`, defaultHost, defaultHost, defaultHost) } -// parseArgs accepts flags before or after the positional argument, since -// "mbpush bundle.zip --user admin" is as natural to type as the reverse. +// parseArgs accepts flags before or after the positional arguments, since +// "mbpush file.mbu --host x" is as natural to type as the reverse. func parseArgs(args []string) (options, error) { var o options fs := flag.NewFlagSet("mbpush", flag.ContinueOnError) fs.SetOutput(io.Discard) fs.StringVar(&o.host, "host", defaultHost, "") - fs.StringVar(&o.user, "user", defaultUser, "") + fs.StringVar(&o.ssh, "ssh", "", "") fs.StringVar(&o.identity, "i", "", "") - fs.StringVar(&o.app, "app", "", "") fs.BoolVar(&o.checkHostKey, "check-host-key", false, "") + fs.StringVar(&o.dir, "dir", ".", "") + fs.StringVar(&o.arch, "arch", "arm64", "") + fs.StringVar(&o.appChannel, "app-channel", defaultAppChannel, "") + fs.StringVar(&o.agentChannel, "agent-channel", "", "") + var pos []string for { if err := fs.Parse(args); err != nil { return o, err @@ -79,16 +106,44 @@ func parseArgs(args []string) (options, error) { if fs.NArg() == 0 { break } - o.args = append(o.args, fs.Arg(0)) + pos = append(pos, fs.Arg(0)) args = fs.Args()[1:] } + switch { - case o.app != "" && len(o.args) > 0: - return o, errors.New("--app takes the build to install; give no other arguments") - case o.app == "" && len(o.args) != 1: - return o, errors.New("give one bundle .zip, --app , or status") - case o.host == "" || o.user == "": - return o, errors.New("--host and --user cannot be empty") + case len(pos) == 0: + return o, errors.New("give one or more .mbu files, status, or fetch") + case pos[0] == "status" || pos[0] == "fetch": + if len(pos) > 1 { + return o, fmt.Errorf("%s takes no other arguments", pos[0]) + } + o.command = pos[0] + default: + o.command = "push" + o.files = pos + } + + if o.host == "" { + return o, errors.New("--host cannot be empty") + } + if o.ssh != "" { + if o.command == "fetch" { + return o, errors.New("fetch downloads to this laptop; --ssh does not apply") + } + if !strings.Contains(o.ssh, "@") || strings.HasPrefix(o.ssh, "@") || strings.HasSuffix(o.ssh, "@") { + return o, errors.New("--ssh needs user@host, e.g. --ssh ibra@board.local") + } + } + if o.arch != "arm64" && o.arch != "amd64" { + return o, errors.New("--arch must be arm64 or amd64") + } + if o.agentChannel == "" { + o.agentChannel = fmt.Sprintf(defaultAgentChannel, o.arch) + } + for _, f := range o.files { + if !strings.EqualFold(filepath.Ext(f), ".mbu") { + return o, fmt.Errorf("%s is not an update package (.mbu)", f) + } } return o, nil } @@ -106,207 +161,39 @@ func main() { usage(os.Stderr) os.Exit(2) } - if _, err := exec.LookPath("ssh"); err != nil { - die("ssh was not found. mbpush uses the system OpenSSH client.\n" + - "On Windows: Settings > System > Optional features > OpenSSH Client.") - } - - r := remote{host: o.host, user: o.user, identity: o.identity, checkHostKey: o.checkHostKey, - stdout: os.Stdout, stderr: os.Stderr} + out := os.Stdout switch { - case o.app != "": - pushApp(r, o.app) - case o.args[0] == "status": - syncClock(r, false) - showStatus(r) + case o.command == "fetch": + err = runFetch(newDownloadClient(), o, out) + case o.ssh != "": + err = runSSH(o, out, os.Stderr) + case o.command == "status": + err = runStatus(newBoardClient(o.host), out) default: - pushBundle(r, o.args[0]) - } -} - -// cleanups run before die exits; os.Exit skips deferred calls. -var cleanups []func() - -func die(format string, args ...any) { - for _, f := range cleanups { - f() - } - fmt.Fprintf(os.Stderr, "\nmbpush: "+format+"\n", args...) - os.Exit(1) -} - -func step(format string, args ...any) { - fmt.Printf("==> "+format+"\n", args...) -} - -// connectHelp explains a failure to reach the board. -func connectHelp(r remote) string { - return fmt.Sprintf("could not connect to %s.\n"+ - " - Is the cable plugged into the board's ethernet port?\n"+ - " - Is this laptop's wired adapter set to get an address automatically (DHCP)?\n"+ - " It should get one in 10.77.0.x within a few seconds of plugging in.\n"+ - " - Is your SSH key the one set up for %q on this board? (use -i to pick one)\n"+ - "Nothing on the board has changed.", r.target(), r.user) -} - -// syncClock compares the board's clock with this laptop's and, when set is -// true and they are more than clockTolerance apart, sets the board's clock -// and writes it to the RTC. The drift is always printed. -func syncClock(r remote, set bool) { - step("Checking the board's clock") - sent := time.Now() - out, err := r.output(gate("clock")) - got := time.Now() - if err != nil { - if isConnectFailure(err) { - die("%s", connectHelp(r)) - } - die("could not read the board's clock: %v", err) + err = runPush(newBoardClient(o.host), o.files, out) } - epoch, err := parseEpoch(out) if err != nil { - die("could not read the board's clock: %v", err) - } - c := compareClocks(sent, got, epoch) - fmt.Printf(" The board's clock is %s.\n", describeDrift(c.Drift)) - if !c.NeedsSet { - return - } - if !set { - fmt.Printf(" That is more than %s off; `mbpush ` would correct it.\n", clockTolerance) - return - } - - // The gate also reports whether it could save the time to an RTC. - if err := r.run(gate(fmt.Sprintf("clock-set %d", time.Now().Unix()))); err != nil { - die("could not set the board's clock: %v\nNothing else on the board has changed.", err) + fmt.Fprintf(os.Stderr, "\nmbpush: %v\n", err) + os.Exit(1) } } -func showStatus(r remote) { - step("Board status") - if err := r.run(gate("status")); err != nil { - if isConnectFailure(err) { - die("%s", connectHelp(r)) - } - die("could not read the board's status: %v", err) - } -} - -func pushBundle(r remote, local string) { - fi, err := os.Stat(local) - if err != nil { - die("cannot read %s: %v", local, err) - } - if fi.IsDir() || !strings.EqualFold(filepath.Ext(local), ".zip") { - die("%s is not a .zip bundle. Download one from LensBridge: Devices > the board > Download offline bundle.", local) - } - - syncClock(r, true) - - step("Sending and installing the bundle") - if err := r.runWithInput(gate("bundle-install"), local); err != nil { - if isConnectFailure(err) { - die("lost the connection to the board during the install.\n" + - "The board is still showing its previous content unless the output above says the bundle was installed.") - } - die("the bundle was not installed (see above).\nThe board is still showing its previous content.") - } - showStatus(r) - fmt.Println("\nDone. You can unplug the cable.") +// step prints a heading for one stage of the run. +func step(w io.Writer, format string, args ...any) { + fmt.Fprintf(w, "==> "+format+"\n", args...) } -func pushApp(r remote, local string) { - fi, err := os.Stat(local) - if err != nil { - die("cannot read %s: %v", local, err) - } - if fi.IsDir() { - if _, err := os.Stat(filepath.Join(local, "index.html")); err != nil { - die("%s has no index.html. Point at the build output directory (usually dist/).", local) - } - tmp, err := tarGzDir(local) - if err != nil { - die("could not package %s: %v", local, err) - } - cleanups = append(cleanups, func() { os.Remove(tmp) }) - defer os.Remove(tmp) - local = tmp - } else if !strings.HasSuffix(local, ".tar.gz") && !strings.HasSuffix(local, ".tgz") { - die("%s is neither a directory nor a .tar.gz.", local) - } - - syncClock(r, true) - - step("Sending and installing the app") - if err := r.runWithInput(gate("app-install"), local); err != nil { - if isConnectFailure(err) { - die("lost the connection to the board during the install.") - } - die("the app was not installed (see above).\nThe board is still showing its previous content.") - } - fmt.Println("\nDone. You can unplug the cable.") -} - -// gate wraps a request for the agent's gate (cmd/agent/gate.go). The push -// account's forced command ignores what we send and reads it back from -// SSH_ORIGINAL_COMMAND; the admin account simply runs it. Either way it -// reaches the gate. -func gate(request string) string { - return "sudo -n musallahboard-agent gate " + request -} - -// tarGzDir packs the files under dir into a temporary .tar.gz and returns -// its path. Only regular files and directories are included. -func tarGzDir(dir string) (string, error) { - f, err := os.CreateTemp("", "mbpush-app-*.tar.gz") - if err != nil { - return "", err - } - name := f.Name() - gz := gzip.NewWriter(f) - tw := tar.NewWriter(gz) - - werr := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - rel, err := filepath.Rel(dir, p) - if err != nil || rel == "." { - return err - } - rel = filepath.ToSlash(rel) - info, err := d.Info() - if err != nil { - return err - } - switch { - case d.IsDir(): - return tw.WriteHeader(&tar.Header{Name: rel + "/", Typeflag: tar.TypeDir, Mode: 0o755, ModTime: info.ModTime()}) - case d.Type().IsRegular(): - if err := tw.WriteHeader(&tar.Header{Name: rel, Typeflag: tar.TypeReg, Mode: 0o644, Size: info.Size(), ModTime: info.ModTime()}); err != nil { - return err - } - src, err := os.Open(p) - if err != nil { - return err - } - defer src.Close() - _, err = io.Copy(tw, src) - return err - default: - return fmt.Errorf("%s is a link or special file; the build should contain only files and directories", rel) - } - }) - for _, c := range []io.Closer{tw, gz, f} { - if err := c.Close(); err != nil && werr == nil { - werr = err - } - } - if werr != nil { - os.Remove(name) - return "", werr +// humanBytes prints a size the way a file manager would. +func humanBytes(n int64) string { + switch { + case n < 1<<10: + return fmt.Sprintf("%d B", n) + case n < 1<<20: + return fmt.Sprintf("%.1f KB", float64(n)/(1<<10)) + case n < 1<<30: + return fmt.Sprintf("%.1f MB", float64(n)/(1<<20)) + default: + return fmt.Sprintf("%.2f GB", float64(n)/(1<<30)) } - return name, nil } diff --git a/cmd/mbpush/mbpush_test.go b/cmd/mbpush/mbpush_test.go index 35d0817..6272eb2 100644 --- a/cmd/mbpush/mbpush_test.go +++ b/cmd/mbpush/mbpush_test.go @@ -1,23 +1,32 @@ package main import ( - "archive/tar" - "compress/gzip" + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" "io" + "net/http" + "net/http/httptest" "os" "path/filepath" "slices" + "strconv" "strings" + "sync" "testing" "time" ) +// ── Clock ──────────────────────────────────────────────────────────────────── + func TestCompareClocks(t *testing.T) { base := time.Date(2026, 9, 24, 14, 0, 0, 0, time.UTC) epochOffset := time.Unix(1, 0).Sub(base) cases := []struct { name string - rtt time.Duration // round trip of the `date +%s` call + rtt time.Duration // round trip of the status request boardAt time.Duration // board's reading, relative to base wantDrift time.Duration wantSet bool @@ -53,17 +62,6 @@ func TestCompareClocksTruncatesLaptopTime(t *testing.T) { } } -func TestParseEpoch(t *testing.T) { - if n, err := parseEpoch(" 1790000000\n"); err != nil || n != 1790000000 { - t.Errorf("got %d, %v", n, err) - } - for _, bad := range []string{"", "sudo: a password is required", "-5", "12.5"} { - if _, err := parseEpoch(bad); err == nil { - t.Errorf("parseEpoch(%q) accepted", bad) - } - } -} - func TestDescribeDrift(t *testing.T) { cases := map[time.Duration]string{ 0: "in step with this laptop", @@ -81,107 +79,496 @@ func TestDescribeDrift(t *testing.T) { } } +func TestDescribeClockReport(t *testing.T) { + cases := []struct { + drift int64 + adjusted bool + want string + }{ + {-180, true, "The board's clock was 3 min behind this laptop. It has been set to this laptop's time."}, + {2, false, "The board's clock is 2 s ahead of this laptop."}, + {0, false, "The board's clock is in step with this laptop."}, + {-86400 * 200, false, "The board's clock is 200 days behind this laptop, and was not changed."}, + } + for _, tc := range cases { + if got := describeClockReport(tc.drift, tc.adjusted); got != tc.want { + t.Errorf("describeClockReport(%d, %v) = %q, want %q", tc.drift, tc.adjusted, got, tc.want) + } + } +} + +// ── Arguments ──────────────────────────────────────────────────────────────── + func TestParseArgs(t *testing.T) { cases := []struct { name string args []string - want options + check func(options) bool wantErr bool }{ - {"bundle, defaults", []string{"b.zip"}, options{host: defaultHost, user: defaultUser, args: []string{"b.zip"}}, false}, - {"flags after the bundle", []string{"b.zip", "--user", "admin", "-i", "k"}, options{host: defaultHost, user: "admin", identity: "k", args: []string{"b.zip"}}, false}, - {"flags before", []string{"--host=10.0.0.5", "status"}, options{host: "10.0.0.5", user: defaultUser, args: []string{"status"}}, false}, - {"app", []string{"--app", "dist"}, options{host: defaultHost, user: defaultUser, app: "dist"}, false}, - {"app plus bundle", []string{"--app", "dist", "b.zip"}, options{}, true}, - {"nothing", nil, options{}, true}, - {"two bundles", []string{"a.zip", "b.zip"}, options{}, true}, - {"unknown flag", []string{"--hots", "x", "a.zip"}, options{}, true}, - {"empty user", []string{"--user=", "a.zip"}, options{}, true}, + {"files, defaults", []string{"a.mbu", "b.MBU"}, func(o options) bool { + return o.command == "push" && o.host == defaultHost && slices.Equal(o.files, []string{"a.mbu", "b.MBU"}) + }, false}, + {"flags after the files", []string{"a.mbu", "--host", "musallahboard.local"}, func(o options) bool { + return o.host == "musallahboard.local" && slices.Equal(o.files, []string{"a.mbu"}) + }, false}, + {"status", []string{"status"}, func(o options) bool { return o.command == "status" }, false}, + {"fetch defaults", []string{"fetch"}, func(o options) bool { + return o.command == "fetch" && o.dir == "." && o.arch == "arm64" && + o.appChannel == defaultAppChannel && + o.agentChannel == "https://github.com/LensBridge/agent/releases/latest/download/agent-channel-arm64.json" + }, false}, + {"fetch amd64 into a dir", []string{"fetch", "--arch=amd64", "--dir", "out"}, func(o options) bool { + return o.dir == "out" && strings.HasSuffix(o.agentChannel, "agent-channel-amd64.json") + }, false}, + {"ssh", []string{"--ssh", "ibra@board.local", "-i", "k", "a.mbu"}, func(o options) bool { + return o.ssh == "ibra@board.local" && o.identity == "k" && o.command == "push" + }, false}, + {"nothing", nil, nil, true}, + {"not a package", []string{"bundle.zip"}, nil, true}, + {"status plus a file", []string{"status", "a.mbu"}, nil, true}, + {"fetch over ssh", []string{"fetch", "--ssh", "a@b"}, nil, true}, + {"ssh without user", []string{"--ssh", "board.local", "a.mbu"}, nil, true}, + {"bad arch", []string{"fetch", "--arch", "armhf"}, nil, true}, + {"unknown flag", []string{"--hots", "x", "a.mbu"}, nil, true}, + {"empty host", []string{"--host=", "status"}, nil, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got, err := parseArgs(tc.args) if tc.wantErr { if err == nil { - t.Fatalf("accepted %v", tc.args) + t.Fatalf("accepted %v: %+v", tc.args, got) } return } if err != nil { t.Fatal(err) } - if got.host != tc.want.host || got.user != tc.want.user || got.identity != tc.want.identity || - got.app != tc.want.app || !slices.Equal(got.args, tc.want.args) { - t.Errorf("got %+v, want %+v", got, tc.want) + if !tc.check(got) { + t.Errorf("got %+v", got) } }) } } -// The exact text matters: the agent's gate strips this prefix back off -// SSH_ORIGINAL_COMMAND (cmd/agent/gate.go, parseGateRequest). -func TestGateRequest(t *testing.T) { - cases := map[string]string{ - "clock": "sudo -n musallahboard-agent gate clock", - "clock-set 1790000000": "sudo -n musallahboard-agent gate clock-set 1790000000", - "status": "sudo -n musallahboard-agent gate status", - "bundle-install": "sudo -n musallahboard-agent gate bundle-install", - "app-install": "sudo -n musallahboard-agent gate app-install", +func TestSSHArgs(t *testing.T) { + o := options{ssh: "ibra@" + defaultHost, identity: "key"} + args := strings.Join(sshArgs(o, remoteAgent+" import -"), " ") + for _, want := range []string{"-i key", "StrictHostKeyChecking=no", "UserKnownHostsFile=", + "ibra@10.77.0.1 sudo -n musallahboard-agent import -"} { + if !strings.Contains(args, want) { + t.Errorf("ssh args %q missing %q", args, want) + } + } + o.checkHostKey = true + if args := strings.Join(sshArgs(o, "x"), " "); strings.Contains(args, "StrictHostKeyChecking") { + t.Errorf("--check-host-key still disables checking: %s", args) + } + o = options{ssh: "ibra@board.local"} + if args := strings.Join(sshArgs(o, "x"), " "); strings.Contains(args, "StrictHostKeyChecking") { + t.Errorf("host keys unchecked on a named host: %s", args) } - for in, want := range cases { - if got := gate(in); got != want { - t.Errorf("gate(%q) = %q, want %q", in, got, want) +} + +// ── Upload ─────────────────────────────────────────────────────────────────── + +type receivedPart struct { + field, file string + body []byte +} + +// fakeBoard is an upload server that records what it was sent. +type fakeBoard struct { + mu sync.Mutex + parts []receivedPart + clientTime string + host string + + status int + response any +} + +func (b *fakeBoard) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/api/import": + mr, err := r.MultipartReader() + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return } + b.mu.Lock() + b.clientTime = r.Header.Get("X-MB-Client-Time") + b.host = r.Host + b.mu.Unlock() + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + body, _ := io.ReadAll(p) + b.mu.Lock() + b.parts = append(b.parts, receivedPart{p.FormName(), p.FileName(), body}) + b.mu.Unlock() + } + case r.Method == http.MethodGet && r.URL.Path == "/api/status": + default: + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + if b.status != 0 { + w.WriteHeader(b.status) } + json.NewEncoder(w).Encode(b.response) } -func TestSSHOptions(t *testing.T) { - r := remote{host: defaultHost, user: "ibra", identity: "key"} - args := strings.Join(r.sshArgs("date +%s"), " ") - for _, want := range []string{"-i key", "StrictHostKeyChecking=no", "UserKnownHostsFile=", "ibra@10.77.0.1 date +%s"} { - if !strings.Contains(args, want) { - t.Errorf("ssh args %q missing %q", args, want) +func writeFile(t *testing.T, dir, name string, data []byte) string { + t.Helper() + p := filepath.Join(dir, name) + if err := os.WriteFile(p, data, 0o644); err != nil { + t.Fatal(err) + } + return p +} + +func TestPushSendsMultipartAndPrintsResults(t *testing.T) { + dir := t.TempDir() + app := writeFile(t, dir, "musallahboard-app-2.1.0.mbu", bytes.Repeat([]byte("A"), 300_000)) + content := writeFile(t, dir, "musallahboard-content-3f2a1b4c-2026-09-24.mbu", []byte("content bytes")) + + board := &fakeBoard{response: map[string]any{ + "results": []map[string]any{ + {"file": "musallahboard-app-2.1.0.mbu", "type": "app", "version": "2.1.0", + "action": "installed", "message": "Installed board app 2.1.0"}, + {"file": "musallahboard-content-3f2a1b4c-2026-09-24.mbu", "type": "content", + "action": "unchanged", "message": "Content already installed"}, + }, + "clock": map[string]any{"driftSeconds": -180, "adjusted": true, "note": "saved to the RTC"}, + }} + srv := httptest.NewServer(board) + defer srv.Close() + + c := newBoardClient(srv.URL) + fixed := time.Unix(1790258531, 0) + c.now = func() time.Time { return fixed } + var out bytes.Buffer + if err := runPush(c, []string{app, content}, &out); err != nil { + t.Fatalf("runPush: %v\n%s", err, out.String()) + } + + if len(board.parts) != 2 { + t.Fatalf("board got %d parts, want 2", len(board.parts)) + } + for i, want := range []string{app, content} { + p := board.parts[i] + data, _ := os.ReadFile(want) + if p.field != "package" || p.file != filepath.Base(want) || !bytes.Equal(p.body, data) { + t.Errorf("part %d: field %q file %q, %d bytes; want package %q, %d bytes", + i, p.field, p.file, len(p.body), filepath.Base(want), len(data)) } } - r.checkHostKey = true - if args := strings.Join(r.sshArgs("x"), " "); strings.Contains(args, "StrictHostKeyChecking") { - t.Errorf("--check-host-key still disables checking: %s", args) + if board.clientTime != strconv.FormatInt(fixed.Unix(), 10) { + t.Errorf("X-MB-Client-Time = %q", board.clientTime) + } + for _, want := range []string{ + "Installed musallahboard-app-2.1.0.mbu", + "Installed board app 2.1.0", + "Already installed musallahboard-content-3f2a1b4c-2026-09-24.mbu", + "The board's clock was 3 min behind this laptop. It has been set to this laptop's time.", + "(saved to the RTC)", + "Update complete", + } { + if !strings.Contains(out.String(), want) { + t.Errorf("output missing %q:\n%s", want, out.String()) + } + } +} + +func TestPushFailsWhenAPackageIsRejected(t *testing.T) { + dir := t.TempDir() + f := writeFile(t, dir, "old.mbu", []byte("x")) + board := &fakeBoard{response: map[string]any{ + "results": []map[string]any{ + {"file": "old.mbu", "type": "content", "action": "rejected", "message": "older than what is installed"}, + }, + }} + srv := httptest.NewServer(board) + defer srv.Close() + + var out bytes.Buffer + err := runPush(newBoardClient(srv.URL), []string{f}, &out) + if err == nil || !strings.Contains(err.Error(), "1 package(s) were not installed") { + t.Fatalf("err = %v", err) + } + if !strings.Contains(out.String(), "NOT INSTALLED old.mbu") || !strings.Contains(out.String(), "older than what is installed") { + t.Errorf("output:\n%s", out.String()) + } +} + +func TestPushStagedAgentMentionsRestart(t *testing.T) { + f := writeFile(t, t.TempDir(), "musallahboard-agent-0.3.0-arm64.mbu", []byte("x")) + board := &fakeBoard{response: map[string]any{ + "results": []map[string]any{{"file": filepath.Base(f), "type": "agent", "action": "staged"}}, + }} + srv := httptest.NewServer(board) + defer srv.Close() + var out bytes.Buffer + if err := runPush(newBoardClient(srv.URL), []string{f}, &out); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "restarts it by itself") { + t.Errorf("output:\n%s", out.String()) + } +} + +func TestPushErrors(t *testing.T) { + f := writeFile(t, t.TempDir(), "a.mbu", []byte("x")) + cases := []struct { + status int + want string + }{ + {http.StatusConflict, "already installing another upload"}, + {http.StatusMisdirectedRequest, "only answers to 10.77.0.1 or musallahboard.local"}, + {http.StatusBadRequest, "the board answered 400 Bad Request: part is not a package"}, + } + for _, tc := range cases { + t.Run(strconv.Itoa(tc.status), func(t *testing.T) { + srv := httptest.NewServer(&fakeBoard{status: tc.status, response: map[string]string{"message": "part is not a package"}}) + defer srv.Close() + var out bytes.Buffer + err := runPush(newBoardClient(srv.URL), []string{f}, &out) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("err = %v, want it to contain %q", err, tc.want) + } + }) + } +} + +// A board that answers before reading the upload (busy) must not hang mbpush. +func TestPushBoardAnswersEarly(t *testing.T) { + f := writeFile(t, t.TempDir(), "big.mbu", bytes.Repeat([]byte("B"), 8<<20)) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusConflict) + fmt.Fprint(w, `{"message":"busy"}`) + })) + defer srv.Close() + done := make(chan error, 1) + go func() { done <- runPush(newBoardClient(srv.URL), []string{f}, io.Discard) }() + select { + case err := <-done: + if err == nil { + t.Fatal("expected an error") + } + case <-time.After(20 * time.Second): + t.Fatal("runPush hung") + } +} + +func TestPushUnreachableBoard(t *testing.T) { + f := writeFile(t, t.TempDir(), "a.mbu", []byte("x")) + srv := httptest.NewServer(http.NotFoundHandler()) + url := srv.URL + srv.Close() // nothing listens there any more + err := runPush(newBoardClient(url), []string{f}, io.Discard) + if err == nil || !strings.Contains(err.Error(), "could not reach the board") || + !strings.Contains(err.Error(), "Nothing on the board has changed") { + t.Errorf("err = %v", err) } } -func TestTarGzDir(t *testing.T) { +func TestPushRefusesOversizedBatchBeforeSending(t *testing.T) { dir := t.TempDir() - must := func(err error) { - t.Helper() + // Sparse files: the size check must not need to read them. + var files []string + for i := 0; i < 3; i++ { + p := filepath.Join(dir, fmt.Sprintf("p%d.mbu", i)) + fh, err := os.Create(p) if err != nil { t.Fatal(err) } + if err := fh.Truncate(400 << 20); err != nil { + t.Fatal(err) + } + fh.Close() + files = append(files, p) } - must(os.MkdirAll(filepath.Join(dir, "assets"), 0o755)) - must(os.WriteFile(filepath.Join(dir, "index.html"), []byte(""), 0o644)) - must(os.WriteFile(filepath.Join(dir, "assets", "app.js"), []byte("js"), 0o644)) + err := runPush(newBoardClient("http://192.0.2.1"), files, io.Discard) + if err == nil || !strings.Contains(err.Error(), "two goes") { + t.Errorf("err = %v", err) + } +} - p, err := tarGzDir(dir) - must(err) - defer os.Remove(p) +// ── Status ─────────────────────────────────────────────────────────────────── - f, err := os.Open(p) - must(err) - defer f.Close() - gz, err := gzip.NewReader(f) - must(err) - tr := tar.NewReader(gz) - var names []string - for { - h, err := tr.Next() - if err == io.EOF { - break +func TestStatus(t *testing.T) { + now := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) + seven, zero := 7, 0 + st := map[string]any{ + "deviceId": "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b", "agentVersion": "0.3.0", + "app": map[string]any{"version": "2.1.0"}, + "content": map[string]any{"firstDay": "2026-09-24", "lastDay": "2026-10-07", + "timezone": "America/Toronto", "source": "usb"}, + "today": "2026-09-30", "daysRemaining": seven, "staleDays": zero, + "clock": map[string]any{"unix": now.Add(-10 * time.Minute).Unix(), "timezone": "America/Toronto"}, + "rtc": true, + "update": map[string]any{"active": false}, + } + srv := httptest.NewServer(&fakeBoard{response: st}) + defer srv.Close() + c := newBoardClient(srv.URL) + c.now = func() time.Time { return now } + var out bytes.Buffer + if err := runStatus(c, &out); err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b", + "Board app 2.1.0", + "2026-09-24 to 2026-10-07 (America/Toronto), from a USB stick", + "Today is 2026-09-30: 7 days of content left after today.", + "10 min behind this laptop (time zone America/Toronto)", + "Sending a package with mbpush sets it", + "RTC fitted", + } { + if !strings.Contains(out.String(), want) { + t.Errorf("output missing %q:\n%s", want, out.String()) } - must(err) - names = append(names, h.Name) } - slices.Sort(names) - if want := []string{"assets/", "assets/app.js", "index.html"}; !slices.Equal(names, want) { - t.Errorf("tar entries = %v, want %v", names, want) +} + +func TestStatusNothingInstalled(t *testing.T) { + var out bytes.Buffer + printStatus(&out, defaultHost, boardStatus{DeviceID: "d"}, time.Now(), time.Now()) + for _, want := range []string{"Board app not installed", "Content none installed", "RTC none"} { + if !strings.Contains(out.String(), want) { + t.Errorf("output missing %q:\n%s", want, out.String()) + } + } + stale := 3 + var st boardStatus + if err := json.Unmarshal([]byte(`{"content":{"firstDay":"2026-09-01","lastDay":"2026-09-14","source":"sync"}}`), &st); err != nil { + t.Fatal(err) + } + st.StaleDays = &stale + out.Reset() + printStatus(&out, defaultHost, st, time.Now(), time.Now()) + if !strings.Contains(out.String(), "OUT OF DATE: it ran out 3 days ago, so the board is repeating 2026-09-14.") { + t.Errorf("output:\n%s", out.String()) + } +} + +// ── Fetch ──────────────────────────────────────────────────────────────────── + +// releaseServer serves two channel files and the packages they point at. +func releaseServer(t *testing.T, app, agent []byte, tamper bool) *httptest.Server { + t.Helper() + sum := func(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) } + mux := http.NewServeMux() + var srv *httptest.Server + mux.HandleFunc("/app-channel.json", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(channel{Version: "2.1.0", URL: srv.URL + "/dl/musallahboard-app-2.1.0.mbu", + SHA256: sum(app), Bytes: int64(len(app))}) + }) + mux.HandleFunc("/agent-channel-arm64.json", func(w http.ResponseWriter, r *http.Request) { + // Relative, to check it resolves against the channel URL. + json.NewEncoder(w).Encode(channel{Version: "0.3.0", URL: "dl/musallahboard-agent-0.3.0-arm64.mbu", + SHA256: sum(agent), Bytes: int64(len(agent))}) + }) + mux.HandleFunc("/dl/musallahboard-app-2.1.0.mbu", func(w http.ResponseWriter, r *http.Request) { w.Write(app) }) + mux.HandleFunc("/dl/musallahboard-agent-0.3.0-arm64.mbu", func(w http.ResponseWriter, r *http.Request) { + if tamper { + w.Write(append([]byte("X"), agent[1:]...)) + return + } + w.Write(agent) + }) + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func fetchOptions(srv *httptest.Server, dir string) options { + return options{command: "fetch", dir: dir, arch: "arm64", + appChannel: srv.URL + "/app-channel.json", agentChannel: srv.URL + "/agent-channel-arm64.json"} +} + +func TestFetch(t *testing.T) { + app, agent := bytes.Repeat([]byte("app"), 1000), []byte("agent binary package") + srv := releaseServer(t, app, agent, false) + dir := filepath.Join(t.TempDir(), "visit") + + var out bytes.Buffer + if err := runFetch(srv.Client(), fetchOptions(srv, dir), &out); err != nil { + t.Fatalf("%v\n%s", err, out.String()) + } + for name, want := range map[string][]byte{ + "musallahboard-app-2.1.0.mbu": app, + "musallahboard-agent-0.3.0-arm64.mbu": agent, + } { + got, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil || !bytes.Equal(got, want) { + t.Errorf("%s: %v, %d bytes", name, err, len(got)) + } + } + entries, _ := os.ReadDir(dir) + if len(entries) != 2 { + t.Errorf("left behind: %v", entries) + } + + // Again: nothing to download. + out.Reset() + if err := runFetch(srv.Client(), fetchOptions(srv, dir), &out); err != nil { + t.Fatal(err) + } + if strings.Count(out.String(), "already downloaded") != 2 { + t.Errorf("second fetch:\n%s", out.String()) + } +} + +func TestFetchRejectsBadDownload(t *testing.T) { + srv := releaseServer(t, []byte("app"), []byte("agent"), true) + dir := t.TempDir() + var out bytes.Buffer + err := runFetch(srv.Client(), fetchOptions(srv, dir), &out) + if err == nil || !strings.Contains(err.Error(), "1 download(s) failed") { + t.Fatalf("err = %v\n%s", err, out.String()) + } + if !strings.Contains(out.String(), "does not match the release channel's sha256") { + t.Errorf("output:\n%s", out.String()) + } + entries, _ := os.ReadDir(dir) + var names []string + for _, e := range entries { + names = append(names, e.Name()) + } + if !slices.Equal(names, []string{"musallahboard-app-2.1.0.mbu"}) { + t.Errorf("files = %v; the bad download (or its .part) was kept", names) + } +} + +func TestFetchChannelValidation(t *testing.T) { + cases := map[string]channel{ + "bad version": {Version: "latest", URL: "x.mbu", SHA256: strings.Repeat("a", 64), Bytes: 1}, + "bad sha": {Version: "1.0.0", URL: "x.mbu", SHA256: "abc", Bytes: 1}, + "too big": {Version: "1.0.0", URL: "x.mbu", SHA256: strings.Repeat("a", 64), Bytes: maxPackageBytes + 1}, + "not a .mbu": {Version: "1.0.0", URL: "x.exe", SHA256: strings.Repeat("a", 64), Bytes: 1}, + "path escape": {Version: "1.0.0", URL: "../..", SHA256: strings.Repeat("a", 64), Bytes: 1}, + "other scheme": {Version: "1.0.0", URL: "file:///etc/x.mbu", SHA256: strings.Repeat("a", 64), Bytes: 1}, + } + for name, ch := range cases { + t.Run(name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(ch) + })) + defer srv.Close() + if _, err := fetchChannel(srv.Client(), srv.URL+"/c.json", t.TempDir()); err == nil { + t.Errorf("accepted %+v", ch) + } + }) } } diff --git a/cmd/mbpush/remote.go b/cmd/mbpush/remote.go deleted file mode 100644 index dee7f97..0000000 --- a/cmd/mbpush/remote.go +++ /dev/null @@ -1,89 +0,0 @@ -package main - -import ( - "bytes" - "errors" - "io" - "os" - "os/exec" -) - -// remote runs commands on the board through the system ssh, so the user's -// existing keys, agent and config apply unchanged. -type remote struct { - host string - user string - identity string - // checkHostKey keeps ssh's normal known_hosts checking. See sshOptions. - checkHostKey bool - - stdout, stderr io.Writer -} - -func (r remote) target() string { return r.user + "@" + r.host } - -// sshOptions are the options for every ssh call. -// -// Every board answers on the same address, 10.77.0.1, with its own host key, -// so with normal checking the second board an admin visits fails with -// "REMOTE HOST IDENTIFICATION HAS CHANGED" and a scary instruction to edit -// known_hosts. On the default service-port address the link is a cable from -// this laptop straight into the board, so host keys are not recorded or -// checked there. Any other --host keeps ssh's normal behaviour, and -// --check-host-key restores it for the default too. -func (r remote) sshOptions() []string { - opts := []string{"-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=15"} - if r.identity != "" { - opts = append(opts, "-i", r.identity) - } - if !r.checkHostKey { - opts = append(opts, - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile="+os.DevNull, - "-o", "LogLevel=ERROR", - ) - } - return opts -} - -func (r remote) sshArgs(command string) []string { - return append(r.sshOptions(), r.target(), command) -} - -// run executes command on the board, streaming its output. -func (r remote) run(command string) error { - cmd := exec.Command("ssh", r.sshArgs(command)...) - cmd.Stdout, cmd.Stderr = r.stdout, r.stderr - return cmd.Run() -} - -// output executes command on the board and returns its stdout. -func (r remote) output(command string) (string, error) { - var out bytes.Buffer - cmd := exec.Command("ssh", r.sshArgs(command)...) - cmd.Stdout, cmd.Stderr = &out, r.stderr - err := cmd.Run() - return out.String(), err -} - -// runWithInput executes command on the board with the local file on its -// stdin, streaming its output. This is how files reach the gate: the push -// account cannot run scp or sftp. -func (r remote) runWithInput(command, local string) error { - f, err := os.Open(local) - if err != nil { - return err - } - defer f.Close() - cmd := exec.Command("ssh", r.sshArgs(command)...) - cmd.Stdin, cmd.Stdout, cmd.Stderr = f, r.stdout, r.stderr - return cmd.Run() -} - -// isConnectFailure reports whether err is ssh failing to reach or log -// in to the board, rather than the remote command failing. ssh reserves exit -// status 255 for its own errors. -func isConnectFailure(err error) bool { - var ee *exec.ExitError - return errors.As(err, &ee) && ee.ExitCode() == 255 -} diff --git a/cmd/mbpush/ssh.go b/cmd/mbpush/ssh.go new file mode 100644 index 0000000..36082e6 --- /dev/null +++ b/cmd/mbpush/ssh.go @@ -0,0 +1,111 @@ +package main + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// The --ssh fallback, for a board reachable only over SSH (for example on a +// building network, with the service port off). It uses the system ssh, so +// the admin's keys, agent and config apply unchanged, and needs the admin +// account on the board (passwordless sudo, as setup.sh configures it). The +// transport is not what makes this safe: the board verifies every package +// exactly as it does an upload. + +const remoteAgent = "sudo -n musallahboard-agent" + +// sshOptions are the options for every ssh call. +// +// On the default service-port address every board answers with its own host +// key, so with normal checking the second board an admin visits fails with +// "REMOTE HOST IDENTIFICATION HAS CHANGED". There the link is a cable from +// this laptop straight into the board, so host keys are not recorded or +// checked. Any other host keeps ssh's normal behaviour, and --check-host-key +// restores it for the default too. +func sshOptions(o options) []string { + opts := []string{"-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=15"} + if o.identity != "" { + opts = append(opts, "-i", o.identity) + } + if sshHost(o.ssh) == defaultHost && !o.checkHostKey { + opts = append(opts, + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile="+os.DevNull, + "-o", "LogLevel=ERROR", + ) + } + return opts +} + +func sshArgs(o options, command string) []string { + return append(sshOptions(o), o.ssh, command) +} + +// sshHost is the host part of user@host. +func sshHost(target string) string { + _, host, _ := strings.Cut(target, "@") + return host +} + +func runSSH(o options, out, errOut io.Writer) error { + if _, err := exec.LookPath("ssh"); err != nil { + return errors.New("ssh was not found. --ssh uses the system OpenSSH client.\n" + + "On Windows: Settings > System > Optional features > OpenSSH Client") + } + if o.command == "status" { + step(out, "Board status (over SSH, %s)", o.ssh) + cmd := exec.Command("ssh", sshArgs(o, remoteAgent+" status")...) + cmd.Stdout, cmd.Stderr = out, errOut + if err := cmd.Run(); err != nil { + return sshError(o, err) + } + return nil + } + + failed := 0 + for _, f := range o.files { + src, err := os.Open(f) + if err != nil { + return fmt.Errorf("cannot read %s: %v", f, err) + } + step(out, "Sending %s over SSH to %s", filepath.Base(f), o.ssh) + // One package per call: `import -` reads exactly one from stdin. + cmd := exec.Command("ssh", sshArgs(o, remoteAgent+" import -")...) + cmd.Stdin, cmd.Stdout, cmd.Stderr = src, out, errOut + err = cmd.Run() + src.Close() + if err != nil { + if isConnectFailure(err) { + return sshError(o, err) + } + failed++ + } + } + if failed > 0 { + return fmt.Errorf("%d package(s) were not installed (see above). The board keeps what it had", failed) + } + fmt.Fprintln(out, "\nDone.") + return nil +} + +func sshError(o options, err error) error { + if isConnectFailure(err) { + return fmt.Errorf("could not connect to %s over SSH.\n"+ + " - Is %q the admin account on this board, with your key? (use -i to pick one)\n"+ + " - Does the board answer on that address?", o.ssh, o.ssh) + } + return fmt.Errorf("the board's agent failed: %v", err) +} + +// isConnectFailure reports whether err is ssh failing to reach or log in to +// the board, rather than the remote command failing. ssh reserves exit +// status 255 for its own errors. +func isConnectFailure(err error) bool { + var ee *exec.ExitError + return errors.As(err, &ee) && ee.ExitCode() == 255 +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..a4f8c2e --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,652 @@ +# MusallahBoard on-device architecture (v2, local-first) + +This document is the contract for everything that runs on a board, and for the +pieces that feed it: the LensBridge backend, the frontend build, `mbpush` on a +laptop and the Android app. If code and this file disagree, fix one of them +deliberately. It replaces the v1 `docs/offline.md`. + +## 1. Why v1 was replaced + +The v1 offline design was bolted onto an online-only board. The audit found: + +| # | v1 problem | Consequence | +|---|---|---| +| A1 | Two runtime paths. Online boards loaded the hosted site and the live API; offline boards were served by the agent. A manual `mode` switch picked one. | An online board that lost internet mid-day, or rebooted during an outage, had nothing to show. Offline behaviour was only ever exercised on offline boards. | +| A2 | Bundles were unsigned. Trust lived entirely in the transport (an SSH push account). | Anyone holding any push key, or any compromised laptop, could put arbitrary content on a board. The device id is not a secret (it was in the kiosk URL). | +| A3 | No anti-rollback. | An old bundle could be replayed over a new one. | +| A4 | The SPA update path (`app install`) accepted any tarball, and the agent itself could only be updated over SSH by an admin. | Offline boards were stuck on whatever software they left the building with. | +| A5 | The push path needed an extra Unix account, per-board key provisioning, sshd surgery, a sudo rule and a root gate parsing `SSH_ORIGINAL_COMMAND`. Host keys were not checked. | Large attack surface and a lot of operational steps for "copy a file to a board". | +| A6 | The clock could be set to anything in 2026-2100 by whoever held a push key. | Wrong prayer times, wrong day's content. | +| A7 | The kiosk and the local server raced at boot; a timer navigated away from Chromium's error page. | Occasional blank board after boot. | + +## 2. Principles + +1. **Local-first, always.** Every board, online or not, renders from state on + its own disk: the kiosk always loads `http://127.0.0.1:8080/`, served by the + agent. The network only changes how fresh that state is. There is no + online/offline mode. +2. **Everything that changes the board is a signed package.** Content, the + board app (SPA) and the agent binary all arrive as `.mbu` files, verified + against keys pinned on the device. Transports (backend sync, USB stick, + laptop, phone) are untrusted pipes that all feed one importer. +3. **Two trust roots.** The backend holds a *content* key and can only sign + content. Software (app and agent) is signed by a separate *release* key held + offline or in CI. A compromised backend can put the wrong posters on a + screen; it cannot run code on a board. +4. **Monotonic.** Packages never go backwards (content by sequence, software by + version). Re-sending what is installed is a harmless no-op. +5. **Least privilege.** The daemon runs unprivileged. Two small root entry + points exist, each of which re-verifies what it is handed: the agent + self-updater and the USB import helper. +6. **Atomic and recoverable.** Every install is stage, fsync, rename, symlink + swap. A power cut at any point leaves the previous state serving. A new + agent that does not come up healthy is rolled back automatically. + +## 3. Components + +``` + LensBridge backend (content key) GitHub releases (release key, CI) + | signed content .mbu | signed app/agent .mbu + channel json + online: device-signed HTTPS online: HTTPS (channel) + | | + laptop (mbpush / browser) --HTTP upload--+ | + Android app ----------------HTTP upload--+ | + USB stick --> usb-import helper (root) --+--> inbox | + sudo musallahboard-agent import ---------+ | | + v v + +--------------------------- musallahboard-agent (daemon, unprivileged) ---------------------------+ + | importer: verify signature, type, device, monotonic --> store (content / app) or stage (agent) | + | local server 127.0.0.1:8080: SPA, payload, media, status, SSE events, update screen | + | upload server on the service port (10.77.0.1:80): upload page + import API | + | sync: content (backend), release channels, refresh socket; backend WS: telemetry + commands | + +--------------------------------------------------------------------------------------------------+ + | CDP 127.0.0.1:9222 (update screen, reload) | staged agent package + v v + Chromium kiosk (cage) musallahboard-agent-update.service (root, re-verifies) +``` + +## 4. Package format (`.mbu`, format version 2) + +A zip file. Entries: + +| Entry | Meaning | +|---|---| +| `mbu.json` | The manifest. This exact byte sequence is what is signed. | +| `mbu.sig` | Signatures over `mbu.json`. | +| anything else | A file listed in `mbu.json` `files`, byte-for-byte. | + +Rules for the zip, checked before anything is written outside a staging directory: + +- Every entry other than `mbu.json` and `mbu.sig` must be listed in `files`, and + every listed file must be present exactly once. **Exception:** a `content` + package may omit listed `media/...` files that the device already holds in + its media store (online delta sync, section 9.1). A missing media file that + the store does not have is an error. +- Entry names: slash-separated segments, each matching `[A-Za-z0-9_][A-Za-z0-9._-]*` + (so no empty, `.`, `..` or hidden segments), at most 8 segments, at most 200 + bytes. No backslashes, no leading `/`. Directory entries (trailing `/`) are + ignored. Anything else rejects the package. +- Only regular files. Duplicate names reject the package. +- Size limits: package at most 512 MiB; `mbu.json` at most 1 MiB; `mbu.sig` at + most 64 KiB; at most 20000 files; the sum of `files[].bytes` at most 1 GiB. + Every file is read to EOF with a limit of its declared size plus one byte. + +### 4.1 `mbu.json` + +```json +{ + "format": "mbu", + "formatVersion": 2, + "type": "content", + "createdAt": "2026-09-24T14:02:11Z", + "sequence": 1790258531000, + "deviceId": "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b", + "version": null, + "files": [ + { "path": "payloads/2026-09-24.json", "sha256": "<64 lowercase hex>", "bytes": 5121 }, + { "path": "media/.jpg", "sha256": "", "bytes": 482113 } + ], + "content": { + "timezone": "America/Toronto", + "firstDay": "2026-09-24", + "lastDay": "2026-10-07", + "media": [ { "path": "media/.jpg", "contentType": "image/jpeg" } ] + } +} +``` + +Common fields, all required unless noted: + +| Field | Rule | +|---|---| +| `format` | `"mbu"` | +| `formatVersion` | `2`. Anything else is rejected. | +| `type` | `"content"`, `"app"` or `"agent"` | +| `createdAt` | RFC 3339 UTC, second precision. Used as a signed lower bound for the clock (section 10). | +| `files` | Array as above. `sha256` lowercase hex of the file bytes; `bytes` its exact size. Paths unique and valid per the entry-name rules. | + +Per type: + +**`content`** (signed by a *content* key) + +- `deviceId`: required, the enrolled device id. Content is always device-bound. +- `sequence`: required, positive integer, strictly increasing per device. The + backend uses the build time in epoch milliseconds (not truncated to seconds, + so two builds in the same second still order). +- `content.timezone`: IANA zone, not empty, not `Local`, loadable on the board. +- `content.firstDay`, `content.lastDay`: `YYYY-MM-DD`, inclusive, first <= last, + at most 31 days. +- `files` contains exactly one `payloads/.json` for every date in the + range and no other payloads; and `media/.` files (ext + `[a-z0-9]{1,10}`) whose name hash equals their `sha256`. Nothing else. +- `content.media` lists every media file with its `contentType` + (`image/jpeg`, `image/png`, `image/webp`, `image/gif`, `image/avif`, + `image/svg+xml`, or `application/octet-stream`). +- Each payload file is a JSON object: the backend's `MusallahBoardPayload` + (built by `BoardPayloadAssembler.assembleForDay`) for the start of that day in `timezone`, with `weather: null`, + posters active at any point that day, and every non-empty `posterUrl` + rewritten to `/media/.` naming a listed media file. The agent + rejects any payload with a `posterUrl` (at any depth) that is not such a path. +- Limits: payload file at most 16 MiB; total media at most 512 MiB. + +**`app`** (signed by a *release* key) + +- `version`: required, `MAJOR.MINOR.PATCH` (digits only). +- `app.localApi`: required integer, the local API version (section 7) the build + needs. The agent refuses a build needing a newer API than it serves, with + "update the agent first". +- `files` are the build output, rooted at the build directory: `index.html` + must be one of them. + +**`agent`** (signed by a *release* key) + +- `version`: required, `MAJOR.MINOR.PATCH`. +- `agent.arch`: `arm64` or `amd64`; must equal the board's `GOARCH`. +- `agent.binary`: the path in `files` of the executable (normally + `musallahboard-agent`). It is the only file. + +Unknown top-level or per-type fields are ignored (forward compatible). A field +that is required for the type and missing, or present with the wrong JSON type, +rejects the package. + +### 4.2 `mbu.sig` and keys + +`mbu.sig` is required; a package without it, or with no signature that +verifies, is refused. + +```json +{ "signatures": [ { "keyId": "0123456789abcdef", "sig": "" } ] } +``` + +- Algorithm: Ed25519 (RFC 8032, pure). +- Signed message: the ASCII bytes `musallahboard-mbu-v2\n` followed by the exact + bytes of `mbu.json` as stored in the zip. The prefix is domain separation + from the WebSocket handshake (`musallahboard-auth-v1`) and device HTTP auth + (`musallahboard-http-v1`). +- `keyId`: lowercase hex of the first 8 bytes of SHA-256 of the raw 32-byte + public key. +- A package is authentic if at least one signature verifies under a key of the + role its type requires: `content` -> content keys; `app` and `agent` -> + release keys. Signatures by unknown keys are ignored, not errors (rotation). +- Private keys are stored as the base64 of the 32-byte Ed25519 seed. Public keys + are exchanged as base64 of the raw 32 bytes. + +### 4.3 File names (convention only; the agent does not parse them) + +- `musallahboard-content--.mbu` +- `musallahboard-app-.mbu` +- `musallahboard-agent--.mbu` + +### 4.4 Tools + +`mbpack` (agent repo, `cmd/mbpack`) builds, signs and inspects packages: + +``` +mbpack keygen print a new seed, public key and key id +mbpack app --version 2.1.0 --local-api 2 --key-file K dist/ -o musallahboard-app-2.1.0.mbu +mbpack agent --version 0.3.0 --arch arm64 --key-file K build/musallahboard-agent-arm64 -o ... +mbpack verify [--content-key B64]... [--release-key B64]... file.mbu +mbpack inspect file.mbu +``` + +`--key-file` may be replaced by `--key-env NAME` (base64 seed in that +environment variable). The frontend repo also carries a dependency-free Node +packer (`scripts/package-mbu.mjs`) producing the same format. + +## 5. Trust store + +`/etc/musallahboard/trust.json`, `root:root 0644`: + +```json +{ + "content": [ { "keyId": "…", "publicKey": "", "note": "lensbridge" } ], + "release": [ { "keyId": "…", "publicKey": "", "note": "ci" } ] +} +``` + +- **Content keys** are pinned at enrollment: `POST /api/agent/enroll` returns + them (`contentSigningKeys`) over the same TLS connection that establishes the + device identity. `sudo musallahboard-agent trust fetch` + (`GET /api/agent/signing-keys`, over TLS) re-reads them, for example after a + key rotation. +- **Release keys** are compiled into the agent (`-ldflags -X + github.com/LensBridge/agent/internal/trust.BuiltinReleaseKeys=[,]`) + and may be extended in `trust.json`. A dev build without a compiled key + accepts only keys in `trust.json`. +- `musallahboard-agent trust show|add |remove |fetch` manage + it (root). The daemon only reads it. +- The root self-updater trusts only `trust.json` and the keys compiled into the + binary running as root. Because the daemon can write the directory (it owns + `kiosk-url`), every root reader and writer of `trust.json` refuses a file + that is not a regular, root-owned, singly linked file writable only by root: + a file the daemon planted there is never trusted or carried forward. + +## 6. On-device layout + +| Path | Owner | Contents | +|---|---|---| +| `/usr/bin/musallahboard-agent` | root 0755 | the agent | +| `/usr/lib/musallahboard/agent.prev` | root 0755 | previous agent, for rollback | +| `/etc/musallahboard/agent.toml` | daemon 0600 | config (section 11) | +| `/etc/musallahboard/agent.key` | daemon 0600 | device identity key (unchanged) | +| `/etc/musallahboard/trust.json` | root 0644 | trust roots | +| `/var/lib/musallahboard/state.json` | daemon | high-water marks and clock floor (below) | +| `/var/lib/musallahboard/content/media/.` | daemon | content-addressed media store | +| `/var/lib/musallahboard/content/bundles//` | daemon | `mbu.json`, `mbu.sig`, `payloads/*.json` | +| `/var/lib/musallahboard/content/current` | daemon | relative symlink to `bundles/` | +| `/var/lib/musallahboard/app/releases//` | daemon | SPA build + its `mbu.json` | +| `/var/lib/musallahboard/app/current` | daemon | relative symlink to `releases/` | +| `/var/lib/musallahboard/agent/staged/` | daemon | `package.mbu` + `ready` for the root updater | +| `/var/lib/musallahboard/agent/last-update.json` | root | outcome of the last self-update | +| `/var/lib/musallahboard/inbox/` | daemon 0770 | `*.mbu` dropped by the CLI and USB helper | +| `/var/lib/musallahboard/inbox/results/.json` | daemon | outcome per inbox file | + +The daemon keeps the current and previous content bundle and app release and +prunes the rest; media not referenced by a kept bundle is deleted. + +`state.json`: + +```json +{ + "contentSequence": 1790258531000, + "appVersion": "2.1.0", + "agentVersionsRejected": ["0.3.1"], + "clockFloor": 1790258531 +} +``` + +High-water marks live here, not only in the installed directories, so deleting +a bundle cannot reopen a rollback. Written atomically (tmp, fsync, rename). + + +## 7. Local kiosk API (`127.0.0.1:8080`, local API version 2) + +Loopback only. `GET`/`HEAD` only; anything else is `405`. All JSON errors are +`{"message": "..."}`. `X-Content-Type-Options: nosniff` everywhere. + +| Path | Behaviour | +|---|---| +| `/api/musallah/payload` | Today's payload from the current content bundle, verbatim (plus the weather overlay). No content: `503 {"message":"no content installed"}`. `Cache-Control: no-store`. | +| `/api/local/status` | Status object below. `no-store`. | +| `/api/local/events` | Server-Sent Events, below. | +| `/api/*` (other) | `404` JSON, never the SPA. | +| `/media/.` | From the media store, if the name is valid and the file exists. `Content-Type` from the current bundle's manifest when listed there, else by extension. `Cache-Control: public, max-age=31536000, immutable`. | +| `/_mb/updating` | The update screen (`update-anim/index.html`, embedded in the agent). `no-store`. | +| anything else | Static file from the current app release. Unknown paths fall back to `index.html` (`no-cache`); `/assets/*` is immutable, and a missing `/assets/*` file is `404`. No app installed: the agent's built-in "board app not installed" page, `503`. | + +**Weather overlay.** Content packages carry `weather: null`. When the board is +online, the sync loop fetches `GET /api/agent/weather` (device-authenticated, +section 9.3) every 30 minutes. The payload endpoint substitutes that object for +`weather` while it is less than 3 hours old; otherwise `weather` stays `null` +and the page hides the chip. + +**Picking today's payload**: `today` is the current date in +the bundle's `timezone`; serve `firstDay` if `today < firstDay`, `lastDay` if +`today > lastDay`, else `today`. + +**`/api/local/status`** + +```json +{ + "localApi": 2, + "agentVersion": "0.3.0", + "deviceId": "3f2a…", + "app": { "version": "2.1.0" }, + "content": { + "sequence": 1790258531000, "createdAt": "2026-09-24T14:02:11Z", + "firstDay": "2026-09-24", "lastDay": "2026-10-07", "timezone": "America/Toronto", + "source": "sync", "installedAt": "2026-09-24T14:03:00Z" + }, + "today": "2026-09-30", "servingDay": "2026-09-30", "daysRemaining": 7, "staleDays": 0, + "sync": { "enabled": true, "lastSuccessAt": "…", "lastAttemptAt": "…", "lastError": null }, + "update": { "active": false } +} +``` + +`app` and `content` are `null` when nothing is installed; `servingDay`, +`daysRemaining` and `staleDays` are then `null` and `today` is in the system +zone. `source` is one of `sync`, `usb`, `upload`, `cli`. `sync.lastError` is a +short human string or `null`. If installed content cannot be read, `content` is +`null` and an `error` string is added. + +**`/api/local/events`** (SSE, `text/event-stream`, `no-store`): + +- `event: content` with `data: {"sequence": n}` after new content is installed + whose payloads differ from what was being served. The page re-fetches the + payload in place. +- `event: app` with `data: {"version": "…"}` after a new app release is + installed. The page reloads itself. +- A comment line (`: ping`) every 25 s keeps the connection alive. + +`EventSource` gives up for good on a non-200 answer (for example while the +agent restarts), which is one reason the page also polls: it polls the payload every 10 minutes (which is what rolls the day +over after midnight) and status every minute, so a missed event costs at most +that long. + +## 8. Importer + +One importer in the daemon serialises every install. Sources: `sync`, `usb`, +`upload`, `cli`. Input is a batch of package files. + +Per package: + +1. **Verify** in a private staging directory: zip rules, manifest schema, + signature by the right role, type rules (section 4), and for content the + device id. A content package for another device is reported as `skipped` + ("for another board"), not as a failure: one USB stick can serve many + boards. +2. **Decide** against `state.json`: newer -> install; equal -> `unchanged`; + older -> `rejected` ("older than what is installed"). An agent version in + `agentVersionsRejected` is `rejected` ("failed to start last time"). +3. **Install**: + - content: move media into the store (verified hash, fsync), write the + bundle directory, fsync, rename into `bundles/`, swap `current`, record the + sequence, prune, emit `content` if payloads changed. + - app: extract into `releases/.staging-*`, fsync, rename, swap `current`, + record the version, prune, emit `app`. + - agent: write the verified package to `agent/staged/package.mbu` and create + `agent/staged/ready`. The root updater does the rest (section 12). +4. **Clock**: every verified package raises `clockFloor` to its `createdAt` + (section 10). + +A batch processes `agent` packages first, then `app`, then `content`. When an +agent package is staged, the rest of the batch stays in the inbox and is +processed by the new agent after the restart. + +**Update screen.** For `usb`, `upload` and `cli` batches containing at least one +package that is not skipped, and for any app or agent install from any source, +the daemon navigates the kiosk (CDP) to `/_mb/updating` and drives it through +`window.mbUpdate` (`caption(text)`, `complete({seconds, headline, message, caption})`), +then navigates back to `/` after the countdown. Background content sync from +the backend is silent: an admin editing a poster must not put "Working on +updates" on every screen. On failure the screen shows +`complete({headline: "Update not installed", message: "Returning to MusallahBoard", caption: })`. + +Results are JSON objects per package: + +```json +{ "file": "musallahboard-app-2.1.0.mbu", "type": "app", "version": "2.1.0", + "action": "installed", "message": "Installed board app 2.1.0" } +``` + +`action` is one of `installed`, `unchanged`, `staged` (agent, pending restart), +`skipped`, `rejected`. + +## 9. Transports + +### 9.1 Online: backend content sync + +The daemon, when `content_sync` is on and the board is enrolled: + +- syncs at startup, every 30 minutes, and 10 s after any message on the + backend's refresh channel (`wss:///api/refresh-musallahboard?deviceId=`, + debounced), +- with `POST /api/agent/content-bundle`, device-authenticated (9.2), body + `{"days": , "haveMedia": ["", ...]}`, +- and imports the returned package with source `sync`. + +`haveMedia` lists the media the store already holds; the backend leaves those +files out of the zip (they are still listed and signed in `mbu.json`). Sync +failures are recorded in `sync.lastError` and retried with backoff (1 min to 30 +min); the board keeps serving what it has, and with a 7-day window an online +board survives a week-long outage with full content. + +### 9.2 Device HTTP authentication (backend) + +Headers on device-authenticated requests: + +| Header | Value | +|---|---| +| `X-MB-Device-Id` | device UUID | +| `X-MB-Timestamp` | Unix milliseconds | +| `X-MB-Signature` | base64 Ed25519 signature by the device key over the message below | + +Message (UTF-8, `\n` separated, no trailing newline): + +``` +musallahboard-http-v1 + + + + + +``` + +The backend rejects: unknown or revoked device (`401`), timestamp more than 5 +minutes from its clock (`401`), bad signature (`401`). Replays inside the window +only repeat an idempotent read. + +### 9.3 Backend endpoints (additions) + +| Endpoint | Auth | Returns | +|---|---|---| +| `POST /api/agent/content-bundle` | device (9.2) | `200 application/vnd.musallahboard.mbu` signed content package. `503` if no content signing key is configured. | +| `GET /api/agent/weather` | device (9.2) | `{"weather": or null, "fetchedAt": ""}`. Never a 5xx for missing weather: `weather` is `null`. | +| `GET /api/agent/signing-keys` | none | `{"content": [{"keyId", "publicKey"}]}` | +| `POST /api/agent/enroll` | token | unchanged, plus `contentSigningKeys: [{"keyId", "publicKey"}]` | +| `GET /api/admin/board/devices/{id}/offline-bundle?days=14` | `BOARD_DEVICE_READ` | the signed `.mbu` (filename `musallahboard-content--.mbu`), media always included | + +Backend configuration: `musallahboard.content-signing.private-key` (base64 +seed; env `MUSALLAHBOARD_CONTENT_SIGNING_KEY`), and optionally +`musallahboard.content-signing.previous-public-keys` (comma-separated base64) +still published in `signing-keys` during a rotation. + +### 9.4 Online: release channels + +When `auto_update` is on, the daemon checks every 6 hours (and 2 minutes after +start) two channel URLs, each returning: + +```json +{ "version": "2.1.0", "url": "https://…/musallahboard-app-2.1.0.mbu", "sha256": "…", "bytes": 1234567 } +``` + +If `version` is newer than what is installed (and not rejected), it downloads +`url` (size-capped, sha256-checked) and imports it with source `sync`. The +channel file is only a pointer; the package's own signature and the monotonic +rules are what make it safe. Defaults: + +- `app_channel_url = "https://github.com/LensBridge/MusallahBoard/releases/latest/download/app-channel.json"` +- `agent_channel_url = "https://github.com/LensBridge/agent/releases/latest/download/agent-channel-.json"` + +### 9.5 Offline: service-port upload server (laptop and phone) + +When `service_port` is on, the daemon listens on `10.77.0.1:80` (bound with +`IP_FREEBIND`, so it is ready before the address exists; the agent has +`CAP_NET_BIND_SERVICE`). Requests whose `Host` is not `10.77.0.1`, +`10.77.0.1:80` or `musallahboard.local` get `421` (DNS-rebinding guard). + +| Method, path | Behaviour | +|---|---| +| `GET /` | A small self-contained upload page: pick or drop `.mbu` files, see progress and per-package results, see board status and clock drift. | +| `GET /api/status` | `{"deviceId", "agentVersion", "app", "content", "today", "daysRemaining", "staleDays", "clock": {"unix", "timezone"}, "rtc": bool, "update": {"active": bool}}` | +| `POST /api/import` | `multipart/form-data`, one or more `package` parts (each at most 512 MiB, total at most 1 GiB). Optional header `X-MB-Client-Time: `. Each part is streamed to the inbox, then processed as one batch (source `upload`). Response `200 {"results": [...], "clock": {"driftSeconds": n, "adjusted": bool, "note": "…"}}`. Only one import runs at a time; a second gets `409`. | + +Errors are `{"message": "..."}` with a 4xx status. `driftSeconds` is the +board's clock minus the uploader's, before any correction (positive: the board +was ahead). + +No authentication: every package is signed, device-bound where it matters and +monotonic, so the worst an unauthenticated uploader can do is make the board +verify a file and refuse it. Sizes and the single-import rule bound that. +`ufw` allows `80/tcp` only on `eth0` from `10.77.0.0/24`. + +Clients: + +- **Browser**: `http://10.77.0.1/` from any laptop. Nothing to install. +- **`mbpush`** (agent repo, Windows/macOS/Linux): `mbpush [--host 10.77.0.1] ...`, + `mbpush status`, `mbpush fetch [--dir D] [--arch arm64]` (downloads the + latest app and agent packages from the channels ahead of a visit; the content + package comes from LensBridge's "Download offline bundle"). `--ssh user@host` + streams to `sudo musallahboard-agent import -` for boards reachable only by + SSH. +- **Android app**: downloads the content package from LensBridge and the + software packages from the channels while it has signal, then POSTs them over + a USB ethernet adapter bound to that `Network`. + +### 9.6 Offline: USB stick + +Copy any `.mbu` files to the root of a USB stick, or to a `MusallahBoard/` +folder on it, and plug it into the board. + +- udev (`/etc/udev/rules.d/90-musallahboard-usb.rules`) starts + `musallahboard-usb-import@.service` (root, oneshot, + `PrivateNetwork=yes`) for a USB block device with a filesystem. +- `musallahboard-agent usb-import ` checks the name + (`^sd[a-z]+[0-9]*$`), reads the type with `blkid` and allows only `vfat`, + `exfat`, `ext4`, `ntfs3`/`ntfs`; mounts it at `/run/musallahboard/usb/` + with `ro,nosuid,nodev,noexec,noatime`; copies at most 16 regular files (no + symlinks) named `*.mbu`, each at most 512 MiB, into the inbox; unmounts; then + waits up to 15 minutes for the daemon's results and logs them. +- The daemon shows the update screen while it works; the stick can be removed + once the screen says "Update complete". +- `usb_import = false` in `agent.toml` disables it (the helper exits at once). + +### 9.7 Admin CLI + +`sudo musallahboard-agent import ... | -` copies into the inbox (`-` +reads one package from stdin) and waits for and prints the results. + +## 10. Clock + +Correct time decides which day's content is shown and the prayer times. + +- `clockFloor` in `state.json` is the maximum of every verified package's + `createdAt` (also kept as `signedClockFloor`) and the wall clock the daemon + persists every 10 minutes. The persisted wall clock may not raise the floor + more than 400 days past `signedClockFloor`, so a clock once set far into the + future cannot lock the board there. +- At daemon start and every 10 minutes: if the clock is more than 60 s behind + `clockFloor`, set it to `clockFloor` (the agent has `CAP_SYS_TIME`) and write + the RTC when one is present. A signed `createdAt` in the future of the board + is proof its clock is behind. +- An upload's `X-MB-Client-Time` (the uploader's clock when it started + sending; the board measures the offset when the request arrives) is applied only after at least one package in + that request verified, only when it differs by more than 5 s, and only within + `[clockFloor, clockFloor + 90 days]`. The response reports the drift. +- A DS3231 RTC is still recommended (`setup.sh --rtc`), and online boards keep + `systemd-timesyncd`. + +## 11. Configuration (`/etc/musallahboard/agent.toml`) + +Existing keys (`device_id`, `backend_url`, `websocket_url`, `key_path`) are +unchanged. New keys, all optional: + +| Key | Default | Meaning | +|---|---|---| +| `service_port` | `false` | Run the eth0 service port and the upload server. Set by `musallahboard-agent service-port on|off`. | +| `usb_import` | `true` | Accept USB sticks. | +| `content_sync` | `true` | Sync content from the backend. | +| `content_days` | `7` | Days per synced content package (1-31). | +| `auto_update` | `true` | Follow the release channels. | +| `app_channel_url`, `agent_channel_url` | section 9.4 | Channel pointers. Empty disables that channel. | + +An offline board needs no configuration change: sync attempts fail fast with no +route and cost nothing, and the moment it gets a network it starts syncing. + +## 12. Agent self-update + +1. The daemon verifies an agent package, writes it to + `agent/staged/package.mbu` and creates `agent/staged/ready`. +2. `musallahboard-agent-update.path` (`PathExists=` on `ready`) starts + `musallahboard-agent-update.service` (root, oneshot): + `musallahboard-agent selfupdate apply`. +3. As root, the updater: removes `ready`; copies `package.mbu` into a + root-only temporary directory (so the daemon cannot swap it mid-check); + verifies it from scratch against root-trusted keys only; checks arch and + that the version is newer than its own; extracts the binary to + `/usr/bin/.musallahboard-agent.new`; runs `.new version` and requires the + expected version; copies the current binary to + `/usr/lib/musallahboard/agent.prev`; renames the new binary into place; + restarts `musallahboard-agent.service`. +4. Health check: within 120 s, `GET http://127.0.0.1:8080/api/local/status` + must report the new `agentVersion`. Otherwise the updater restores + `agent.prev`, restarts the service, and adds the version to + `agentVersionsRejected`, so a broken release cannot loop. +5. The outcome is written to `agent/last-update.json`. The new daemon, finding + the kiosk on the update screen at startup, completes it and returns to the + board. + +## 13. Service port (eth0) + +Two NetworkManager +profiles on eth0, `musallahboard-lan` (DHCP client, priority 0, +`ipv4.dhcp-timeout 30`) tried first, then `musallahboard-service-port` +(`ipv4.method shared`, `10.77.0.1/24`, priority -999), whose autoconnect is +owned by `musallahboard-agent service-port on|off`. A board plugged into a real +network stays a client there; a laptop or phone plugged straight in gets an +address from the board within about a minute. Turn the service port off before +connecting a board to a network permanently. + +## 14. Kiosk + +- `kiosk-url` is `http://127.0.0.1:8080/` once enrolled (no query string; the + page asks the agent who it is). Before enrollment it is absent and the + existing `waiting.html` splash shows. +- `musallahboard-kiosk.service` is ordered `After=` and `Wants=` + `musallahboard-agent.service`, and the agent reports `READY=1` only after the + local server is listening, so Chromium never starts before the server. +- The daemon still watches for Chromium's error page and navigates back to the + board, as a backstop. + +## 15. Frontend (MusallahBoard) + +The board app only ever runs served by the agent; there is no hosted board. +The backend's live `GET /api/musallah/payload` has been removed: boards read +per-day payloads from installed content, and weather comes through the agent. + +- API calls are same-origin to the agent. The device id comes from + `/api/local/status`; `/api/local/events` drives in-place refreshes and app + reloads. +- No content yet: a clear "waiting for content" screen that says whether the + board is syncing (with the last error) or needs a USB stick / upload. +- Stale content: the "Content last updated ..." note when `staleDays > 0`. +- Diagnostics (Alt+Shift+F) show agent and app versions, content range and + source, days remaining, sync status. +- Development: `npm run dev` proxies `/api` and `/media` to an agent, for + example a real board through `ssh -L 8080:127.0.0.1:8080 `. +- Releases: `npm run package` builds and writes + `musallahboard-app-.mbu` signed with `MB_RELEASE_SIGNING_KEY`, plus + `app-channel.json`. + +## 16. Security summary + +| Threat | Mitigation | +|---|---| +| Malicious content via any transport | Content must be signed by a pinned content key and bound to this device id. | +| Malicious code (SPA or agent) | Only the release key signs software; the backend never holds it. | +| Backend compromise | Limited to content on screens; cannot push code. Content key rotation via `previous-public-keys` and `trust` CLI. | +| Replay of old packages | Monotonic sequence/version in `state.json`. | +| Bad agent release | Health-checked self-update with automatic rollback and a rejected-version list. | +| Daemon compromise escalating to root | Daemon is unprivileged (only `CAP_SYS_TIME`, `CAP_NET_BIND_SERVICE`); the root updater re-verifies from a private copy with root-owned keys. | +| Zip-slip, zip bombs, disk exhaustion | Strict entry names, declared sizes enforced while reading, global caps, one import at a time. | +| Hostile USB stick | Allow-listed filesystems, read-only `nosuid,nodev,noexec` mount, only `*.mbu` regular files copied, no network in the helper, signature checks as for any source. | +| Rogue DHCP on a building network | Service port only after a DHCP client attempt fails; off by default; `service-port off` before connecting. | +| Clock tampering | Clock only moves forward to signed times automatically; uploader time only with a verified package and within 90 days of the floor. | +| DNS rebinding against the upload server | `Host` allow-list. | + +## 17. Out of scope + +- Weather on boards without internet (online boards get it through the weather + overlay in section 7). +- Automatic eth0 switching between service port and LAN. +- Release key rotation other than shipping a new agent with an added key. diff --git a/docs/offline.md b/docs/offline.md deleted file mode 100644 index f1f61bc..0000000 --- a/docs/offline.md +++ /dev/null @@ -1,446 +0,0 @@ -# MusallahBoard Offline Mode - -Plan and interface contract for running a board with no internet connection. -Every component below is built against this document. If code and this file -disagree, fix one of them deliberately — do not let them drift. - -## Why - -Some musallahs cannot give the board's Pi (a Raspberry Pi 4B) an internet -connection. Instead, an admin downloads a content bundle from LensBridge while -online, walks to the board, plugs a laptop into the Pi's ethernet port, and -pushes the bundle over SSH. Prayer times and the Hijri date are computed on the -board, so they stay correct indefinitely; only posters, events, weekly content -and the ticker need refreshing. - -When a board later gets a network connection, one command switches it back to -online mode. Nothing is reinstalled and the device does not re-enroll. - -## Design summary - -| Concern | Decision | -|---|---| -| Transport format | A zip: a manifest, one fully-assembled payload per day, poster images. | -| Window | 14 days by default (1–31 allowed). Visit roughly every 7–10 days. | -| Payload assembly | The backend's existing `BoardPayloadAssembler`, run once per day with the clock set to that day. No frame logic is reimplemented anywhere else. | -| Local server | The existing Go agent grows an `offline` mode that serves the SPA, payloads and media on `127.0.0.1:8080`. There is no separate daemon. | -| Frontend | One build for both modes. Prayer times via `adhan`, Hijri date via `Intl`, fonts bundled — in **both** modes. `?mode=offline` in the kiosk URL only disables the refresh socket and enables the stale-content indicator. No service worker / PWA. | -| Updating | `mbpush` on a laptop, or the Android app, over SSH as the restricted `mbpush` account, whose only command is `musallahboard-agent gate` (see "Push account"). The bundle is streamed on stdin. No upload endpoint exists on the Pi. | -| Laptop ↔ Pi link | In offline mode eth0 falls back to a NetworkManager "shared" connection at `10.77.0.1/24`, which hands the laptop an address by DHCP. It is only tried after a plain DHCP-client profile has failed, so a board plugged into a real network stays a client there. | -| Clock | A DS3231 RTC module per board. Every `mbpush` also corrects the Pi's clock from the laptop's. | -| Identity | Every board enrolls online first, exactly as today. Offline mode keeps the enrolled identity. | - -## The update visit, end to end - -1. **Before the visit (online, anywhere).** Enter the next two weeks of content - in LensBridge. Then Devices → the board → **Download offline bundle**, which - saves `musallahboard--.zip`. -2. **At the board.** Ethernet cable from the laptop to the Pi, then: - ``` - mbpush musallahboard-3f2a1b4c-2026-09-24.zip - ``` - `mbpush`, over `ssh mbpush@10.77.0.1`, sends gate requests: - 1. `clock` reads the Pi's clock; if it is more than 5 s off the laptop's, - `clock-set ` sets it and saves it to the RTC, and the drift is - reported; - 2. `bundle-install` with the zip on stdin validates and installs it; - 3. `status` prints the result. -3. Unplug. The new content is on screen within seconds (the install reloads - Chromium over CDP). - -Failure at any step leaves the previously installed bundle serving. Nothing on -screen changes until a bundle passes every check. - ---- - -## Contract 1 — the bundle zip - -Produced by the backend, consumed by `musallahboard-agent bundle install`. - -``` -manifest.json -payloads/2026-09-24.json -payloads/2026-09-25.json -... -payloads/2026-10-07.json -media/. -... -``` - -No other entries are allowed. Entry names are exactly as above: no leading -`/`, no `..`, no directories other than `payloads/` and `media/`. - -### `manifest.json` - -```json -{ - "formatVersion": 1, - "deviceId": "3f2a1b4c-....", - "timezone": "America/Toronto", - "generatedAt": "2026-09-24T14:02:11Z", - "firstDay": "2026-09-24", - "lastDay": "2026-10-07", - "media": [ - { "path": "media/9b1c...e4.jpg", "sha256": "9b1c...e4", "bytes": 482113, "contentType": "image/jpeg" } - ] -} -``` - -- `formatVersion` — integer. Consumers reject any value they do not support. - Only `1` exists. -- `timezone` — the device's resolved zone (`BoardContext.zone()`), IANA id. - "Today" on the board is always evaluated in this zone. -- `firstDay` / `lastDay` — ISO dates, inclusive. There is exactly one payload - file for every date in this range and no others. -- `media` — every file under `media/`, with its SHA-256 (lowercase hex, which is - also the file's base name), size and content type. - -### `payloads/.json` - -Exactly the JSON that `GET /api/musallah/payload?deviceId=` would return -(`MusallahBoardPayload`) if requested at the **start of that day** in the -device's timezone, with these differences: - -- `weather` is `null` (the frontend already hides the weather chip). -- Posters are those active **at any point during that day** in the device's - timezone (`startTime < dayEnd && endTime > dayStart`), not those active at the - instant of assembly. -- Every poster frame's `frameConfig.posterUrl` is rewritten to - `/media/.` and that file is in the zip. Identical images are - stored once. - -### Backend endpoint - -``` -GET /api/admin/board/devices/{deviceId}/offline-bundle?days=14 -Authority: BOARD_DEVICE_READ -200 application/zip -Content-Disposition: attachment; filename="musallahboard--.zip" -``` - -- `days`: default 14, allowed 1–31, else `400`. -- Unknown device: `404`. Revoked device: `409`. -- `firstDay` is today in the device's timezone. -- If any poster image cannot be fetched, the export fails with `502` naming the - poster. A bundle is never silently missing content. - ---- - -## Contract 2 — the agent in offline mode - -### Config - -`/etc/musallahboard/agent.toml` gains one key: - -```toml -mode = "offline" # or "online"; absent means "online" -``` - -Everything else about enrollment and identity is unchanged. - -### Filesystem - -| Path | Contents | -|---|---| -| `/var/lib/musallahboard/offline/bundles//` | an extracted, validated bundle | -| `/var/lib/musallahboard/offline/current` | symlink to the bundle being served | -| `/usr/share/musallahboard/board/` | the built frontend SPA (`index.html`, `assets/`, …) | - -The install keeps the current and previous bundle, and deletes older ones. - -### Behaviour by mode - -| | `online` | `offline` | -|---|---|---| -| Backend WebSocket, telemetry, remote commands | started, as today | **not started**; the agent makes no network calls | -| Local HTTP server | none | `127.0.0.1:8080` | -| kiosk-url | `?deviceId=`, as today | `http://127.0.0.1:8080/?deviceId=&mode=offline` | -| eth0 | whatever the OS does (`musallahboard-lan`, a DHCP client, if provisioned) | `musallahboard-lan` first; `musallahboard-service-port` when that fails (see `mode` below) | - -Watchdog, safe mode and sd_notify are shared by both modes. - -### Local HTTP server (offline mode only) - -Bound to `127.0.0.1:8080` only. Read-only. - -| Method | Path | Behaviour | -|---|---|---| -| GET | `/api/musallah/payload?deviceId=` | Today's payload file, verbatim. `404` if `deviceId` does not match the bundle's. `503` `{"message":"no bundle installed"}` if there is none. `Cache-Control: no-store`. | -| GET | `/api/local/status` | Status JSON, below. `Cache-Control: no-store`. | -| GET | `/media/` | From the current bundle. `Cache-Control: public, max-age=31536000, immutable`. | -| GET | `/api/*` (anything else) | `404` JSON. Never the SPA fallback: the page parses these as JSON. | -| GET | anything else | Static file from the SPA directory; unknown paths fall back to `index.html`. `index.html` is `no-cache`; `/assets/*` is immutable. A missing `/assets/*` file is a `404`, not `index.html` (it is a stale hashed URL). `503` if no SPA is installed. | - -**Picking today's payload.** `today` = the current date in the manifest's -`timezone`. If `today < firstDay`, serve `firstDay`. If `today > lastDay`, serve -`lastDay`. The current bundle is re-read (symlink resolved) at least once a -minute or on every request, so a newly installed bundle is picked up without a -restart. - -**`/api/local/status`:** - -```json -{ - "mode": "offline", - "deviceId": "3f2a1b4c-....", - "bundle": { - "firstDay": "2026-09-24", - "lastDay": "2026-10-07", - "generatedAt": "2026-09-24T14:02:11Z", - "timezone": "America/Toronto" - }, - "today": "2026-09-30", - "servingDay": "2026-09-30", - "daysRemaining": 7, - "staleDays": 0 -} -``` - -`bundle` is `null` when nothing is installed; `servingDay`, `daysRemaining` and -`staleDays` are then `null` too, and `today` is in the system timezone. -`daysRemaining` = `max(0, lastDay − today)` (0 on the last day). -`staleDays` = `max(0, today − lastDay)`. If a bundle is installed but cannot be -read, `bundle` is `null` and an extra `error` string says why. - -### CLI subcommands - -All of these print plain English for a human at a terminal. Any that change -the system require root and say so if run without it. - -**`musallahboard-agent bundle install `** - -1. Reject if the zip is over 200 MB. -2. Validate, without writing anything outside a staging directory: - - every entry name is allowed (see Contract 1), so zip-slip is impossible; - - `manifest.json` parses, `formatVersion == 1`; - - `deviceId` equals this device's enrolled id; - - `timezone` loads; - - a payload exists for every date `firstDay..lastDay` and nothing else, and - each parses as a JSON object; - - every manifest media entry exists, matches its size and SHA-256, and every - `/media/...` `posterUrl` in every payload refers to one of them; - - no non-empty `posterUrl` points anywhere other than `/media/` (Contract 1 - has the exporter rewrite them all; one that still points at the internet - would be a blank tile on the board); - - the range is at most 31 days, and the media at most 512 MB uncompressed. -3. Extract to `bundles/.staging-`, fsync, rename to - `bundles/`. -4. Atomically replace `current` (create `current.tmp` symlink, rename over). -5. Prune to current + previous. -6. If the mode is offline and Chromium's CDP answers on `127.0.0.1:9222`, - reload the page. Failure to reload is a warning, not an error: the board - polls every 10 minutes anyway. -7. Print the resulting status. - -Installing while in online mode is allowed (pre-staging before a switch) and -says so. - -**`musallahboard-agent status [--json]`** — mode, device, bundle range, days -remaining / stale days, the system clock with its timezone, whether an RTC is -present (`/dev/rtc0`). `--json` prints the same object as `/api/local/status` -plus `clock` and `rtc`. - -**`musallahboard-agent mode online|offline [--force]`** - -- Writes `mode` to `agent.toml` (atomically, like `config.Save`). -- `offline`: sets `musallahboard-service-port` to autoconnect (at priority - −999, the lowest NetworkManager has), gives `musallahboard-lan` a single - autoconnect retry, and brings the service port up — **unless eth0 is in use**: - another NetworkManager connection is active on it, or the IPv4 default route - goes through it. Then it still writes the mode and enables autoconnect, but - does not start the service port (that would kill the board's connection and - run a DHCP server on that network), says so, and exits 0. `--force` skips - the check. Warns if no bundle is installed. -- `online`: disables the service port's autoconnect, restores - `musallahboard-lan`'s default retries, and brings the service port down. - Switch to online **before** plugging the board into a real network: in - offline mode the protection below applies, but it is not absolute. -- Boot-time protection. eth0 has two profiles, tried in priority order: - `musallahboard-lan` (DHCP client, priority 0, `ipv4.dhcp-timeout 30`), then - `musallahboard-service-port` (shared, priority −999). On a real network the - first gets a lease and the board is only a client there. With a laptop - plugged in (no DHCP server), it fails after 30 s and, with one retry, - NetworkManager falls back to the service port — so allow up to a minute - after plugging in. The remaining exposure is a network whose DHCP server - does not answer within 30 s; then the board would start serving DHCP there. - If the board has its own saved wired profile instead of `musallahboard-lan`, - that profile is left alone and tried first, with NetworkManager's default - retries (so fallback to the service port can take a few minutes). -- Restarts `musallahboard-agent.service`, which rewrites kiosk-url; the - existing `.path` watcher bounces the kiosk. -- If run over the ethernet SSH session, switching to online drops the session. - Print a warning before doing it. - -**`musallahboard-agent app install `** — installs a new SPA -build into `/usr/share/musallahboard/board/` with the same validate / stage / -atomic-swap approach (the directory must contain `index.html`), then reloads -Chromium. - -### Provisioning — `setup.sh --offline` - -Run **while the Pi still has internet**, after a normal online setup and -enrollment. It: - -- installs `dnsmasq-base` (used by NetworkManager's shared mode) and - `util-linux-extra` (`hwclock`); -- creates `/var/lib/musallahboard/offline` (`root:musallahdaemon`, 0750: root - installs bundles, the agent reads them); -- creates the NetworkManager connection `musallahboard-lan` (eth0, DHCP client, - priority 0, `ipv4.dhcp-timeout 30`), unless the board already has a saved - wired profile of its own; -- creates the NetworkManager connection `musallahboard-service-port`: eth0, - `ipv4.method shared`, `ipv4.addresses 10.77.0.1/24`, IPv6 disabled, - priority −999, autoconnect off (the `mode` command owns autoconnect); -- allows DHCP (67/udp) and DNS (53) in on eth0 in ufw, alongside the existing - SSH rule; -- with `--rtc`: adds `dtoverlay=i2c-rtc,ds3231` to `/boot/firmware/config.txt`, - removes `fake-hwclock`, and installs a udev rule + `musallahboard-rtc.service` - that sets the system clock from `/dev/rtc0` when it appears at boot (the - ds3231 driver is a module, so the kernel's own boot-time RTC read has - already happened) — only once an RTC is fitted; -- installs the SPA from `--board-dist=` into - `/usr/share/musallahboard/board/`; -- sets up the `mbpush` push account (next section), allowing each - `--push-key="ssh-ed25519 AAAA... name"` given (repeatable); -- runs `musallahboard-agent mode offline`. If the board is on the network - through eth0 at the time (the usual case when converting a board), that - leaves the service port stopped and says so; it starts by itself when a - laptop is plugged in later. - -It is idempotent: re-running it is safe, and is how you add push keys later. -Packages are only fetched if missing, so a re-run works on a board that has no -internet any more. - -### Push account - -`mbpush` and the Android app log in as `mbpush`, not the admin. A leaked push -key can install a bundle (which must still pass every check, including the -device id), read status and set the clock within 2026–2100. It cannot open a -shell, read files, forward ports or run anything else as root. The admin -account is unchanged and stays the way to administer the board. - -What `setup.sh --offline` sets up: - -| Piece | Content | -|---|---| -| user | `mbpush`, system account, shell `/bin/sh` (sshd runs the forced command through it), password `*` (no password matches; not "locked", which would make sshd refuse its keys) | -| `/home/mbpush/.ssh/authorized_keys` | owned by root, one line per `--push-key`: `restrict,command="sudo -n /usr/bin/musallahboard-agent gate" ` | -| `/etc/sudoers.d/musallahboard-push` | `Defaults:mbpush env_keep += "SSH_ORIGINAL_COMMAND"` and `mbpush ALL=(root) NOPASSWD: /usr/bin/musallahboard-agent gate` | -| `/etc/ssh/sshd_config.d/99-musallahboard-push.conf` | `Match User mbpush`: `ForceCommand` the same gate command, no TTY, no forwarding of any kind, no password auth | -| `kiosk-hardening.conf` | `mbpush` appended to the existing `AllowUsers` line | - -The forced command is set twice, per key and per user, so a key added to -`authorized_keys` by hand without the option is still confined. Before sshd is -reloaded, setup compares the admin account's effective sshd settings -(`sshd -T -C user=,...`) before and after the change, and checks that -`mbpush` gets the ForceCommand. Any difference, or a config `sshd -t` rejects, -restores the previous files and stops. A mistake here cannot lock the admin -out. - -To revoke a key, delete its line from `/home/mbpush/.ssh/authorized_keys`. - -**The gate** (`musallahboard-agent gate`, `cmd/agent/gate.go`) reads the request -from its arguments or, when run as the forced command, from -`SSH_ORIGINAL_COMMAND`. It splits it on whitespace, strips an optional -`sudo -n musallahboard-agent gate` prefix, and matches token for token. Nothing -reaches a shell. - -| Request | Does | -|---|---| -| `status`, `status --json` | as `musallahboard-agent status` | -| `clock` | prints the board's clock as Unix seconds | -| `clock-set ` | plain decimal only, 2026–2100; sets the clock and saves it to the RTC (prints a `Warning:` line if there is none) | -| `bundle-install` | the zip on stdin (≤ 200 MB) → a root-only temp file → the normal `bundle install`, then the temp file is removed | -| `app-install` | a `.tar.gz` of the build on stdin (≤ 200 MB) → the normal `app install` | - -Anything else is refused with exit status 126. Clients always send -`sudo -n musallahboard-agent gate `: for `mbpush` the forced command -takes over and reads it back, for the admin it simply runs, so one client -works against either account. - -### Converting an existing online board - -1. Fit the DS3231 RTC (power off first). -2. Update the agent binary to one with offline mode: - `make deploy PI=@` (which runs `packaging/update.sh`). -3. While the board is still online, on the board: - `bash setup.sh --offline --rtc --board-dist= --push-key=""` - (copy the frontend build over first; `setup.sh` is in the release tarball). - Because eth0 is on the network, the service port is left stopped. -4. Install the first bundle over the current network: - `mbpush --host musallahboard--.zip`. - This also sets the clock and saves it to the RTC. -5. Reboot. On the same network the board comes back as a DHCP client - (`musallahboard-lan`), still offline mode. -6. Verify: `sudo musallahboard-agent status` shows mode offline, the bundle and - `RTC: present`; `ls /dev/rtc0` exists. -7. Move the board to the musallah. At the next visit, plug the laptop into its - ethernet port and use `mbpush` with the default host. - -### `mbpush` (on the admin's laptop) - -A Go binary in the agent repo (`cmd/mbpush`), cross-compiled for Windows, -macOS and Linux. It shells out to the system `ssh` so the user's existing keys -and agent just work (Windows 10+ ships it), and speaks only gate requests, with -files streamed on stdin. The Android app (`MusallahBoard/mbpush-android`) sends -the same requests. - -``` -mbpush # clock sync, send + install, status -mbpush --app # update the SPA -mbpush status # clock check + status, changes nothing -Flags: --host (default 10.77.0.1) --user (default mbpush; an admin account - works too) -i --check-host-key -``` - -Every board answers on 10.77.0.1 with its own host key, so on that address -`mbpush` does not record or check host keys (otherwise the second board visited -fails with "REMOTE HOST IDENTIFICATION HAS CHANGED"). The link is a cable from -the laptop into the board. Any other `--host`, or `--check-host-key`, keeps -ssh's normal checking. Requests use `sudo -n`, so an account without the -sudo rule fails immediately rather than hanging on a password prompt. - -Clock sync: compare the gate's `clock` with the laptop's; if more than 5 s -apart, `clock-set ` (no RTC fitted is a warning, not a failure). -Always print the measured drift. - -On any failure it says what failed in plain English, and, when the install -step was reached and failed, that the board is still showing its previous -content. - ---- - -## Contract 3 — the frontend - -One build serves both modes. - -**Both modes:** - -- Prayer times from the `adhan` package, computed for the board's date in the - board's timezone, replacing the Aladhan API. Method parameters must match - Aladhan's definitions for each `CalculationMethod` value the backend can send - (`ALADHAN_METHOD` keys), including the Hanafi Asr alongside the standard one. - Verified against Aladhan for a real location over at least one month. -- Hijri date from `Intl.DateTimeFormat` with the `islamic-umalqura` calendar, - for the board's date in the board's timezone, mapped to the existing - `{ day, monthEn, monthAr, year }` shape. -- Fonts (Cinzel, Montserrat incl. italic, Amiri) bundled via `@fontsource` - packages. No request to Google Fonts. -- After this, the only network requests are to the payload API origin. - -**When the page URL has `mode=offline`:** - -- No refresh WebSocket. -- Payload polled every 10 minutes from same-origin `/api/musallah/payload`, as - today, plus `/api/local/status`. -- If `staleDays > 0`, a small, unobtrusive "Content last updated " - indicator. The diagnostics panel (Alt+Shift+F) shows mode, bundle range and - days remaining. - -## Out of scope for v1 - -- Updating the agent binary through `mbpush` (use the existing update path - over SSH by hand). -- Any automatic online/offline fallback. Mode is set explicitly. -- Weather offline. -- A web upload page on the Pi. diff --git a/internal/api/client.gen.go b/internal/api/client.gen.go index 5b5a438..4e312d4 100644 --- a/internal/api/client.gen.go +++ b/internal/api/client.gen.go @@ -27,8 +27,10 @@ type AgentEnrollRequest struct { // AgentEnrollResponse defines model for AgentEnrollResponse. type AgentEnrollResponse struct { - DeviceId *openapi_types.UUID `json:"deviceId,omitempty"` - WebsocketUrl *string `json:"websocketUrl,omitempty"` + // ContentSigningKeys Public content signing keys the agent pins at enrollment. Always present; empty when the server has no content key configured. + ContentSigningKeys []SigningKeyView `json:"contentSigningKeys"` + DeviceId *openapi_types.UUID `json:"deviceId,omitempty"` + WebsocketUrl *string `json:"websocketUrl,omitempty"` } // MessageResponse defines model for MessageResponse. @@ -36,6 +38,12 @@ type MessageResponse struct { Message *string `json:"message,omitempty"` } +// SigningKeyView defines model for SigningKeyView. +type SigningKeyView struct { + KeyId string `json:"keyId"` + PublicKey string `json:"publicKey"` +} + // EnrollAgentJSONRequestBody defines body for EnrollAgent for application/json ContentType. type EnrollAgentJSONRequestBody = AgentEnrollRequest @@ -115,7 +123,7 @@ type ClientInterface interface { // EnrollAgentWithBody Exchange a one-time enrollment token for a device identity // - // Called once per device by the MusallahBoard agent. The returned websocketUrl is persisted verbatim into the agent's config and never requested again. + // Called once per device by the MusallahBoard agent. The returned websocketUrl is persisted verbatim into the agent's config and never requested again. contentSigningKeys are the public content keys the agent pins in its trust store. // // Takes any type of body and a specified content type. // @@ -124,7 +132,7 @@ type ClientInterface interface { // EnrollAgent Exchange a one-time enrollment token for a device identity // - // Called once per device by the MusallahBoard agent. The returned websocketUrl is persisted verbatim into the agent's config and never requested again. + // Called once per device by the MusallahBoard agent. The returned websocketUrl is persisted verbatim into the agent's config and never requested again. contentSigningKeys are the public content keys the agent pins in its trust store. // // Takes a body of the `application/json` content type. // @@ -134,7 +142,7 @@ type ClientInterface interface { // EnrollAgentWithBody Exchange a one-time enrollment token for a device identity // -// Called once per device by the MusallahBoard agent. The returned websocketUrl is persisted verbatim into the agent's config and never requested again. +// Called once per device by the MusallahBoard agent. The returned websocketUrl is persisted verbatim into the agent's config and never requested again. contentSigningKeys are the public content keys the agent pins in its trust store. // // Takes any type of body and a specified content type. // @@ -153,7 +161,7 @@ func (c *Client) EnrollAgentWithBody(ctx context.Context, contentType string, bo // EnrollAgent Exchange a one-time enrollment token for a device identity // -// Called once per device by the MusallahBoard agent. The returned websocketUrl is persisted verbatim into the agent's config and never requested again. +// Called once per device by the MusallahBoard agent. The returned websocketUrl is persisted verbatim into the agent's config and never requested again. contentSigningKeys are the public content keys the agent pins in its trust store. // // Takes a body of the `application/json` content type. // @@ -256,7 +264,7 @@ type ClientWithResponsesInterface interface { // EnrollAgentWithBodyWithResponse Exchange a one-time enrollment token for a device identity // - // Called once per device by the MusallahBoard agent. The returned websocketUrl is persisted verbatim into the agent's config and never requested again. + // Called once per device by the MusallahBoard agent. The returned websocketUrl is persisted verbatim into the agent's config and never requested again. contentSigningKeys are the public content keys the agent pins in its trust store. // // Takes any type of body and a specified content type, and returns a wrapper object for the known response body format(s). // @@ -265,7 +273,7 @@ type ClientWithResponsesInterface interface { // EnrollAgentWithResponse Exchange a one-time enrollment token for a device identity // - // Called once per device by the MusallahBoard agent. The returned websocketUrl is persisted verbatim into the agent's config and never requested again. + // Called once per device by the MusallahBoard agent. The returned websocketUrl is persisted verbatim into the agent's config and never requested again. contentSigningKeys are the public content keys the agent pins in its trust store. // // Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). // @@ -337,7 +345,7 @@ func (r EnrollAgentResponse) ContentType() string { // EnrollAgentWithBodyWithResponse Exchange a one-time enrollment token for a device identity // -// Called once per device by the MusallahBoard agent. The returned websocketUrl is persisted verbatim into the agent's config and never requested again. +// Called once per device by the MusallahBoard agent. The returned websocketUrl is persisted verbatim into the agent's config and never requested again. contentSigningKeys are the public content keys the agent pins in its trust store. // // Takes any type of body and a specified content type, and returns a wrapper object for the known response body format(s). // @@ -352,7 +360,7 @@ func (c *ClientWithResponses) EnrollAgentWithBodyWithResponse(ctx context.Contex // EnrollAgentWithResponse Exchange a one-time enrollment token for a device identity // -// Called once per device by the MusallahBoard agent. The returned websocketUrl is persisted verbatim into the agent's config and never requested again. +// Called once per device by the MusallahBoard agent. The returned websocketUrl is persisted verbatim into the agent's config and never requested again. contentSigningKeys are the public content keys the agent pins in its trust store. // // Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). // diff --git a/internal/boardsync/boardsync.go b/internal/boardsync/boardsync.go new file mode 100644 index 0000000..fad35a0 --- /dev/null +++ b/internal/boardsync/boardsync.go @@ -0,0 +1,486 @@ +// Package boardsync keeps an online board's local state fresh from the +// network (docs/architecture.md, sections 7 and 9): content from the +// LensBridge backend, the live weather overlay, and new board app and agent +// releases from the release channels. +// +// Nothing here is trusted. Everything that changes the board arrives as a +// signed package and goes through the same importer as a USB stick or an +// upload; the network only decides how fresh the board is. A board with no +// network simply fails these fetches quietly and keeps serving what it has. +package boardsync + +import ( + "context" + "crypto/ed25519" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/LensBridge/agent/internal/config" + "github.com/LensBridge/agent/internal/importer" + "github.com/LensBridge/agent/internal/store" +) + +// Timings from the contract. They are fields on the Syncer (set from these in +// New) so tests can shrink them. +const ( + ContentInterval = 30 * time.Minute + TriggerDelay = 10 * time.Second + ContentMinBackoff = time.Minute + ContentMaxBackoff = 30 * time.Minute + WeatherInterval = 30 * time.Minute + WeatherMaxAge = 3 * time.Hour + ChannelFirstDelay = 2 * time.Minute + ChannelInterval = 6 * time.Hour + RefreshMinBackoff = time.Second + RefreshMaxBackoff = 5 * time.Minute + + // MaxHaveMedia caps the haveMedia list. A store bigger than this only + // costs a larger download; the backend includes whatever is not listed. + MaxHaveMedia = 2000 + // maxErrorBody bounds how much of an error response is read for its + // message. + maxErrorBody = 4 << 10 + // maxChannelBytes bounds a channel pointer file. + maxChannelBytes = 1 << 20 + // maxWeatherResponse bounds GET /api/agent/weather's answer. + maxWeatherResponse = 1 << 20 +) + +// Deps is what a Syncer needs. +type Deps struct { + Cfg *config.Config + Key ed25519.PrivateKey + Layout store.Layout + Importer *importer.Importer + AgentVersion string + Logger *slog.Logger + // HTTP is optional; New builds one with sane timeouts. + HTTP *http.Client +} + +// Status is the "sync" object of /api/local/status. +type Status struct { + Enabled bool `json:"enabled"` + LastAttemptAt *time.Time `json:"lastAttemptAt"` + LastSuccessAt *time.Time `json:"lastSuccessAt"` + LastError *string `json:"lastError"` +} + +// Syncer runs the background sync loops. +type Syncer struct { + d Deps + log *slog.Logger + client *http.Client + trigger chan struct{} + now func() time.Time + + contentInterval, triggerDelay, minBackoff, maxBackoff time.Duration + weatherInterval, channelFirstDelay, channelInterval time.Duration + refreshMinBackoff, refreshMaxBackoff time.Duration + + mu sync.Mutex + status Status + weather json.RawMessage + weatherAt time.Time +} + +// New returns a Syncer. Call Run to start it. +func New(d Deps) *Syncer { + log := d.Logger + if log == nil { + log = slog.New(slog.DiscardHandler) + } + client := d.HTTP + if client == nil { + client = NewHTTPClient() + } + return &Syncer{ + d: d, + log: log.With("component", "sync"), + client: client, + trigger: make(chan struct{}, 1), + now: time.Now, + contentInterval: ContentInterval, + triggerDelay: TriggerDelay, + minBackoff: ContentMinBackoff, + maxBackoff: ContentMaxBackoff, + weatherInterval: WeatherInterval, + channelFirstDelay: ChannelFirstDelay, + channelInterval: ChannelInterval, + refreshMinBackoff: RefreshMinBackoff, + refreshMaxBackoff: RefreshMaxBackoff, + } +} + +// NewHTTPClient is the client every sync request uses: a bounded connect so +// a board with no route gives up fast, and an overall limit long enough for +// a large package on a slow link. +func NewHTTPClient() *http.Client { + return &http.Client{ + Timeout: 10 * time.Minute, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext, + TLSHandshakeTimeout: 15 * time.Second, + ResponseHeaderTimeout: 2 * time.Minute, + IdleConnTimeout: 90 * time.Second, + MaxIdleConns: 4, + ForceAttemptHTTP2: true, + }, + } +} + +// Run runs every enabled loop until ctx ends. +func (s *Syncer) Run(ctx context.Context) { + s.cleanStaleDownloads() + var wg sync.WaitGroup + run := func(f func(context.Context)) { + wg.Add(1) + go func() { defer wg.Done(); f(ctx) }() + } + if s.d.Cfg.ContentSync() { + run(s.contentLoop) + run(s.refreshLoop) + run(s.weatherLoop) + } else { + s.log.Info("content sync is off (content_sync = false); the board only takes content from USB, uploads and the CLI") + } + if s.d.Cfg.AutoUpdate() { + run(s.channelLoop) + } else { + s.log.Info("release channels are off (auto_update = false)") + } + wg.Wait() +} + +// Trigger asks for a content sync in TriggerDelay. Calls within that window +// fold into one sync: the backend's refresh channel fires once per admin +// edit, and an admin editing five posters should cost one download. +func (s *Syncer) Trigger() { + select { + case s.trigger <- struct{}{}: + default: + } +} + +// Status reports the content sync state. +func (s *Syncer) Status() Status { + s.mu.Lock() + defer s.mu.Unlock() + st := s.status + st.Enabled = s.d.Cfg.ContentSync() + return st +} + +// Weather returns the latest weather object from the backend, if it was +// fetched less than WeatherMaxAge ago. +func (s *Syncer) Weather() (json.RawMessage, bool) { + s.mu.Lock() + defer s.mu.Unlock() + if s.weather == nil || s.now().Sub(s.weatherAt) >= WeatherMaxAge { + return nil, false + } + return s.weather, true +} + +// ── content ────────────────────────────────────────────────────────────────── + +func (s *Syncer) contentLoop(ctx context.Context) { + timer := time.NewTimer(0) // sync at start + defer timer.Stop() + deadline := s.now() + backoff := s.minBackoff + for { + select { + case <-ctx.Done(): + return + case <-s.trigger: + // Bring the next sync forward to at most triggerDelay away, but + // never push an already-closer one back: a steady stream of + // refresh messages must not postpone the sync forever. + if until := deadline.Sub(s.now()); until > s.triggerDelay { + deadline = s.now().Add(s.triggerDelay) + resetTimer(timer, s.triggerDelay) + } + continue + case <-timer.C: + } + + wait := s.contentInterval + if err := s.SyncContent(ctx); err != nil { + if ctx.Err() != nil { + return + } + wait = backoff + backoff = min(backoff*2, s.maxBackoff) + } else { + backoff = s.minBackoff + } + deadline = s.now().Add(wait) + resetTimer(timer, wait) + } +} + +func resetTimer(t *time.Timer, d time.Duration) { + if !t.Stop() { + select { + case <-t.C: + default: + } + } + t.Reset(d) +} + +// SyncContent fetches a content package from the backend and imports it, +// recording the outcome in Status. It is exported for the daemon's tests and +// for a future "sync now" command; the loop calls it on schedule. +func (s *Syncer) SyncContent(ctx context.Context) error { + start := s.now() + s.mu.Lock() + s.status.LastAttemptAt = &start + s.mu.Unlock() + + err := s.syncContent(ctx) + + s.mu.Lock() + defer s.mu.Unlock() + if err != nil { + msg := err.Error() + s.status.LastError = &msg + s.log.Warn("content sync failed", "err", msg) + return err + } + done := s.now() + s.status.LastSuccessAt = &done + s.status.LastError = nil + return nil +} + +// syncError is a failure with a short message for the status page and a +// longer one for the journal. +type syncError struct { + short string + detail error +} + +func (e *syncError) Error() string { return e.short } +func (e *syncError) Unwrap() error { return e.detail } + +func (s *Syncer) syncContent(ctx context.Context) error { + have := s.d.Layout.MediaHashes() + if len(have) > MaxHaveMedia { + have = have[:MaxHaveMedia] + } + if have == nil { + have = []string{} + } + body, err := json.Marshal(struct { + Days int `json:"days"` + HaveMedia []string `json:"haveMedia"` + }{s.d.Cfg.ContentDays(), have}) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.backend("/api/agent/content-bundle"), strings.NewReader(string(body))) + if err != nil { + return fmt.Errorf("bad backend_url in the config: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/vnd.musallahboard.mbu") + signRequest(req, body, s.d.Cfg.DeviceID, s.d.Key, s.now()) + resp, err := s.do(req) + if err != nil { + s.log.Debug("content sync request failed", "err", err) + return &syncError{short: "backend unreachable", detail: err} + } + defer resp.Body.Close() + if err := responseError(resp); err != nil { + return err + } + + dir, err := s.downloadDir() + if err != nil { + return fmt.Errorf("cannot prepare the download: %w", err) + } + defer os.RemoveAll(dir) + file := filepath.Join(dir, "content.mbu") + if _, err := saveCapped(file, resp.Body, maxPackage, nil); err != nil { + if errors.Is(err, errTooLarge) { + return err + } + return &syncError{short: "download from the backend was interrupted", detail: err} + } + + b := s.d.Importer.Import(ctx, importer.SourceSync, []string{file}) + return batchError(b) +} + +// batchError turns a sync batch's results into a status error, or nil when +// the package installed or was already installed. +func batchError(b importer.Batch) error { + for _, r := range b.Results { + switch r.Action { + case importer.ActionRejected: + return fmt.Errorf("the package from the backend was refused: %s", r.Message) + case importer.ActionSkipped: + return fmt.Errorf("the backend sent a package for another board: %s", r.Message) + } + } + if len(b.Results) == 0 { + return fmt.Errorf("the backend's package produced no result") + } + return nil +} + +// responseError maps a non-200 backend response to a short message. 401 gets +// its own wording because the two usual causes need a person: the device was +// revoked, or its clock is so far off the signature timestamp is refused. +func responseError(resp *http.Response) error { + if resp.StatusCode == http.StatusOK { + return nil + } + msg := errorMessage(resp) + if resp.StatusCode == http.StatusUnauthorized { + s := "backend refused this board (401): the device may have been revoked, or the board's clock is wrong" + if msg != "" { + s += " (" + msg + ")" + } + return errors.New(s) + } + if msg == "" { + return fmt.Errorf("backend returned %d", resp.StatusCode) + } + return fmt.Errorf("backend returned %d: %s", resp.StatusCode, msg) +} + +// errorMessage reads {"message": "..."} from an error body, or a short plain +// text body, and "" otherwise. +func errorMessage(resp *http.Response) string { + raw, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody)) + var m struct { + Message string `json:"message"` + } + if json.Unmarshal(raw, &m) == nil && m.Message != "" { + return oneLine(m.Message, 200) + } + if ct := resp.Header.Get("Content-Type"); strings.HasPrefix(ct, "text/plain") { + return oneLine(string(raw), 200) + } + return "" +} + +func oneLine(s string, max int) string { + s = strings.Join(strings.Fields(s), " ") + if len(s) > max { + s = s[:max] + "..." + } + return s +} + +// backend joins path onto the configured backend URL. +func (s *Syncer) backend(path string) string { + return strings.TrimRight(s.d.Cfg.BackendURL, "/") + path +} + +func (s *Syncer) do(req *http.Request) (*http.Response, error) { + req.Header.Set("User-Agent", "musallahboard-agent/"+s.d.AgentVersion) + return s.client.Do(req) +} + +// downloadDir makes a fresh hidden directory in the inbox for one download. +// Hidden entries are ignored by the inbox runner, so a half-written package +// is never picked up; it is on the same filesystem as the inbox, and it is +// removed after the import. +func (s *Syncer) downloadDir() (string, error) { + inbox := s.d.Layout.Inbox() + if err := os.MkdirAll(inbox, 0o770); err != nil { + return "", err + } + return os.MkdirTemp(inbox, ".sync-") +} + +// cleanStaleDownloads removes .sync-* directories a previous run left behind +// when it was killed mid-download. +func (s *Syncer) cleanStaleDownloads() { + matches, _ := filepath.Glob(filepath.Join(s.d.Layout.Inbox(), ".sync-*")) + for _, m := range matches { + os.RemoveAll(m) + } +} + +// ── weather ────────────────────────────────────────────────────────────────── + +func (s *Syncer) weatherLoop(ctx context.Context) { + for { + if err := s.FetchWeather(ctx); err != nil && ctx.Err() == nil { + s.log.Debug("weather fetch failed", "err", err) + } + if !sleep(ctx, s.weatherInterval) { + return + } + } +} + +// FetchWeather asks the backend for the current weather (GET +// /api/agent/weather, device-authenticated) and keeps it. A null weather (the +// backend has none) clears the overlay; a failed fetch keeps the old one until +// it ages out. +func (s *Syncer) FetchWeather(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.backend("/api/agent/weather"), nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + signRequest(req, nil, s.d.Cfg.DeviceID, s.d.Key, s.now()) + resp, err := s.do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if err := responseError(resp); err != nil { + return err + } + var body struct { + Weather json.RawMessage `json:"weather"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, maxWeatherResponse)).Decode(&body); err != nil { + return fmt.Errorf("weather response is not JSON: %w", err) + } + s.mu.Lock() + defer s.mu.Unlock() + if isJSONObject(body.Weather) { + s.weather = append(json.RawMessage(nil), body.Weather...) + s.weatherAt = s.now() + } else { + s.weather = nil + } + return nil +} + +func isJSONObject(raw json.RawMessage) bool { + var m map[string]json.RawMessage + return len(raw) > 0 && json.Unmarshal(raw, &m) == nil && m != nil +} + +// ── shared ─────────────────────────────────────────────────────────────────── + +func sleep(ctx context.Context, d time.Duration) bool { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return false + case <-t.C: + return true + } +} diff --git a/internal/boardsync/boardsync_test.go b/internal/boardsync/boardsync_test.go new file mode 100644 index 0000000..56d36ef --- /dev/null +++ b/internal/boardsync/boardsync_test.go @@ -0,0 +1,524 @@ +package boardsync + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/coder/websocket" + + "github.com/LensBridge/agent/internal/config" + "github.com/LensBridge/agent/internal/devauth" + "github.com/LensBridge/agent/internal/importer" + "github.com/LensBridge/agent/internal/mbu" + "github.com/LensBridge/agent/internal/state" + "github.com/LensBridge/agent/internal/store" + "github.com/LensBridge/agent/internal/trust" +) + +const testDevice = "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b" + +func seedKey(b byte) ed25519.PrivateKey { + return ed25519.NewKeyFromSeed(bytes.Repeat([]byte{b}, 32)) +} + +var ( + contentKey = seedKey(1) + releaseKey = seedKey(2) + deviceKey = seedKey(3) +) + +type env struct { + layout store.Layout + cfg *config.Config + syncer *Syncer +} + +func strp(s string) *string { return &s } +func boolp(b bool) *bool { return &b } + +// newEnv builds a Syncer over a temp store with a real importer that trusts +// contentKey and releaseKey. Channels are disabled unless a test sets them. +func newEnv(t *testing.T, backend string) *env { + t.Helper() + l := store.Layout{Root: t.TempDir()} + cfg := &config.Config{ + DeviceID: testDevice, + BackendURL: backend, + WebSocketURL: "ws://unused/api/agent/ws", + AppChannelSet: strp(""), + AgentChannelSet: strp(""), + } + ring := trust.NewRing( + []ed25519.PublicKey{contentKey.Public().(ed25519.PublicKey)}, + []ed25519.PublicKey{releaseKey.Public().(ed25519.PublicKey)}, + ) + im := importer.New(importer.Deps{ + Layout: l, + DeviceID: testDevice, + AgentVersion: "0.3.0", + Ring: func() (*trust.Ring, error) { return ring, nil }, + }) + s := New(Deps{Cfg: cfg, Key: deviceKey, Layout: l, Importer: im, AgentVersion: "0.3.0"}) + return &env{layout: l, cfg: cfg, syncer: s} +} + +func build(t *testing.T, m mbu.Manifest, src []mbu.Source, key ed25519.PrivateKey) []byte { + t.Helper() + var buf bytes.Buffer + if err := mbu.Build(&buf, m, src, []ed25519.PrivateKey{key}); err != nil { + t.Fatalf("Build: %v", err) + } + return buf.Bytes() +} + +func contentPackage(t *testing.T, seq int64) []byte { + m := mbu.Manifest{ + Type: mbu.TypeContent, Sequence: seq, DeviceID: testDevice, + Content: &mbu.ContentInfo{Timezone: "America/Toronto", FirstDay: "2026-09-24", LastDay: "2026-09-25"}, + } + src := []mbu.Source{ + {Path: "payloads/2026-09-24.json", Data: []byte(`{"frames":[],"weather":null}`)}, + {Path: "payloads/2026-09-25.json", Data: []byte(`{"frames":[],"weather":null}`)}, + } + return build(t, m, src, contentKey) +} + +func appPackage(t *testing.T, version string) []byte { + m := mbu.Manifest{Type: mbu.TypeApp, Version: version, App: &mbu.AppInfo{LocalAPI: 2}} + return build(t, m, []mbu.Source{{Path: "index.html", Data: []byte("" + version)}}, releaseKey) +} + +func agentPackage(t *testing.T, version string) []byte { + m := mbu.Manifest{Type: mbu.TypeAgent, Version: version, + Agent: &mbu.AgentInfo{Arch: runtime.GOARCH, Binary: "musallahboard-agent"}} + return build(t, m, []mbu.Source{{Path: "musallahboard-agent", Data: []byte("#!/bin/sh\necho " + version)}}, releaseKey) +} + +func TestSyncContentSignedAndImported(t *testing.T) { + pkg := contentPackage(t, 1000) + var gotBody struct { + Days int `json:"days"` + HaveMedia []string `json:"haveMedia"` + } + haveSHA := strings.Repeat("ab", 32) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/agent/content-bundle" { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &gotBody); err != nil { + t.Errorf("body: %v", err) + } + if r.Header.Get(devauth.HeaderDeviceID) != testDevice { + t.Errorf("device id header = %q", r.Header.Get(devauth.HeaderDeviceID)) + } + ts, err := strconv.ParseInt(r.Header.Get(devauth.HeaderTimestamp), 10, 64) + if err != nil || time.Since(time.UnixMilli(ts)).Abs() > time.Minute { + t.Errorf("timestamp header = %q", r.Header.Get(devauth.HeaderTimestamp)) + } + sig, _ := base64.StdEncoding.DecodeString(r.Header.Get(devauth.HeaderSignature)) + msg := devauth.Message(r.Method, r.URL.Path, testDevice, ts, body) + if !ed25519.Verify(deviceKey.Public().(ed25519.PublicKey), msg, sig) { + t.Error("signature does not verify over the received body") + } + if !strings.HasSuffix(string(msg), hexSHA(body)) { + t.Error("message does not end in the body hash") + } + w.Header().Set("Content-Type", "application/vnd.musallahboard.mbu") + w.Write(pkg) + })) + defer srv.Close() + + e := newEnv(t, srv.URL+"/") + // A media file already in the store must be offered as haveMedia. + os.MkdirAll(e.layout.MediaDir(), 0o750) + os.WriteFile(filepath.Join(e.layout.MediaDir(), haveSHA+".png"), []byte("x"), 0o640) + + if err := e.syncer.SyncContent(context.Background()); err != nil { + t.Fatalf("SyncContent: %v", err) + } + if gotBody.Days != 7 { + t.Errorf("days = %d, want 7", gotBody.Days) + } + if len(gotBody.HaveMedia) != 1 || gotBody.HaveMedia[0] != haveSHA { + t.Errorf("haveMedia = %v", gotBody.HaveMedia) + } + c, err := e.layout.CurrentContent() + if err != nil || c.Manifest.Sequence != 1000 || c.Info.Source != "sync" { + t.Fatalf("installed content = %+v, %v", c, err) + } + st := e.syncer.Status() + if !st.Enabled || st.LastSuccessAt == nil || st.LastAttemptAt == nil || st.LastError != nil { + t.Errorf("status = %+v", st) + } + if left, _ := filepath.Glob(filepath.Join(e.layout.Inbox(), ".sync-*")); len(left) != 0 { + t.Errorf("download dirs left behind: %v", left) + } + // The same package again is "unchanged", which is still a success. + if err := e.syncer.SyncContent(context.Background()); err != nil { + t.Fatalf("second SyncContent: %v", err) + } +} + +func hexSHA(b []byte) string { + s := sha256.Sum256(b) + return hex.EncodeToString(s[:]) +} + +func TestSyncContentErrors(t *testing.T) { + cases := []struct { + status int + body string + want string + }{ + {503, `{"message":"no content signing key configured"}`, "backend returned 503: no content signing key configured"}, + {401, `{"message":"unknown device"}`, "revoked, or the board's clock is wrong"}, + {500, `oops`, "backend returned 500"}, + } + for _, tc := range cases { + t.Run(strconv.Itoa(tc.status), func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tc.status) + io.WriteString(w, tc.body) + })) + defer srv.Close() + e := newEnv(t, srv.URL) + err := e.syncer.SyncContent(context.Background()) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want %q", err, tc.want) + } + st := e.syncer.Status() + if st.LastError == nil || *st.LastError != err.Error() || st.LastSuccessAt != nil { + t.Errorf("status = %+v", st) + } + }) + } +} + +func TestSyncContentUnreachable(t *testing.T) { + srv := httptest.NewServer(http.NotFoundHandler()) + url := srv.URL + srv.Close() + e := newEnv(t, url) + err := e.syncer.SyncContent(context.Background()) + if err == nil || err.Error() != "backend unreachable" { + t.Fatalf("err = %v", err) + } +} + +func TestSyncContentRejectedPackage(t *testing.T) { + // Signed by the release key: authentic to nobody for content. + m := mbu.Manifest{Type: mbu.TypeContent, Sequence: 5, DeviceID: testDevice, + Content: &mbu.ContentInfo{Timezone: "UTC", FirstDay: "2026-09-24", LastDay: "2026-09-24"}} + pkg := build(t, m, []mbu.Source{{Path: "payloads/2026-09-24.json", Data: []byte(`{}`)}}, releaseKey) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write(pkg) })) + defer srv.Close() + e := newEnv(t, srv.URL) + err := e.syncer.SyncContent(context.Background()) + if err == nil || !strings.Contains(err.Error(), "refused") { + t.Fatalf("err = %v", err) + } +} + +func TestContentBackoff(t *testing.T) { + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/agent/content-bundle" { + hits.Add(1) + } + w.WriteHeader(503) + })) + defer srv.Close() + e := newEnv(t, srv.URL) + s := e.syncer + s.minBackoff, s.maxBackoff = 20*time.Millisecond, 80*time.Millisecond + s.contentInterval = time.Hour + ctx, cancel := context.WithTimeout(context.Background(), 330*time.Millisecond) + defer cancel() + s.contentLoop(ctx) + // Attempts at about 0, 20, 60, 140, 220, 300 ms: doubling, then capped. + if n := hits.Load(); n < 4 || n > 8 { + t.Fatalf("%d attempts in 330 ms, want about 6", n) + } +} + +func TestRefreshTriggersDebouncedSync(t *testing.T) { + pkg := contentPackage(t, 1000) + var hits atomic.Int32 + var mu sync.Mutex + var sockets []*websocket.Conn + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/agent/content-bundle": + hits.Add(1) + w.Write(pkg) + case "/api/refresh-musallahboard": + if r.URL.Query().Get("deviceId") != testDevice { + t.Errorf("refresh deviceId = %q", r.URL.Query().Get("deviceId")) + } + c, err := websocket.Accept(w, r, nil) + if err != nil { + return + } + mu.Lock() + sockets = append(sockets, c) + mu.Unlock() + // Three messages in quick succession fold into one sync. + for range 3 { + c.Write(r.Context(), websocket.MessageText, []byte("refresh")) + } + c.Read(r.Context()) // hold open until the client goes + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + e := newEnv(t, srv.URL) + e.syncer.triggerDelay = 50 * time.Millisecond + ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond) + defer cancel() + e.syncer.Run(ctx) + mu.Lock() + for _, c := range sockets { + c.CloseNow() + } + mu.Unlock() + // One at start, one for the burst of refresh messages. + if n := hits.Load(); n != 2 { + t.Fatalf("%d content syncs, want 2", n) + } +} + +func TestRefreshURL(t *testing.T) { + for in, want := range map[string]string{ + "https://api.example": "wss://api.example/api/refresh-musallahboard?deviceId=" + testDevice, + "http://10.0.0.2:8080/": "ws://10.0.0.2:8080/api/refresh-musallahboard?deviceId=" + testDevice, + "https://api.example/base/": "wss://api.example/base/api/refresh-musallahboard?deviceId=" + testDevice, + } { + got, err := refreshURL(in, testDevice) + if err != nil || got != want { + t.Errorf("refreshURL(%q) = %q, %v; want %q", in, got, err, want) + } + } + if _, err := refreshURL("ftp://x", testDevice); err == nil { + t.Error("ftp scheme accepted") + } +} + +func TestWeather(t *testing.T) { + body := `{"weather":{"tempC":12,"icon":"sun"},"fetchedAt":"2026-09-24T12:00:00Z"}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/agent/weather" || r.Method != http.MethodGet { + t.Errorf("unexpected %s %s", r.Method, r.URL) + } + if r.Header.Get("X-MB-Device-Id") != testDevice || r.Header.Get("X-MB-Signature") == "" { + t.Errorf("weather request is not device-signed: %v", r.Header) + } + io.WriteString(w, body) + })) + defer srv.Close() + e := newEnv(t, srv.URL) + s := e.syncer + now := time.Date(2026, 9, 24, 12, 0, 0, 0, time.UTC) + s.now = func() time.Time { return now } + + if _, ok := s.Weather(); ok { + t.Fatal("weather before any fetch") + } + if err := s.FetchWeather(context.Background()); err != nil { + t.Fatal(err) + } + w, ok := s.Weather() + if !ok || string(w) != `{"tempC":12,"icon":"sun"}` { + t.Fatalf("weather = %s, %v", w, ok) + } + now = now.Add(2*time.Hour + 59*time.Minute) + if _, ok := s.Weather(); !ok { + t.Fatal("weather expired early") + } + now = now.Add(time.Minute) + if _, ok := s.Weather(); ok { + t.Fatal("weather older than 3 h still served") + } + + // No weather object clears it. + body = `{"weather":null}` + s.FetchWeather(context.Background()) + if _, ok := s.Weather(); ok { + t.Fatal("null weather kept an old value") + } + body = `{"weather":[1,2]}` + s.FetchWeather(context.Background()) + if _, ok := s.Weather(); ok { + t.Fatal("non-object weather accepted") + } +} + +// channelServer serves a channel file at /channel.json and the package at +// /pkg.mbu, counting package downloads. +type channelServer struct { + *httptest.Server + downloads atomic.Int32 + channel Channel + pkg []byte +} + +func newChannelServer(t *testing.T, version string, pkg []byte) *channelServer { + cs := &channelServer{pkg: pkg} + cs.channel = Channel{Version: version, URL: "pkg.mbu", SHA256: hexSHA(pkg), Bytes: int64(len(pkg))} + cs.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/channel.json": + json.NewEncoder(w).Encode(cs.channel) + case "/pkg.mbu": + cs.downloads.Add(1) + w.Write(cs.pkg) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(cs.Close) + return cs +} + +func setAppVersion(t *testing.T, l store.Layout, v string) { + if _, err := l.State().Update(func(s *state.State) error { s.AppVersion = v; return nil }); err != nil { + t.Fatal(err) + } +} + +func TestAppChannel(t *testing.T) { + cases := []struct { + name string + installed string + channel string + downloads int32 + wantApp string + }{ + {"newer installs", "2.0.0", "2.1.0", 1, "2.1.0"}, + {"nothing installed installs", "", "2.1.0", 1, "2.1.0"}, + {"equal skips", "2.1.0", "2.1.0", 0, "2.1.0"}, + {"older skips", "2.2.0", "2.1.0", 0, "2.2.0"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cs := newChannelServer(t, tc.channel, appPackage(t, tc.channel)) + e := newEnv(t, "http://unused") + e.cfg.AppChannelSet = strp(cs.URL + "/channel.json") + setAppVersion(t, e.layout, tc.installed) + e.syncer.CheckChannels(context.Background()) + if n := cs.downloads.Load(); n != tc.downloads { + t.Errorf("downloads = %d, want %d", n, tc.downloads) + } + st, _ := e.layout.State().Load() + if st.AppVersion != tc.wantApp { + t.Errorf("app version = %q, want %q", st.AppVersion, tc.wantApp) + } + }) + } +} + +func TestAppChannelShaMismatch(t *testing.T) { + cs := newChannelServer(t, "2.1.0", appPackage(t, "2.1.0")) + cs.channel.SHA256 = strings.Repeat("0", 64) + e := newEnv(t, "http://unused") + e.cfg.AppChannelSet = strp(cs.URL + "/channel.json") + err := e.syncer.checkChannel(context.Background(), mbu.TypeApp, cs.URL+"/channel.json") + if err == nil || !strings.Contains(err.Error(), "SHA-256") { + t.Fatalf("err = %v", err) + } + if st, _ := e.layout.State().Load(); st.AppVersion != "" { + t.Fatalf("installed %q despite the hash mismatch", st.AppVersion) + } +} + +func TestAppChannelSizeMismatch(t *testing.T) { + cs := newChannelServer(t, "2.1.0", appPackage(t, "2.1.0")) + cs.channel.Bytes -= 10 + e := newEnv(t, "http://unused") + err := e.syncer.checkChannel(context.Background(), mbu.TypeApp, cs.URL+"/channel.json") + if err == nil || !strings.Contains(err.Error(), "larger than") { + t.Fatalf("err = %v", err) + } +} + +func TestChannelValidation(t *testing.T) { + cs := newChannelServer(t, "2.1", appPackage(t, "2.1.0")) + e := newEnv(t, "http://unused") + err := e.syncer.checkChannel(context.Background(), mbu.TypeApp, cs.URL+"/channel.json") + if err == nil || !strings.Contains(err.Error(), "MAJOR.MINOR.PATCH") { + t.Fatalf("err = %v", err) + } +} + +func TestAgentChannel(t *testing.T) { + cases := []struct { + name string + running string + channel string + rejected []string + downloads int32 + staged bool + }{ + {"newer stages", "0.3.0", "0.4.0", nil, 1, true}, + {"equal skips", "0.3.0", "0.3.0", nil, 0, false}, + {"older skips", "0.3.0", "0.2.9", nil, 0, false}, + {"rejected skips", "0.3.0", "0.4.0", []string{"0.4.0"}, 0, false}, + {"dev build skips", "dev", "0.4.0", nil, 0, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cs := newChannelServer(t, tc.channel, agentPackage(t, tc.channel)) + e := newEnv(t, "http://unused") + e.cfg.AgentChannelSet = strp(cs.URL + "/channel.json") + e.syncer.d.AgentVersion = tc.running + if tc.rejected != nil { + e.layout.State().Update(func(s *state.State) error { s.AgentVersionsRejected = tc.rejected; return nil }) + } + e.syncer.CheckChannels(context.Background()) + if n := cs.downloads.Load(); n != tc.downloads { + t.Errorf("downloads = %d, want %d", n, tc.downloads) + } + _, err := os.Stat(e.layout.StagedReady()) + if staged := err == nil; staged != tc.staged { + t.Errorf("staged = %v, want %v", staged, tc.staged) + } + }) + } +} + +func TestAutoUpdateOffSkipsChannels(t *testing.T) { + cs := newChannelServer(t, "2.1.0", appPackage(t, "2.1.0")) + e := newEnv(t, "http://127.0.0.1:1") + e.cfg.AppChannelSet = strp(cs.URL + "/channel.json") + e.cfg.AutoUpdateSet = boolp(false) + e.cfg.ContentSyncSet = boolp(false) + e.syncer.channelFirstDelay = time.Millisecond + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + e.syncer.Run(ctx) + if cs.downloads.Load() != 0 { + t.Fatal("channel followed with auto_update off") + } + if e.syncer.Status().Enabled { + t.Fatal("status reports sync enabled with content_sync off") + } +} diff --git a/internal/boardsync/channel.go b/internal/boardsync/channel.go new file mode 100644 index 0000000..07aa25c --- /dev/null +++ b/internal/boardsync/channel.go @@ -0,0 +1,213 @@ +package boardsync + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "path/filepath" + "regexp" + "runtime" + "strings" + + "github.com/LensBridge/agent/internal/importer" + "github.com/LensBridge/agent/internal/mbu" +) + +// Channel is a release channel pointer file (section 9.4). It is only a +// pointer: the package it names is verified against the release keys and the +// monotonic rules like any other, so a tampered channel can at worst make the +// board download a file and refuse it. +type Channel struct { + Version string `json:"version"` + URL string `json:"url"` + SHA256 string `json:"sha256"` + Bytes int64 `json:"bytes"` +} + +var sha256HexRE = regexp.MustCompile(`^[0-9a-f]{64}$`) + +func (s *Syncer) channelLoop(ctx context.Context) { + if !sleep(ctx, s.channelFirstDelay) { + return + } + for { + s.CheckChannels(ctx) + if !sleep(ctx, s.channelInterval) { + return + } + } +} + +// CheckChannels checks the app channel, then the agent channel. The app goes +// first because an app release may need a newer local API than the running +// agent serves; installing the agent first would restart the daemon and the +// app would wait six hours, while installing the app first either works or +// is refused with "update the agent first" and retried on the next round. +func (s *Syncer) CheckChannels(ctx context.Context) { + if u := s.d.Cfg.AppChannelURL(); u != "" { + if err := s.checkChannel(ctx, mbu.TypeApp, u); err != nil && ctx.Err() == nil { + s.log.Warn("app release channel check failed", "url", u, "err", err) + } + } + if u := s.d.Cfg.AgentChannelURL(runtime.GOARCH); u != "" { + if err := s.checkChannel(ctx, mbu.TypeAgent, u); err != nil && ctx.Err() == nil { + s.log.Warn("agent release channel check failed", "url", u, "err", err) + } + } +} + +// checkChannel fetches one channel and, if it points at something newer than +// what is installed, downloads and imports it. +func (s *Syncer) checkChannel(ctx context.Context, kind mbu.Type, channelURL string) error { + ch, err := s.fetchChannel(ctx, channelURL) + if err != nil { + return err + } + + var installed string + switch kind { + case mbu.TypeApp: + st, err := s.d.Layout.State().Load() + if err != nil { + return err + } + installed = st.AppVersion + case mbu.TypeAgent: + installed = s.d.AgentVersion + if !mbu.ValidVersion(installed) { + // A developer build ("dev") sorts before every release, so it + // would be replaced by the first channel check. Someone running + // a dev build on a board wants to keep it. + s.log.Info("not following the agent channel: this is a development build", "version", installed) + return nil + } + st, err := s.d.Layout.State().Load() + if err != nil { + return err + } + if st.AgentRejected(ch.Version) { + s.log.Info("agent channel offers a version that failed on this board before; skipping", "version", ch.Version) + return nil + } + } + if mbu.CompareVersions(ch.Version, installed) <= 0 { + s.log.Debug("release channel: up to date", "type", kind, "installed", installed, "channel", ch.Version) + return nil + } + s.log.Info("release channel offers an update", "type", kind, "installed", installed, "version", ch.Version) + + pkgURL, err := resolve(channelURL, ch.URL) + if err != nil { + return err + } + dir, err := s.downloadDir() + if err != nil { + return err + } + defer removeAll(dir) + file := filepath.Join(dir, fmt.Sprintf("musallahboard-%s-%s.mbu", kind, ch.Version)) + if err := s.download(ctx, pkgURL, file, ch); err != nil { + return err + } + + b := s.d.Importer.Import(ctx, importer.SourceSync, []string{file}) + for _, r := range b.Results { + s.log.Info("release channel import", "type", kind, "version", ch.Version, "action", r.Action, "message", r.Message) + } + return nil +} + +func (s *Syncer) fetchChannel(ctx context.Context, channelURL string) (*Channel, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, channelURL, nil) + if err != nil { + return nil, fmt.Errorf("bad channel URL: %w", err) + } + req.Header.Set("Accept", "application/json") + resp, err := s.do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("channel returned %d", resp.StatusCode) + } + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxChannelBytes+1)) + if err != nil { + return nil, err + } + if len(raw) > maxChannelBytes { + return nil, fmt.Errorf("channel file is larger than %d KiB", maxChannelBytes>>10) + } + var ch Channel + if err := json.Unmarshal(raw, &ch); err != nil { + return nil, fmt.Errorf("channel file is not valid JSON: %w", err) + } + ch.SHA256 = strings.ToLower(ch.SHA256) + switch { + case !mbu.ValidVersion(ch.Version): + return nil, fmt.Errorf("channel version %q is not MAJOR.MINOR.PATCH", ch.Version) + case ch.URL == "": + return nil, fmt.Errorf("channel has no url") + case !sha256HexRE.MatchString(ch.SHA256): + return nil, fmt.Errorf("channel sha256 is malformed") + case ch.Bytes <= 0 || ch.Bytes > maxPackage: + return nil, fmt.Errorf("channel bytes %d is not a valid package size", ch.Bytes) + } + return &ch, nil +} + +// download fetches the package and checks its size and hash against the +// channel before anything else looks at it. +func (s *Syncer) download(ctx context.Context, pkgURL, file string, ch *Channel) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, pkgURL, nil) + if err != nil { + return err + } + resp, err := s.do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("package download returned %d", resp.StatusCode) + } + h := sha256.New() + n, err := saveCapped(file, resp.Body, ch.Bytes, h) + if err != nil { + if err == errTooLarge { + return fmt.Errorf("package is larger than the %d bytes the channel promised", ch.Bytes) + } + return fmt.Errorf("package download failed: %w", err) + } + if n != ch.Bytes { + removeAll(file) + return fmt.Errorf("package is %d bytes but the channel says %d", n, ch.Bytes) + } + if got := hex.EncodeToString(h.Sum(nil)); got != ch.SHA256 { + removeAll(file) + return fmt.Errorf("package SHA-256 does not match the channel (got %s)", got) + } + return nil +} + +// resolve lets a channel give its url relative to the channel file. +func resolve(base, ref string) (string, error) { + b, err := url.Parse(base) + if err != nil { + return "", err + } + r, err := url.Parse(ref) + if err != nil { + return "", fmt.Errorf("channel url is not a URL: %w", err) + } + u := b.ResolveReference(r) + if u.Scheme != "https" && u.Scheme != "http" { + return "", fmt.Errorf("channel url scheme %q is not http(s)", u.Scheme) + } + return u.String(), nil +} diff --git a/internal/boardsync/download.go b/internal/boardsync/download.go new file mode 100644 index 0000000..0122868 --- /dev/null +++ b/internal/boardsync/download.go @@ -0,0 +1,60 @@ +package boardsync + +import ( + "crypto/ed25519" + "errors" + "fmt" + "hash" + "io" + "net/http" + "net/url" + "os" + "time" + + "github.com/LensBridge/agent/internal/devauth" + "github.com/LensBridge/agent/internal/mbu" +) + +// maxPackage is the largest package the board will download. +const maxPackage = mbu.MaxPackageBytes + +var errTooLarge = fmt.Errorf("the download is larger than %d MiB, the most a package may be", maxPackage>>20) + +func signRequest(req *http.Request, body []byte, deviceID string, key ed25519.PrivateKey, now time.Time) { + devauth.Sign(req, body, deviceID, key, now) +} + +func urlQueryEscape(s string) string { return url.QueryEscape(s) } + +// saveCapped streams r into a new file at path, reading at most limit bytes; +// anything longer is errTooLarge and the file is removed. When h is not nil +// every byte is also hashed into it. +func saveCapped(path string, r io.Reader, limit int64, h hash.Hash) (int64, error) { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640) + if err != nil { + return 0, err + } + var w io.Writer = f + if h != nil { + w = io.MultiWriter(f, h) + } + // One byte past the limit tells "exactly at the limit" from "too big". + n, err := io.Copy(w, io.LimitReader(r, limit+1)) + if err == nil && n > limit { + err = errTooLarge + } + if err == nil { + err = f.Sync() + } + if cerr := f.Close(); err == nil { + err = cerr + } + if err != nil { + os.Remove(path) + if errors.Is(err, errTooLarge) { + return n, errTooLarge + } + return n, err + } + return n, nil +} diff --git a/internal/boardsync/refresh.go b/internal/boardsync/refresh.go new file mode 100644 index 0000000..43e34b5 --- /dev/null +++ b/internal/boardsync/refresh.go @@ -0,0 +1,105 @@ +package boardsync + +import ( + "context" + "fmt" + "math/rand" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/coder/websocket" +) + +// refreshStable is how long a refresh connection must stay up before the +// reconnect backoff starts over. A server that accepts and immediately drops +// the socket would otherwise be redialled every second. +const refreshStable = 30 * time.Second + +// refreshLoop holds the backend's refresh channel open, the same socket the +// hosted board listens on. The backend sends a message whenever something on +// the board's content changes; each one schedules a content sync. +func (s *Syncer) refreshLoop(ctx context.Context) { + wsURL, err := refreshURL(s.d.Cfg.BackendURL, s.d.Cfg.DeviceID) + if err != nil { + s.log.Warn("no refresh channel: cannot derive its address from backend_url", "err", err) + return + } + backoff := s.refreshMinBackoff + for { + started := time.Now() + err := s.refreshOnce(ctx, wsURL) + if ctx.Err() != nil { + return + } + if time.Since(started) >= refreshStable { + backoff = s.refreshMinBackoff + } + s.log.Debug("refresh channel closed", "err", err, "retryIn", backoff) + if !sleep(ctx, backoff+jitter(backoff)) { + return + } + backoff = min(backoff*2, s.refreshMaxBackoff) + } +} + +func (s *Syncer) refreshOnce(ctx context.Context, wsURL string) error { + dialCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + // The shared client's overall timeout is for downloads; the socket is + // meant to stay open for hours and is bounded by dialCtx instead. + wsClient := *s.client + wsClient.Timeout = 0 + conn, _, err := websocket.Dial(dialCtx, wsURL, &websocket.DialOptions{ + HTTPClient: &wsClient, + HTTPHeader: http.Header{"User-Agent": []string{"musallahboard-agent/" + s.d.AgentVersion}}, + }) + if err != nil { + return fmt.Errorf("dial: %w", err) + } + defer conn.CloseNow() + conn.SetReadLimit(64 << 10) + s.log.Debug("refresh channel connected") + for { + typ, _, err := conn.Read(ctx) + if err != nil { + return err + } + if typ == websocket.MessageText { + s.Trigger() + } + } +} + +// refreshURL derives ws(s):///api/refresh-musallahboard?deviceId= +// from backend_url. +func refreshURL(backendURL, deviceID string) (string, error) { + u, err := url.Parse(strings.TrimRight(backendURL, "/")) + if err != nil { + return "", err + } + switch u.Scheme { + case "https": + u.Scheme = "wss" + case "http": + u.Scheme = "ws" + default: + return "", fmt.Errorf("backend_url scheme %q is not http or https", u.Scheme) + } + u.Path = strings.TrimRight(u.Path, "/") + "/api/refresh-musallahboard" + u.RawPath = "" + u.RawQuery = url.Values{"deviceId": {deviceID}}.Encode() + u.Fragment = "" + return u.String(), nil +} + +func jitter(b time.Duration) time.Duration { + if b < 4 { + return 0 + } + return time.Duration(rand.Int63n(int64(b / 4))) +} + +func removeAll(p string) { _ = os.RemoveAll(p) } diff --git a/internal/clock/clock.go b/internal/clock/clock.go new file mode 100644 index 0000000..9dd40f9 --- /dev/null +++ b/internal/clock/clock.go @@ -0,0 +1,214 @@ +// Package clock keeps the board's wall clock from going wrong +// (docs/architecture.md, section 10). +// +// Which day's content is shown, and every prayer time on it, depends on the +// clock. A Pi has no battery-backed clock unless someone fitted one, so after +// a power cut on a board without internet it can come up years in the past. +// The defence is a floor that only moves forward: the newest signed createdAt +// of any verified package, and the wall clock itself, persisted every ten +// minutes. The clock is never allowed to sit more than a minute behind it. +// +// The only other way the clock moves is an uploader's time, and only when a +// package in that same upload verified and the time is within 90 days of the +// floor, so an unauthenticated client cannot set the board to any date it +// likes. +package clock + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/exec" + "time" + + "github.com/LensBridge/agent/internal/importer" + "github.com/LensBridge/agent/internal/store" +) + +const ( + // Interval is how often the wall clock is persisted and checked. + Interval = 10 * time.Minute + // BehindTolerance is how far behind the floor the clock may be before it + // is corrected. Persisting happens every ten minutes, so a floor a few + // seconds ahead of a correct clock is normal and not worth a jump. + BehindTolerance = 60 * time.Second + // ClientTolerance is the drift an uploader's time must exceed to be + // applied; below it, laptop clocks disagree with each other anyway. + ClientTolerance = 5 * time.Second + // ClientWindow is how far past the floor an uploader's time may be. + ClientWindow = 90 * 24 * time.Hour + + // DefaultRTCDevice is the kernel's first hardware clock. + DefaultRTCDevice = "/dev/rtc0" +) + +// ClockReport is what the upload server returns about the uploader's clock. +// DriftSeconds is the board's clock minus the uploader's, measured before any +// correction: positive means the board was ahead. +type ClockReport struct { + DriftSeconds int64 `json:"driftSeconds"` + Adjusted bool `json:"adjusted"` + Note string `json:"note"` +} + +// Keeper owns the board's clock. +type Keeper struct { + layout store.Layout + logger *slog.Logger + + // Now, SetClock and WriteRTC are replaceable for tests. + Now func() time.Time + SetClock func(time.Time) error + WriteRTC func() error + // RTCDevice is checked for existence by RTCPresent. + RTCDevice string +} + +// New returns a Keeper for the board's state in layout. +func New(layout store.Layout, logger *slog.Logger) *Keeper { + if logger == nil { + logger = slog.New(slog.DiscardHandler) + } + return &Keeper{ + layout: layout, + logger: logger, + Now: time.Now, + SetClock: setSystemClock, + WriteRTC: writeRTC, + RTCDevice: DefaultRTCDevice, + } +} + +// RTCPresent reports whether the board has a hardware clock. +func (k *Keeper) RTCPresent() bool { + _, err := os.Stat(k.RTCDevice) + return err == nil +} + +// Run checks at once and then every Interval until ctx ends. +func (k *Keeper) Run(ctx context.Context) { + t := time.NewTicker(Interval) + defer t.Stop() + for { + k.Check() + select { + case <-ctx.Done(): + return + case <-t.C: + } + } +} + +// Check corrects a clock that is behind the floor, then persists the wall +// clock into the floor. The order matters: persisting first would be a no-op +// for a clock behind the floor anyway, but checking first means the floor is +// then raised to the corrected time. +func (k *Keeper) Check() { + st, err := k.layout.State().Load() + if err != nil { + k.logger.Warn("clock: cannot read the clock floor", "err", err) + return + } + now := k.Now() + floor := time.Unix(st.ClockFloor, 0) + if st.ClockFloor > 0 && now.Before(floor.Add(-BehindTolerance)) { + k.logger.Warn("clock is behind the newest time this board has proof of; moving it forward", + "now", now.UTC().Format(time.RFC3339), "floor", floor.UTC().Format(time.RFC3339), + "behind", floor.Sub(now).Round(time.Second).String()) + if err := k.set(floor); err != nil { + k.logger.Error("clock: could not set the system clock", "err", err) + return + } + now = floor + } + if err := k.layout.State().RaiseClockFloor(now.Unix()); err != nil { + k.logger.Warn("clock: could not persist the clock floor", "err", err) + } +} + +// set sets the system clock and, when there is one, the hardware clock, so +// the correction survives the next power cut. +func (k *Keeper) set(t time.Time) error { + if err := k.SetClock(t); err != nil { + return err + } + if k.RTCPresent() { + if err := k.WriteRTC(); err != nil { + k.logger.Warn("clock: set the system clock but could not write the hardware clock", "err", err) + } + } + return nil +} + +// ApplyClientTime considers an uploader's clock (X-MB-Client-Time, Unix +// seconds) after the upload's batch b ran. It applies it only when a package +// in b verified, the difference is more than ClientTolerance, and the time is +// within [floor, floor + ClientWindow]. +func (k *Keeper) ApplyClientTime(clientUnix int64, b importer.Batch) ClockReport { + now := k.Now() + drift := now.Unix() - clientUnix + rep := ClockReport{DriftSeconds: drift} + if abs(drift) <= int64(ClientTolerance/time.Second) { + rep.Note = "the board's clock agrees with this device" + return rep + } + if !b.Verified { + rep.Note = fmt.Sprintf("the board's clock is %s; it was not changed because no package in this upload verified", describeDrift(drift)) + return rep + } + st, err := k.layout.State().Load() + if err != nil { + rep.Note = "the board's clock was not changed: cannot read its clock floor: " + err.Error() + return rep + } + floor := st.ClockFloor + switch { + case clientUnix < floor: + rep.Note = fmt.Sprintf("the board's clock is %s, but this device's time is earlier than a signed package the board has seen; this device's clock looks wrong, so the board's was not changed", describeDrift(drift)) + return rep + case clientUnix > floor+int64(ClientWindow/time.Second): + rep.Note = fmt.Sprintf("the board's clock is %s, but this device's time is more than 90 days past anything the board can confirm; the board's clock was not changed", describeDrift(drift)) + return rep + } + target := time.Unix(clientUnix, 0) + if err := k.set(target); err != nil { + rep.Note = fmt.Sprintf("the board's clock is %s; setting it failed: %v", describeDrift(drift), err) + k.logger.Error("clock: could not apply uploader time", "err", err) + return rep + } + rep.Adjusted = true + rep.Note = fmt.Sprintf("the board's clock was %s and has been set to this device's time", describeDrift(drift)) + k.logger.Info("clock set from uploader", "driftSeconds", drift, "to", target.UTC().Format(time.RFC3339)) + if err := k.layout.State().RaiseClockFloor(clientUnix); err != nil { + k.logger.Warn("clock: could not persist the clock floor", "err", err) + } + return rep +} + +// describeDrift says which way the board is off, against the uploader. +func describeDrift(drift int64) string { + d := (time.Duration(abs(drift)) * time.Second).String() + if drift > 0 { + return d + " ahead" + } + return d + " behind" +} + +func abs(n int64) int64 { + if n < 0 { + return -n + } + return n +} + +// writeRTC copies the system clock into the hardware clock. +func writeRTC() error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, "hwclock", "--systohc", "--utc", "--noadjfile").CombinedOutput() + if err != nil { + return fmt.Errorf("hwclock --systohc --utc --noadjfile: %v: %s", err, out) + } + return nil +} diff --git a/internal/clock/clock_test.go b/internal/clock/clock_test.go new file mode 100644 index 0000000..aaf4989 --- /dev/null +++ b/internal/clock/clock_test.go @@ -0,0 +1,147 @@ +package clock + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/LensBridge/agent/internal/importer" + "github.com/LensBridge/agent/internal/state" + "github.com/LensBridge/agent/internal/store" +) + +type fake struct { + now time.Time + sets []time.Time + rtc int + fail error +} + +func newKeeper(t *testing.T, floor int64, now time.Time, withRTC bool) (*Keeper, *fake) { + t.Helper() + l := store.Layout{Root: t.TempDir()} + if floor > 0 { + if _, err := l.State().Update(func(s *state.State) error { s.ClockFloor = floor; return nil }); err != nil { + t.Fatal(err) + } + } + f := &fake{now: now} + k := New(l, nil) + k.Now = func() time.Time { return f.now } + k.SetClock = func(t time.Time) error { + if f.fail != nil { + return f.fail + } + f.sets = append(f.sets, t) + f.now = t + return nil + } + k.WriteRTC = func() error { f.rtc++; return nil } + k.RTCDevice = filepath.Join(t.TempDir(), "rtc0") + if withRTC { + os.WriteFile(k.RTCDevice, nil, 0o644) + } + return k, f +} + +func floorOf(t *testing.T, k *Keeper) int64 { + st, err := k.layout.State().Load() + if err != nil { + t.Fatal(err) + } + return st.ClockFloor +} + +var base = time.Date(2026, 9, 24, 12, 0, 0, 0, time.UTC) + +func TestCheckPersistsWallClock(t *testing.T) { + k, f := newKeeper(t, 0, base, false) + k.Check() + if len(f.sets) != 0 { + t.Fatalf("clock set with no floor: %v", f.sets) + } + if got := floorOf(t, k); got != base.Unix() { + t.Fatalf("floor = %d, want %d", got, base.Unix()) + } +} + +func TestCheckSetsClockBehindFloor(t *testing.T) { + floor := base.Unix() + k, f := newKeeper(t, floor, base.Add(-2*time.Hour), true) + k.Check() + if len(f.sets) != 1 || f.sets[0].Unix() != floor { + t.Fatalf("sets = %v, want one set to the floor", f.sets) + } + if f.rtc != 1 { + t.Fatalf("RTC written %d times, want 1", f.rtc) + } +} + +func TestCheckToleratesSmallLag(t *testing.T) { + k, f := newKeeper(t, base.Unix(), base.Add(-30*time.Second), true) + k.Check() + if len(f.sets) != 0 { + t.Fatalf("clock set for a 30 s lag: %v", f.sets) + } + if got := floorOf(t, k); got != base.Unix() { + t.Fatalf("floor moved down to %d", got) + } +} + +func TestCheckNoRTC(t *testing.T) { + k, f := newKeeper(t, base.Unix(), base.Add(-time.Hour), false) + k.Check() + if len(f.sets) != 1 || f.rtc != 0 { + t.Fatalf("sets=%v rtc=%d", f.sets, f.rtc) + } +} + +func TestApplyClientTime(t *testing.T) { + floor := base.Unix() + verified := importer.Batch{Verified: true} + cases := []struct { + name string + now time.Time + client int64 + batch importer.Batch + adjusted bool + drift int64 + }{ + {"within tolerance", base, floor + 4, verified, false, -4}, + {"not verified", base, floor + 3600, importer.Batch{}, false, -3600}, + {"behind floor", base.Add(time.Hour), floor - 10, verified, false, 3610}, + {"beyond 90 days", base, floor + int64(91*24*3600), verified, false, -int64(91 * 24 * 3600)}, + {"board behind", base.Add(-time.Hour), floor + 60, verified, true, -3660}, + {"board ahead, client at floor", base.Add(time.Hour), floor, verified, true, 3600}, + {"exactly 90 days", base, floor + int64(90*24*3600), verified, true, -int64(90 * 24 * 3600)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + k, f := newKeeper(t, floor, tc.now, false) + rep := k.ApplyClientTime(tc.client, tc.batch) + if rep.Adjusted != tc.adjusted || rep.DriftSeconds != tc.drift { + t.Fatalf("report = %+v, want adjusted=%v drift=%d", rep, tc.adjusted, tc.drift) + } + if rep.Note == "" { + t.Error("empty note") + } + if tc.adjusted && (len(f.sets) != 1 || f.sets[0].Unix() != tc.client) { + t.Fatalf("sets = %v", f.sets) + } + if !tc.adjusted && len(f.sets) != 0 { + t.Fatalf("clock set: %v", f.sets) + } + }) + } +} + +func TestApplyClientTimeSetFails(t *testing.T) { + k, f := newKeeper(t, base.Unix(), base.Add(-time.Hour), false) + f.fail = errors.New("EPERM") + rep := k.ApplyClientTime(base.Unix()+10, importer.Batch{Verified: true}) + if rep.Adjusted { + t.Fatal("reported adjusted after a failed set") + } +} diff --git a/internal/clock/settime_linux.go b/internal/clock/settime_linux.go new file mode 100644 index 0000000..cb4cf0e --- /dev/null +++ b/internal/clock/settime_linux.go @@ -0,0 +1,19 @@ +//go:build linux + +package clock + +import ( + "fmt" + "syscall" + "time" +) + +// setSystemClock sets CLOCK_REALTIME. The daemon's unit grants CAP_SYS_TIME +// for exactly this. +func setSystemClock(t time.Time) error { + tv := syscall.NsecToTimeval(t.UnixNano()) + if err := syscall.Settimeofday(&tv); err != nil { + return fmt.Errorf("set the system clock: %w (the agent needs CAP_SYS_TIME)", err) + } + return nil +} diff --git a/internal/clock/settime_other.go b/internal/clock/settime_other.go new file mode 100644 index 0000000..2e1dd81 --- /dev/null +++ b/internal/clock/settime_other.go @@ -0,0 +1,14 @@ +//go:build !linux + +package clock + +import ( + "errors" + "time" +) + +// setSystemClock is only implemented on Linux, the only system the agent +// runs on; this stub lets the package build and test elsewhere. +func setSystemClock(time.Time) error { + return errors.New("setting the system clock is only supported on Linux") +} diff --git a/internal/commands/config_refresh.go b/internal/commands/config_refresh.go index 147403c..575c9d9 100644 --- a/internal/commands/config_refresh.go +++ b/internal/commands/config_refresh.go @@ -10,46 +10,32 @@ import ( "github.com/LensBridge/agent/internal/kioskurl" ) -// ConfigRefresh re-composes the on-disk kiosk URL (so an operator's board-url -// change is picked up), re-asserts this device's identity in the live page, -// and asks it to re-fetch its board payload in place — no reload, so the -// screen never flashes. +// ConfigRefresh pulls fresh content and repaints the board in place, with no +// reload, so the screen never flashes. It re-asserts the on-disk kiosk URL +// (the local server), asks the content syncer for a sync, and asks the live +// page to re-fetch its payload. // -// The page contract is `window.MusallahBoard` (see frontend src/App.jsx): -// -// setDeviceId(uuid, {reload:false}) → persists the id, no navigation -// refresh() → Promise<{ok, deviceId, at|error}> -// -// Re-asserting the id here is what repairs a board whose cookie was wiped or -// points at the wrong device: the agent is the only component that knows the -// enrolled identity, and this is the one command that can push it into a page -// that is already loaded. +// The page contract is `window.MusallahBoard.refresh()` (see the frontend's +// src/App.jsx), which resolves to {ok, deviceId, at | error}. The page learns +// its device id from the agent's /api/local/status, so nothing is pushed into +// it any more. type ConfigRefresh struct { - CDP *cdp.Client - DeviceID string + CDP *cdp.Client + // Before, when set, runs first. The daemon passes the content syncer's + // Trigger: the page only shows what the agent has installed, so a refresh + // from the admin portal should also pull new content. + Before func() } func (h *ConfigRefresh) Kind() string { return "config.refresh" } -// refreshScript builds the expression evaluated in the page. Both steps run in -// a single round trip on purpose: split across two Runtime.evaluate calls, the -// refresh would race React's re-render and refetch with the pre-update id. -func refreshScript(deviceID string) string { - // json.Marshal, not string concatenation — the id is interpolated into - // live JS and must not be able to terminate the literal. - id, _ := json.Marshal(deviceID) - return `(async () => { +// refreshScript is evaluated in the page. +const refreshScript = `(async () => { const mb = window.MusallahBoard; if (!mb || typeof mb.refresh !== 'function') return { hook: false }; - const id = ` + string(id) + `; - if (id) { - try { mb.setDeviceId(id, { reload: false }); } - catch (e) { return { hook: true, ok: false, error: 'setDeviceId: ' + (e && e.message || e) }; } - } const r = await mb.refresh(); return Object.assign({ hook: true }, r); })()` -} // pageResult is the shape refreshScript resolves to. type pageResult struct { @@ -62,37 +48,34 @@ type pageResult struct { } func (h *ConfigRefresh) Execute(ctx context.Context, _ json.RawMessage, progress ProgressFn) (any, error) { - if h.DeviceID != "" { - if err := kioskurl.Write(kioskurl.DefaultBoardURLPath, kioskurl.DefaultOutPath, h.DeviceID); err != nil { - progress("warn", "kiosk url not rewritten: "+err.Error(), nil) - } + if h.Before != nil { + h.Before() + } + if err := kioskurl.WriteLocal(kioskurl.DefaultOutPath); err != nil { + progress("warn", "kiosk url not rewritten: "+err.Error(), nil) } progress("evaluating", "window.MusallahBoard.refresh()", nil) var res pageResult - err := h.CDP.EvaluateValue(ctx, refreshScript(h.DeviceID), &res) + err := h.CDP.EvaluateValue(ctx, refreshScript, &res) // A short-circuited optional call yields `undefined`. Reporting that as a - // success is how this command spent its life lying to the admin portal: - // the board sat on stale content while the dashboard said SUCCEEDED. + // success is how this command once spent its life lying to the admin + // portal: the board sat on stale content while the dashboard said + // SUCCEEDED. if errors.Is(err, cdp.ErrEvalUndefined) { - return nil, fmt.Errorf("kiosk page returned nothing — it is not the board (splash?) or predates the refresh hook") + return nil, fmt.Errorf("kiosk page returned nothing: it is not the board (splash or update screen?)") } if err != nil { return nil, err } if !res.Hook { - return nil, fmt.Errorf("page does not expose window.MusallahBoard.refresh() — frontend predates the refresh hook") + return nil, fmt.Errorf("page does not expose window.MusallahBoard.refresh()") } if !res.OK { - detail := firstNonEmpty(res.Error, res.Reason, "unknown") - return nil, fmt.Errorf("board refresh failed: %s", detail) + return nil, fmt.Errorf("board refresh failed: %s", firstNonEmpty(res.Error, res.Reason, "unknown")) } - - return map[string]any{ - "deviceId": res.DeviceID, - "refreshed": res.At, - }, nil + return map[string]any{"deviceId": res.DeviceID, "refreshed": res.At}, nil } func firstNonEmpty(vals ...string) string { diff --git a/internal/commands/config_refresh_test.go b/internal/commands/config_refresh_test.go index 6b3fb68..c97e07d 100644 --- a/internal/commands/config_refresh_test.go +++ b/internal/commands/config_refresh_test.go @@ -5,61 +5,17 @@ import ( "testing" ) -// The device id is interpolated into an expression evaluated in the page. It -// comes from our own config, but a quote in it must never be able to close the -// JS literal and change what runs in the browser. -func TestRefreshScriptEscapesDeviceID(t *testing.T) { - // json.Marshal emits a double-quoted literal, so single quotes and the - // `);` that would otherwise close the call stay inert data. - script := refreshScript(`'); alert('pwned`) - if !strings.Contains(script, `const id = "'); alert('pwned";`) { - t.Errorf("single quotes not contained in a JSON literal; got:\n%s", script) - } - - // A double quote must be backslash-escaped rather than terminating it. - script = refreshScript(`a" + b + "c`) - if !strings.Contains(script, `const id = "a\" + b + \"c";`) { - t.Errorf("double quotes not escaped; got:\n%s", script) - } -} - -// An unenrolled agent shouldn't push an empty identity into the page. The -// script guards on `if (id)`, so an empty id must still produce valid JS that -// refreshes without calling setDeviceId. -func TestRefreshScriptEmptyDeviceID(t *testing.T) { - script := refreshScript("") - - if !strings.Contains(script, `const id = "";`) { - t.Errorf("expected an empty JS string literal; got:\n%s", script) - } - if !strings.Contains(script, "if (id) {") { - t.Error("script must guard setDeviceId behind a non-empty id") - } -} - -func TestRefreshScriptCallsBothHooks(t *testing.T) { - script := refreshScript("3f2504e0-4f89-11d3-9a0c-0305e82c3301") - - for _, want := range []string{ - "window.MusallahBoard", - "mb.setDeviceId(id, { reload: false })", - "await mb.refresh()", - "hook: false", // the "page has no hook" signal - } { - if !strings.Contains(script, want) { - t.Errorf("script missing %q; got:\n%s", want, script) - } +func TestRefreshScriptCallsOnlyRefresh(t *testing.T) { + if !strings.Contains(refreshScript, "mb.refresh()") || strings.Contains(refreshScript, "setDeviceId") { + t.Fatalf("refresh script: %s", refreshScript) } } func TestFirstNonEmpty(t *testing.T) { - if got := firstNonEmpty("", "", "third"); got != "third" { - t.Errorf("got %q, want %q", got, "third") - } - if got := firstNonEmpty("first", "second"); got != "first" { - t.Errorf("got %q, want %q", got, "first") + if got := firstNonEmpty("", "b", "c"); got != "b" { + t.Fatalf("got %q", got) } if got := firstNonEmpty("", ""); got != "" { - t.Errorf("got %q, want empty", got) + t.Fatalf("got %q", got) } } diff --git a/internal/config/config.go b/internal/config/config.go index 1f148e9..f594045 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,15 +1,17 @@ // Package config persists the agent's runtime identity at /etc/musallahboard/agent.toml. // -// Schema (v2 — Ed25519 era): +// Schema: // // device_id = "uuid" // backend_url = "https://api.utmmsa.ca" // websocket_url = "wss://api.utmmsa.ca/api/agent/ws" // key_path = "/etc/musallahboard/agent.key" -// mode = "offline" # optional; absent means "online" // -// The HMAC `device_secret` field from the v1 schema is gone; identity is now -// proved by signing a server-issued challenge with the Ed25519 key at KeyPath. +// Optional keys (docs/architecture.md, section 11): service_port, usb_import, +// content_sync, content_days, auto_update, app_channel_url, agent_channel_url. +// +// Identity is proved by signing a server-issued challenge with the Ed25519 key +// at KeyPath. package config import ( @@ -23,35 +25,77 @@ import ( // DefaultKeyPath is where Save writes if the caller leaves Config.KeyPath empty. const DefaultKeyPath = "/etc/musallahboard/agent.key" -// Operating modes. Online is the original behaviour: WebSocket to the backend, -// telemetry, remote commands. Offline makes no network calls and serves an -// installed content bundle to the kiosk from 127.0.0.1 (see docs/offline.md). +// Channel defaults (section 9.4). %s is the board's GOARCH. const ( - ModeOnline = "online" - ModeOffline = "offline" + DefaultAppChannelURL = "https://github.com/LensBridge/MusallahBoard/releases/latest/download/app-channel.json" + DefaultAgentChannelURL = "https://github.com/LensBridge/agent/releases/latest/download/agent-channel-%s.json" + DefaultContentDays = 7 ) // Config is the persisted agent configuration written at enrollment. // // The first four fields are required at runtime; Load returns an error if any -// is empty. Mode is optional and Load normalizes an absent one to ModeOnline, so -// every config written before offline mode existed keeps behaving as it did. +// is empty. Everything else is optional: a nil pointer means "not set", and +// the accessor methods supply the default, so a freshly enrolled config needs +// none of them. type Config struct { DeviceID string `toml:"device_id"` BackendURL string `toml:"backend_url"` WebSocketURL string `toml:"websocket_url"` KeyPath string `toml:"key_path"` - Mode string `toml:"mode,omitempty"` + + ServicePortSet *bool `toml:"service_port,omitempty"` + USBImportSet *bool `toml:"usb_import,omitempty"` + ContentSyncSet *bool `toml:"content_sync,omitempty"` + ContentDaysSet *int `toml:"content_days,omitempty"` + AutoUpdateSet *bool `toml:"auto_update,omitempty"` + AppChannelSet *string `toml:"app_channel_url,omitempty"` + AgentChannelSet *string `toml:"agent_channel_url,omitempty"` +} + +func boolOr(p *bool, def bool) bool { + if p == nil { + return def + } + return *p +} + +// ServicePort reports whether the eth0 service port and upload server run. +func (c *Config) ServicePort() bool { + return boolOr(c.ServicePortSet, false) +} + +// USBImport reports whether USB sticks are accepted. +func (c *Config) USBImport() bool { return boolOr(c.USBImportSet, true) } + +// ContentSync reports whether content is synced from the backend. +func (c *Config) ContentSync() bool { return boolOr(c.ContentSyncSet, true) } + +// AutoUpdate reports whether the release channels are followed. +func (c *Config) AutoUpdate() bool { return boolOr(c.AutoUpdateSet, true) } + +// ContentDays is the window of a synced content package, 1-31. +func (c *Config) ContentDays() int { + if c.ContentDaysSet == nil || *c.ContentDaysSet < 1 || *c.ContentDaysSet > 31 { + return DefaultContentDays + } + return *c.ContentDaysSet +} + +// AppChannelURL is the app release channel, "" when disabled. +func (c *Config) AppChannelURL() string { + if c.AppChannelSet != nil { + return *c.AppChannelSet + } + return DefaultAppChannelURL } -// ValidateMode reports whether m is a mode the agent can run in. The empty -// string is not accepted here; Load maps it to ModeOnline before validating. -func ValidateMode(m string) error { - switch m { - case ModeOnline, ModeOffline: - return nil +// AgentChannelURL is the agent release channel for arch, "" when disabled. +func (c *Config) AgentChannelURL(arch string) string { + if c.AgentChannelSet != nil { + return *c.AgentChannelSet } - return fmt.Errorf("unknown mode %q (want %q or %q)", m, ModeOnline, ModeOffline) + return fmt.Sprintf(DefaultAgentChannelURL, arch) } func Load(path string) (*Config, error) { @@ -65,29 +109,21 @@ func Load(path string) (*Config, error) { if c.DeviceID == "" || c.BackendURL == "" || c.WebSocketURL == "" { return nil, fmt.Errorf("config %s is incomplete (run `agent enroll` first)", path) } - if c.Mode == "" { - c.Mode = ModeOnline - } - if err := ValidateMode(c.Mode); err != nil { - return nil, fmt.Errorf("config %s: %w", path, err) - } return &c, nil } -// SetMode rewrites the mode key of the config at path, leaving every other -// field as it was. The file keeps its owner: `mode` runs as root via sudo, but -// the daemon reading the result runs as the service user, and Save creates a -// fresh 0600 file that would otherwise belong to root. -func SetMode(path, mode string) error { - if err := ValidateMode(mode); err != nil { - return err - } +// SetServicePort rewrites service_port in the config at path, leaving every +// other field as it was. The file +// keeps its owner: this runs as root via sudo, but the daemon reading the +// result runs as the service user, and Save creates a fresh 0600 file that +// would otherwise belong to root. +func SetServicePort(path string, on bool) error { c, err := Load(path) if err != nil { return err } owner, haveOwner := fileOwner(path) - c.Mode = mode + c.ServicePortSet = &on if err := Save(path, c); err != nil { return err } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5aa17de..fb16494 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -22,83 +22,48 @@ func writeConfig(t *testing.T, body string) string { return p } -func TestLoadMode(t *testing.T) { - cases := []struct { - name string - extra string - want string - wantErr string - }{ - // Every config written before offline mode existed has no mode key. - {name: "absent means online", extra: "", want: ModeOnline}, - {name: "explicit online", extra: `mode = "online"`, want: ModeOnline}, - {name: "offline", extra: `mode = "offline"`, want: ModeOffline}, - {name: "empty string means online", extra: `mode = ""`, want: ModeOnline}, - {name: "unknown", extra: `mode = "airplane"`, wantErr: `unknown mode "airplane"`}, - {name: "wrong case", extra: `mode = "Offline"`, wantErr: "unknown mode"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - c, err := Load(writeConfig(t, baseTOML+tc.extra+"\n")) - if tc.wantErr != "" { - if err == nil || !strings.Contains(err.Error(), tc.wantErr) { - t.Fatalf("err = %v, want containing %q", err, tc.wantErr) - } - return - } - if err != nil { - t.Fatal(err) - } - if c.Mode != tc.want { - t.Errorf("Mode = %q, want %q", c.Mode, tc.want) - } - }) +func TestServicePort(t *testing.T) { + for extra, want := range map[string]bool{"": false, "service_port = true": true, "service_port = false": false} { + c, err := Load(writeConfig(t, baseTOML+extra+"\n")) + if err != nil { + t.Fatalf("%q: %v", extra, err) + } + if got := c.ServicePort(); got != want { + t.Errorf("%q: ServicePort = %v, want %v", extra, got, want) + } } } -func TestSetModeKeepsOtherFields(t *testing.T) { - p := writeConfig(t, baseTOML) - - if err := SetMode(p, ModeOffline); err != nil { - t.Fatal(err) - } - c, err := Load(p) +func TestDefaults(t *testing.T) { + c, err := Load(writeConfig(t, baseTOML)) if err != nil { t.Fatal(err) } - if c.Mode != ModeOffline { - t.Errorf("Mode = %q, want offline", c.Mode) - } - if c.DeviceID != "3f2a1b4c-0000-4000-8000-000000000001" || c.BackendURL != "https://api.example.com" || - c.WebSocketURL != "wss://api.example.com/api/agent/ws" || c.KeyPath != "/etc/musallahboard/agent.key" { - t.Errorf("other fields changed: %+v", c) - } - - if err := SetMode(p, ModeOnline); err != nil { - t.Fatal(err) + if !c.USBImport() || !c.ContentSync() || !c.AutoUpdate() || c.ContentDays() != DefaultContentDays { + t.Errorf("defaults wrong: %+v", c) } - if c, _ = Load(p); c.Mode != ModeOnline { - t.Errorf("Mode = %q, want online", c.Mode) + if !strings.HasSuffix(c.AgentChannelURL("arm64"), "agent-channel-arm64.json") { + t.Errorf("agent channel = %s", c.AgentChannelURL("arm64")) } - if _, err := os.Stat(p + ".tmp"); !os.IsNotExist(err) { - t.Errorf("temp file left behind: %v", err) + c, _ = Load(writeConfig(t, baseTOML+"app_channel_url = \"\"\ncontent_days = 99\n")) + if c.AppChannelURL() != "" || c.ContentDays() != DefaultContentDays { + t.Errorf("overrides wrong: app=%q days=%d", c.AppChannelURL(), c.ContentDays()) } } -func TestSetModeRejectsUnknownWithoutWriting(t *testing.T) { - p := writeConfig(t, baseTOML) - before, _ := os.ReadFile(p) - if err := SetMode(p, "sideways"); err == nil { - t.Fatal("expected an error") +func TestSetServicePortKeepsOtherFields(t *testing.T) { + p := writeConfig(t, baseTOML+"service_port = true\n") + if err := SetServicePort(p, false); err != nil { + t.Fatal(err) + } + c, err := Load(p) + if err != nil { + t.Fatal(err) } - after, _ := os.ReadFile(p) - if string(before) != string(after) { - t.Error("config was rewritten despite an invalid mode") + if c.ServicePort() || c.DeviceID != "3f2a1b4c-0000-4000-8000-000000000001" { + t.Errorf("after SetServicePort(false): %+v", c) } -} - -func TestSetModeRequiresEnrolledConfig(t *testing.T) { - if err := SetMode(filepath.Join(t.TempDir(), "missing.toml"), ModeOffline); err == nil { + if err := SetServicePort(filepath.Join(t.TempDir(), "missing.toml"), true); err == nil { t.Fatal("expected an error for a missing config") } } diff --git a/internal/devauth/devauth.go b/internal/devauth/devauth.go new file mode 100644 index 0000000..fc0bd9d --- /dev/null +++ b/internal/devauth/devauth.go @@ -0,0 +1,59 @@ +// Package devauth signs device-authenticated HTTP requests to the backend +// (docs/architecture.md, section 9.2). +// +// The device proves who it is with the same Ed25519 key it uses for the +// WebSocket handshake, over a message that binds the method, path, time and +// body. The prefix line is domain separation: a signature made here can never +// be replayed as a WebSocket auth (musallahboard-auth-v1) or as a package +// signature (musallahboard-mbu-v2), and the other way round. +package devauth + +import ( + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "net/http" + "strconv" + "time" +) + +// Prefix is the first line of the signed message. It must match the backend. +const Prefix = "musallahboard-http-v1" + +// Header names. +const ( + HeaderDeviceID = "X-MB-Device-Id" + HeaderTimestamp = "X-MB-Timestamp" + HeaderSignature = "X-MB-Signature" +) + +// Message builds the exact bytes that are signed: six lines joined by "\n", +// with no trailing newline. path is the request path without its query +// string; the query is deliberately not covered, so nothing in it may matter +// to an authenticated endpoint. +func Message(method, path, deviceID string, timestampMillis int64, body []byte) []byte { + sum := sha256.Sum256(body) + s := Prefix + "\n" + + method + "\n" + + path + "\n" + + deviceID + "\n" + + strconv.FormatInt(timestampMillis, 10) + "\n" + + hex.EncodeToString(sum[:]) + return []byte(s) +} + +// Sign sets the three device-auth headers on req. body must be exactly the +// bytes req will send (nil or empty for none). The path signed is +// req.URL.Path, or "/" when it is empty, which is what the server sees. +func Sign(req *http.Request, body []byte, deviceID string, key ed25519.PrivateKey, now time.Time) { + path := req.URL.Path + if path == "" { + path = "/" + } + ts := now.UnixMilli() + sig := ed25519.Sign(key, Message(req.Method, path, deviceID, ts, body)) + req.Header.Set(HeaderDeviceID, deviceID) + req.Header.Set(HeaderTimestamp, strconv.FormatInt(ts, 10)) + req.Header.Set(HeaderSignature, base64.StdEncoding.EncodeToString(sig)) +} diff --git a/internal/devauth/devauth_test.go b/internal/devauth/devauth_test.go new file mode 100644 index 0000000..fe6a050 --- /dev/null +++ b/internal/devauth/devauth_test.go @@ -0,0 +1,67 @@ +package devauth + +import ( + "bytes" + "crypto/ed25519" + "encoding/base64" + "net/http" + "strings" + "testing" + "time" +) + +const testDevice = "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b" + +// The message format is a contract with the backend; a change here without +// the same change there breaks every board's sync. +func TestMessageGolden(t *testing.T) { + got := string(Message("POST", "/api/agent/content-bundle", testDevice, 1790258531000, []byte(`{"days":7}`))) + want := "musallahboard-http-v1\n" + + "POST\n" + + "/api/agent/content-bundle\n" + + testDevice + "\n" + + "1790258531000\n" + + // lowercase hex sha256 of {"days":7} + "a2e0f104633a5821e6d1ec00745f8f1a10727d392249d4ddf92f4fcf7d3095ed" + if got != want { + t.Fatalf("message mismatch:\n got=%q\nwant=%q", got, want) + } +} + +func TestEmptyBodyHash(t *testing.T) { + got := string(Message("GET", "/x", "d", 1, nil)) + const emptySHA = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + if !strings.HasSuffix(got, "\n"+emptySHA) { + t.Fatalf("empty body must hash as the empty string: %q", got) + } +} + +func TestSignFixedKeyGolden(t *testing.T) { + key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{7}, 32)) + req, _ := http.NewRequest("POST", "https://api.example/api/agent/content-bundle?ignored=1", nil) + body := []byte(`{"days":7,"haveMedia":[]}`) + now := time.UnixMilli(1790258531123) + Sign(req, body, testDevice, key, now) + + if req.Header.Get(HeaderDeviceID) != testDevice { + t.Errorf("device header = %q", req.Header.Get(HeaderDeviceID)) + } + if req.Header.Get(HeaderTimestamp) != "1790258531123" { + t.Errorf("timestamp header = %q", req.Header.Get(HeaderTimestamp)) + } + sig, err := base64.StdEncoding.DecodeString(req.Header.Get(HeaderSignature)) + if err != nil { + t.Fatal(err) + } + // The query string is not part of the signed path. + msg := Message("POST", "/api/agent/content-bundle", testDevice, 1790258531123, body) + if !ed25519.Verify(key.Public().(ed25519.PublicKey), msg, sig) { + t.Fatal("signature does not verify over the documented message") + } + // Ed25519 is deterministic, so a fixed key and message give a fixed + // signature: a golden value any other implementation can check against. + const golden = "UCYvISeIAOj3C+g314gFko/54swdK2XPyNRCJu4xMCiK1N4Idgm1B443tj7+31hroI7pruE6JNfaav7R8uLdDQ==" + if got := req.Header.Get(HeaderSignature); got != golden { + t.Errorf("signature = %s, want %s", got, golden) + } +} diff --git a/internal/enroll/enroll.go b/internal/enroll/enroll.go index 9d0698f..7e6de89 100644 --- a/internal/enroll/enroll.go +++ b/internal/enroll/enroll.go @@ -2,7 +2,9 @@ // // On the Pi: generate a fresh Ed25519 keypair, send the public key (raw 32 // bytes, base64) along with a one-time admin-issued token to the backend, and -// persist the (deviceId, websocketUrl) reply alongside the private key. +// persist the (deviceId, websocketUrl) reply alongside the private key. The +// reply also carries the backend's content signing keys, which are pinned in +// the trust store so the board can verify the content packages it syncs. // // The private key never leaves the device. The token is single-use and // time-bound — the backend rejects replays, so re-running `agent enroll` @@ -24,14 +26,17 @@ import ( "github.com/LensBridge/agent/internal/api" "github.com/LensBridge/agent/internal/config" "github.com/LensBridge/agent/internal/keystore" - "github.com/LensBridge/agent/internal/kioskurl" + "github.com/LensBridge/agent/internal/trust" ) type Params struct { - Token string - BackendURL string - ConfigPath string - KeyPath string // optional; defaults to config.DefaultKeyPath + Token string + BackendURL string + ConfigPath string + KeyPath string // optional; defaults to config.DefaultKeyPath + // TrustPath is the trust store the backend's content keys are pinned + // in; optional, defaults to trust.DefaultPath. + TrustPath string AgentVersion string } @@ -89,6 +94,11 @@ func Run(ctx context.Context, logger *slog.Logger, p Params) error { deviceID := enrolled.DeviceId.String() + var contentKeys []SigningKey + for _, k := range enrolled.ContentSigningKeys { + contentKeys = append(contentKeys, SigningKey{KeyID: k.KeyId, PublicKey: k.PublicKey}) + } + // The backend sometimes constructs the WebSocket URL from the HTTP request's // Host header, which drops the port when behind a reverse proxy or when the // client connects directly without a Host: port. Patch it back in if the @@ -114,14 +124,25 @@ func Run(ctx context.Context, logger *slog.Logger, p Params) error { } _ = chownToDirOwner(p.ConfigPath, filepath.Dir(p.ConfigPath)) - // Compose the kiosk URL now. This file is also the kiosk unit's enrollment - // sentinel — writing it here is what releases cage/Chromium to launch the - // board for the first time. Non-fatal: if the operator hasn't provisioned - // the base board URL yet, the daemon retries this on every startup. - if err := kioskurl.Write(kioskurl.DefaultBoardURLPath, kioskurl.DefaultOutPath, deviceID); err != nil { - logger.Warn("could not compose kiosk url (kiosk will wait)", "err", err) + // The kiosk URL is not written here any more: in v2 the kiosk always + // loads the agent's own local server, and the daemon writes kiosk-url + // itself once it sees the config (docs/architecture.md, section 14). + + // Pin the backend's content keys. They arrive over the same TLS + // connection that just established this device's identity, which is as + // much as any later fetch could offer. Failing here would strand a + // device the backend already counts as enrolled, so it is a warning + // with the fix in it, not an error. + trustPath := p.TrustPath + if trustPath == "" { + trustPath = trust.DefaultPath + } + if len(contentKeys) == 0 { + logger.Warn("the backend sent no content signing keys, so this board cannot verify content until they are fetched: run `sudo musallahboard-agent trust fetch` once the backend has a content key configured") + } else if added, err := PinContentKeys(trustPath, contentKeys, "lensbridge"); err != nil { + logger.Warn("could not pin the backend's content signing keys; run `sudo musallahboard-agent trust fetch`", "err", err, "path", trustPath) } else { - logger.Info("kiosk url written", "path", kioskurl.DefaultOutPath) + logger.Info("content signing keys pinned", "received", len(contentKeys), "added", len(added), "path", trustPath) } logger.Info("device enrolled", diff --git a/internal/enroll/enroll_test.go b/internal/enroll/enroll_test.go index dc96a5d..9abcf1d 100644 --- a/internal/enroll/enroll_test.go +++ b/internal/enroll/enroll_test.go @@ -1,7 +1,10 @@ package enroll import ( + "bytes" "context" + "crypto/ed25519" + "encoding/base64" "encoding/json" "io" "log/slog" @@ -13,6 +16,7 @@ import ( "testing" "github.com/LensBridge/agent/internal/config" + "github.com/LensBridge/agent/internal/trust" ) func quietLogger() *slog.Logger { @@ -26,6 +30,18 @@ func paths(t *testing.T) (cfgPath, keyPath string) { return filepath.Join(dir, "agent.toml"), filepath.Join(dir, "agent.key") } +// trustPathBeside is the trust store tests use: next to the config, never +// the real /etc/musallahboard/trust.json. +func trustPathBeside(cfgPath string) string { + return filepath.Join(filepath.Dir(cfgPath), "trust.json") +} + +var contentPub = ed25519.NewKeyFromSeed(bytes.Repeat([]byte{5}, 32)).Public().(ed25519.PublicKey) + +func signingKeyJSON(pub ed25519.PublicKey) map[string]any { + return map[string]any{"keyId": trust.KeyID(pub), "publicKey": base64.StdEncoding.EncodeToString(pub)} +} + func TestRunEnrollsAndPersistsConfig(t *testing.T) { const deviceID = "6f1e1d94-1f4a-4a1e-9a1e-2f3c4d5e6a7b" @@ -44,8 +60,9 @@ func TestRunEnrollsAndPersistsConfig(t *testing.T) { // Carries an explicit port so restorePort leaves it untouched; the // port-patching behaviour has its own test below. json.NewEncoder(w).Encode(map[string]any{ - "deviceId": deviceID, - "websocketUrl": "ws://board.example:8080/api/agent/ws", + "deviceId": deviceID, + "websocketUrl": "ws://board.example:8080/api/agent/ws", + "contentSigningKeys": []any{signingKeyJSON(contentPub)}, }) })) defer server.Close() @@ -56,6 +73,7 @@ func TestRunEnrollsAndPersistsConfig(t *testing.T) { BackendURL: server.URL, ConfigPath: cfgPath, KeyPath: keyPath, + TrustPath: trustPathBeside(cfgPath), AgentVersion: "1.2.3", }) if err != nil { @@ -89,6 +107,82 @@ func TestRunEnrollsAndPersistsConfig(t *testing.T) { if _, err := os.Stat(keyPath); err != nil { t.Errorf("private key not written: %v", err) } + + // The backend's content key is pinned, as a content key only. + ts, err := trust.Load(trustPathBeside(cfgPath)) + if err != nil { + t.Fatalf("load trust store: %v", err) + } + if len(ts.Content) != 1 || ts.Content[0].KeyID != trust.KeyID(contentPub) || len(ts.Release) != 0 { + t.Errorf("trust store = %+v, want exactly the backend's content key", ts) + } +} + +// A backend with no content signing key configured sends no keys; the board +// still enrolls, and gets them later from `trust fetch`. +func TestRunWithoutContentKeys(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "deviceId": "6f1e1d94-1f4a-4a1e-9a1e-2f3c4d5e6a7b", + "websocketUrl": "ws://board.example:8080/api/agent/ws", + }) + })) + defer server.Close() + + cfgPath, keyPath := paths(t) + if err := Run(context.Background(), quietLogger(), Params{ + Token: "t", BackendURL: server.URL, ConfigPath: cfgPath, KeyPath: keyPath, + TrustPath: trustPathBeside(cfgPath), + }); err != nil { + t.Fatalf("Run() = %v", err) + } + if _, err := os.Stat(trustPathBeside(cfgPath)); err == nil { + t.Error("trust store written with no keys to pin") + } +} + +func TestPinContentKeys(t *testing.T) { + p := filepath.Join(t.TempDir(), "trust.json") + k := SigningKey{KeyID: trust.KeyID(contentPub), PublicKey: base64.StdEncoding.EncodeToString(contentPub)} + added, err := PinContentKeys(p, []SigningKey{k}, "lensbridge") + if err != nil || len(added) != 1 { + t.Fatalf("first pin: %v, %v", added, err) + } + // Idempotent. + if added, err := PinContentKeys(p, []SigningKey{k}, "lensbridge"); err != nil || len(added) != 0 { + t.Fatalf("second pin: %v, %v", added, err) + } + // A key whose id does not match is refused, and nothing else in the + // same response is taken either. + other := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{6}, 32)).Public().(ed25519.PublicKey) + bad := SigningKey{KeyID: "0000000000000000", PublicKey: base64.StdEncoding.EncodeToString(other)} + good := SigningKey{PublicKey: base64.StdEncoding.EncodeToString(other)} + if _, err := PinContentKeys(p, []SigningKey{good, bad}, "x"); err == nil { + t.Fatal("mismatched key id accepted") + } + ts, _ := trust.Load(p) + if len(ts.Content) != 1 { + t.Fatalf("trust store changed by a refused response: %+v", ts) + } +} + +func TestFetchSigningKeys(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/agent/signing-keys" { + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode(map[string]any{"content": []any{signingKeyJSON(contentPub)}}) + })) + defer server.Close() + keys, err := FetchSigningKeys(context.Background(), server.URL+"/", nil) + if err != nil || len(keys) != 1 || keys[0].KeyID != trust.KeyID(contentPub) { + t.Fatalf("FetchSigningKeys = %v, %v", keys, err) + } + if _, err := FetchSigningKeys(context.Background(), server.URL+"/nope", nil); err == nil { + t.Fatal("404 not reported") + } } // A rejected token must surface the server's message rather than a bare status. @@ -110,6 +204,7 @@ func TestRunReportsRejectionMessage(t *testing.T) { BackendURL: server.URL, ConfigPath: cfgPath, KeyPath: keyPath, + TrustPath: trustPathBeside(cfgPath), }) if err == nil { t.Fatal("Run() = nil, want error") @@ -142,6 +237,7 @@ func TestRunRejectsIncompleteResponse(t *testing.T) { BackendURL: server.URL, ConfigPath: cfgPath, KeyPath: keyPath, + TrustPath: trustPathBeside(cfgPath), }) if err == nil || !strings.Contains(err.Error(), "missing deviceId or websocketUrl") { t.Fatalf("Run() = %v, want missing-field error", err) diff --git a/internal/enroll/trustkeys.go b/internal/enroll/trustkeys.go new file mode 100644 index 0000000..247b598 --- /dev/null +++ b/internal/enroll/trustkeys.go @@ -0,0 +1,94 @@ +package enroll + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/LensBridge/agent/internal/trust" +) + +// SigningKey is one content signing key as the backend publishes it, in the +// enrollment response (contentSigningKeys) and at GET /api/agent/signing-keys. +type SigningKey struct { + KeyID string `json:"keyId"` + PublicKey string `json:"publicKey"` +} + +// PinContentKeys adds keys to the trust store at trustPath as content keys and +// returns the ids it added (keys already trusted are left as they are). Every +// key is checked before anything is written: a key whose id does not match +// its public key means the backend or the connection is broken, and none of +// that response is trusted. +// +// Keys are only ever added, never removed: a key the backend stopped +// publishing may still have signed the content on a USB stick in someone's +// pocket. Removal is a deliberate `trust remove`. +func PinContentKeys(trustPath string, keys []SigningKey, note string) ([]string, error) { + pubs := make([][]byte, 0, len(keys)) + for _, k := range keys { + pub, err := trust.ParsePublicKey(k.PublicKey) + if err != nil { + return nil, fmt.Errorf("content key %s: %w", k.KeyID, err) + } + if k.KeyID != "" && trust.KeyID(pub) != k.KeyID { + return nil, fmt.Errorf("content key %s does not match its public key (its id is %s)", k.KeyID, trust.KeyID(pub)) + } + pubs = append(pubs, pub) + } + s, err := trust.LoadForWrite(trustPath) + if err != nil { + return nil, err + } + var added []string + for _, pub := range pubs { + if s.Add(trust.RoleContent, pub, note) { + added = append(added, trust.KeyID(pub)) + } + } + if len(added) == 0 { + return nil, nil + } + if err := s.Save(trustPath); err != nil { + return nil, fmt.Errorf("save %s: %w", trustPath, err) + } + return added, nil +} + +// FetchSigningKeys gets the backend's published content keys (GET +// /api/agent/signing-keys). This is trust on first use over TLS, the same +// footing enrollment itself stands on. +func FetchSigningKeys(ctx context.Context, backendURL string, client *http.Client) ([]SigningKey, error) { + if client == nil { + client = &http.Client{Timeout: 30 * time.Second} + } + u := strings.TrimRight(backendURL, "/") + "/api/agent/signing-keys" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, fmt.Errorf("bad backend URL %q: %w", backendURL, err) + } + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("could not reach %s: %w", u, err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("reading the answer from %s: %w", u, err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s returned %d: %s", u, resp.StatusCode, truncate(raw, 300)) + } + var body struct { + Content []SigningKey `json:"content"` + } + if err := json.Unmarshal(raw, &body); err != nil { + return nil, fmt.Errorf("%s did not return the expected JSON: %w", u, err) + } + return body.Content, nil +} diff --git a/internal/events/events.go b/internal/events/events.go new file mode 100644 index 0000000..715a69f --- /dev/null +++ b/internal/events/events.go @@ -0,0 +1,108 @@ +// Package events fans install notifications out to the kiosk page as +// Server-Sent Events (docs/architecture.md, section 7, /api/local/events). +// +// The page is the only subscriber in practice, but Chromium can briefly hold +// two connections across a reload, so this is a small broadcast hub rather +// than a single channel. A slow subscriber loses events instead of blocking +// an install: the page also polls, so a lost event costs at most a minute. +package events + +import ( + "encoding/json" + "fmt" + "net/http" + "sync" + "time" +) + +// Event is one SSE message. +type Event struct { + Name string + Data any +} + +// Hub broadcasts events. The zero value is ready to use. +type Hub struct { + mu sync.Mutex + subs map[chan Event]struct{} +} + +// ContentChanged implements importer.Events. +func (h *Hub) ContentChanged(sequence int64) { + h.Publish(Event{Name: "content", Data: map[string]int64{"sequence": sequence}}) +} + +// AppChanged implements importer.Events. +func (h *Hub) AppChanged(version string) { + h.Publish(Event{Name: "app", Data: map[string]string{"version": version}}) +} + +// Publish sends e to every subscriber that has room for it. +func (h *Hub) Publish(e Event) { + h.mu.Lock() + defer h.mu.Unlock() + for c := range h.subs { + select { + case c <- e: + default: + } + } +} + +func (h *Hub) subscribe() chan Event { + c := make(chan Event, 8) + h.mu.Lock() + if h.subs == nil { + h.subs = map[chan Event]struct{}{} + } + h.subs[c] = struct{}{} + h.mu.Unlock() + return c +} + +func (h *Hub) unsubscribe(c chan Event) { + h.mu.Lock() + delete(h.subs, c) + h.mu.Unlock() +} + +// PingInterval keeps idle connections open through anything that times them +// out, and lets the server notice a closed page. +const PingInterval = 25 * time.Second + +// ServeHTTP streams events to one client until it disconnects. +func (h *Hub) ServeHTTP(w http.ResponseWriter, r *http.Request) { + fl, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "retry: 5000\n\n") + fl.Flush() + + c := h.subscribe() + defer h.unsubscribe(c) + t := time.NewTicker(PingInterval) + defer t.Stop() + for { + select { + case <-r.Context().Done(): + return + case e := <-c: + data, _ := json.Marshal(e.Data) + if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", e.Name, data); err != nil { + return + } + fl.Flush() + case <-t.C: + if _, err := fmt.Fprint(w, ": ping\n\n"); err != nil { + return + } + fl.Flush() + } + } +} diff --git a/internal/offline/fs_unix.go b/internal/fsutil/fs_unix.go similarity index 79% rename from internal/offline/fs_unix.go rename to internal/fsutil/fs_unix.go index 0177f94..7a0d227 100644 --- a/internal/offline/fs_unix.go +++ b/internal/fsutil/fs_unix.go @@ -1,6 +1,6 @@ //go:build !windows -package offline +package fsutil import ( "errors" @@ -10,8 +10,8 @@ import ( "syscall" ) -// syncDir fsyncs a directory, making renames and creations inside it durable. -func syncDir(dir string) error { +// SyncDir fsyncs a directory, making renames and creations inside it durable. +func SyncDir(dir string) error { d, err := os.Open(dir) if err != nil { return err @@ -23,10 +23,10 @@ func syncDir(dir string) error { return nil } -// lockDir takes an exclusive, non-blocking flock on dir/.lock so two installs +// LockDir takes an exclusive, non-blocking flock on dir/.lock so two installs // cannot interleave their staging, swap and prune. The kernel drops the lock // when the process exits, so a crashed install never wedges the next one. -func lockDir(dir string) (unlock func(), err error) { +func LockDir(dir string) (unlock func(), err error) { f, err := os.OpenFile(filepath.Join(dir, ".lock"), os.O_RDWR|os.O_CREATE, 0o644) if err != nil { return nil, err diff --git a/internal/offline/fs_windows.go b/internal/fsutil/fs_windows.go similarity index 63% rename from internal/offline/fs_windows.go rename to internal/fsutil/fs_windows.go index 7b1f680..2a2de94 100644 --- a/internal/offline/fs_windows.go +++ b/internal/fsutil/fs_windows.go @@ -1,11 +1,11 @@ //go:build windows -package offline +package fsutil // The agent only runs on Linux. These stubs let the package build and its // tests run on a Windows dev box, where directories cannot be opened for // fsync and flock does not exist. -func syncDir(string) error { return nil } +func SyncDir(string) error { return nil } -func lockDir(string) (func(), error) { return func() {}, nil } +func LockDir(string) (func(), error) { return func() {}, nil } diff --git a/internal/fsutil/fsutil.go b/internal/fsutil/fsutil.go new file mode 100644 index 0000000..0f97690 --- /dev/null +++ b/internal/fsutil/fsutil.go @@ -0,0 +1,161 @@ +// Package fsutil holds the small filesystem primitives every installer on the +// board is built from: fsynced writes, atomic replace, atomic symlink swap and +// an exclusive directory lock. +// +// Every install in the agent follows one shape: write into a staging +// directory, fsync the files and the directory, rename into place, then swap a +// symlink. A power cut at any point before the swap leaves the old state +// serving; after it, the new. These helpers are that shape's parts. +package fsutil + +import ( + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strconv" +) + +// WriteFileSync creates path (which must not exist), writes data and fsyncs it. +func WriteFileSync(path string, data []byte, mode os.FileMode) error { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if err != nil { + return err + } + if _, err := f.Write(data); err != nil { + f.Close() + return err + } + if err := f.Sync(); err != nil { + f.Close() + return err + } + return f.Close() +} + +// CopyFileSync copies at most limit bytes from r to a new file at path, fsyncs +// it, and returns how many bytes were written. The caller decides whether a +// copy that reached the limit is an error. +func CopyFileSync(path string, r io.Reader, limit int64, mode os.FileMode) (int64, error) { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if err != nil { + return 0, err + } + n, err := io.Copy(f, io.LimitReader(r, limit)) + if err != nil { + f.Close() + return n, err + } + if err := f.Sync(); err != nil { + f.Close() + return n, err + } + return n, f.Close() +} + +// WriteAtomic replaces path with data: a temp file beside it is written, +// fsynced and renamed over it, then the directory is fsynced. Readers see the +// old content or the new, never a partial file. +func WriteAtomic(path string, data []byte, mode os.FileMode) error { + dir := filepath.Dir(path) + f, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-") + if err != nil { + return err + } + tmp := f.Name() + fail := func(err error) error { + f.Close() + os.Remove(tmp) + return err + } + if _, err := f.Write(data); err != nil { + return fail(err) + } + if err := f.Chmod(mode); err != nil { + return fail(err) + } + if err := f.Sync(); err != nil { + return fail(err) + } + if err := f.Close(); err != nil { + os.Remove(tmp) + return err + } + if err := os.Rename(tmp, path); err != nil { + os.Remove(tmp) + return err + } + return SyncDir(dir) +} + +// UniqueName returns base, or base.2, base.3, ...: the first that does not +// exist in dir. +func UniqueName(dir, base string) string { + name := base + for i := 2; ; i++ { + if _, err := os.Lstat(filepath.Join(dir, name)); os.IsNotExist(err) { + return name + } + name = base + "." + strconv.Itoa(i) + } +} + +// SwapSymlink points link at target atomically: a new symlink is made beside +// it and renamed over it, so a reader resolving link sees the old target or +// the new one and never nothing. The containing directory is fsynced so the +// swap survives a power cut. +func SwapSymlink(link, target string) error { + tmp := link + ".tmp" + // A leftover from an install that died between these two steps. + _ = os.Remove(tmp) + if err := os.Symlink(target, tmp); err != nil { + return fmt.Errorf("create symlink %s: %w", tmp, err) + } + if err := os.Rename(tmp, link); err != nil { + os.Remove(tmp) + return fmt.Errorf("replace %s: %w", link, err) + } + return SyncDir(filepath.Dir(link)) +} + +// ReadLinkAbs resolves one level of symlink at link to an absolute path. A +// relative target is taken relative to link's directory. +func ReadLinkAbs(link string) (string, error) { + target, err := os.Readlink(link) + if err != nil { + return "", err + } + if !filepath.IsAbs(target) { + target = filepath.Join(filepath.Dir(link), target) + } + return filepath.Clean(target), nil +} + +// Prune deletes every entry of dir whose name is not in keep, and returns the +// names it removed, sorted. Callers hold the directory's install lock, so a +// leftover .staging-* directory cannot belong to a live install and goes too. +func Prune(dir string, keep ...string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + keepSet := make(map[string]bool, len(keep)) + for _, k := range keep { + if k != "" { + keepSet[k] = true + } + } + var removed []string + for _, e := range entries { + if keepSet[e.Name()] { + continue + } + if err := os.RemoveAll(filepath.Join(dir, e.Name())); err != nil { + return removed, fmt.Errorf("remove old %s: %w", e.Name(), err) + } + removed = append(removed, e.Name()) + } + sort.Strings(removed) + return removed, nil +} diff --git a/internal/importer/importer.go b/internal/importer/importer.go new file mode 100644 index 0000000..c985209 --- /dev/null +++ b/internal/importer/importer.go @@ -0,0 +1,434 @@ +// Package importer is the one path every package takes onto a board +// (docs/architecture.md, section 8), whatever carried it: backend sync, a USB +// stick, the service-port upload server or the admin CLI. +// +// For each package it verifies (mbu.Open against the trust ring), decides +// against the board's high-water marks, and installs content and app packages +// itself or stages an agent package for the root self-updater. Batches are +// serialised: only one import runs at a time. +package importer + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "sync" + "time" + + "github.com/LensBridge/agent/internal/fsutil" + "github.com/LensBridge/agent/internal/mbu" + "github.com/LensBridge/agent/internal/state" + "github.com/LensBridge/agent/internal/store" + "github.com/LensBridge/agent/internal/trust" +) + +// LocalAPIVersion is the local kiosk API this agent serves (section 7). An app +// package needing a newer one is refused. +const LocalAPIVersion = 2 + +// Source is where a batch came from. +type Source string + +const ( + SourceSync Source = "sync" + SourceUSB Source = "usb" + SourceUpload Source = "upload" + SourceCLI Source = "cli" +) + +// Actions a package can end in. +const ( + ActionInstalled = "installed" + ActionUnchanged = "unchanged" + ActionStaged = "staged" + ActionSkipped = "skipped" + ActionRejected = "rejected" + ActionQueued = "queued" +) + +// Result is the outcome for one package. +type Result struct { + File string `json:"file"` + Type mbu.Type `json:"type,omitempty"` + Version string `json:"version,omitempty"` + Sequence int64 `json:"sequence,omitempty"` + Action string `json:"action"` + Message string `json:"message"` +} + +// Batch is the outcome of one import. +type Batch struct { + Results []Result `json:"results"` + // Verified reports whether at least one package authenticated. The + // upload server only trusts an uploader's clock when it did. + Verified bool `json:"-"` + // MaxCreatedAt is the newest createdAt among authenticated packages. + MaxCreatedAt time.Time `json:"-"` + // AgentStaged means a new agent is about to replace this one. + AgentStaged bool `json:"-"` +} + +// OK reports whether nothing was rejected. +func (b Batch) OK() bool { + for _, r := range b.Results { + if r.Action == ActionRejected { + return false + } + } + return true +} + +// Screen is the kiosk's update screen (package updatescreen). +type Screen interface { + Begin(ctx context.Context) + Caption(ctx context.Context, text string) + // Finish shows the outcome and returns the kiosk to the board after a + // countdown. restarting means the agent is about to be replaced. + Finish(ctx context.Context, ok bool, detail string, restarting bool) +} + +// Events is told about installs the page should react to. +type Events interface { + ContentChanged(sequence int64) + AppChanged(version string) +} + +// Deps is what an Importer needs. +type Deps struct { + Layout store.Layout + DeviceID string + AgentVersion string + // Ring loads the trust ring. Called per batch, so `trust fetch` or + // `trust add` take effect without a restart. + Ring func() (*trust.Ring, error) + Screen Screen // may be nil + Events Events // may be nil + Logger *slog.Logger + Now func() time.Time +} + +// Importer serialises imports. +type Importer struct { + d Deps + mu sync.Mutex +} + +// New returns an Importer. +func New(d Deps) *Importer { + if d.Now == nil { + d.Now = time.Now + } + if d.Logger == nil { + d.Logger = slog.New(slog.DiscardHandler) + } + return &Importer{d: d} +} + +// ErrBusy is returned by TryImport while another import runs. +var ErrBusy = errors.New("another import is in progress") + +// Import processes files as one batch, waiting for any running import. +func (im *Importer) Import(ctx context.Context, src Source, files []string) Batch { + im.mu.Lock() + defer im.mu.Unlock() + return im.run(ctx, src, files) +} + +// TryImport is Import, but fails at once with ErrBusy if an import is running. +func (im *Importer) TryImport(ctx context.Context, src Source, files []string) (Batch, error) { + if !im.mu.TryLock() { + return Batch{}, ErrBusy + } + defer im.mu.Unlock() + return im.run(ctx, src, files), nil +} + +type item struct { + path string + peek *mbu.Manifest // unverified; ordering and screen only +} + +func rank(t mbu.Type) int { + switch t { + case mbu.TypeAgent: + return 0 + case mbu.TypeApp: + return 1 + } + return 2 +} + +func (im *Importer) run(ctx context.Context, src Source, files []string) Batch { + var b Batch + items := make([]item, 0, len(files)) + for _, f := range files { + m, _ := mbu.Peek(f) + items = append(items, item{path: f, peek: m}) + } + sort.SliceStable(items, func(i, j int) bool { return rank(peekType(items[i])) < rank(peekType(items[j])) }) + + // The screen goes up for anything a person brought to the board, unless + // everything in it is someone else's content. Background sync only puts + // it up for software. + screen := false + for _, it := range items { + if it.peek != nil && it.peek.Type == mbu.TypeContent && it.peek.DeviceID != "" && it.peek.DeviceID != im.d.DeviceID { + continue + } + if src != SourceSync || peekType(it) != mbu.TypeContent { + screen = true + } + } + shown := false + show := func(caption string) { + if im.d.Screen == nil || !screen { + return + } + if !shown { + im.d.Screen.Begin(ctx) + shown = true + } + im.d.Screen.Caption(ctx, caption) + } + + ring, ringErr := im.d.Ring() + for i, it := range items { + name := filepath.Base(it.path) + if b.AgentStaged { + b.Results = append(b.Results, im.requeue(it.path, name)) + continue + } + if it.peek != nil && it.peek.Type == mbu.TypeContent && it.peek.DeviceID != "" && it.peek.DeviceID != im.d.DeviceID { + b.Results = append(b.Results, Result{File: name, Type: mbu.TypeContent, Action: ActionSkipped, + Message: "content for another board (" + shortID(it.peek.DeviceID) + "); left alone"}) + continue + } + label := "update" + if it.peek != nil { + label = it.peek.Describe() + } + show(fmt.Sprintf("Checking %s (%d of %d)", label, i+1, len(items))) + var r Result + if ringErr != nil { + r = reject(name, fmt.Errorf("cannot read the trust store: %w", ringErr)) + } else { + r = im.one(ctx, src, it.path, name, ring, &b, show) + } + im.d.Logger.Info("import", "source", src, "file", name, "type", r.Type, "action", r.Action, "message", r.Message) + b.Results = append(b.Results, r) + } + + if shown { + ok := b.OK() + detail := summary(b) + im.d.Screen.Finish(ctx, ok, detail, b.AgentStaged) + } + return b +} + +func peekType(it item) mbu.Type { + if it.peek == nil { + return mbu.TypeContent + } + return it.peek.Type +} + +func (im *Importer) one(ctx context.Context, src Source, path, name string, ring *trust.Ring, b *Batch, show func(string)) Result { + l := im.d.Layout + pkg, err := mbu.Open(path, ring, mbu.OpenOptions{HaveMedia: l.HaveMedia}) + if err != nil { + return reject(name, err) + } + defer pkg.Close() + m := pkg.Manifest + b.Verified = true + if t := m.CreatedTime(); t.After(b.MaxCreatedAt) { + b.MaxCreatedAt = t + } + // A signed createdAt is a lower bound on the real time (section 10). + if err := l.State().RaiseSignedClockFloor(m.CreatedTime().Unix()); err != nil { + im.d.Logger.Warn("could not record clock floor", "err", err) + } + st, err := l.State().Load() + if err != nil { + return reject(name, err) + } + res := Result{File: name, Type: m.Type, Version: m.Version, Sequence: m.Sequence} + now := im.d.Now() + + switch m.Type { + case mbu.TypeContent: + if m.DeviceID != im.d.DeviceID { + res.Action, res.Message = ActionSkipped, "content for another board ("+shortID(m.DeviceID)+"); left alone" + return res + } + switch { + case m.Sequence < st.ContentSequence: + return rejectAs(res, "this content is older than what is installed") + case m.Sequence == st.ContentSequence: + res.Action, res.Message = ActionUnchanged, "this content is already installed" + return res + } + show("Installing " + m.Describe()) + changed, err := l.InstallContent(pkg, string(src), now) + if err != nil && !strings.Contains(err.Error(), "content installed, but") { + return rejectAs(res, err.Error()) + } + if err != nil { + im.d.Logger.Warn("content cleanup", "err", err) + } + if _, err := l.State().Update(func(s *state.State) error { + if m.Sequence > s.ContentSequence { + s.ContentSequence = m.Sequence + } + return nil + }); err != nil { + im.d.Logger.Error("could not record content sequence", "err", err) + } + if changed && im.d.Events != nil { + im.d.Events.ContentChanged(m.Sequence) + } + res.Action, res.Message = ActionInstalled, "Installed "+m.Describe() + + case mbu.TypeApp: + if m.App.LocalAPI > LocalAPIVersion { + return rejectAs(res, fmt.Sprintf("board app %s needs a newer agent (local API %d; this agent serves %d): install the agent update first", + m.Version, m.App.LocalAPI, LocalAPIVersion)) + } + switch c := mbu.CompareVersions(m.Version, st.AppVersion); { + case c < 0: + return rejectAs(res, "board app "+m.Version+" is older than the installed "+st.AppVersion) + case c == 0: + res.Action, res.Message = ActionUnchanged, "board app "+m.Version+" is already installed" + return res + } + show("Installing " + m.Describe()) + if err := l.InstallApp(pkg, string(src), now); err != nil && !strings.Contains(err.Error(), "app installed, but") { + return rejectAs(res, err.Error()) + } + if _, err := l.State().Update(func(s *state.State) error { + if mbu.CompareVersions(m.Version, s.AppVersion) > 0 { + s.AppVersion = m.Version + } + return nil + }); err != nil { + im.d.Logger.Error("could not record app version", "err", err) + } + if im.d.Events != nil { + im.d.Events.AppChanged(m.Version) + } + res.Action, res.Message = ActionInstalled, "Installed "+m.Describe() + + case mbu.TypeAgent: + if m.Agent.Arch != runtime.GOARCH { + return rejectAs(res, fmt.Sprintf("agent %s is built for %s; this board is %s", m.Version, m.Agent.Arch, runtime.GOARCH)) + } + if st.AgentRejected(m.Version) { + return rejectAs(res, "agent "+m.Version+" failed to start on this board before; it will not be retried") + } + switch c := mbu.CompareVersions(m.Version, im.d.AgentVersion); { + case c < 0: + return rejectAs(res, "agent "+m.Version+" is older than the running "+im.d.AgentVersion) + case c == 0: + res.Action, res.Message = ActionUnchanged, "agent "+m.Version+" is already running" + return res + } + show("Preparing " + m.Describe()) + if err := im.stageAgent(path); err != nil { + return rejectAs(res, err.Error()) + } + b.AgentStaged = true + res.Action, res.Message = ActionStaged, "Installing "+m.Describe()+"; the agent restarts in a moment" + } + return res +} + +// stageAgent hands a verified agent package to the root updater. The updater +// verifies it again from its own copy; staging only has to be atomic. +func (im *Importer) stageAgent(path string) error { + l := im.d.Layout + if err := os.MkdirAll(l.AgentStagedDir(), 0o750); err != nil { + return err + } + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + tmp := l.StagedPackage() + ".part" + os.Remove(tmp) + if _, err := fsutil.CopyFileSync(tmp, f, mbu.MaxPackageBytes+1, 0o640); err != nil { + os.Remove(tmp) + return err + } + if err := os.Rename(tmp, l.StagedPackage()); err != nil { + return err + } + return fsutil.WriteAtomic(l.StagedReady(), []byte(im.d.Now().UTC().Format(time.RFC3339)+"\n"), 0o640) +} + +// requeue moves a file that follows a staged agent into the inbox, for the +// new agent to process. +func (im *Importer) requeue(path, name string) Result { + inbox := im.d.Layout.Inbox() + if filepath.Dir(path) != inbox { + dst := filepath.Join(inbox, fsutil.UniqueName(inbox, name)) + if err := os.MkdirAll(inbox, 0o770); err == nil { + if err := os.Rename(path, dst); err != nil { + return Result{File: name, Action: ActionRejected, Message: "could not queue it behind the agent update: " + err.Error()} + } + } + } + return Result{File: name, Action: ActionQueued, Message: "installs after the agent update restarts the agent"} +} + +func reject(name string, err error) Result { + return Result{File: name, Action: ActionRejected, Message: err.Error()} +} + +func rejectAs(r Result, msg string) Result { + r.Action, r.Message = ActionRejected, msg + return r +} + +func shortID(id string) string { + if len(id) > 8 { + return id[:8] + } + return id +} + +// summary is the one line the update screen shows under its outcome. +func summary(b Batch) string { + var installed, rejected []string + for _, r := range b.Results { + switch r.Action { + case ActionInstalled, ActionStaged: + installed = append(installed, r.Message) + case ActionRejected: + rejected = append(rejected, r.File+": "+r.Message) + } + } + if len(rejected) > 0 { + return rejected[0] + } + if len(installed) > 0 { + return strings.Join(installed, " · ") + } + return "Nothing new to install" +} + +// Busy reports whether an import is running right now. +func (im *Importer) Busy() bool { + if im.mu.TryLock() { + im.mu.Unlock() + return false + } + return true +} diff --git a/internal/importer/importer_test.go b/internal/importer/importer_test.go new file mode 100644 index 0000000..24e3a3a --- /dev/null +++ b/internal/importer/importer_test.go @@ -0,0 +1,247 @@ +package importer + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "github.com/LensBridge/agent/internal/mbu" + "github.com/LensBridge/agent/internal/store" + "github.com/LensBridge/agent/internal/trust" +) + +const dev = "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b" + +var ( + contentKey = ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, 32)) + releaseKey = ed25519.NewKeyFromSeed(bytes.Repeat([]byte{2}, 32)) + img = []byte("\x89PNG pretend") +) + +func imgPath() string { + s := sha256.Sum256(img) + return "media/" + hex.EncodeToString(s[:]) + ".png" +} + +type fakeEvents struct { + content []int64 + app []string +} + +func (f *fakeEvents) ContentChanged(s int64) { f.content = append(f.content, s) } +func (f *fakeEvents) AppChanged(v string) { f.app = append(f.app, v) } + +type fakeScreen struct{ begun, finished int } + +func (f *fakeScreen) Begin(context.Context) { f.begun++ } +func (f *fakeScreen) Caption(context.Context, string) {} +func (f *fakeScreen) Finish(context.Context, bool, string, bool) { f.finished++ } + +func setup(t *testing.T) (*Importer, store.Layout, *fakeEvents, *fakeScreen) { + t.Helper() + l := store.Layout{Root: t.TempDir()} + ev, sc := &fakeEvents{}, &fakeScreen{} + ring := trust.NewRing([]ed25519.PublicKey{contentKey.Public().(ed25519.PublicKey)}, + []ed25519.PublicKey{releaseKey.Public().(ed25519.PublicKey)}) + im := New(Deps{ + Layout: l, DeviceID: dev, AgentVersion: "0.2.0", + Ring: func() (*trust.Ring, error) { return ring, nil }, + Screen: sc, Events: ev, + Now: func() time.Time { return time.Date(2026, 9, 24, 12, 0, 0, 0, time.UTC) }, + }) + return im, l, ev, sc +} + +func build(t *testing.T, m mbu.Manifest, src []mbu.Source, k ed25519.PrivateKey) string { + t.Helper() + var buf bytes.Buffer + if err := mbu.Build(&buf, m, src, []ed25519.PrivateKey{k}); err != nil { + t.Fatal(err) + } + p := filepath.Join(t.TempDir(), "pkg.mbu") + os.WriteFile(p, buf.Bytes(), 0o644) + return p +} + +func content(t *testing.T, seq int64, device, poster string, omitMedia bool) string { + m := mbu.Manifest{Type: mbu.TypeContent, Sequence: seq, DeviceID: device, + CreatedAt: "2026-09-24T10:00:00Z", + Content: &mbu.ContentInfo{Timezone: "America/Toronto", FirstDay: "2026-09-24", LastDay: "2026-09-25", + Media: []mbu.MediaInfo{{Path: imgPath(), ContentType: "image/png"}}}} + src := []mbu.Source{ + {Path: "payloads/2026-09-24.json", Data: []byte(`{"n":` + strconv.FormatInt(seq, 10) + `,"frames":[{"frameConfig":{"posterUrl":"` + poster + `"}}]}`)}, + {Path: "payloads/2026-09-25.json", Data: []byte(`{"frames":[]}`)}, + {Path: imgPath(), Data: img, Omit: omitMedia}, + } + return build(t, m, src, contentKey) +} + +func app(t *testing.T, version string, localAPI int) string { + return build(t, mbu.Manifest{Type: mbu.TypeApp, Version: version, App: &mbu.AppInfo{LocalAPI: localAPI}}, + []mbu.Source{{Path: "index.html", Data: []byte("" + version)}}, releaseKey) +} + +func agent(t *testing.T, version, arch string) string { + return build(t, mbu.Manifest{Type: mbu.TypeAgent, Version: version, Agent: &mbu.AgentInfo{Arch: arch, Binary: "musallahboard-agent"}}, + []mbu.Source{{Path: "musallahboard-agent", Data: []byte("#!binary")}}, releaseKey) +} + +func one(t *testing.T, b Batch) Result { + t.Helper() + if len(b.Results) != 1 { + t.Fatalf("results = %+v", b.Results) + } + return b.Results[0] +} + +func TestContentLifecycle(t *testing.T) { + im, l, ev, _ := setup(t) + ctx := context.Background() + poster := "/" + imgPath() + + if r := one(t, im.Import(ctx, SourceUSB, []string{content(t, 100, dev, poster, false)})); r.Action != ActionInstalled { + t.Fatalf("first install: %+v", r) + } + c, err := l.CurrentContent() + if err != nil || c.Manifest.Sequence != 100 || c.Info.Source != "usb" { + t.Fatalf("current = %+v, %v", c, err) + } + if len(ev.content) != 1 { + t.Fatalf("content events = %v", ev.content) + } + if r := one(t, im.Import(ctx, SourceUSB, []string{content(t, 100, dev, poster, false)})); r.Action != ActionUnchanged { + t.Fatalf("same sequence: %+v", r) + } + if r := one(t, im.Import(ctx, SourceUSB, []string{content(t, 50, dev, poster, false)})); r.Action != ActionRejected { + t.Fatalf("older sequence: %+v", r) + } + // Delta: media omitted, satisfied from the store. + if r := one(t, im.Import(ctx, SourceSync, []string{content(t, 200, dev, poster, true)})); r.Action != ActionInstalled { + t.Fatalf("delta install: %+v", r) + } + st, _ := l.State().Load() + if st.ContentSequence != 200 || st.ClockFloor == 0 { + t.Fatalf("state = %+v", st) + } + // Rollback stays closed even if the bundles are deleted. + os.RemoveAll(l.BundlesDir()) + if r := one(t, im.Import(ctx, SourceUSB, []string{content(t, 150, dev, poster, false)})); r.Action != ActionRejected { + t.Fatalf("rollback after deleting bundles: %+v", r) + } +} + +func TestContentForAnotherBoardIsSkippedQuietly(t *testing.T) { + im, _, _, sc := setup(t) + r := one(t, im.Import(context.Background(), SourceUSB, + []string{content(t, 100, "11111111-2222-4333-8444-555555555555", "/"+imgPath(), false)})) + if r.Action != ActionSkipped || sc.begun != 0 { + t.Fatalf("foreign content: %+v, screen begun %d", r, sc.begun) + } +} + +func TestPosterMustBeLocalMedia(t *testing.T) { + im, _, _, _ := setup(t) + r := one(t, im.Import(context.Background(), SourceUSB, + []string{content(t, 100, dev, "https://evil.example/x.png", false)})) + if r.Action != ActionRejected || !strings.Contains(r.Message, "posterUrl") { + t.Fatalf("remote posterUrl: %+v", r) + } +} + +func TestAppRules(t *testing.T) { + im, l, ev, _ := setup(t) + ctx := context.Background() + if r := one(t, im.Import(ctx, SourceUpload, []string{app(t, "2.1.0", 2)})); r.Action != ActionInstalled { + t.Fatalf("app: %+v", r) + } + if a, err := l.CurrentApp(); err != nil || a.Manifest.Version != "2.1.0" { + t.Fatalf("current app %+v %v", a, err) + } + if len(ev.app) != 1 { + t.Fatalf("app events %v", ev.app) + } + if r := one(t, im.Import(ctx, SourceUpload, []string{app(t, "2.0.9", 2)})); r.Action != ActionRejected { + t.Fatalf("older app: %+v", r) + } + if r := one(t, im.Import(ctx, SourceUpload, []string{app(t, "3.0.0", LocalAPIVersion+1)})); r.Action != ActionRejected || + !strings.Contains(r.Message, "newer agent") { + t.Fatalf("app needing newer API: %+v", r) + } + // An app signed with the content key is not an app this board runs. + p := build(t, mbu.Manifest{Type: mbu.TypeApp, Version: "9.0.0", App: &mbu.AppInfo{LocalAPI: 2}}, + []mbu.Source{{Path: "index.html", Data: []byte("x")}}, contentKey) + if r := one(t, im.Import(ctx, SourceUpload, []string{p})); r.Action != ActionRejected { + t.Fatalf("app signed by content key: %+v", r) + } +} + +func TestAgentStagingRequeuesTheRest(t *testing.T) { + im, l, _, sc := setup(t) + ctx := context.Background() + if r := one(t, im.Import(ctx, SourceUpload, []string{agent(t, "0.3.0", otherArch())})); r.Action != ActionRejected { + t.Fatalf("wrong arch: %+v", r) + } + if r := one(t, im.Import(ctx, SourceUpload, []string{agent(t, "0.1.0", runtime.GOARCH)})); r.Action != ActionRejected { + t.Fatalf("older agent: %+v", r) + } + // Content listed first must still wait for the agent: agents go first. + b := im.Import(ctx, SourceUpload, []string{content(t, 100, dev, "/"+imgPath(), false), agent(t, "0.3.0", runtime.GOARCH)}) + if !b.AgentStaged || len(b.Results) != 2 || b.Results[0].Action != ActionStaged || b.Results[1].Action != ActionQueued { + t.Fatalf("batch = %+v", b) + } + if _, err := os.Stat(l.StagedReady()); err != nil { + t.Fatal("ready marker missing") + } + if files, _ := filepath.Glob(filepath.Join(l.Inbox(), "*.mbu")); len(files) != 1 { + t.Fatalf("requeued files = %v", files) + } + if sc.finished == 0 { + t.Fatal("screen not finished") + } +} + +func TestSyncContentIsSilent(t *testing.T) { + im, _, _, sc := setup(t) + one(t, im.Import(context.Background(), SourceSync, []string{content(t, 100, dev, "/"+imgPath(), false)})) + if sc.begun != 0 { + t.Fatal("background content sync put up the update screen") + } +} + +func TestInboxRunner(t *testing.T) { + im, l, _, _ := setup(t) + os.MkdirAll(l.Inbox(), 0o770) + src := content(t, 100, dev, "/"+imgPath(), false) + raw, _ := os.ReadFile(src) + os.WriteFile(filepath.Join(l.Inbox(), "usb-sda1-1-x.mbu"), raw, 0o640) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + go im.RunInbox(ctx) + for ctx.Err() == nil { + if _, err := os.Stat(filepath.Join(l.InboxResults(), "usb-sda1-1-x.mbu.json")); err == nil { + c, err := l.CurrentContent() + if err != nil || c.Info.Source != "usb" { + t.Fatalf("current %+v %v", c, err) + } + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatal("no inbox result") +} + +func otherArch() string { + if runtime.GOARCH == "arm64" { + return "amd64" + } + return "arm64" +} diff --git a/internal/importer/inbox.go b/internal/importer/inbox.go new file mode 100644 index 0000000..7b62448 --- /dev/null +++ b/internal/importer/inbox.go @@ -0,0 +1,110 @@ +package importer + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/LensBridge/agent/internal/fsutil" +) + +// InboxPoll is how often the inbox is scanned. Polling rather than inotify: +// it is one ReadDir of a nearly always empty directory, and it cannot miss a +// file dropped while the daemon was restarting. +const InboxPoll = 2 * time.Second + +// InboxResult is what the daemon writes to inbox/results/.json. +type InboxResult struct { + Result + At string `json:"at"` +} + +// RunInbox processes *.mbu files dropped into the inbox (by the CLI, the USB +// helper, or a batch requeued behind an agent update) until ctx ends. Each +// scan's files form one batch. A file whose name starts with "usb-" counts as +// source usb; anything else as cli. Processed files are deleted and their +// results written for whoever is waiting on them. +func (im *Importer) RunInbox(ctx context.Context) { + inbox, results := im.d.Layout.Inbox(), im.d.Layout.InboxResults() + t := time.NewTicker(InboxPoll) + defer t.Stop() + for { + files := pending(inbox) + if len(files) > 0 { + // Group by source so a USB stick and a CLI import that land in + // the same scan still report under the right source. + groups := map[Source][]string{} + for _, f := range files { + src := SourceCLI + if strings.HasPrefix(filepath.Base(f), "usb-") { + src = SourceUSB + } + groups[src] = append(groups[src], f) + } + for _, src := range []Source{SourceUSB, SourceCLI} { + if len(groups[src]) == 0 { + continue + } + b := im.Import(ctx, src, groups[src]) + _ = os.MkdirAll(results, 0o770) + for _, r := range b.Results { + if r.Action == ActionQueued { + continue // moved back into the inbox; reported when it runs + } + raw, _ := json.MarshalIndent(InboxResult{Result: r, At: im.d.Now().UTC().Format(time.RFC3339)}, "", " ") + _ = fsutil.WriteAtomic(filepath.Join(results, r.File+".json"), raw, 0o660) + } + for _, f := range groups[src] { + if _, err := os.Stat(f); err == nil { + os.Remove(f) + } + } + if b.AgentStaged { + // The updater is about to restart us. Stop taking work + // so the rest waits for the new agent. + <-ctx.Done() + return + } + } + pruneResults(results, im.d.Now()) + } + select { + case <-ctx.Done(): + return + case <-t.C: + } + } +} + +// pending lists complete *.mbu files in dir, oldest name first. Writers copy +// to a .part name and rename, so a listed file is complete. +func pending(dir string) []string { + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + var out []string + for _, e := range entries { + n := e.Name() + if !e.Type().IsRegular() || strings.HasPrefix(n, ".") || !strings.HasSuffix(n, ".mbu") { + continue + } + out = append(out, filepath.Join(dir, n)) + } + sort.Strings(out) + return out +} + +// pruneResults drops results older than a day; nobody waits that long. +func pruneResults(dir string, now time.Time) { + entries, _ := os.ReadDir(dir) + for _, e := range entries { + if fi, err := e.Info(); err == nil && now.Sub(fi.ModTime()) > 24*time.Hour { + os.Remove(filepath.Join(dir, e.Name())) + } + } +} diff --git a/internal/kioskurl/kioskurl.go b/internal/kioskurl/kioskurl.go index 1c470a1..b4d2afd 100644 --- a/internal/kioskurl/kioskurl.go +++ b/internal/kioskurl/kioskurl.go @@ -1,90 +1,36 @@ -// Package kioskurl composes the URL the on-device kiosk browser loads and -// persists it where the cage/Chromium launcher can read it. +// Package kioskurl persists the URL the on-device kiosk browser loads, where +// the cage/Chromium launcher can read it. // -// The board's base URL (e.g. https://board.lensbridge.tech) is provisioned by -// the setup scripts into BoardURLPath. The agent owns the device identity, so -// it is the only component that can append ?deviceId=. It writes the -// composed URL to OutPath, which doubles as the launcher's enrollment sentinel: -// the kiosk systemd unit blocks until this file exists, guaranteeing the board -// never loads without a device id. -// -// The frontend reads ?deviceId off the URL before its first render, persists -// it to a cookie, and strips the query in place (history.replaceState — no -// reload; an extra navigation here would flash the screen on every boot). So -// re-asserting the param on every launch is idempotent and self-healing. -// -// This file is the provisioning path for a board that is starting up. To fix -// a board that is already running — wrong cookie, wiped profile — the agent -// pushes the id into the live page instead, via the config.refresh command. -// -// In offline mode the base URL is not the provisioned one but the agent's own -// local server (OfflineBaseURL), and the page is told so with &mode=offline. -// board-url is left alone, so switching back to online needs nothing restored. +// In v2 every board is served by its own agent (docs/architecture.md, +// section 14), so once a device is enrolled the URL is always the local +// server, with no query string: the page asks the agent who it is. The file +// doubles as the launcher's enrollment sentinel: until it exists the kiosk +// shows the "waiting for enrollment" splash, and the systemd .path watcher +// restarts the kiosk when it appears or changes. package kioskurl import ( "fmt" - "net/url" "os" "path/filepath" - "strings" ) -// DefaultBoardURLPath holds the operator-provisioned base board URL (no query). -const DefaultBoardURLPath = "/etc/musallahboard/board-url" - -// DefaultOutPath is the composed URL the kiosk launcher reads. It is also the -// enrollment sentinel the kiosk systemd unit waits on. +// DefaultOutPath is the URL the kiosk launcher reads. It is also the +// enrollment sentinel the kiosk waits on. const DefaultOutPath = "/etc/musallahboard/kiosk-url" -// OfflineBaseURL is the agent's local server, which serves the board in -// offline mode. It must match the listen address in internal/offline. -const OfflineBaseURL = "http://127.0.0.1:8080/" - -// Write reads the base board URL from boardURLPath, appends ?deviceId=, -// and atomically writes the result to outPath (0644 so the unprivileged kiosk -// user can read it across the 0751 config dir). -// -// Returns an error if the base URL file is missing/empty or deviceID is empty — -// callers treat a write failure as non-fatal (the launcher keeps waiting) but -// should log it. -func Write(boardURLPath, outPath, deviceID string) error { - if deviceID == "" { - return fmt.Errorf("kioskurl: empty deviceID") - } - raw, err := os.ReadFile(boardURLPath) - if err != nil { - return fmt.Errorf("kioskurl: read base url %s: %w", boardURLPath, err) - } - base := strings.TrimSpace(string(raw)) - if base == "" { - return fmt.Errorf("kioskurl: base url %s is empty", boardURLPath) - } - - return writeURL(outPath, Compose(base, deviceID)) -} - -// WriteOffline writes the offline-mode kiosk URL for deviceID to outPath: -// the local server, with the device id and mode=offline. It ignores board-url. -func WriteOffline(outPath, deviceID string) error { - if deviceID == "" { - return fmt.Errorf("kioskurl: empty deviceID") - } - return writeURL(outPath, OfflineURL(deviceID)) -} +// LocalURL is the agent's local server. It must match +// localserver.BaseURL; a test checks. +const LocalURL = "http://127.0.0.1:8080/" -// Compose appends deviceId to base, respecting a query base already has. -func Compose(base, deviceID string) string { - sep := "?" - if strings.Contains(base, "?") { - sep = "&" +// WriteLocal writes LocalURL to outPath (0644 so the unprivileged kiosk user +// can read it across the 0751 config dir). An unchanged file is not +// rewritten, so a restart of the agent never bounces the kiosk. +func WriteLocal(outPath string) error { + if outPath == "" { + return fmt.Errorf("kioskurl: empty path") } - return base + sep + "deviceId=" + deviceID -} - -// OfflineURL is the URL the kiosk loads in offline mode. -func OfflineURL(deviceID string) string { - return OfflineBaseURL + "?deviceId=" + url.QueryEscape(deviceID) + "&mode=offline" + return writeURL(outPath, LocalURL) } // writeURL atomically writes u (plus a trailing newline) to outPath. diff --git a/internal/kioskurl/kioskurl_test.go b/internal/kioskurl/kioskurl_test.go index fef2cdf..f67c96a 100644 --- a/internal/kioskurl/kioskurl_test.go +++ b/internal/kioskurl/kioskurl_test.go @@ -4,82 +4,34 @@ import ( "os" "path/filepath" "testing" + "time" ) -const id = "3f2a1b4c-0000-4000-8000-000000000001" - -func TestCompose(t *testing.T) { - cases := []struct{ base, want string }{ - {"https://board.example.com", "https://board.example.com?deviceId=" + id}, - {"https://board.example.com/", "https://board.example.com/?deviceId=" + id}, - {"https://board.example.com/?theme=dark", "https://board.example.com/?theme=dark&deviceId=" + id}, - } - for _, tc := range cases { - if got := Compose(tc.base, id); got != tc.want { - t.Errorf("Compose(%q) = %q, want %q", tc.base, got, tc.want) - } - } -} - -func TestOfflineURL(t *testing.T) { - want := "http://127.0.0.1:8080/?deviceId=" + id + "&mode=offline" - if got := OfflineURL(id); got != want { - t.Errorf("OfflineURL = %q, want %q", got, want) - } - // Not a real device id, but the query must stay well-formed regardless. - if got := OfflineURL("a&b c"); got != "http://127.0.0.1:8080/?deviceId=a%26b+c&mode=offline" { - t.Errorf("OfflineURL did not escape: %q", got) - } -} - -func TestWriteAndWriteOffline(t *testing.T) { - dir := t.TempDir() - board := filepath.Join(dir, "board-url") - out := filepath.Join(dir, "kiosk-url") - if err := os.WriteFile(board, []byte(" https://board.example.com \n"), 0o644); err != nil { +func TestWriteLocal(t *testing.T) { + out := filepath.Join(t.TempDir(), "kiosk-url") + if err := WriteLocal(out); err != nil { t.Fatal(err) } - - if err := Write(board, out, id); err != nil { - t.Fatal(err) + got, _ := os.ReadFile(out) + if string(got) != LocalURL+"\n" { + t.Fatalf("kiosk-url = %q", got) } - assertFile(t, out, "https://board.example.com?deviceId="+id+"\n") - - if err := WriteOffline(out, id); err != nil { + fi, _ := os.Stat(out) + // An identical rewrite must not touch the file: the kiosk .path watcher + // would restart the browser. + old := time.Now().Add(-time.Hour) + os.Chtimes(out, old, old) + if err := WriteLocal(out); err != nil { t.Fatal(err) } - assertFile(t, out, "http://127.0.0.1:8080/?deviceId="+id+"&mode=offline\n") - - // Switching back needs nothing restored: board-url was never touched. - if err := Write(board, out, id); err != nil { - t.Fatal(err) - } - assertFile(t, out, "https://board.example.com?deviceId="+id+"\n") -} - -func TestWriteRejectsEmpty(t *testing.T) { - dir := t.TempDir() - out := filepath.Join(dir, "kiosk-url") - if err := WriteOffline(out, ""); err == nil { - t.Error("WriteOffline accepted an empty device id") - } - board := filepath.Join(dir, "board-url") - _ = os.WriteFile(board, []byte("\n"), 0o644) - if err := Write(board, out, id); err == nil { - t.Error("Write accepted an empty board-url") - } - if _, err := os.Stat(out); !os.IsNotExist(err) { - t.Error("kiosk-url written despite errors") + fi2, _ := os.Stat(out) + if !fi2.ModTime().Equal(old) || fi.Mode().Perm() != 0o644 { + t.Fatalf("rewrote an unchanged kiosk-url, or wrong mode %v", fi.Mode()) } } -func assertFile(t *testing.T, p, want string) { - t.Helper() - got, err := os.ReadFile(p) - if err != nil { - t.Fatal(err) - } - if string(got) != want { - t.Errorf("%s = %q, want %q", filepath.Base(p), got, want) +func TestWriteLocalRejectsEmpty(t *testing.T) { + if err := WriteLocal(""); err == nil { + t.Fatal("expected an error") } } diff --git a/internal/localserver/server.go b/internal/localserver/server.go new file mode 100644 index 0000000..abfc246 --- /dev/null +++ b/internal/localserver/server.go @@ -0,0 +1,316 @@ +// Package localserver is the agent's HTTP server for the kiosk on +// 127.0.0.1:8080 (docs/architecture.md, section 7): the board app, today's +// payload from the installed content, media, status, install events and the +// update screen. +// +// Every board is served from here, online or not. The server is read-only and +// loopback-only; installs arrive through the importer, never through it. +package localserver + +import ( + "context" + "encoding/json" + "errors" + "io" + "io/fs" + "log/slog" + "mime" + "net" + "net/http" + "os" + "path" + "path/filepath" + "strings" + "time" + + "github.com/LensBridge/agent/internal/events" + "github.com/LensBridge/agent/internal/mbu" + "github.com/LensBridge/agent/internal/store" + "github.com/LensBridge/agent/internal/updatescreen" + updateanim "github.com/LensBridge/agent/update-anim" +) + +// ListenAddr is loopback only: nothing off the box has any business here. +const ListenAddr = "127.0.0.1:8080" + +// BaseURL is what the kiosk loads. +const BaseURL = "http://" + ListenAddr + "/" + +// WeatherMaxAge is how long an overlaid weather object stays on screen. +const WeatherMaxAge = 3 * time.Hour + +const ( + cacheNoStore = "no-store" + cacheNoCache = "no-cache" + cacheImmutable = "public, max-age=31536000, immutable" +) + +// Deps is what the server reads. +type Deps struct { + Layout store.Layout + DeviceID string + AgentVersion string + Hub *events.Hub + // Sync returns the sync status object for /api/local/status. May be nil. + Sync func() any + // Weather returns a fresh weather object, if any. May be nil. + Weather func() (json.RawMessage, bool) + // UpdateActive reports whether the update screen is up. May be nil. + UpdateActive func() bool + Logger *slog.Logger + Now func() time.Time +} + +// Server serves the kiosk. +type Server struct{ d Deps } + +// New returns a Server. +func New(d Deps) *Server { + if d.Now == nil { + d.Now = time.Now + } + if d.Logger == nil { + d.Logger = slog.New(slog.DiscardHandler) + } + if d.Hub == nil { + d.Hub = &events.Hub{} + } + return &Server{d: d} +} + +// Serve accepts connections on ln until ctx ends. The caller creates ln so a +// port conflict is reported before anything depends on the server. +func (s *Server) Serve(ctx context.Context, ln net.Listener) error { + srv := &http.Server{Handler: s, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 2 * time.Minute} + go func() { + <-ctx.Done() + sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(sctx) + }() + if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + writeJSONError(w, http.StatusMethodNotAllowed, "read-only") + return + } + w.Header().Set("X-Content-Type-Options", "nosniff") + switch p := r.URL.Path; { + case p == "/api/musallah/payload": + s.handlePayload(w, r) + case p == "/api/local/status": + w.Header().Set("Cache-Control", cacheNoStore) + writeJSON(w, http.StatusOK, s.Status()) + case p == "/api/local/events": + s.d.Hub.ServeHTTP(w, r) + case strings.HasPrefix(p, "/api/"): + writeJSONError(w, http.StatusNotFound, "not found") + case strings.HasPrefix(p, "/media/"): + s.handleMedia(w, r) + case p == updatescreen.Path: + w.Header().Set("Cache-Control", cacheNoStore) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(updateanim.HTML) + default: + s.handleApp(w, r) + } +} + +// Status is the current /api/local/status object. +func (s *Server) Status() Status { + st := BuildStatus(s.d.Layout, s.d.DeviceID, s.d.AgentVersion, s.d.Now()) + if s.d.Sync != nil { + st.Sync = s.d.Sync() + } + if s.d.UpdateActive != nil { + st.Update.Active = s.d.UpdateActive() + } + return st +} + +func (s *Server) handlePayload(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Cache-Control", cacheNoStore) + c, err := s.d.Layout.CurrentContent() + if errors.Is(err, store.ErrNoContent) { + writeJSONError(w, http.StatusServiceUnavailable, "no content installed") + return + } + if err != nil { + s.d.Logger.Error("installed content unreadable", "err", err) + writeJSONError(w, http.StatusInternalServerError, "installed content is unreadable") + return + } + day := c.PickDay(s.d.Now()) + // day.Serving is a date we formatted ourselves, not request input. + raw, err := os.ReadFile(c.PayloadPath(day.Serving)) + if err != nil { + s.d.Logger.Error("payload missing from installed content", "day", day.Serving, "err", err) + writeJSONError(w, http.StatusInternalServerError, "payload missing from installed content") + return + } + if s.d.Weather != nil { + if weather, ok := s.d.Weather(); ok { + raw = overlayWeather(raw, weather) + } + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(raw) +} + +// overlayWeather replaces the payload's weather with a live one. Only the one +// key changes; if the payload cannot be re-encoded it is served untouched. +func overlayWeather(raw, weather []byte) []byte { + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return raw + } + obj["weather"] = weather + out, err := json.Marshal(obj) + if err != nil { + return raw + } + return out +} + +func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) { + file := strings.TrimPrefix(r.URL.Path, "/media/") + // Must look exactly like a store file before it goes near a path; that + // also rules out "..", "/" and "\". + if !mbu.MediaFileRE.MatchString(file) { + http.NotFound(w, r) + return + } + f, err := os.Open(s.d.Layout.MediaPath(file)) + if err != nil { + http.NotFound(w, r) + return + } + defer f.Close() + fi, err := f.Stat() + if err != nil || !fi.Mode().IsRegular() { + http.NotFound(w, r) + return + } + ct := "" + if c, err := s.d.Layout.CurrentContent(); err == nil { + ct = c.Manifest.Content.MediaType(file) + } + if ct == "" { + ct = contentTypeFor(file) + } + if ct != "" { + w.Header().Set("Content-Type", ct) + } + w.Header().Set("Cache-Control", cacheImmutable) + http.ServeContent(w, r, file, fi.ModTime(), f) +} + +// handleApp serves the current app release. Real files are served as they +// are; any other path is a client-side route and gets index.html. A missing +// /assets/ file is a 404 instead: it is hashed build output, and HTML under an +// immutable cache header for a stale script URL is worse than an honest miss. +func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) { + a, err := s.d.Layout.CurrentApp() + if err != nil { + w.Header().Set("Cache-Control", cacheNoStore) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = io.WriteString(w, noAppPage) + return + } + fsys := os.DirFS(a.Dir) + name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/") + if name == "" { + name = "index.html" + } + // .mbu/ holds the release's manifest; it is not part of the build. + hidden := strings.HasPrefix(name, ".") || strings.Contains(name, "/.") + if !hidden && fs.ValidPath(name) && serveFile(w, r, fsys, name) { + return + } + if strings.HasPrefix(name, "assets/") { + http.NotFound(w, r) + return + } + if !serveFile(w, r, fsys, "index.html") { + http.Error(w, "the installed board app has no index.html", http.StatusInternalServerError) + } +} + +func serveFile(w http.ResponseWriter, r *http.Request, fsys fs.FS, name string) bool { + f, err := fsys.Open(name) + if err != nil { + return false + } + defer f.Close() + fi, err := f.Stat() + if err != nil || !fi.Mode().IsRegular() { + return false + } + rs, ok := f.(io.ReadSeeker) + if !ok { + return false + } + if strings.HasPrefix(name, "assets/") { + w.Header().Set("Cache-Control", cacheImmutable) + } else { + w.Header().Set("Cache-Control", cacheNoCache) + } + if ct := contentTypeFor(name); ct != "" { + w.Header().Set("Content-Type", ct) + } + http.ServeContent(w, r, path.Base(name), fi.ModTime(), rs) + return true +} + +// extraTypes covers what a Vite build emits that the minimal mime table on a +// fresh Pi OS may not know. A font sniffed as octet-stream is refused under +// nosniff. +var extraTypes = map[string]string{ + ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", ".html": "text/html; charset=utf-8", + ".json": "application/json", ".svg": "image/svg+xml", ".woff": "font/woff", + ".woff2": "font/woff2", ".ttf": "font/ttf", ".otf": "font/otf", ".ico": "image/x-icon", + ".webmanifest": "application/manifest+json", ".wasm": "application/wasm", + ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", + ".gif": "image/gif", ".avif": "image/avif", +} + +func contentTypeFor(name string) string { + ext := strings.ToLower(filepath.Ext(name)) + if ct, ok := extraTypes[ext]; ok { + return ct + } + return mime.TypeByExtension(ext) +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +func writeJSONError(w http.ResponseWriter, code int, msg string) { + writeJSON(w, code, map[string]string{"message": msg}) +} + +// noAppPage is shown until a board app is installed. It polls, so the board +// appears on its own once an app package lands. +const noAppPage = ` +MusallahBoard + +

Waiting for the board app

+

This board has no board app installed yet. Online boards download it automatically. +Without internet, plug in a USB stick holding a MusallahBoard update, or connect a laptop or phone +to the board's ethernet port and open http://10.77.0.1/.

+ +` diff --git a/internal/localserver/server_test.go b/internal/localserver/server_test.go new file mode 100644 index 0000000..019bb95 --- /dev/null +++ b/internal/localserver/server_test.go @@ -0,0 +1,151 @@ +package localserver + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/LensBridge/agent/internal/importer" + "github.com/LensBridge/agent/internal/mbu" + "github.com/LensBridge/agent/internal/store" + "github.com/LensBridge/agent/internal/trust" +) + +const dev = "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b" + +var ( + ck = ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, 32)) + rk = ed25519.NewKeyFromSeed(bytes.Repeat([]byte{2}, 32)) +) + +func pkg(t *testing.T, m mbu.Manifest, src []mbu.Source, k ed25519.PrivateKey) string { + t.Helper() + var buf bytes.Buffer + if err := mbu.Build(&buf, m, src, []ed25519.PrivateKey{k}); err != nil { + t.Fatal(err) + } + p := filepath.Join(t.TempDir(), "p.mbu") + os.WriteFile(p, buf.Bytes(), 0o644) + return p +} + +// installed returns a layout holding content for 2026-09-24..25 and an app. +func installed(t *testing.T) store.Layout { + t.Helper() + l := store.Layout{Root: t.TempDir()} + ring := trust.NewRing([]ed25519.PublicKey{ck.Public().(ed25519.PublicKey)}, []ed25519.PublicKey{rk.Public().(ed25519.PublicKey)}) + im := importer.New(importer.Deps{Layout: l, DeviceID: dev, AgentVersion: "0.2.0", + Ring: func() (*trust.Ring, error) { return ring, nil }}) + c := pkg(t, mbu.Manifest{Type: mbu.TypeContent, Sequence: 1, DeviceID: dev, + Content: &mbu.ContentInfo{Timezone: "America/Toronto", FirstDay: "2026-09-24", LastDay: "2026-09-25"}}, + []mbu.Source{ + {Path: "payloads/2026-09-24.json", Data: []byte(`{"day":24,"weather":null}`)}, + {Path: "payloads/2026-09-25.json", Data: []byte(`{"day":25,"weather":null}`)}, + }, ck) + a := pkg(t, mbu.Manifest{Type: mbu.TypeApp, Version: "2.0.0", App: &mbu.AppInfo{LocalAPI: 2}}, + []mbu.Source{{Path: "index.html", Data: []byte("board")}, {Path: "assets/app.js", Data: []byte("js")}}, rk) + b := im.Import(context.Background(), importer.SourceCLI, []string{c, a}) + if !b.OK() { + t.Fatalf("setup import: %+v", b.Results) + } + return l +} + +func get(t *testing.T, s *Server, path string) *httptest.ResponseRecorder { + t.Helper() + w := httptest.NewRecorder() + s.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + return w +} + +func at(day int, hour int) func() time.Time { + loc, _ := time.LoadLocation("America/Toronto") + return func() time.Time { return time.Date(2026, 9, day, hour, 0, 0, 0, loc) } +} + +func TestPayloadPicksDayAndClamps(t *testing.T) { + l := installed(t) + for _, c := range []struct { + day int + want string + }{{23, `"day":24`}, {24, `"day":24`}, {25, `"day":25`}, {28, `"day":25`}} { + s := New(Deps{Layout: l, DeviceID: dev, Now: at(c.day, 12)}) + w := get(t, s, "/api/musallah/payload") + if w.Code != 200 || !strings.Contains(w.Body.String(), c.want) { + t.Errorf("day %d: %d %s", c.day, w.Code, w.Body) + } + } +} + +func TestWeatherOverlay(t *testing.T) { + l := installed(t) + s := New(Deps{Layout: l, DeviceID: dev, Now: at(24, 12), + Weather: func() (json.RawMessage, bool) { return json.RawMessage(`{"tempC":12}`), true }}) + if w := get(t, s, "/api/musallah/payload"); !strings.Contains(w.Body.String(), `"weather":{"tempC":12}`) { + t.Fatalf("overlay missing: %s", w.Body) + } +} + +func TestNoContentNoApp(t *testing.T) { + s := New(Deps{Layout: store.Layout{Root: t.TempDir()}, DeviceID: dev}) + if w := get(t, s, "/api/musallah/payload"); w.Code != 503 || !strings.Contains(w.Body.String(), "no content installed") { + t.Errorf("payload without content: %d %s", w.Code, w.Body) + } + if w := get(t, s, "/"); w.Code != 503 || !strings.Contains(w.Body.String(), "Waiting for the board app") { + t.Errorf("app page without app: %d", w.Code) + } + var st Status + json.Unmarshal(get(t, s, "/api/local/status").Body.Bytes(), &st) + if st.App != nil || st.Content != nil || st.LocalAPI != importer.LocalAPIVersion { + t.Errorf("status %+v", st) + } +} + +func TestAppServing(t *testing.T) { + s := New(Deps{Layout: installed(t), DeviceID: dev, Now: at(24, 12)}) + if w := get(t, s, "/"); !strings.Contains(w.Body.String(), "board") || w.Header().Get("Cache-Control") != cacheNoCache { + t.Errorf("index: %s %v", w.Body, w.Header()) + } + if w := get(t, s, "/some/route"); !strings.Contains(w.Body.String(), "board") { + t.Errorf("SPA fallback: %s", w.Body) + } + if w := get(t, s, "/assets/app.js"); w.Header().Get("Cache-Control") != cacheImmutable { + t.Errorf("asset caching: %v", w.Header()) + } + if w := get(t, s, "/assets/missing.js"); w.Code != 404 { + t.Errorf("missing asset: %d", w.Code) + } + if w := get(t, s, "/.mbu/mbu.json"); strings.Contains(w.Body.String(), "formatVersion") { + t.Errorf("release metadata served") + } + if w := get(t, s, "/api/nope"); w.Code != 404 || !strings.Contains(w.Header().Get("Content-Type"), "json") { + t.Errorf("unknown api: %d", w.Code) + } + if w := get(t, s, "/_mb/updating"); !strings.Contains(w.Body.String(), "mbUpdate") { + t.Errorf("update screen missing") + } + w := httptest.NewRecorder() + s.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/local/status", nil)) + if w.Code != 405 { + t.Errorf("POST: %d", w.Code) + } +} + +func TestStatus(t *testing.T) { + s := New(Deps{Layout: installed(t), DeviceID: dev, AgentVersion: "0.2.0", Now: at(25, 12), + Sync: func() any { return map[string]bool{"enabled": true} }}) + var st Status + json.Unmarshal(get(t, s, "/api/local/status").Body.Bytes(), &st) + if st.App.Version != "2.0.0" || st.Content.FirstDay != "2026-09-24" || *st.DaysRemaining != 0 || + *st.StaleDays != 0 || st.Content.Source != "cli" || st.Sync == nil { + t.Errorf("status %+v", st) + } +} diff --git a/internal/localserver/status.go b/internal/localserver/status.go new file mode 100644 index 0000000..bcaedca --- /dev/null +++ b/internal/localserver/status.go @@ -0,0 +1,77 @@ +package localserver + +import ( + "errors" + "time" + + "github.com/LensBridge/agent/internal/importer" + "github.com/LensBridge/agent/internal/store" +) + +// Status is /api/local/status (docs/architecture.md, section 7). The CLI and +// the upload server build theirs from it. +type Status struct { + LocalAPI int `json:"localApi"` + AgentVersion string `json:"agentVersion"` + DeviceID string `json:"deviceId"` + App *AppInfo `json:"app"` + Content *ContentInfo `json:"content"` + Today string `json:"today"` + ServingDay *string `json:"servingDay"` + DaysRemaining *int `json:"daysRemaining"` + StaleDays *int `json:"staleDays"` + Sync any `json:"sync"` + Update UpdateInfo `json:"update"` + Error string `json:"error,omitempty"` +} + +// AppInfo describes the installed app. +type AppInfo struct { + Version string `json:"version"` + Source string `json:"source,omitempty"` + InstalledAt string `json:"installedAt,omitempty"` +} + +// ContentInfo describes the installed content bundle. +type ContentInfo struct { + Sequence int64 `json:"sequence"` + CreatedAt string `json:"createdAt"` + FirstDay string `json:"firstDay"` + LastDay string `json:"lastDay"` + Timezone string `json:"timezone"` + Source string `json:"source,omitempty"` + InstalledAt string `json:"installedAt,omitempty"` +} + +// UpdateInfo says whether the update screen is up. +type UpdateInfo struct { + Active bool `json:"active"` +} + +// BuildStatus reads the installed state. It never fails: a board must always +// be able to say what it has, including that something is broken. +func BuildStatus(l store.Layout, deviceID, agentVersion string, now time.Time) Status { + s := Status{LocalAPI: importer.LocalAPIVersion, AgentVersion: agentVersion, DeviceID: deviceID} + if a, err := l.CurrentApp(); err == nil { + s.App = &AppInfo{Version: a.Manifest.Version} + } + c, err := l.CurrentContent() + switch { + case errors.Is(err, store.ErrNoContent): + s.Today = now.Format("2006-01-02") + case err != nil: + s.Today = now.Format("2006-01-02") + s.Error = err.Error() + default: + m := c.Manifest + s.Content = &ContentInfo{ + Sequence: m.Sequence, CreatedAt: m.CreatedAt, + FirstDay: m.Content.FirstDay, LastDay: m.Content.LastDay, Timezone: m.Content.Timezone, + Source: c.Info.Source, InstalledAt: c.Info.InstalledAt, + } + d := c.PickDay(now) + s.Today = d.Today + s.ServingDay, s.DaysRemaining, s.StaleDays = &d.Serving, &d.DaysRemaining, &d.StaleDays + } + return s +} diff --git a/internal/mbu/manifest.go b/internal/mbu/manifest.go new file mode 100644 index 0000000..aa24bd7 --- /dev/null +++ b/internal/mbu/manifest.go @@ -0,0 +1,420 @@ +// Package mbu reads, verifies and writes MusallahBoard update packages +// (".mbu", docs/architecture.md section 4). +// +// A package is a zip holding mbu.json (the manifest), mbu.sig (Ed25519 +// signatures over the manifest) and the files the manifest lists with their +// SHA-256 and size. Every kind of change to a board (content, the board app, +// the agent) travels in this one format, so there is exactly one verifier and +// every transport is an untrusted pipe into it. +// +// Nothing in a package is used as a filesystem path, or trusted at all, until +// the signature has verified and the entry has matched the manifest. +package mbu + +import ( + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" + "time" +) + +const ( + // Format and FormatVersion identify this layout. Any other version is + // refused. + Format = "mbu" + FormatVersion = 2 + + // SignaturePrefix is prepended to the manifest bytes before signing: + // domain separation from the agent's other two Ed25519 messages + // (musallahboard-auth-v1, musallahboard-http-v1). + SignaturePrefix = "musallahboard-mbu-v2\n" + + ManifestName = "mbu.json" + SignatureName = "mbu.sig" + + // DateLayout is a content package's day format. + DateLayout = "2006-01-02" +) + +// Limits from section 4. They bound what a small hostile file can make the +// board read, allocate or write before its hashes are even checked. +const ( + MaxPackageBytes = 512 << 20 + MaxManifestBytes = 1 << 20 + MaxSignatureBytes = 64 << 10 + MaxFiles = 20000 + MaxTotalBytes = 1 << 30 + MaxPayloadBytes = 16 << 20 + MaxMediaBytes = 512 << 20 + MaxContentDays = 31 + maxPathBytes = 200 + maxPathSegments = 8 +) + +// Type is what a package installs. +type Type string + +const ( + TypeContent Type = "content" + TypeApp Type = "app" + TypeAgent Type = "agent" +) + +// File is one entry of the manifest's files list. +type File struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` + Bytes int64 `json:"bytes"` +} + +// Manifest is mbu.json. Pointer fields distinguish "absent" from "zero" so a +// missing required field is caught rather than read as 0 or "". +type Manifest struct { + Format string `json:"format"` + FormatVersion int `json:"formatVersion"` + Type Type `json:"type"` + CreatedAt string `json:"createdAt"` + Sequence int64 `json:"sequence,omitempty"` + DeviceID string `json:"deviceId,omitempty"` + Version string `json:"version,omitempty"` + Files []File `json:"files"` + Content *ContentInfo `json:"content,omitempty"` + App *AppInfo `json:"app,omitempty"` + Agent *AgentInfo `json:"agent,omitempty"` + + createdAt time.Time + byPath map[string]File +} + +// ContentInfo is the content-specific part of a content manifest. +type ContentInfo struct { + Timezone string `json:"timezone"` + FirstDay string `json:"firstDay"` + LastDay string `json:"lastDay"` + Media []MediaInfo `json:"media"` +} + +// MediaInfo gives a media file its content type. +type MediaInfo struct { + Path string `json:"path"` + ContentType string `json:"contentType"` +} + +// AppInfo is the app-specific part of an app manifest. +type AppInfo struct { + // LocalAPI is the local kiosk API version the build needs. + LocalAPI int `json:"localApi"` +} + +// AgentInfo is the agent-specific part of an agent manifest. +type AgentInfo struct { + Arch string `json:"arch"` + Binary string `json:"binary"` +} + +// CreatedTime is createdAt, parsed. +func (m *Manifest) CreatedTime() time.Time { return m.createdAt } + +// File returns the listed file at path. +func (m *Manifest) File(path string) (File, bool) { + f, ok := m.byPath[path] + return f, ok +} + +// Describe is a short human name for the package, for logs and screens. +func (m *Manifest) Describe() string { + switch m.Type { + case TypeContent: + if m.Content != nil { + return fmt.Sprintf("content %s to %s", m.Content.FirstDay, m.Content.LastDay) + } + return "content" + case TypeApp: + return "board app " + m.Version + case TypeAgent: + return "agent " + m.Version + } + return string(m.Type) +} + +var ( + segmentRE = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9._-]*$`) + sha256RE = regexp.MustCompile(`^[0-9a-f]{64}$`) + versionRE = regexp.MustCompile(`^(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})$`) + payloadRE = regexp.MustCompile(`^payloads/(\d{4}-\d{2}-\d{2})\.json$`) + mediaRE = regexp.MustCompile(`^media/([0-9a-f]{64})\.([a-z0-9]{1,10})$`) + uuidLikeRE = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) +) + +// MediaFileRE matches a media file's base name as requested under /media/. +var MediaFileRE = regexp.MustCompile(`^[0-9a-f]{64}\.[a-z0-9]{1,10}$`) + +// allowedMediaTypes are the content types a content package may declare. +var allowedMediaTypes = map[string]bool{ + "image/jpeg": true, "image/png": true, "image/webp": true, "image/gif": true, + "image/avif": true, "image/svg+xml": true, "application/octet-stream": true, +} + +// ValidPath reports whether name is an acceptable entry or file path. This is +// the whole zip-slip defence: a path that passes can be joined onto a +// directory without escaping it. +func ValidPath(name string) error { + if name == "" || len(name) > maxPathBytes { + return fmt.Errorf("path %q is empty or longer than %d bytes", name, maxPathBytes) + } + segs := strings.Split(name, "/") + if len(segs) > maxPathSegments { + return fmt.Errorf("path %q is nested more than %d deep", name, maxPathSegments) + } + for _, s := range segs { + if !segmentRE.MatchString(s) { + return fmt.Errorf("path %q has a disallowed segment %q", name, s) + } + } + return nil +} + +// ValidVersion reports whether v is MAJOR.MINOR.PATCH. +func ValidVersion(v string) bool { return versionRE.MatchString(v) } + +// CompareVersions compares two MAJOR.MINOR.PATCH versions: -1, 0 or 1. An +// invalid version sorts before every valid one, so "dev" is older than any +// release. +func CompareVersions(a, b string) int { + pa, oka := parseVersion(a) + pb, okb := parseVersion(b) + switch { + case !oka && !okb: + return 0 + case !oka: + return -1 + case !okb: + return 1 + } + for i := range 3 { + if pa[i] != pb[i] { + if pa[i] < pb[i] { + return -1 + } + return 1 + } + } + return 0 +} + +func parseVersion(v string) ([3]int64, bool) { + var out [3]int64 + if !ValidVersion(v) { + return out, false + } + for i, p := range strings.SplitN(v, ".", 3) { + n, err := strconv.ParseInt(p, 10, 64) + if err != nil { + return out, false + } + out[i] = n + } + return out, true +} + +// ParseManifest decodes raw and checks everything about it that does not +// depend on the rest of the zip, on keys or on which board reads it. +func ParseManifest(raw []byte) (*Manifest, error) { + var m Manifest + if err := json.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("mbu.json is not valid JSON: %w", err) + } + if m.Format != Format { + return nil, fmt.Errorf("mbu.json format is %q, want %q (is this a MusallahBoard update package?)", m.Format, Format) + } + if m.FormatVersion != FormatVersion { + return nil, fmt.Errorf("package format version %d is not supported by this agent (it reads %d); update the agent or rebuild the package", + m.FormatVersion, FormatVersion) + } + t, err := time.Parse(time.RFC3339, m.CreatedAt) + if err != nil { + return nil, fmt.Errorf("mbu.json createdAt %q is not an RFC 3339 time", m.CreatedAt) + } + m.createdAt = t + + if len(m.Files) > MaxFiles { + return nil, fmt.Errorf("package lists %d files; at most %d are allowed", len(m.Files), MaxFiles) + } + m.byPath = make(map[string]File, len(m.Files)) + var total int64 + for _, f := range m.Files { + if err := ValidPath(f.Path); err != nil { + return nil, fmt.Errorf("mbu.json: %w", err) + } + if f.Path == ManifestName || f.Path == SignatureName { + return nil, fmt.Errorf("mbu.json lists %s as a file", f.Path) + } + if !sha256RE.MatchString(f.SHA256) { + return nil, fmt.Errorf("mbu.json: %s has a malformed sha256", f.Path) + } + if f.Bytes < 0 { + return nil, fmt.Errorf("mbu.json: %s has a negative size", f.Path) + } + if _, dup := m.byPath[f.Path]; dup { + return nil, fmt.Errorf("mbu.json lists %s twice", f.Path) + } + m.byPath[f.Path] = f + total += f.Bytes + if total > MaxTotalBytes { + return nil, fmt.Errorf("package files total more than %d MiB", MaxTotalBytes>>20) + } + } + + switch m.Type { + case TypeContent: + err = m.checkContent() + case TypeApp: + err = m.checkApp() + case TypeAgent: + err = m.checkAgent() + default: + err = fmt.Errorf("mbu.json type %q is not content, app or agent", m.Type) + } + if err != nil { + return nil, err + } + return &m, nil +} + +func (m *Manifest) checkContent() error { + if !uuidLikeRE.MatchString(m.DeviceID) { + return fmt.Errorf("content package has no valid deviceId") + } + if m.Sequence <= 0 { + return fmt.Errorf("content package has no positive sequence") + } + c := m.Content + if c == nil { + return fmt.Errorf("content package has no content section") + } + if c.Timezone == "" || c.Timezone == "Local" { + return fmt.Errorf("content package has no usable timezone (%q)", c.Timezone) + } + if _, err := time.LoadLocation(c.Timezone); err != nil { + return fmt.Errorf("content timezone %q is not known on this board: %w", c.Timezone, err) + } + days, err := c.Days() + if err != nil { + return err + } + + want := make(map[string]bool, len(days)) + for _, d := range days { + want[d] = true + f, ok := m.byPath["payloads/"+d+".json"] + if !ok { + return fmt.Errorf("content package is missing payloads/%s.json (it covers %s to %s)", d, c.FirstDay, c.LastDay) + } + if f.Bytes > MaxPayloadBytes { + return fmt.Errorf("payloads/%s.json is larger than %d MiB", d, MaxPayloadBytes>>20) + } + } + + types := make(map[string]string, len(c.Media)) + for _, mi := range c.Media { + if !allowedMediaTypes[mi.ContentType] { + return fmt.Errorf("media %s has content type %q, which is not allowed", mi.Path, mi.ContentType) + } + if _, dup := types[mi.Path]; dup { + return fmt.Errorf("content media lists %s twice", mi.Path) + } + types[mi.Path] = mi.ContentType + } + + var mediaTotal int64 + for _, f := range m.Files { + switch { + case payloadRE.MatchString(f.Path): + if d := payloadRE.FindStringSubmatch(f.Path)[1]; !want[d] { + return fmt.Errorf("content package has %s, outside its range %s to %s", f.Path, c.FirstDay, c.LastDay) + } + case mediaRE.MatchString(f.Path): + if mediaRE.FindStringSubmatch(f.Path)[1] != f.SHA256 { + return fmt.Errorf("media %s: its name does not match its sha256", f.Path) + } + if _, ok := types[f.Path]; !ok { + return fmt.Errorf("media %s has no content type in the content section", f.Path) + } + mediaTotal += f.Bytes + default: + return fmt.Errorf("content package lists %q, which is neither payloads/.json nor media/.", f.Path) + } + } + for p := range types { + if _, ok := m.byPath[p]; !ok { + return fmt.Errorf("content media lists %s, which is not in files", p) + } + } + if mediaTotal > MaxMediaBytes { + return fmt.Errorf("content media totals %d MiB; at most %d MiB is allowed", mediaTotal>>20, MaxMediaBytes>>20) + } + return nil +} + +func (m *Manifest) checkApp() error { + if !ValidVersion(m.Version) { + return fmt.Errorf("app package version %q is not MAJOR.MINOR.PATCH", m.Version) + } + if m.App == nil || m.App.LocalAPI <= 0 { + return fmt.Errorf("app package has no app.localApi") + } + if _, ok := m.byPath["index.html"]; !ok { + return fmt.Errorf("app package has no index.html at its root") + } + return nil +} + +func (m *Manifest) checkAgent() error { + if !ValidVersion(m.Version) { + return fmt.Errorf("agent package version %q is not MAJOR.MINOR.PATCH", m.Version) + } + a := m.Agent + if a == nil || (a.Arch != "arm64" && a.Arch != "amd64") { + return fmt.Errorf("agent package has no valid agent.arch") + } + if len(m.Files) != 1 || m.Files[0].Path != a.Binary { + return fmt.Errorf("agent package must list exactly one file, its binary %q", a.Binary) + } + return nil +} + +// Days lists every date firstDay..lastDay inclusive, checking the range. +func (c *ContentInfo) Days() ([]string, error) { + first, err := time.Parse(DateLayout, c.FirstDay) + if err != nil { + return nil, fmt.Errorf("content firstDay %q is not a YYYY-MM-DD date", c.FirstDay) + } + last, err := time.Parse(DateLayout, c.LastDay) + if err != nil { + return nil, fmt.Errorf("content lastDay %q is not a YYYY-MM-DD date", c.LastDay) + } + if last.Before(first) { + return nil, fmt.Errorf("content lastDay %s is before firstDay %s", c.LastDay, c.FirstDay) + } + if n := int(last.Sub(first).Hours()/24) + 1; n > MaxContentDays { + return nil, fmt.Errorf("content covers %d days; at most %d are allowed", n, MaxContentDays) + } + var out []string + for d := first; !d.After(last); d = d.AddDate(0, 0, 1) { + out = append(out, d.Format(DateLayout)) + } + return out, nil +} + +// MediaType returns the declared content type of the media file with base +// name file ("."), or "". +func (c *ContentInfo) MediaType(file string) string { + for _, mi := range c.Media { + if mi.Path == "media/"+file { + return mi.ContentType + } + } + return "" +} diff --git a/internal/mbu/mbu_test.go b/internal/mbu/mbu_test.go new file mode 100644 index 0000000..3ca5fb1 --- /dev/null +++ b/internal/mbu/mbu_test.go @@ -0,0 +1,264 @@ +package mbu + +import ( + "archive/zip" + "bytes" + "crypto/ed25519" + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/LensBridge/agent/internal/trust" +) + +const testDevice = "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b" + +func key(t *testing.T, b byte) ed25519.PrivateKey { + t.Helper() + return ed25519.NewKeyFromSeed(bytes.Repeat([]byte{b}, 32)) +} + +func pub(k ed25519.PrivateKey) ed25519.PublicKey { return k.Public().(ed25519.PublicKey) } + +func write(t *testing.T, m Manifest, src []Source, signers ...ed25519.PrivateKey) string { + t.Helper() + var buf bytes.Buffer + if err := Build(&buf, m, src, signers); err != nil { + t.Fatalf("Build: %v", err) + } + p := filepath.Join(t.TempDir(), "p.mbu") + if err := os.WriteFile(p, buf.Bytes(), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +var img = []byte("\x89PNG fake image bytes") + +func imgPath() string { + s := sha256.Sum256(img) + return "media/" + hex.EncodeToString(s[:]) + ".png" +} + +func contentManifest() (Manifest, []Source) { + m := Manifest{ + Type: TypeContent, + Sequence: 1000, + DeviceID: testDevice, + Content: &ContentInfo{ + Timezone: "America/Toronto", FirstDay: "2026-09-24", LastDay: "2026-09-25", + Media: []MediaInfo{{Path: imgPath(), ContentType: "image/png"}}, + }, + } + src := []Source{ + {Path: "payloads/2026-09-24.json", Data: []byte(`{"frames":[{"frameConfig":{"posterUrl":"/` + imgPath() + `"}}]}`)}, + {Path: "payloads/2026-09-25.json", Data: []byte(`{"frames":[]}`)}, + {Path: imgPath(), Data: img}, + } + return m, src +} + +func appManifest() (Manifest, []Source) { + return Manifest{Type: TypeApp, Version: "2.1.0", App: &AppInfo{LocalAPI: 2}}, + []Source{{Path: "index.html", Data: []byte("")}, {Path: "assets/app-abc.js", Data: []byte("1")}} +} + +func TestRoundTripContent(t *testing.T) { + ck := key(t, 1) + m, src := contentManifest() + p := write(t, m, src, ck) + pkg, err := Open(p, trust.NewRing([]ed25519.PublicKey{pub(ck)}, nil), OpenOptions{}) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer pkg.Close() + if pkg.SignedBy != trust.KeyID(pub(ck)) { + t.Fatalf("SignedBy = %s", pkg.SignedBy) + } + got, err := pkg.ReadFile(imgPath(), 1<<20) + if err != nil || !bytes.Equal(got, img) { + t.Fatalf("ReadFile = %q, %v", got, err) + } +} + +func TestRolesAreSeparate(t *testing.T) { + ck, rk := key(t, 1), key(t, 2) + ring := trust.NewRing([]ed25519.PublicKey{pub(ck)}, []ed25519.PublicKey{pub(rk)}) + + // An app signed with the content key must not verify. + m, src := appManifest() + if _, err := Open(write(t, m, src, ck), ring, OpenOptions{}); !errors.Is(err, ErrNotAuthentic) { + t.Fatalf("app signed by content key: err = %v", err) + } + if pkg, err := Open(write(t, m, src, rk), ring, OpenOptions{}); err != nil { + t.Fatalf("app signed by release key: %v", err) + } else { + pkg.Close() + } + // And content signed with the release key must not either. + cm, csrc := contentManifest() + if _, err := Open(write(t, cm, csrc, rk), ring, OpenOptions{}); !errors.Is(err, ErrNotAuthentic) { + t.Fatalf("content signed by release key: err = %v", err) + } +} + +func TestUnknownSignerIgnoredWhenAnotherVerifies(t *testing.T) { + old, cur := key(t, 3), key(t, 4) + m, src := appManifest() + p := write(t, m, src, old, cur) + pkg, err := Open(p, trust.NewRing(nil, []ed25519.PublicKey{pub(cur)}), OpenOptions{}) + if err != nil { + t.Fatalf("Open: %v", err) + } + pkg.Close() +} + +// rewrite copies the zip at p, letting edit change or drop entries and add +// extra ones. +func rewrite(t *testing.T, p string, edit func(name string, data []byte) ([]byte, bool), extra map[string][]byte) string { + t.Helper() + zr, err := zip.OpenReader(p) + if err != nil { + t.Fatal(err) + } + defer zr.Close() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for _, f := range zr.File { + rc, _ := f.Open() + var b bytes.Buffer + b.ReadFrom(rc) + rc.Close() + data, keep := edit(f.Name, b.Bytes()) + if !keep { + continue + } + w, _ := zw.Create(f.Name) + w.Write(data) + } + for n, d := range extra { + w, _ := zw.Create(n) + w.Write(d) + } + zw.Close() + out := filepath.Join(t.TempDir(), "edited.mbu") + os.WriteFile(out, buf.Bytes(), 0o644) + return out +} + +func TestTampering(t *testing.T) { + ck := key(t, 1) + ring := trust.NewRing([]ed25519.PublicKey{pub(ck)}, nil) + m, src := contentManifest() + p := write(t, m, src, ck) + keepAll := func(n string, d []byte) ([]byte, bool) { return d, true } + + cases := map[string]string{ + "manifest edited": rewrite(t, p, func(n string, d []byte) ([]byte, bool) { + if n == ManifestName { + return bytes.Replace(d, []byte("2026-09-25"), []byte("2026-09-26"), 1), true + } + return d, true + }, nil), + "payload edited": rewrite(t, p, func(n string, d []byte) ([]byte, bool) { + if n == "payloads/2026-09-25.json" { + return []byte(`{"frames":[1]}`), true + } + return d, true + }, nil), + "extra file": rewrite(t, p, keepAll, map[string][]byte{"payloads/2026-09-26.json": []byte("{}")}), + "zip slip": rewrite(t, p, keepAll, map[string][]byte{"../evil": []byte("x")}), + "hidden file": rewrite(t, p, keepAll, map[string][]byte{"media/.x": []byte("x")}), + "signature dropped": rewrite(t, p, func(n string, d []byte) ([]byte, bool) { return d, n != SignatureName }, nil), + "media dropped": rewrite(t, p, func(n string, d []byte) ([]byte, bool) { return d, !strings.HasPrefix(n, "media/") }, nil), + } + for name, path := range cases { + t.Run(name, func(t *testing.T) { + pkg, err := Open(path, ring, OpenOptions{}) + if err == nil { + // Content changes that keep sizes are caught on read. + _, err = pkg.ReadFile("payloads/2026-09-25.json", 1<<20) + pkg.Close() + } + if err == nil { + t.Fatal("tampered package accepted") + } + }) + } +} + +func TestOmittedMediaNeedsStore(t *testing.T) { + ck := key(t, 1) + ring := trust.NewRing([]ed25519.PublicKey{pub(ck)}, nil) + m, src := contentManifest() + src[2].Omit = true + p := write(t, m, src, ck) + if _, err := Open(p, ring, OpenOptions{}); err == nil { + t.Fatal("package with omitted media accepted without a store") + } + have := func(sha string) bool { return "media/"+sha+".png" == imgPath() } + pkg, err := Open(p, ring, OpenOptions{HaveMedia: have}) + if err != nil { + t.Fatalf("Open with store: %v", err) + } + if pkg.Has(imgPath()) { + t.Fatal("omitted media reported present") + } + pkg.Close() +} + +func TestManifestRules(t *testing.T) { + ck := key(t, 1) + bad := []func(m *Manifest, src *[]Source){ + func(m *Manifest, _ *[]Source) { m.DeviceID = "" }, + func(m *Manifest, _ *[]Source) { m.Sequence = 0 }, + func(m *Manifest, _ *[]Source) { m.Content.Timezone = "Local" }, + func(m *Manifest, _ *[]Source) { m.Content.LastDay = "2026-12-31" }, + func(m *Manifest, s *[]Source) { *s = (*s)[1:] }, // missing a day + func(m *Manifest, s *[]Source) { *s = append(*s, Source{Path: "other.txt", Data: []byte("x")}) }, + func(m *Manifest, _ *[]Source) { m.Content.Media[0].ContentType = "text/html" }, + } + for i, mut := range bad { + m, src := contentManifest() + m.Content = &ContentInfo{Timezone: m.Content.Timezone, FirstDay: m.Content.FirstDay, LastDay: m.Content.LastDay, + Media: append([]MediaInfo(nil), m.Content.Media...)} + mut(&m, &src) + if err := Build(&bytes.Buffer{}, m, src, []ed25519.PrivateKey{ck}); err == nil { + t.Errorf("case %d: invalid manifest built", i) + } + } +} + +func TestCompareVersions(t *testing.T) { + cases := []struct { + a, b string + want int + }{ + {"1.2.3", "1.2.3", 0}, {"1.2.10", "1.2.9", 1}, {"0.9.0", "1.0.0", -1}, + {"dev", "0.0.1", -1}, {"1.0.0", "dev", 1}, {"01.0.0", "1.0.0", -1}, + } + for _, c := range cases { + if got := CompareVersions(c.a, c.b); got != c.want { + t.Errorf("CompareVersions(%s, %s) = %d, want %d", c.a, c.b, got, c.want) + } + } +} + +// TestKeyIDVector pins the key id derivation so the backend, the Node packer +// and this code agree: seed 0x01..0x20. +func TestKeyIDVector(t *testing.T) { + seed := make([]byte, 32) + for i := range seed { + seed[i] = byte(i + 1) + } + k := ed25519.NewKeyFromSeed(seed) + id := trust.KeyID(pub(k)) + if len(id) != 16 { + t.Fatalf("key id %q", id) + } + t.Logf("seed 0x01..0x20 -> keyId %s", id) +} diff --git a/internal/mbu/reader.go b/internal/mbu/reader.go new file mode 100644 index 0000000..8415639 --- /dev/null +++ b/internal/mbu/reader.go @@ -0,0 +1,341 @@ +package mbu + +import ( + "archive/zip" + "bytes" + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/LensBridge/agent/internal/trust" +) + +// ErrNotAuthentic means no signature on the package verified under a key +// trusted for its type. +var ErrNotAuthentic = errors.New("the package is not signed by a key this board trusts") + +// KeyRing supplies the keys a package may be signed with, by role. +type KeyRing interface { + Keys(role trust.Role) map[string]ed25519.PublicKey +} + +// RoleFor is the key role that may sign packages of type t. +func RoleFor(t Type) trust.Role { + if t == TypeContent { + return trust.RoleContent + } + return trust.RoleRelease +} + +// OpenOptions adjusts verification. +type OpenOptions struct { + // HaveMedia reports whether the board's media store already holds the + // media file with this sha256. A content package may omit such files + // (online delta sync); any other listed file must be in the zip. + HaveMedia func(sha256 string) bool +} + +// Package is an opened, authenticated package. Its files are not yet read; +// ReadFile and CopyFile check each one's hash as they read it. +type Package struct { + Manifest *Manifest + RawManifest []byte + RawSig []byte + // SignedBy is the id of the key whose signature verified. + SignedBy string + + zr *zip.ReadCloser + entries map[string]*zip.File +} + +// Close releases the zip. +func (p *Package) Close() error { return p.zr.Close() } + +// Has reports whether the zip carries the listed file at path (it may be +// omitted media). +func (p *Package) Has(path string) bool { return p.entries[path] != nil } + +// Open opens the package at path, checks its structure, verifies its +// signature against ring, and checks that its entries are exactly the files +// its manifest lists. Only then is a Package returned. Board-specific checks +// (device id, monotonicity, arch) are the importer's. +func Open(path string, ring KeyRing, opt OpenOptions) (*Package, error) { + fi, err := os.Stat(path) + if err != nil { + return nil, err + } + if !fi.Mode().IsRegular() { + return nil, fmt.Errorf("%s is not a regular file", filepath.Base(path)) + } + if fi.Size() > MaxPackageBytes { + return nil, fmt.Errorf("%s is %d MiB; packages over %d MiB are refused", + filepath.Base(path), fi.Size()>>20, MaxPackageBytes>>20) + } + zr, err := zip.OpenReader(path) + if err != nil { + return nil, fmt.Errorf("%s is not a readable package (zip): %w", filepath.Base(path), err) + } + p, err := open(zr, ring, opt) + if err != nil { + zr.Close() + return nil, err + } + return p, nil +} + +func open(zr *zip.ReadCloser, ring KeyRing, opt OpenOptions) (*Package, error) { + entries, err := classify(zr.File) + if err != nil { + return nil, err + } + mf, sf := entries[ManifestName], entries[SignatureName] + if mf == nil { + return nil, fmt.Errorf("package has no %s (is this a MusallahBoard update package?)", ManifestName) + } + if sf == nil { + return nil, fmt.Errorf("package has no %s: it is unsigned", SignatureName) + } + rawM, err := readAll(mf, MaxManifestBytes) + if err != nil { + return nil, err + } + rawS, err := readAll(sf, MaxSignatureBytes) + if err != nil { + return nil, err + } + + // The type decides which keys may sign, so it is read before the + // signature is checked. That is safe: the type is inside the signed + // bytes, so a content key cannot vouch for a manifest claiming "app". + var head struct { + Type Type `json:"type"` + } + if err := json.Unmarshal(rawM, &head); err != nil { + return nil, fmt.Errorf("%s is not valid JSON: %w", ManifestName, err) + } + signedBy, err := VerifySignature(rawM, rawS, ring.Keys(RoleFor(head.Type))) + if err != nil { + return nil, err + } + m, err := ParseManifest(rawM) + if err != nil { + return nil, err + } + + delete(entries, ManifestName) + delete(entries, SignatureName) + for _, name := range sortedKeys(entries) { + f, listed := m.File(name) + if !listed { + return nil, fmt.Errorf("package contains %s, which its manifest does not list", name) + } + if int64(entries[name].UncompressedSize64) != f.Bytes { + return nil, fmt.Errorf("%s is %d bytes in the zip but %d in the manifest", name, entries[name].UncompressedSize64, f.Bytes) + } + } + for _, f := range m.Files { + if entries[f.Path] != nil { + continue + } + omittable := m.Type == TypeContent && strings.HasPrefix(f.Path, "media/") && + opt.HaveMedia != nil && opt.HaveMedia(f.SHA256) + if !omittable { + return nil, fmt.Errorf("package is missing %s, which its manifest lists", f.Path) + } + } + return &Package{ + Manifest: m, + RawManifest: rawM, + RawSig: rawS, + SignedBy: signedBy, + zr: zr, + entries: entries, + }, nil +} + +// classify indexes the zip's entries by name, rejecting anything that is not +// a plain file with an acceptable name. Directory entries are skipped. +func classify(files []*zip.File) (map[string]*zip.File, error) { + out := make(map[string]*zip.File, len(files)) + if len(files) > MaxFiles+2 { + return nil, fmt.Errorf("package has %d entries; at most %d are allowed", len(files), MaxFiles+2) + } + for _, f := range files { + name := f.Name + if strings.HasSuffix(name, "/") && f.UncompressedSize64 == 0 { + if err := ValidPath(strings.TrimSuffix(name, "/")); err != nil { + return nil, fmt.Errorf("package entry: %w", err) + } + continue + } + if err := ValidPath(name); err != nil { + return nil, fmt.Errorf("package entry: %w", err) + } + if !f.Mode().IsRegular() { + return nil, fmt.Errorf("package entry %q is not a regular file", name) + } + if out[name] != nil { + return nil, fmt.Errorf("package contains %s twice", name) + } + out[name] = f + } + return out, nil +} + +// sigFile is mbu.sig. +type sigFile struct { + Signatures []struct { + KeyID string `json:"keyId"` + Sig string `json:"sig"` + } `json:"signatures"` +} + +// VerifySignature checks rawSig (mbu.sig) over rawManifest against keys, and +// returns the id of the first key whose signature verifies. Signatures by +// keys not in keys are skipped: a package may be signed by an old and a new +// key during a rotation. +func VerifySignature(rawManifest, rawSig []byte, keys map[string]ed25519.PublicKey) (string, error) { + var s sigFile + if err := json.Unmarshal(rawSig, &s); err != nil { + return "", fmt.Errorf("%s is not valid JSON: %w", SignatureName, err) + } + if len(s.Signatures) == 0 { + return "", fmt.Errorf("%s holds no signatures", SignatureName) + } + if len(keys) == 0 { + return "", fmt.Errorf("%w (no keys of the required kind are installed; see `musallahboard-agent trust show`)", ErrNotAuthentic) + } + msg := SignedMessage(rawManifest) + var ids []string + for _, sig := range s.Signatures { + ids = append(ids, sig.KeyID) + pub, ok := keys[sig.KeyID] + if !ok { + continue + } + raw, err := base64.StdEncoding.DecodeString(sig.Sig) + if err != nil || len(raw) != ed25519.SignatureSize { + continue + } + if ed25519.Verify(pub, msg, raw) { + return sig.KeyID, nil + } + } + return "", fmt.Errorf("%w (signed by %s)", ErrNotAuthentic, strings.Join(ids, ", ")) +} + +// SignedMessage is the byte sequence a signature covers. +func SignedMessage(rawManifest []byte) []byte { + msg := make([]byte, 0, len(SignaturePrefix)+len(rawManifest)) + msg = append(msg, SignaturePrefix...) + return append(msg, rawManifest...) +} + +// ReadFile reads the listed file at path in full, verifying its size and +// hash. It refuses files larger than limit. +func (p *Package) ReadFile(path string, limit int64) ([]byte, error) { + f, ok := p.Manifest.File(path) + if !ok { + return nil, fmt.Errorf("%s is not listed in the package", path) + } + if f.Bytes > limit { + return nil, fmt.Errorf("%s is larger than %d MiB", path, limit>>20) + } + var buf bytes.Buffer + if err := p.CopyFile(path, &buf); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// CopyFile streams the listed file at path to w, hashing as it goes. On error +// w has received unverified bytes and the caller must discard them; callers +// only ever write into staging. +func (p *Package) CopyFile(path string, w io.Writer) error { + f, ok := p.Manifest.File(path) + if !ok { + return fmt.Errorf("%s is not listed in the package", path) + } + ze := p.entries[path] + if ze == nil { + return fmt.Errorf("%s is not in the package", path) + } + rc, err := ze.Open() + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + defer rc.Close() + h := sha256.New() + // One byte past the declared size tells "too big" apart; reading to EOF + // is also what makes archive/zip check the CRC. + n, err := io.Copy(io.MultiWriter(w, h), io.LimitReader(rc, f.Bytes+1)) + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + if n != f.Bytes { + return fmt.Errorf("%s is %d bytes but the manifest says %d", path, n, f.Bytes) + } + if got := hex.EncodeToString(h.Sum(nil)); got != f.SHA256 { + return fmt.Errorf("%s is corrupt: its SHA-256 does not match the signed manifest", path) + } + return nil +} + +func readAll(f *zip.File, limit int64) ([]byte, error) { + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("read %s: %w", f.Name, err) + } + defer rc.Close() + raw, err := io.ReadAll(io.LimitReader(rc, limit+1)) + if err != nil { + return nil, fmt.Errorf("read %s: %w", f.Name, err) + } + if int64(len(raw)) > limit { + return nil, fmt.Errorf("%s is larger than %d KiB", f.Name, limit>>10) + } + return raw, nil +} + +// Peek reads the manifest of the package at path without verifying anything. +// It is for ordering a batch and for the screen, never for a decision about +// what to install. +func Peek(path string) (*Manifest, error) { + zr, err := zip.OpenReader(path) + if err != nil { + return nil, err + } + defer zr.Close() + for _, f := range zr.File { + if f.Name == ManifestName { + raw, err := readAll(f, MaxManifestBytes) + if err != nil { + return nil, err + } + var m Manifest + if err := json.Unmarshal(raw, &m); err != nil { + return nil, err + } + return &m, nil + } + } + return nil, fmt.Errorf("no %s", ManifestName) +} + +func sortedKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/internal/mbu/writer.go b/internal/mbu/writer.go new file mode 100644 index 0000000..a307c90 --- /dev/null +++ b/internal/mbu/writer.go @@ -0,0 +1,153 @@ +package mbu + +import ( + "archive/zip" + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "sort" + "strings" + "time" + + "github.com/LensBridge/agent/internal/trust" +) + +// Source is one file to put in a package. +type Source struct { + // Path is the file's path inside the package. + Path string + // Exactly one of Data and FromFile is set. + Data []byte + FromFile string + // Omit lists the file in the manifest but leaves it out of the zip + // (delta content packages). Its hash and size are still computed. + Omit bool +} + +func (s Source) open() (io.ReadCloser, error) { + if s.FromFile != "" { + return os.Open(s.FromFile) + } + return io.NopCloser(strings.NewReader(string(s.Data))), nil +} + +// Build writes a signed package to w. m supplies everything but Files and +// Format/FormatVersion, which Build fills in from sources. createdAt is set to +// now (UTC, whole seconds) if m.CreatedAt is empty. The manifest is checked +// with ParseManifest before anything is written, so Build never produces a +// package the agent would refuse on structure. +func Build(w io.Writer, m Manifest, sources []Source, signers []ed25519.PrivateKey) error { + if len(signers) == 0 { + return fmt.Errorf("no signing key given") + } + m.Format, m.FormatVersion = Format, FormatVersion + if m.CreatedAt == "" { + m.CreatedAt = time.Now().UTC().Truncate(time.Second).Format(time.RFC3339) + } + sort.Slice(sources, func(i, j int) bool { return sources[i].Path < sources[j].Path }) + m.Files = make([]File, 0, len(sources)) + for _, s := range sources { + f, err := hashSource(s) + if err != nil { + return err + } + m.Files = append(m.Files, f) + } + + rawM, err := json.MarshalIndent(&m, "", " ") + if err != nil { + return err + } + if _, err := ParseManifest(rawM); err != nil { + return fmt.Errorf("refusing to build an invalid package: %w", err) + } + rawS, err := Sign(rawM, signers) + if err != nil { + return err + } + + zw := zip.NewWriter(w) + if err := writeEntry(zw, ManifestName, strings.NewReader(string(rawM)), true); err != nil { + return err + } + if err := writeEntry(zw, SignatureName, strings.NewReader(string(rawS)), true); err != nil { + return err + } + for _, s := range sources { + if s.Omit { + continue + } + rc, err := s.open() + if err != nil { + return err + } + // Media is already compressed; deflating it again only costs CPU on + // the board. + deflate := !strings.HasPrefix(s.Path, "media/") + err = writeEntry(zw, s.Path, rc, deflate) + rc.Close() + if err != nil { + return err + } + } + return zw.Close() +} + +// Sign produces mbu.sig for rawManifest. +func Sign(rawManifest []byte, signers []ed25519.PrivateKey) ([]byte, error) { + type sig struct { + KeyID string `json:"keyId"` + Sig string `json:"sig"` + } + var out struct { + Signatures []sig `json:"signatures"` + } + msg := SignedMessage(rawManifest) + for _, k := range signers { + pub := k.Public().(ed25519.PublicKey) + out.Signatures = append(out.Signatures, sig{ + KeyID: trust.KeyID(pub), + Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(k, msg)), + }) + } + return json.MarshalIndent(out, "", " ") +} + +func hashSource(s Source) (File, error) { + if err := ValidPath(s.Path); err != nil { + return File{}, err + } + rc, err := s.open() + if err != nil { + return File{}, err + } + defer rc.Close() + h := sha256.New() + n, err := io.Copy(h, rc) + if err != nil { + return File{}, fmt.Errorf("read %s: %w", s.Path, err) + } + return File{Path: s.Path, SHA256: hex.EncodeToString(h.Sum(nil)), Bytes: n}, nil +} + +func writeEntry(zw *zip.Writer, name string, r io.Reader, deflate bool) error { + method := zip.Store + if deflate { + method = zip.Deflate + } + w, err := zw.CreateHeader(&zip.FileHeader{Name: name, Method: method, Modified: fixedModTime}) + if err != nil { + return err + } + _, err = io.Copy(w, r) + return err +} + +// fixedModTime makes builds of the same inputs byte-identical. DOS time cannot +// go before 1980, so not the Unix epoch. +var fixedModTime = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) diff --git a/internal/offline/app.go b/internal/offline/app.go deleted file mode 100644 index d9a65e3..0000000 --- a/internal/offline/app.go +++ /dev/null @@ -1,336 +0,0 @@ -package offline - -import ( - "archive/tar" - "compress/gzip" - "errors" - "fmt" - "io" - "io/fs" - "os" - "path" - "path/filepath" - "strings" - "time" -) - -// Limits on an SPA build. A Vite build of the board is a few MB; these only -// stop a wrong directory or archive from filling the disk. -const ( - maxAppBytes = 500 << 20 - maxAppFiles = 20000 -) - -// AppResult describes a successful app install. -type AppResult struct { - // Name is the new release's directory under .d/. - Name string - Previous string - Removed []string -} - -// InstallApp installs a built frontend from src — a directory, or a .tar.gz -// / .tgz of one — so that p.SPA serves it. The build must have index.html at -// its root, or inside a single top-level directory (a tarball of dist/). -// -// Same approach as InstallBundle: copy into .d/.staging-, fsync, -// rename to .d/, then atomically repoint the p.SPA symlink and -// prune to current + previous. The first install on a board where p.SPA is a -// plain directory moves that directory into .d/ first; that one step is -// not atomic, which is acceptable because it happens once, at provisioning. -func InstallApp(p Paths, src string) (*AppResult, error) { - releases := p.appReleases() - if err := os.MkdirAll(releases, 0o755); err != nil { - return nil, fmt.Errorf("create %s: %w", releases, err) - } - unlock, err := lockDir(releases) - if err != nil { - return nil, err - } - defer unlock() - - stage, err := os.MkdirTemp(releases, ".staging-") - if err != nil { - return nil, fmt.Errorf("create staging directory: %w", err) - } - defer os.RemoveAll(stage) // a no-op once the release has been renamed out - if err := os.Chmod(stage, 0o755); err != nil { - return nil, err - } - - fi, err := os.Stat(src) - if err != nil { - return nil, err - } - switch { - case fi.IsDir(): - err = copyTree(src, stage) - case strings.HasSuffix(src, ".tar.gz") || strings.HasSuffix(src, ".tgz"): - err = extractTarGz(src, stage) - default: - err = fmt.Errorf("%s is neither a directory nor a .tar.gz", src) - } - if err != nil { - return nil, err - } - - root, err := appRoot(stage) - if err != nil { - return nil, err - } - if err := os.Chmod(root, 0o755); err != nil { - return nil, err - } - - previous, err := appCurrentName(p) - if err != nil { - return nil, err - } - name := uniqueName(releases, time.Now().UTC().Format("20060102T150405Z")) - if err := os.Rename(root, filepath.Join(releases, name)); err != nil { - return nil, fmt.Errorf("move app into place: %w", err) - } - if err := syncDir(releases); err != nil { - return nil, err - } - - if err := adoptPlainSPADir(p, &previous); err != nil { - return nil, err - } - if err := swapSymlink(p.SPA, filepath.Join(filepath.Base(releases), name)); err != nil { - return nil, err - } - - res := &AppResult{Name: name, Previous: previous} - res.Removed, err = prune(releases, name, previous, ".lock") - if err != nil { - return res, fmt.Errorf("app installed, but cleaning up old builds failed: %w", err) - } - return res, nil -} - -// appCurrentName is the release p.SPA points at, or "" if it is not a symlink -// (not installed yet, or a plain directory from before InstallApp existed). -func appCurrentName(p Paths) (string, error) { - fi, err := os.Lstat(p.SPA) - if errors.Is(err, os.ErrNotExist) || (err == nil && fi.Mode()&os.ModeSymlink == 0) { - return "", nil - } - if err != nil { - return "", err - } - target, err := readLinkAbs(p.SPA) - if err != nil { - return "", err - } - return filepath.Base(target), nil -} - -// adoptPlainSPADir moves a pre-existing real directory at p.SPA into the -// releases directory so a symlink can replace it, and records it as previous. -func adoptPlainSPADir(p Paths, previous *string) error { - fi, err := os.Lstat(p.SPA) - if errors.Is(err, os.ErrNotExist) || (err == nil && fi.Mode()&os.ModeSymlink != 0) { - return nil - } - if err != nil { - return err - } - if !fi.IsDir() { - return fmt.Errorf("%s exists and is not a directory or symlink", p.SPA) - } - name := uniqueName(p.appReleases(), "adopted") - if err := os.Rename(p.SPA, filepath.Join(p.appReleases(), name)); err != nil { - return fmt.Errorf("move existing %s aside: %w", p.SPA, err) - } - *previous = name - return nil -} - -// appRoot finds the directory in stage that holds index.html: stage itself, -// or its only subdirectory. -func appRoot(stage string) (string, error) { - if isRegular(filepath.Join(stage, "index.html")) { - return stage, nil - } - entries, err := os.ReadDir(stage) - if err != nil { - return "", err - } - if len(entries) == 1 && entries[0].IsDir() { - sub := filepath.Join(stage, entries[0].Name()) - if isRegular(filepath.Join(sub, "index.html")) { - return sub, nil - } - } - return "", fmt.Errorf("the app build has no index.html at its top level (point at the build output directory, e.g. dist/)") -} - -func isRegular(p string) bool { - fi, err := os.Lstat(p) - return err == nil && fi.Mode().IsRegular() -} - -// appBudget enforces the size and file-count limits across one install. -type appBudget struct{ bytes, files int64 } - -func (b *appBudget) add(n int64) error { - b.files++ - b.bytes += n - if b.files > maxAppFiles { - return fmt.Errorf("the app build has more than %d files", maxAppFiles) - } - if b.bytes > maxAppBytes { - return fmt.Errorf("the app build is larger than %d MB", maxAppBytes>>20) - } - return nil -} - -// copyTree copies the regular files and directories under src into dst. -// Symlinks and special files are refused: a build output has none, and -// following one could copy something from outside the build. -func copyTree(src, dst string) error { - var budget appBudget - var dirs []string - err := filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - rel, err := filepath.Rel(src, p) - if err != nil { - return err - } - target := filepath.Join(dst, rel) - switch { - case d.IsDir(): - if rel != "." { - if err := os.Mkdir(target, 0o755); err != nil { - return err - } - } - dirs = append(dirs, target) - return nil - case d.Type().IsRegular(): - fi, err := d.Info() - if err != nil { - return err - } - if err := budget.add(fi.Size()); err != nil { - return err - } - f, err := os.Open(p) - if err != nil { - return err - } - defer f.Close() - n, err := copyFileSync(target, f, fi.Size()+1, 0o644) - if err != nil { - return fmt.Errorf("copy %s: %w", rel, err) - } - if n != fi.Size() { - return fmt.Errorf("copy %s: file changed while being copied", rel) - } - return nil - default: - return fmt.Errorf("%s is a symlink or special file; the app build must contain only files and directories", rel) - } - }) - if err != nil { - return err - } - return syncDirs(dirs) -} - -// extractTarGz unpacks a gzipped tar into dst. Only regular files and -// directories are accepted, and every name is checked before it is joined -// onto dst, so an entry cannot land outside it. -func extractTarGz(src, dst string) error { - f, err := os.Open(src) - if err != nil { - return err - } - defer f.Close() - gz, err := gzip.NewReader(f) - if err != nil { - return fmt.Errorf("%s is not a gzip file: %w", filepath.Base(src), err) - } - defer gz.Close() - tr := tar.NewReader(gz) - - var budget appBudget - dirs := []string{dst} - for { - h, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - return fmt.Errorf("read %s: %w", filepath.Base(src), err) - } - name, err := safeArchiveName(h.Name) - if err != nil { - return err - } - if name == "" { // "./" — the archive's own root - continue - } - target := filepath.Join(dst, filepath.FromSlash(name)) - switch h.Typeflag { - case tar.TypeDir: - if err := os.MkdirAll(target, 0o755); err != nil { - return err - } - dirs = append(dirs, target) - case tar.TypeReg: - if err := budget.add(h.Size); err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - return err - } - n, err := copyFileSync(target, tr, h.Size+1, 0o644) - if err != nil { - return fmt.Errorf("extract %s: %w", name, err) - } - if n != h.Size { - return fmt.Errorf("extract %s: truncated", name) - } - dirs = append(dirs, filepath.Dir(target)) - case tar.TypeXGlobalHeader: - // pax metadata, not a file - default: - return fmt.Errorf("%s in the archive is a link or special file; the app build must contain only files and directories", h.Name) - } - } - return syncDirs(dirs) -} - -// safeArchiveName normalises an archive entry name and rejects any that -// could escape the extraction directory. "" means the archive root itself. -func safeArchiveName(name string) (string, error) { - if strings.Contains(name, `\`) || strings.HasPrefix(name, "/") { - return "", fmt.Errorf("archive entry %q has an unsafe name", name) - } - clean := path.Clean(name) - if clean == "." { - return "", nil - } - if !fs.ValidPath(clean) || (len(clean) >= 2 && clean[1] == ':') { - return "", fmt.Errorf("archive entry %q has an unsafe name", name) - } - return clean, nil -} - -func syncDirs(dirs []string) error { - seen := map[string]bool{} - for _, d := range dirs { - if seen[d] { - continue - } - seen[d] = true - if err := syncDir(d); err != nil { - return err - } - } - return nil -} diff --git a/internal/offline/app_test.go b/internal/offline/app_test.go deleted file mode 100644 index 0cc0029..0000000 --- a/internal/offline/app_test.go +++ /dev/null @@ -1,172 +0,0 @@ -package offline - -import ( - "archive/tar" - "compress/gzip" - "os" - "path/filepath" - "strings" - "testing" -) - -type tarEntry struct { - name string - body string - typeflag byte -} - -func writeTarGz(t *testing.T, entries []tarEntry) string { - t.Helper() - p := filepath.Join(t.TempDir(), "dist.tar.gz") - f, err := os.Create(p) - if err != nil { - t.Fatal(err) - } - gz := gzip.NewWriter(f) - tw := tar.NewWriter(gz) - for _, e := range entries { - h := &tar.Header{Name: e.name, Mode: 0o644, Size: int64(len(e.body)), Typeflag: e.typeflag} - switch e.typeflag { - case 0: - h.Typeflag = tar.TypeReg - case tar.TypeDir: - h.Mode, h.Size = 0o755, 0 - case tar.TypeSymlink: - h.Linkname, h.Size = e.body, 0 - } - if err := tw.WriteHeader(h); err != nil { - t.Fatal(err) - } - if h.Typeflag == tar.TypeReg { - if _, err := tw.Write([]byte(e.body)); err != nil { - t.Fatal(err) - } - } - } - for _, c := range []interface{ Close() error }{tw, gz, f} { - if err := c.Close(); err != nil { - t.Fatal(err) - } - } - return p -} - -func readSPA(t *testing.T, p Paths, name string) string { - t.Helper() - b, err := os.ReadFile(filepath.Join(p.SPA, filepath.FromSlash(name))) - if err != nil { - t.Fatal(err) - } - return string(b) -} - -func TestInstallAppFromTarGz(t *testing.T) { - requireSymlinks(t) - p := testPaths(t) - - // A tarball of dist/ — index.html one level down. - src := writeTarGz(t, []tarEntry{ - {name: "dist/", typeflag: tar.TypeDir}, - {name: "dist/index.html", body: "v1"}, - {name: "dist/assets/app.js", body: "js"}, - }) - res, err := InstallApp(p, src) - if err != nil { - t.Fatal(err) - } - if got := readSPA(t, p, "index.html"); got != "v1" { - t.Errorf("index.html = %q", got) - } - if got := readSPA(t, p, "assets/app.js"); got != "js" { - t.Errorf("app.js = %q", got) - } - if res.Previous != "" { - t.Errorf("previous = %q", res.Previous) - } -} - -func TestInstallAppFromDirAdoptsPlainDirAndPrunes(t *testing.T) { - requireSymlinks(t) - p := testPaths(t) - // An SPA copied in by hand before app install existed. - if err := os.MkdirAll(p.SPA, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(p.SPA, "index.html"), []byte("hand"), 0o644); err != nil { - t.Fatal(err) - } - - var names []string - for _, v := range []string{"v1", "v2", "v3"} { - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(v), 0o644); err != nil { - t.Fatal(err) - } - res, err := InstallApp(p, dir) - if err != nil { - t.Fatal(err) - } - if v == "v1" && res.Previous != "adopted" { - t.Errorf("first install previous = %q, want adopted", res.Previous) - } - if got := readSPA(t, p, "index.html"); got != v { - t.Errorf("index.html = %q, want %q", got, v) - } - names = append(names, res.Name) - } - fi, err := os.Lstat(p.SPA) - if err != nil || fi.Mode()&os.ModeSymlink == 0 { - t.Fatalf("SPA dir is not a symlink after install: %v", err) - } - got := listDir(t, p.appReleases()) - for _, n := range got { - if n == "adopted" || n == names[0] || strings.HasPrefix(n, ".staging-") { - t.Errorf("not pruned: %s (have %v)", n, got) - } - } -} - -func TestInstallAppRejects(t *testing.T) { - cases := []struct { - name string - entries []tarEntry - wantErr string - }{ - {"no index.html", []tarEntry{{name: "dist/app.js", body: "x"}}, "no index.html"}, - {"parent traversal", []tarEntry{{name: "index.html", body: "x"}, {name: "../evil", body: "x"}}, "unsafe name"}, - {"hidden traversal", []tarEntry{{name: "index.html", body: "x"}, {name: "assets/../../evil", body: "x"}}, "unsafe name"}, - {"absolute", []tarEntry{{name: "/etc/cron.d/evil", body: "x"}}, "unsafe name"}, - {"backslash", []tarEntry{{name: `..\evil`, body: "x"}}, "unsafe name"}, - {"symlink", []tarEntry{{name: "index.html", body: "x"}, {name: "leak", body: "/etc/shadow", typeflag: tar.TypeSymlink}}, "link or special"}, - {"two top-level dirs", []tarEntry{{name: "a/index.html", body: "x"}, {name: "b/index.html", body: "y"}}, "no index.html"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - p := testPaths(t) - _, err := InstallApp(p, writeTarGz(t, tc.entries)) - if err == nil || !strings.Contains(err.Error(), tc.wantErr) { - t.Fatalf("err = %v, want containing %q", err, tc.wantErr) - } - if _, err := os.Lstat(p.SPA); !os.IsNotExist(err) { - t.Error("SPA path created by a failed install") - } - for _, n := range listDir(t, p.appReleases()) { - if n != ".lock" { - t.Errorf("left behind in releases: %s", n) - } - } - if _, err := os.Stat(filepath.Join(filepath.Dir(p.Root), "evil")); !os.IsNotExist(err) { - t.Error("traversal entry was written") - } - }) - } -} - -func TestInstallAppRejectsOtherFiles(t *testing.T) { - p := testPaths(t) - f := filepath.Join(t.TempDir(), "dist.zip") - _ = os.WriteFile(f, []byte("x"), 0o644) - if _, err := InstallApp(p, f); err == nil || !strings.Contains(err.Error(), "neither a directory nor a .tar.gz") { - t.Errorf("err = %v", err) - } -} diff --git a/internal/offline/bundle.go b/internal/offline/bundle.go deleted file mode 100644 index 6083de4..0000000 --- a/internal/offline/bundle.go +++ /dev/null @@ -1,289 +0,0 @@ -package offline - -import ( - "archive/zip" - "bytes" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "os" - "path/filepath" - "sort" - "strings" -) - -// Size limits. MaxBundleBytes is the contract's 200 MB cap on the zip itself; -// the others bound what a small zip can inflate to, so a malformed or hostile -// bundle cannot fill the SD card before its hashes are even checked. -const ( - MaxBundleBytes = 200 << 20 - maxManifestBytes = 1 << 20 - maxPayloadBytes = 16 << 20 - maxMediaTotal = 512 << 20 -) - -// extractBundle validates the bundle zip at zipPath against Contract 1 and -// writes its contents into stageDir, which must exist and be empty. It writes -// nowhere else. Every file it writes is fsynced; so are the directories. -// -// On error stageDir may hold a partial extraction — the caller discards it. -// Nothing in the zip is ever used as a filesystem path until it has matched -// one of the entry-name patterns, so a name like "../../etc/passwd" or -// "/media/x" is rejected before it can be joined onto anything. -func extractBundle(zipPath, stageDir, deviceID string) (*Manifest, error) { - fi, err := os.Stat(zipPath) - if err != nil { - return nil, err - } - if fi.Size() > MaxBundleBytes { - return nil, fmt.Errorf("%s is %d MB; bundles over %d MB are refused", - filepath.Base(zipPath), fi.Size()>>20, MaxBundleBytes>>20) - } - - zr, err := zip.OpenReader(zipPath) - if err != nil { - // Includes zip.ErrInsecurePath when GODEBUG asks for it; either way - // the file is not something we install. - return nil, fmt.Errorf("%s is not a readable zip file: %w", filepath.Base(zipPath), err) - } - defer zr.Close() - - // Pass 1: classify every entry by name. Nothing is read yet. - var manifestFile *zip.File - payloads := map[string]*zip.File{} // date -> entry - media := map[string]*zip.File{} // "media/." -> entry - for _, f := range zr.File { - name := f.Name - // Bare directory entries for the two allowed directories carry no - // data; some zip writers emit them and some do not. - if name == "payloads/" || name == "media/" { - continue - } - if !f.Mode().IsRegular() { - return nil, fmt.Errorf("bundle entry %q is not a regular file", name) - } - switch { - case name == "manifest.json": - if manifestFile != nil { - return nil, fmt.Errorf("bundle contains manifest.json twice") - } - manifestFile = f - case payloadNameRE.MatchString(name): - date := payloadNameRE.FindStringSubmatch(name)[1] - if payloads[date] != nil { - return nil, fmt.Errorf("bundle contains %s twice", name) - } - payloads[date] = f - case mediaNameRE.MatchString(name): - if media[name] != nil { - return nil, fmt.Errorf("bundle contains %s twice", name) - } - media[name] = f - default: - return nil, fmt.Errorf("bundle contains %q, which is not allowed (only manifest.json, payloads/.json and media/.)", name) - } - } - if manifestFile == nil { - return nil, fmt.Errorf("bundle has no manifest.json") - } - - rawManifest, err := readEntry(manifestFile, maxManifestBytes) - if err != nil { - return nil, err - } - m, err := parseManifest(rawManifest) - if err != nil { - return nil, err - } - if m.DeviceID != deviceID { - return nil, fmt.Errorf("this bundle is for device %s, but this board is %s (download the bundle for this board from LensBridge)", - m.DeviceID, deviceID) - } - - // The payload set must be exactly firstDay..lastDay. - days := m.Days() - want := make(map[string]bool, len(days)) - for _, d := range days { - want[d] = true - if payloads[d] == nil { - return nil, fmt.Errorf("bundle is missing payloads/%s.json (the manifest covers %s to %s)", d, m.FirstDay, m.LastDay) - } - } - for _, d := range sortedKeys(payloads) { - if !want[d] { - return nil, fmt.Errorf("bundle has payloads/%s.json, outside its range %s to %s", d, m.FirstDay, m.LastDay) - } - } - - // The media set must be exactly the manifest's. - var mediaTotal int64 - for _, e := range m.Media { - if media[e.Path] == nil { - return nil, fmt.Errorf("manifest lists %s but the bundle does not contain it", e.Path) - } - mediaTotal += e.Bytes - } - if mediaTotal > maxMediaTotal { - return nil, fmt.Errorf("bundle media totals %d MB; at most %d MB is allowed", mediaTotal>>20, maxMediaTotal>>20) - } - listed := m.mediaByFile() - for _, name := range sortedKeys(media) { - if _, ok := listed[strings.TrimPrefix(name, "media/")]; !ok { - return nil, fmt.Errorf("bundle contains %s, which the manifest does not list", name) - } - } - - // Pass 2: read, check and write. Validation of each file happens before - // or while it is written into the staging directory, never elsewhere. - for _, dir := range []string{"payloads", "media"} { - if err := os.Mkdir(filepath.Join(stageDir, dir), 0o755); err != nil { - return nil, err - } - } - if err := writeFileSync(filepath.Join(stageDir, "manifest.json"), rawManifest); err != nil { - return nil, err - } - - for _, d := range days { - raw, err := readEntry(payloads[d], maxPayloadBytes) - if err != nil { - return nil, err - } - if err := checkPayload(raw, listed); err != nil { - return nil, fmt.Errorf("payloads/%s.json: %w", d, err) - } - if err := writeFileSync(filepath.Join(stageDir, "payloads", d+".json"), raw); err != nil { - return nil, err - } - } - - for _, e := range m.Media { - if err := extractMedia(media[e.Path], e, stageDir); err != nil { - return nil, err - } - } - - for _, dir := range []string{"payloads", "media", ""} { - if err := syncDir(filepath.Join(stageDir, dir)); err != nil { - return nil, err - } - } - return m, nil -} - -// readEntry reads a whole zip entry, refusing one larger than limit. Reading -// to EOF is also what makes archive/zip verify the entry's CRC. -func readEntry(f *zip.File, limit int64) ([]byte, error) { - rc, err := f.Open() - if err != nil { - return nil, fmt.Errorf("read %s: %w", f.Name, err) - } - defer rc.Close() - raw, err := io.ReadAll(io.LimitReader(rc, limit+1)) - if err != nil { - return nil, fmt.Errorf("read %s: %w", f.Name, err) - } - if int64(len(raw)) > limit { - return nil, fmt.Errorf("%s is larger than %d MB", f.Name, limit>>20) - } - return raw, nil -} - -// extractMedia streams one media entry to disk, hashing as it goes, and -// checks the result against the manifest. The name was already matched -// against mediaNameRE, so joining it onto stageDir is safe. -func extractMedia(f *zip.File, e MediaEntry, stageDir string) error { - rc, err := f.Open() - if err != nil { - return fmt.Errorf("read %s: %w", e.Path, err) - } - defer rc.Close() - - h := sha256.New() - // One byte over the declared size is enough to tell "too big" apart, - // and reading to EOF lets archive/zip check the CRC. - n, err := copyFileSync(filepath.Join(stageDir, filepath.FromSlash(e.Path)), io.TeeReader(rc, h), e.Bytes+1, 0o644) - if err != nil { - return fmt.Errorf("extract %s: %w", e.Path, err) - } - if n != e.Bytes { - return fmt.Errorf("%s is %d bytes but the manifest says %d", e.Path, n, e.Bytes) - } - if got := hex.EncodeToString(h.Sum(nil)); got != e.SHA256 { - return fmt.Errorf("%s is corrupt: its SHA-256 is %s, the manifest says %s", e.Path, got, e.SHA256) - } - return nil -} - -// checkPayload requires raw to be a JSON object and every posterUrl anywhere -// in it to name a media file in the bundle. -// -// The walk is structural rather than tied to the payload schema, so a new -// frame type that carries a poster is covered without a change here. -// Contract 1 has the exporter rewrite every posterUrl to /media/; one -// that still points anywhere else would be a blank tile on a board with no -// internet, so it is refused too rather than discovered on screen. -func checkPayload(raw []byte, media map[string]MediaEntry) error { - dec := json.NewDecoder(bytes.NewReader(raw)) - dec.UseNumber() - var v any - if err := dec.Decode(&v); err != nil { - return fmt.Errorf("not valid JSON: %w", err) - } - if dec.More() { - return fmt.Errorf("not valid JSON: trailing data after the object") - } - if _, ok := v.(map[string]any); !ok { - return fmt.Errorf("not a JSON object") - } - return walkPosterURLs(v, media) -} - -func walkPosterURLs(v any, media map[string]MediaEntry) error { - switch t := v.(type) { - case map[string]any: - // Sorted so the first error reported is the same every time. - for _, k := range sortedKeys(t) { - child := t[k] - if k == "posterUrl" { - if s, ok := child.(string); ok && s != "" { - if err := checkPosterURL(s, media); err != nil { - return err - } - } - } - if err := walkPosterURLs(child, media); err != nil { - return err - } - } - case []any: - for _, child := range t { - if err := walkPosterURLs(child, media); err != nil { - return err - } - } - } - return nil -} - -func checkPosterURL(s string, media map[string]MediaEntry) error { - file, ok := strings.CutPrefix(s, "/media/") - if !ok { - return fmt.Errorf("posterUrl %q is not a /media/ path; an offline board cannot load images from anywhere else", s) - } - if _, ok := media[file]; !ok { - return fmt.Errorf("posterUrl %q refers to a media file that is not in the bundle", s) - } - return nil -} - -func sortedKeys[V any](m map[string]V) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} diff --git a/internal/offline/bundle_test.go b/internal/offline/bundle_test.go deleted file mode 100644 index b9aea85..0000000 --- a/internal/offline/bundle_test.go +++ /dev/null @@ -1,159 +0,0 @@ -package offline - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestExtractBundleValidation(t *testing.T) { - const first = "2026-09-24" - cases := []struct { - name string - mutate func(b *bundleSpec) - wantErr string // "" means the bundle must be accepted - }{ - {name: "valid", mutate: func(*bundleSpec) {}}, - {name: "directory entries for the allowed dirs are fine", mutate: func(b *bundleSpec) { - b.extra = append(b.extra, zipEntry{"payloads/", nil}, zipEntry{"media/", nil}) - }}, - - // Zip-slip and friends: none of these may be joined onto a path. - {name: "parent traversal", mutate: addEntry("../evil.json"), wantErr: "not allowed"}, - {name: "deep traversal", mutate: addEntry("../../../../etc/cron.d/x"), wantErr: "not allowed"}, - {name: "absolute path", mutate: addEntry("/etc/passwd"), wantErr: "not allowed"}, - {name: "traversal inside payloads", mutate: addEntry("payloads/../../x.json"), wantErr: "not allowed"}, - {name: "traversal inside media", mutate: addEntry("media/../../" + sha(testImage) + ".jpg"), wantErr: "not allowed"}, - {name: "backslash separator", mutate: addEntry(`payloads\2026-09-24.json`), wantErr: "not allowed"}, - {name: "windows drive", mutate: addEntry(`C:/x.json`), wantErr: "not allowed"}, - {name: "unknown top-level dir", mutate: addEntry("assets/app.js"), wantErr: "not allowed"}, - {name: "nested payload dir", mutate: addEntry("payloads/x/2026-09-24.json"), wantErr: "not allowed"}, - {name: "wrong manifest case", mutate: addEntry("Manifest.json"), wantErr: "not allowed"}, - {name: "media name not a sha", mutate: addEntry("media/poster.jpg"), wantErr: "not allowed"}, - {name: "uppercase sha", mutate: addEntry("media/" + strings.ToUpper(sha(testImage)) + ".jpg"), wantErr: "not allowed"}, - {name: "duplicate payload", mutate: func(b *bundleSpec) { - b.extra = append(b.extra, zipEntry{"payloads/" + first + ".json", b.payloads[first]}) - }, wantErr: "twice"}, - - {name: "no manifest", mutate: func(b *bundleSpec) { b.noManifest = true }, wantErr: "no manifest.json"}, - {name: "unknown formatVersion", mutate: func(b *bundleSpec) { b.manifest.FormatVersion = 2 }, wantErr: "formatVersion 2"}, - {name: "missing formatVersion", mutate: func(b *bundleSpec) { b.manifest.FormatVersion = 0 }, wantErr: "formatVersion 0"}, - {name: "wrong device", mutate: func(b *bundleSpec) { b.manifest.DeviceID = "someone-else" }, wantErr: "for device someone-else"}, - {name: "unknown timezone", mutate: func(b *bundleSpec) { b.manifest.Timezone = "Mars/Olympus_Mons" }, wantErr: "timezone"}, - {name: "empty timezone", mutate: func(b *bundleSpec) { b.manifest.Timezone = "" }, wantErr: "timezone"}, - {name: "Local is not a timezone", mutate: func(b *bundleSpec) { b.manifest.Timezone = "Local" }, wantErr: "timezone"}, - {name: "bad generatedAt", mutate: func(b *bundleSpec) { b.manifest.GeneratedAt = "yesterday" }, wantErr: "generatedAt"}, - {name: "lastDay before firstDay", mutate: func(b *bundleSpec) { b.manifest.LastDay = "2026-09-01" }, wantErr: "before firstDay"}, - - {name: "missing day", mutate: func(b *bundleSpec) { delete(b.payloads, "2026-09-26") }, wantErr: "missing payloads/2026-09-26.json"}, - {name: "extra day after range", mutate: func(b *bundleSpec) { - b.payloads["2026-09-28"] = payloadFor("2026-09-28", "") - }, wantErr: "payloads/2026-09-28.json, outside its range"}, - {name: "extra day before range", mutate: func(b *bundleSpec) { - b.payloads["2026-09-23"] = payloadFor("2026-09-23", "") - }, wantErr: "outside its range"}, - {name: "impossible date", mutate: func(b *bundleSpec) { - b.payloads["2026-13-45"] = []byte(`{}`) - }, wantErr: "outside its range"}, - {name: "payload is an array", mutate: func(b *bundleSpec) { b.payloads[first] = []byte(`[]`) }, wantErr: "not a JSON object"}, - {name: "payload is not JSON", mutate: func(b *bundleSpec) { b.payloads[first] = []byte(`{"frames":`) }, wantErr: "not valid JSON"}, - {name: "payload has trailing data", mutate: func(b *bundleSpec) { b.payloads[first] = []byte(`{} {}`) }, wantErr: "trailing"}, - - {name: "hash mismatch", mutate: func(b *bundleSpec) { - // Same size, different bytes: only the hash can catch it. - bad := append([]byte(nil), testImage...) - bad[len(bad)-1] ^= 0xff - b.media["media/"+sha(testImage)+".jpg"] = bad - }, wantErr: "corrupt"}, - {name: "size mismatch", mutate: func(b *bundleSpec) { b.manifest.Media[0].Bytes++ }, wantErr: "the manifest says"}, - {name: "manifest media missing from zip", mutate: func(b *bundleSpec) { - b.media = map[string][]byte{} - }, wantErr: "does not contain it"}, - {name: "media not in manifest", mutate: func(b *bundleSpec) { - other := []byte("other") - b.media["media/"+sha(other)+".png"] = other - }, wantErr: "does not list"}, - {name: "manifest sha disagrees with file name", mutate: func(b *bundleSpec) { - b.manifest.Media[0].SHA256 = sha([]byte("x")) - }, wantErr: "does not match its file name"}, - {name: "posterUrl names missing media", mutate: func(b *bundleSpec) { - b.payloads["2026-09-25"] = payloadFor("2026-09-25", "/media/"+sha([]byte("gone"))+".jpg") - }, wantErr: "not in the bundle"}, - {name: "posterUrl still on the internet", mutate: func(b *bundleSpec) { - b.payloads["2026-09-25"] = payloadFor("2026-09-25", "https://cdn.example.com/p.jpg") - }, wantErr: "not a /media/ path"}, - {name: "posterUrl traversal", mutate: func(b *bundleSpec) { - b.payloads["2026-09-25"] = payloadFor("2026-09-25", "/media/../manifest.json") - }, wantErr: "not in the bundle"}, - {name: "empty posterUrl is allowed", mutate: func(b *bundleSpec) { - b.payloads["2026-09-25"] = payloadFor("2026-09-25", "") - }}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - b := newBundle(first, 3, "2026-09-24T14:02:11Z") - tc.mutate(b) - zipPath := b.write(t, t.TempDir()) - - stage := t.TempDir() - m, err := extractBundle(zipPath, stage, testDevice) - if tc.wantErr == "" { - if err != nil { - t.Fatalf("rejected a valid bundle: %v", err) - } - if m.FirstDay != first || len(m.Days()) != 3 { - t.Errorf("manifest = %+v", m) - } - got := listDir(t, filepath.Join(stage, "payloads")) - if len(got) != 3 { - t.Errorf("payloads extracted = %v", got) - } - return - } - if err == nil { - t.Fatalf("accepted a bad bundle; want error containing %q", tc.wantErr) - } - if !strings.Contains(err.Error(), tc.wantErr) { - t.Errorf("err = %q, want containing %q", err, tc.wantErr) - } - // Whatever happened, nothing may have been written outside stage. - assertOnlyUnder(t, filepath.Dir(zipPath), filepath.Base(zipPath)) - }) - } -} - -func addEntry(name string) func(*bundleSpec) { - return func(b *bundleSpec) { b.extra = append(b.extra, zipEntry{name, []byte(`{}`)}) } -} - -// assertOnlyUnder checks dir holds exactly the named entries. -func assertOnlyUnder(t *testing.T, dir string, want ...string) { - t.Helper() - got := listDir(t, dir) - if strings.Join(got, ",") != strings.Join(want, ",") { - t.Errorf("%s holds %v, want %v", dir, got, want) - } -} - -func TestExtractBundleRejectsNonZip(t *testing.T) { - p := filepath.Join(t.TempDir(), "x.zip") - if err := os.WriteFile(p, []byte("definitely not a zip"), 0o644); err != nil { - t.Fatal(err) - } - if _, err := extractBundle(p, t.TempDir(), testDevice); err == nil || !strings.Contains(err.Error(), "not a readable zip") { - t.Errorf("err = %v", err) - } -} - -func TestExtractBundleWindowLimit(t *testing.T) { - b := newBundle("2026-09-01", 32, "2026-09-01T00:00:00Z") - if _, err := extractBundle(b.write(t, t.TempDir()), t.TempDir(), testDevice); err == nil || !strings.Contains(err.Error(), "at most 31") { - t.Errorf("err = %v", err) - } - b = newBundle("2026-09-01", 31, "2026-09-01T00:00:00Z") - if _, err := extractBundle(b.write(t, t.TempDir()), t.TempDir(), testDevice); err != nil { - t.Errorf("31 days rejected: %v", err) - } -} diff --git a/internal/offline/fs.go b/internal/offline/fs.go deleted file mode 100644 index 913f363..0000000 --- a/internal/offline/fs.go +++ /dev/null @@ -1,119 +0,0 @@ -package offline - -import ( - "fmt" - "io" - "os" - "path/filepath" - "sort" - "strconv" -) - -// writeFileSync writes data to path and fsyncs it before returning. -func writeFileSync(path string, data []byte) error { - f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) - if err != nil { - return err - } - if _, err := f.Write(data); err != nil { - f.Close() - return err - } - if err := f.Sync(); err != nil { - f.Close() - return err - } - return f.Close() -} - -// copyFileSync copies at most limit bytes from r to a new file at path, -// fsyncs it, and returns how many bytes were written. The caller decides -// whether a copy that hit the limit is an error. -func copyFileSync(path string, r io.Reader, limit int64, mode os.FileMode) (int64, error) { - f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) - if err != nil { - return 0, err - } - n, err := io.Copy(f, io.LimitReader(r, limit)) - if err != nil { - f.Close() - return n, err - } - if err := f.Sync(); err != nil { - f.Close() - return n, err - } - return n, f.Close() -} - -// uniqueName returns base, or base.2, base.3, ... — the first that does not -// exist in dir. -func uniqueName(dir, base string) string { - name := base - for i := 2; ; i++ { - if _, err := os.Lstat(filepath.Join(dir, name)); os.IsNotExist(err) { - return name - } - name = base + "." + strconv.Itoa(i) - } -} - -// swapSymlink points link at target atomically: a new symlink is made beside -// it and renamed over it, so a reader resolving link sees the old target or -// the new one and never nothing. The containing directory is fsynced so the -// swap survives a power cut. -func swapSymlink(link, target string) error { - tmp := link + ".tmp" - // A leftover from an install that died between these two steps. - _ = os.Remove(tmp) - if err := os.Symlink(target, tmp); err != nil { - return fmt.Errorf("create symlink %s: %w", tmp, err) - } - if err := os.Rename(tmp, link); err != nil { - os.Remove(tmp) - return fmt.Errorf("replace %s: %w", link, err) - } - return syncDir(filepath.Dir(link)) -} - -// readLinkAbs resolves a single level of symlink at link to an absolute -// path. A relative target is taken relative to link's directory. -func readLinkAbs(link string) (string, error) { - target, err := os.Readlink(link) - if err != nil { - return "", err - } - if !filepath.IsAbs(target) { - target = filepath.Join(filepath.Dir(link), target) - } - return filepath.Clean(target), nil -} - -// prune deletes every entry of dir whose name is not in keep, and returns -// the names it removed, sorted. Stale .staging-* directories from an install -// that died go the same way; callers hold the install lock, so no staging -// directory can belong to a live install. -func prune(dir string, keep ...string) ([]string, error) { - entries, err := os.ReadDir(dir) - if err != nil { - return nil, err - } - keepSet := make(map[string]bool, len(keep)) - for _, k := range keep { - if k != "" { - keepSet[k] = true - } - } - var removed []string - for _, e := range entries { - if keepSet[e.Name()] { - continue - } - if err := os.RemoveAll(filepath.Join(dir, e.Name())); err != nil { - return removed, fmt.Errorf("remove old %s: %w", e.Name(), err) - } - removed = append(removed, e.Name()) - } - sort.Strings(removed) - return removed, nil -} diff --git a/internal/offline/helpers_test.go b/internal/offline/helpers_test.go deleted file mode 100644 index 8822bba..0000000 --- a/internal/offline/helpers_test.go +++ /dev/null @@ -1,162 +0,0 @@ -package offline - -import ( - "archive/zip" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "os" - "path/filepath" - "sort" - "testing" - "time" - - // Tests run on Windows dev boxes too; don't depend on a system zoneinfo. - _ "time/tzdata" -) - -const testDevice = "3f2a1b4c-0000-4000-8000-000000000001" - -// requireSymlinks skips the test where the OS will not let this process make -// symlinks — Windows without Developer Mode. Production only runs on Linux, -// where the atomic swap depends on them; the code is not weakened for this. -func requireSymlinks(t *testing.T) { - t.Helper() - dir := t.TempDir() - if err := os.Symlink("x", filepath.Join(dir, "probe")); err != nil { - t.Skipf("symlinks unavailable on this machine (%v); the install swap needs them", err) - } -} - -// bundleSpec is a bundle under construction. newBundle returns a valid one; -// tests break it in exactly one way and build the zip. -type bundleSpec struct { - manifest Manifest - noManifest bool - payloads map[string][]byte // date -> JSON - media map[string][]byte // "media/." -> bytes - extra []zipEntry // written verbatim, after everything else -} - -type zipEntry struct { - name string - data []byte -} - -// testImage is the one poster every generated payload refers to. -var testImage = []byte("\xff\xd8\xff\xe0 not really a jpeg") - -func sha(b []byte) string { - s := sha256.Sum256(b) - return hex.EncodeToString(s[:]) -} - -// newBundle returns a valid bundle for testDevice covering n days from first, -// generated at gen. -func newBundle(first string, n int, gen string) *bundleSpec { - f, _ := time.Parse(dateLayout, first) - last := f.AddDate(0, 0, n-1).Format(dateLayout) - h := sha(testImage) - mediaPath := "media/" + h + ".jpg" - - b := &bundleSpec{ - manifest: Manifest{ - FormatVersion: 1, - DeviceID: testDevice, - Timezone: "America/Toronto", - GeneratedAt: gen, - FirstDay: first, - LastDay: last, - Media: []MediaEntry{{ - Path: mediaPath, SHA256: h, Bytes: int64(len(testImage)), ContentType: "image/jpeg", - }}, - }, - payloads: map[string][]byte{}, - media: map[string][]byte{mediaPath: testImage}, - } - for d := f; n > 0; d, n = d.AddDate(0, 0, 1), n-1 { - day := d.Format(dateLayout) - b.payloads[day] = payloadFor(day, "/media/"+h+".jpg") - } - return b -} - -// payloadFor is a cut-down MusallahBoardPayload: enough shape to exercise the -// posterUrl walk, with the date in it so tests can tell days apart. -func payloadFor(day, posterURL string) []byte { - p := map[string]any{ - "day": day, - "weather": nil, - "frames": []any{ - map[string]any{"frameType": "POSTER", "frameConfig": map[string]any{"posterUrl": posterURL}}, - map[string]any{"frameType": "PRAYER", "frameConfig": map[string]any{"title": "Salah"}}, - }, - } - raw, _ := json.Marshal(p) - return raw -} - -// write builds the zip in dir and returns its path. -func (b *bundleSpec) write(t *testing.T, dir string) string { - t.Helper() - p := filepath.Join(dir, fmt.Sprintf("bundle-%d.zip", time.Now().UnixNano())) - f, err := os.Create(p) - if err != nil { - t.Fatal(err) - } - zw := zip.NewWriter(f) - add := func(name string, data []byte) { - w, err := zw.Create(name) - if err != nil { - t.Fatal(err) - } - if _, err := w.Write(data); err != nil { - t.Fatal(err) - } - } - if !b.noManifest { - raw, err := json.Marshal(b.manifest) - if err != nil { - t.Fatal(err) - } - add("manifest.json", raw) - } - for _, d := range sortedKeys(b.payloads) { - add("payloads/"+d+".json", b.payloads[d]) - } - for _, m := range sortedKeys(b.media) { - add(m, b.media[m]) - } - for _, e := range b.extra { - add(e.name, e.data) - } - if err := zw.Close(); err != nil { - t.Fatal(err) - } - if err := f.Close(); err != nil { - t.Fatal(err) - } - return p -} - -// listDir returns the sorted entry names of dir. -func listDir(t *testing.T, dir string) []string { - t.Helper() - entries, err := os.ReadDir(dir) - if err != nil { - t.Fatal(err) - } - var names []string - for _, e := range entries { - names = append(names, e.Name()) - } - sort.Strings(names) - return names -} - -func testPaths(t *testing.T) Paths { - t.Helper() - dir := t.TempDir() - return Paths{Root: filepath.Join(dir, "offline"), SPA: filepath.Join(dir, "board")} -} diff --git a/internal/offline/install.go b/internal/offline/install.go deleted file mode 100644 index 874a2ad..0000000 --- a/internal/offline/install.go +++ /dev/null @@ -1,120 +0,0 @@ -package offline - -import ( - "errors" - "fmt" - "os" - "path/filepath" -) - -// InstallResult describes a successful bundle install. -type InstallResult struct { - Manifest *Manifest - // Name is the new bundle's directory under bundles/. - Name string - // Previous is the bundle that was current before, or "" if none was. - Previous string - // Removed lists what pruning deleted from bundles/. - Removed []string -} - -// InstallBundle validates the zip at zipPath for deviceID and, only if every -// check passes, makes it the bundle being served. The sequence is the one in -// Contract 2: extract into bundles/.staging-, fsync, rename to -// bundles/, swap the current symlink atomically, then prune to -// current + previous. -// -// Until the symlink swap, nothing a reader of current can see has changed, so -// a failure (or a power cut) at any earlier step leaves the previous bundle -// serving. A failure after the swap — only pruning — is reported but the new -// bundle is already live. -func InstallBundle(p Paths, zipPath, deviceID string) (*InstallResult, error) { - bundles := p.Bundles() - if err := os.MkdirAll(bundles, 0o755); err != nil { - return nil, fmt.Errorf("create %s: %w", bundles, err) - } - unlock, err := lockDir(p.Root) - if err != nil { - return nil, err - } - defer unlock() - - stage, err := os.MkdirTemp(bundles, ".staging-") - if err != nil { - return nil, fmt.Errorf("create staging directory: %w", err) - } - committed := false - defer func() { - if !committed { - _ = os.RemoveAll(stage) - } - }() - // MkdirTemp makes it 0700. Installs run as root and the daemon that - // serves the bundle does not, so it has to be traversable. - if err := os.Chmod(stage, 0o755); err != nil { - return nil, err - } - - m, err := extractBundle(zipPath, stage, deviceID) - if err != nil { - return nil, err - } - - previous, err := currentName(p) - if err != nil { - return nil, err - } - - name := uniqueName(bundles, m.compactStamp()) - final := filepath.Join(bundles, name) - if err := os.Rename(stage, final); err != nil { - return nil, fmt.Errorf("move bundle into place: %w", err) - } - committed = true - if err := syncDir(bundles); err != nil { - return nil, err - } - - // Relative, so the offline root could be moved or bind-mounted without - // breaking the link. - if err := swapSymlink(p.Current(), filepath.Join("bundles", name)); err != nil { - return nil, err - } - - res := &InstallResult{Manifest: m, Name: name, Previous: previous} - res.Removed, err = prune(bundles, name, previous) - if err != nil { - return res, fmt.Errorf("bundle installed, but cleaning up old bundles failed: %w", err) - } - return res, nil -} - -// currentName returns the directory name under bundles/ that current points -// at, or "" if there is no current bundle. -func currentName(p Paths) (string, error) { - target, err := readLinkAbs(p.Current()) - if errors.Is(err, os.ErrNotExist) { - return "", nil - } - if err != nil { - return "", fmt.Errorf("read %s: %w", p.Current(), err) - } - return filepath.Base(target), nil -} - -// CurrentBundle resolves the current symlink and loads that bundle's -// manifest. It returns ErrNoBundle if nothing is installed. -func CurrentBundle(p Paths) (dir string, m *Manifest, err error) { - dir, err = readLinkAbs(p.Current()) - if errors.Is(err, os.ErrNotExist) { - return "", nil, ErrNoBundle - } - if err != nil { - return "", nil, err - } - m, err = readManifest(dir) - if err != nil { - return "", nil, fmt.Errorf("installed bundle %s is unreadable: %w", filepath.Base(dir), err) - } - return dir, m, nil -} diff --git a/internal/offline/install_test.go b/internal/offline/install_test.go deleted file mode 100644 index 43ab79e..0000000 --- a/internal/offline/install_test.go +++ /dev/null @@ -1,148 +0,0 @@ -package offline - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestInstallBundleSwapsCurrent(t *testing.T) { - requireSymlinks(t) - p := testPaths(t) - src := t.TempDir() - - res, err := InstallBundle(p, newBundle("2026-09-24", 14, "2026-09-24T14:02:11Z").write(t, src), testDevice) - if err != nil { - t.Fatal(err) - } - if res.Name != "20260924T140211Z" || res.Previous != "" { - t.Errorf("result = %+v", res) - } - dir, m, err := CurrentBundle(p) - if err != nil { - t.Fatal(err) - } - if filepath.Base(dir) != res.Name || m.LastDay != "2026-10-07" { - t.Errorf("current = %s, lastDay %s", dir, m.LastDay) - } - // The link is relative, so the offline root can move. - if target, _ := os.Readlink(p.Current()); target != filepath.Join("bundles", res.Name) { - t.Errorf("current -> %q", target) - } - // No staging directory or temp link left behind. - assertOnlyUnder(t, p.Bundles(), res.Name) - if _, err := os.Lstat(p.Current() + ".tmp"); !os.IsNotExist(err) { - t.Error("current.tmp left behind") - } -} - -// A failed install must leave the served bundle exactly as it was. -func TestFailedInstallLeavesCurrentUntouched(t *testing.T) { - requireSymlinks(t) - p := testPaths(t) - src := t.TempDir() - - good, err := InstallBundle(p, newBundle("2026-09-24", 3, "2026-09-24T14:02:11Z").write(t, src), testDevice) - if err != nil { - t.Fatal(err) - } - before, _ := os.Readlink(p.Current()) - - failures := map[string]func(*bundleSpec){ - "hash mismatch": func(b *bundleSpec) { b.media["media/"+sha(testImage)+".jpg"] = []byte("tampered bytes!!!!!!!!!") }, - "wrong device": func(b *bundleSpec) { b.manifest.DeviceID = "other" }, - "zip slip": addEntry("../../escape.json"), - "missing day": func(b *bundleSpec) { delete(b.payloads, "2026-10-02") }, - } - for name, mutate := range failures { - t.Run(name, func(t *testing.T) { - b := newBundle("2026-10-01", 3, "2026-10-01T09:00:00Z") - mutate(b) - if _, err := InstallBundle(p, b.write(t, src), testDevice); err == nil { - t.Fatal("install succeeded") - } - after, _ := os.Readlink(p.Current()) - if after != before { - t.Errorf("current moved from %q to %q", before, after) - } - // The staging directory is gone too; only the good bundle remains. - assertOnlyUnder(t, p.Bundles(), good.Name) - if _, err := os.Stat(filepath.Join(filepath.Dir(p.Root), "escape.json")); !os.IsNotExist(err) { - t.Error("zip-slip entry was written") - } - }) - } -} - -func TestInstallPrunesToCurrentAndPrevious(t *testing.T) { - requireSymlinks(t) - p := testPaths(t) - src := t.TempDir() - - gens := []string{"2026-09-01T10:00:00Z", "2026-09-10T10:00:00Z", "2026-09-20T10:00:00Z", "2026-09-30T10:00:00Z"} - var last *InstallResult - for _, g := range gens { - res, err := InstallBundle(p, newBundle(g[:10], 7, g).write(t, src), testDevice) - if err != nil { - t.Fatal(err) - } - last = res - } - assertOnlyUnder(t, p.Bundles(), "20260920T100000Z", "20260930T100000Z") - if last.Previous != "20260920T100000Z" { - t.Errorf("previous = %q", last.Previous) - } - if strings.Join(last.Removed, ",") != "20260910T100000Z" { - t.Errorf("removed = %v", last.Removed) - } - - // Reinstalling the same bundle gets a distinct directory rather than - // clobbering the one being served. - res, err := InstallBundle(p, newBundle("2026-09-30", 7, gens[3]).write(t, src), testDevice) - if err != nil { - t.Fatal(err) - } - if res.Name != "20260930T100000Z.2" { - t.Errorf("reinstall name = %q", res.Name) - } - assertOnlyUnder(t, p.Bundles(), "20260930T100000Z", "20260930T100000Z.2") -} - -// Stale staging directories from an install that died mid-way are cleaned -// up by the next successful one. -func TestInstallRemovesStaleStaging(t *testing.T) { - requireSymlinks(t) - p := testPaths(t) - if err := os.MkdirAll(filepath.Join(p.Bundles(), ".staging-dead", "payloads"), 0o755); err != nil { - t.Fatal(err) - } - res, err := InstallBundle(p, newBundle("2026-09-24", 2, "2026-09-24T00:00:00Z").write(t, t.TempDir()), testDevice) - if err != nil { - t.Fatal(err) - } - assertOnlyUnder(t, p.Bundles(), res.Name) -} - -func TestPrune(t *testing.T) { - dir := t.TempDir() - for _, n := range []string{"a", "b", "c", ".staging-x"} { - if err := os.Mkdir(filepath.Join(dir, n), 0o755); err != nil { - t.Fatal(err) - } - } - removed, err := prune(dir, "c", "a", "") - if err != nil { - t.Fatal(err) - } - if strings.Join(removed, ",") != ".staging-x,b" { - t.Errorf("removed = %v", removed) - } - assertOnlyUnder(t, dir, "a", "c") -} - -func TestCurrentBundleNoneInstalled(t *testing.T) { - if _, _, err := CurrentBundle(testPaths(t)); err != ErrNoBundle { - t.Errorf("err = %v, want ErrNoBundle", err) - } -} diff --git a/internal/offline/manifest.go b/internal/offline/manifest.go deleted file mode 100644 index b4b5384..0000000 --- a/internal/offline/manifest.go +++ /dev/null @@ -1,158 +0,0 @@ -package offline - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "regexp" - "strings" - "time" -) - -// FormatVersion is the only bundle format this agent understands. -const FormatVersion = 1 - -// maxWindowDays bounds firstDay..lastDay. The backend allows 1–31; a larger -// range is not a bundle we were designed to receive. -const maxWindowDays = 31 - -const dateLayout = "2006-01-02" - -// Entry-name rules from Contract 1. These are the whole zip-slip defence: -// nothing that fails them is ever joined onto a filesystem path. -var ( - payloadNameRE = regexp.MustCompile(`^payloads/(\d{4}-\d{2}-\d{2})\.json$`) - mediaNameRE = regexp.MustCompile(`^media/([0-9a-f]{64})\.([A-Za-z0-9]{1,10})$`) - // mediaFileRE is a media file's base name, as requested under /media/. - mediaFileRE = regexp.MustCompile(`^[0-9a-f]{64}\.[A-Za-z0-9]{1,10}$`) -) - -// Manifest is manifest.json. See Contract 1. -type Manifest struct { - FormatVersion int `json:"formatVersion"` - DeviceID string `json:"deviceId"` - Timezone string `json:"timezone"` - GeneratedAt string `json:"generatedAt"` - FirstDay string `json:"firstDay"` - LastDay string `json:"lastDay"` - Media []MediaEntry `json:"media"` - - // Parsed forms, filled by parseManifest. - loc *time.Location - generatedAt time.Time - first, last time.Time // civil dates at 00:00 UTC -} - -// MediaEntry describes one file under media/. -type MediaEntry struct { - Path string `json:"path"` - SHA256 string `json:"sha256"` - Bytes int64 `json:"bytes"` - ContentType string `json:"contentType"` -} - -// Location is the manifest's timezone. "Today" is always evaluated in it. -func (m *Manifest) Location() *time.Location { return m.loc } - -// Days lists every date firstDay..lastDay inclusive. -func (m *Manifest) Days() []string { - var out []string - for d := m.first; !d.After(m.last); d = d.AddDate(0, 0, 1) { - out = append(out, d.Format(dateLayout)) - } - return out -} - -// parseManifest decodes raw and checks everything about it that does not -// depend on the rest of the zip or on which device is installing it. -func parseManifest(raw []byte) (*Manifest, error) { - var m Manifest - if err := json.Unmarshal(raw, &m); err != nil { - return nil, fmt.Errorf("manifest.json is not valid JSON: %w", err) - } - if m.FormatVersion != FormatVersion { - return nil, fmt.Errorf("manifest.json has formatVersion %d; this agent only understands %d (update the agent, or export the bundle again)", - m.FormatVersion, FormatVersion) - } - if m.DeviceID == "" { - return nil, fmt.Errorf("manifest.json has no deviceId") - } - - // LoadLocation("") and ("Local") both succeed, and neither is a zone. - if m.Timezone == "" || m.Timezone == "Local" { - return nil, fmt.Errorf("manifest.json has no usable timezone (%q)", m.Timezone) - } - loc, err := time.LoadLocation(m.Timezone) - if err != nil { - return nil, fmt.Errorf("manifest.json timezone %q is not known on this board: %w", m.Timezone, err) - } - m.loc = loc - - if m.generatedAt, err = time.Parse(time.RFC3339, m.GeneratedAt); err != nil { - return nil, fmt.Errorf("manifest.json generatedAt %q is not an ISO-8601 timestamp", m.GeneratedAt) - } - if m.first, err = parseDate(m.FirstDay); err != nil { - return nil, fmt.Errorf("manifest.json firstDay: %w", err) - } - if m.last, err = parseDate(m.LastDay); err != nil { - return nil, fmt.Errorf("manifest.json lastDay: %w", err) - } - if m.last.Before(m.first) { - return nil, fmt.Errorf("manifest.json lastDay %s is before firstDay %s", m.LastDay, m.FirstDay) - } - if n := daysBetween(m.first, m.last) + 1; n > maxWindowDays { - return nil, fmt.Errorf("manifest.json covers %d days; at most %d are allowed", n, maxWindowDays) - } - - seen := make(map[string]bool, len(m.Media)) - for _, e := range m.Media { - sub := mediaNameRE.FindStringSubmatch(e.Path) - if sub == nil { - return nil, fmt.Errorf("manifest.json media path %q is not media/.", e.Path) - } - if sub[1] != e.SHA256 { - return nil, fmt.Errorf("manifest.json media %s: sha256 %q does not match its file name", e.Path, e.SHA256) - } - if e.Bytes < 0 { - return nil, fmt.Errorf("manifest.json media %s: negative size", e.Path) - } - if seen[e.Path] { - return nil, fmt.Errorf("manifest.json lists %s twice", e.Path) - } - seen[e.Path] = true - } - return &m, nil -} - -// readManifest loads and parses the manifest of an installed bundle. -func readManifest(bundleDir string) (*Manifest, error) { - raw, err := os.ReadFile(filepath.Join(bundleDir, "manifest.json")) - if err != nil { - return nil, err - } - return parseManifest(raw) -} - -// mediaByFile indexes the manifest's media by base name ("."). -func (m *Manifest) mediaByFile() map[string]MediaEntry { - out := make(map[string]MediaEntry, len(m.Media)) - for _, e := range m.Media { - out[strings.TrimPrefix(e.Path, "media/")] = e - } - return out -} - -func parseDate(s string) (time.Time, error) { - t, err := time.Parse(dateLayout, s) - if err != nil { - return time.Time{}, fmt.Errorf("%q is not a YYYY-MM-DD date", s) - } - return t, nil -} - -// compactStamp turns generatedAt into a directory-name-safe stamp: -// 2026-09-24T14:02:11Z -> 20260924T140211Z. -func (m *Manifest) compactStamp() string { - return m.generatedAt.UTC().Format("20060102T150405Z") -} diff --git a/internal/offline/offline.go b/internal/offline/offline.go deleted file mode 100644 index 9a1f253..0000000 --- a/internal/offline/offline.go +++ /dev/null @@ -1,58 +0,0 @@ -// Package offline is the device side of MusallahBoard's offline mode: it -// validates and installs content bundles carried to the board on a laptop, -// works out which day's payload to show, and serves the board SPA, payloads -// and media to the kiosk from 127.0.0.1 when the board has no internet. -// -// docs/offline.md is the contract. "Contract 1" defines the bundle zip checked -// here; "Contract 2" defines the filesystem layout, the HTTP endpoints and the -// install steps. The backend exporter and the frontend are built against the -// same document, so behaviour here should change only alongside it. -// -// Nothing in this package makes a network call other than accepting local -// HTTP connections. -package offline - -import ( - "errors" - "path/filepath" -) - -// Defaults for a real board. Every function takes a Paths so tests can point -// them at a temp dir. -const ( - DefaultRoot = "/var/lib/musallahboard/offline" - DefaultSPADir = "/usr/share/musallahboard/board" - - // ListenAddr is loopback only: the server is for the kiosk on the same - // box, and nothing on the service-port cable has any business reaching it. - // kioskurl.OfflineBaseURL must agree. - ListenAddr = "127.0.0.1:8080" -) - -// ErrNoBundle means no bundle has been installed yet (current does not exist). -var ErrNoBundle = errors.New("no bundle installed") - -// Paths locates the on-disk state. The zero value is not usable; use -// DefaultPaths or fill both fields. -type Paths struct { - // Root holds bundles/ and the current symlink. - Root string - // SPA is the built frontend (index.html, assets/...). May be a symlink, - // which is how InstallApp swaps it atomically. - SPA string -} - -// DefaultPaths returns the production layout from docs/offline.md. -func DefaultPaths() Paths { - return Paths{Root: DefaultRoot, SPA: DefaultSPADir} -} - -// Bundles is the directory holding each extracted bundle. -func (p Paths) Bundles() string { return filepath.Join(p.Root, "bundles") } - -// Current is the symlink to the bundle being served. -func (p Paths) Current() string { return filepath.Join(p.Root, "current") } - -// appReleases holds SPA builds; p.SPA is a symlink into it once InstallApp -// has run. -func (p Paths) appReleases() string { return p.SPA + ".d" } diff --git a/internal/offline/server.go b/internal/offline/server.go deleted file mode 100644 index 85f1902..0000000 --- a/internal/offline/server.go +++ /dev/null @@ -1,292 +0,0 @@ -package offline - -import ( - "context" - "encoding/json" - "errors" - "io" - "io/fs" - "log/slog" - "mime" - "net" - "net/http" - "os" - "path" - "path/filepath" - "strings" - "sync" - "time" -) - -// Cache-Control values from Contract 2. -const ( - cacheNoStore = "no-store" - cacheNoCache = "no-cache" - cacheImmutable = "public, max-age=31536000, immutable" -) - -// Server serves the board to the kiosk in offline mode: the SPA, today's -// payload, the bundle's media and a status object. It is read-only. -// -// The current symlink is resolved on every request, so a bundle installed by -// `bundle install` is served from the next request on, without a restart. -// Installed bundle directories never change after their rename into place, -// so the parsed manifest is cached per resolved directory. -type Server struct { - paths Paths - deviceID string - logger *slog.Logger - now func() time.Time - - mu sync.Mutex - cachedDir string - cachedM *Manifest -} - -// NewServer returns a Server for the enrolled deviceID. logger may be nil. -func NewServer(p Paths, deviceID string, logger *slog.Logger) *Server { - if logger == nil { - logger = slog.New(slog.NewTextHandler(io.Discard, nil)) - } - return &Server{paths: p, deviceID: deviceID, logger: logger, now: time.Now} -} - -// Serve accepts connections on ln until ctx is cancelled, then shuts down -// gracefully. The caller creates ln (net.Listen("tcp", ListenAddr)) so that a -// port conflict is reported before anything depends on the server. -func (s *Server) Serve(ctx context.Context, ln net.Listener) error { - srv := &http.Server{ - Handler: s, - ReadHeaderTimeout: 10 * time.Second, - IdleTimeout: 2 * time.Minute, - } - done := make(chan struct{}) - go func() { - defer close(done) - <-ctx.Done() - shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - _ = srv.Shutdown(shutCtx) - }() - err := srv.Serve(ln) - if errors.Is(err, http.ErrServerClosed) { - <-done - return nil - } - return err -} - -func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet && r.Method != http.MethodHead { - w.Header().Set("Allow", "GET, HEAD") - writeJSONError(w, http.StatusMethodNotAllowed, "read-only") - return - } - w.Header().Set("X-Content-Type-Options", "nosniff") - - switch p := r.URL.Path; { - case p == "/api/musallah/payload": - s.handlePayload(w, r) - case p == "/api/local/status": - s.handleStatus(w, r) - case strings.HasPrefix(p, "/api/"): - // Not an SPA route: the page parses these as JSON, and an index.html - // fallback would turn a typo into a confusing parse error. - writeJSONError(w, http.StatusNotFound, "not found") - case strings.HasPrefix(p, "/media/"): - s.handleMedia(w, r) - default: - s.handleSPA(w, r) - } -} - -// current returns the served bundle's directory and manifest. -func (s *Server) current() (string, *Manifest, error) { - dir, err := readLinkAbs(s.paths.Current()) - if errors.Is(err, os.ErrNotExist) { - return "", nil, ErrNoBundle - } - if err != nil { - return "", nil, err - } - - s.mu.Lock() - defer s.mu.Unlock() - if dir == s.cachedDir && s.cachedM != nil { - return dir, s.cachedM, nil - } - m, err := readManifest(dir) - if err != nil { - return "", nil, err - } - s.cachedDir, s.cachedM = dir, m - return dir, m, nil -} - -func (s *Server) handlePayload(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Cache-Control", cacheNoStore) - dir, m, err := s.current() - if errors.Is(err, ErrNoBundle) { - writeJSONError(w, http.StatusServiceUnavailable, "no bundle installed") - return - } - if err != nil { - s.logger.Error("offline: current bundle unreadable", "err", err) - writeJSONError(w, http.StatusInternalServerError, "installed bundle is unreadable") - return - } - if r.URL.Query().Get("deviceId") != m.DeviceID { - writeJSONError(w, http.StatusNotFound, "unknown device") - return - } - - day := PickDay(m, s.now()) - // day.Serving is a date we formatted ourselves, not request input. - raw, err := os.ReadFile(filepath.Join(dir, "payloads", day.Serving+".json")) - if err != nil { - s.logger.Error("offline: payload missing from installed bundle", "day", day.Serving, "err", err) - writeJSONError(w, http.StatusInternalServerError, "payload missing from installed bundle") - return - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(raw) -} - -func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) { - st := ReadStatus(s.paths, "offline", s.deviceID, s.now()) - w.Header().Set("Cache-Control", cacheNoStore) - writeJSON(w, http.StatusOK, st) -} - -func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) { - file := strings.TrimPrefix(r.URL.Path, "/media/") - // The name must look exactly like a bundle media file before it goes - // anywhere near a path; that also rules out "..", "/" and "\". - if !mediaFileRE.MatchString(file) { - http.NotFound(w, r) - return - } - dir, m, err := s.current() - if err != nil { - http.NotFound(w, r) - return - } - e, ok := m.mediaByFile()[file] - if !ok { - http.NotFound(w, r) - return - } - f, err := os.Open(filepath.Join(dir, "media", file)) - if err != nil { - http.NotFound(w, r) - return - } - defer f.Close() - fi, err := f.Stat() - if err != nil { - http.NotFound(w, r) - return - } - if e.ContentType != "" { - w.Header().Set("Content-Type", e.ContentType) - } - w.Header().Set("Cache-Control", cacheImmutable) - http.ServeContent(w, r, file, fi.ModTime(), f) -} - -// handleSPA serves the built frontend. Real files are served as-is; any other -// path is a client-side route and gets index.html. Missing files under -// /assets/ are a 404 instead: they are hashed build output, and handing back -// HTML under an immutable cache header for a stale script URL is worse than -// an honest failure. -func (s *Server) handleSPA(w http.ResponseWriter, r *http.Request) { - // os.DirFS re-resolves s.paths.SPA on every open, so an `app install` - // swapping that symlink is picked up here immediately. fs.ValidPath - // rejects "..", so nothing outside the SPA directory is reachable. - fsys := os.DirFS(s.paths.SPA) - name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/") - if name == "" { - name = "index.html" - } - - if fs.ValidPath(name) && s.serveFile(w, r, fsys, name) { - return - } - if strings.HasPrefix(name, "assets/") { - http.NotFound(w, r) - return - } - if !s.serveFile(w, r, fsys, "index.html") { - w.Header().Set("Cache-Control", cacheNoStore) - http.Error(w, "The board app is not installed on this device.", http.StatusServiceUnavailable) - } -} - -// serveFile serves name from fsys if it is a regular file, and reports -// whether it did. -func (s *Server) serveFile(w http.ResponseWriter, r *http.Request, fsys fs.FS, name string) bool { - f, err := fsys.Open(name) - if err != nil { - return false - } - defer f.Close() - fi, err := f.Stat() - if err != nil || !fi.Mode().IsRegular() { - return false - } - rs, ok := f.(io.ReadSeeker) - if !ok { - return false - } - - switch { - case strings.HasPrefix(name, "assets/"): - w.Header().Set("Cache-Control", cacheImmutable) - default: - // index.html must be revalidated so a new build shows up; anything - // else at the top level (favicon, manifest) is unhashed, so the same. - w.Header().Set("Cache-Control", cacheNoCache) - } - if ct := contentTypeFor(name); ct != "" { - w.Header().Set("Content-Type", ct) - } - http.ServeContent(w, r, path.Base(name), fi.ModTime(), rs) - return true -} - -// extraTypes covers what a Vite build emits that the minimal mime table on a -// fresh Pi OS install may not know. ServeContent would otherwise sniff them, -// and a font sniffed as application/octet-stream is refused by nosniff. -var extraTypes = map[string]string{ - ".js": "text/javascript; charset=utf-8", - ".mjs": "text/javascript; charset=utf-8", - ".css": "text/css; charset=utf-8", - ".html": "text/html; charset=utf-8", - ".json": "application/json", - ".svg": "image/svg+xml", - ".woff": "font/woff", - ".woff2": "font/woff2", - ".ttf": "font/ttf", - ".otf": "font/otf", - ".ico": "image/x-icon", - ".webmanifest": "application/manifest+json", - ".wasm": "application/wasm", -} - -func contentTypeFor(name string) string { - ext := strings.ToLower(path.Ext(name)) - if ct, ok := extraTypes[ext]; ok { - return ct - } - return mime.TypeByExtension(ext) -} - -func writeJSON(w http.ResponseWriter, code int, v any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(code) - _ = json.NewEncoder(w).Encode(v) -} - -func writeJSONError(w http.ResponseWriter, code int, msg string) { - writeJSON(w, code, map[string]string{"message": msg}) -} diff --git a/internal/offline/server_test.go b/internal/offline/server_test.go deleted file mode 100644 index 9f7e0a5..0000000 --- a/internal/offline/server_test.go +++ /dev/null @@ -1,263 +0,0 @@ -package offline - -import ( - "context" - "encoding/json" - "net" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -// newTestServer returns a server over fresh paths with a fixed clock and an -// SPA build on disk (a plain directory — SPA serving needs no symlink). -func newTestServer(t *testing.T, now time.Time) (*Server, Paths) { - t.Helper() - p := testPaths(t) - files := map[string]string{ - "index.html": "board", - "assets/app-3f2a1b.js": "console.log('board')", - "assets/font-9c.woff2": "wOF2", - "favicon.ico": "ico", - } - for name, body := range files { - full := filepath.Join(p.SPA, filepath.FromSlash(name)) - if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(full, []byte(body), 0o644); err != nil { - t.Fatal(err) - } - } - s := NewServer(p, testDevice, nil) - s.now = func() time.Time { return now } - return s, p -} - -func get(t *testing.T, h http.Handler, target string) *httptest.ResponseRecorder { - t.Helper() - rec := httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil)) - return rec -} - -var midRange = time.Date(2026, 9, 25, 16, 0, 0, 0, time.UTC) - -func TestServerNoBundle(t *testing.T) { - s, _ := newTestServer(t, midRange) - - rec := get(t, s, "/api/musallah/payload?deviceId="+testDevice) - if rec.Code != http.StatusServiceUnavailable { - t.Errorf("payload code = %d, want 503", rec.Code) - } - if strings.TrimSpace(rec.Body.String()) != `{"message":"no bundle installed"}` { - t.Errorf("body = %s", rec.Body) - } - if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { - t.Errorf("Cache-Control = %q", cc) - } - - rec = get(t, s, "/api/local/status") - var st Status - if err := json.Unmarshal(rec.Body.Bytes(), &st); err != nil || rec.Code != 200 { - t.Fatalf("status %d: %s", rec.Code, rec.Body) - } - if st.Bundle != nil || st.Mode != "offline" || st.DeviceID != testDevice { - t.Errorf("status = %+v", st) - } - - if rec := get(t, s, "/media/"+sha(testImage)+".jpg"); rec.Code != 404 { - t.Errorf("media without bundle = %d", rec.Code) - } -} - -func TestServerWithBundle(t *testing.T) { - requireSymlinks(t) - s, p := newTestServer(t, midRange) - if _, err := InstallBundle(p, newBundle("2026-09-24", 14, "2026-09-24T14:02:11Z").write(t, t.TempDir()), testDevice); err != nil { - t.Fatal(err) - } - media := "/media/" + sha(testImage) + ".jpg" - - cases := []struct { - name string - target string - code int - cache string - contentType string - body string // substring - }{ - {"today's payload", "/api/musallah/payload?deviceId=" + testDevice, 200, "no-store", "application/json", `"day":"2026-09-25"`}, - {"wrong device", "/api/musallah/payload?deviceId=someone-else", 404, "no-store", "application/json", "unknown device"}, - {"no device", "/api/musallah/payload", 404, "no-store", "", ""}, - {"status", "/api/local/status", 200, "no-store", "application/json", `"servingDay":"2026-09-25"`}, - {"unknown api", "/api/musallah/other", 404, "", "application/json", ""}, - {"media", media, 200, "public, max-age=31536000, immutable", "image/jpeg", string(testImage)}, - {"unknown media", "/media/" + sha([]byte("nope")) + ".jpg", 404, "", "", ""}, - {"media traversal", "/media/../manifest.json", 404, "", "", ""}, - {"media bad name", "/media/manifest.json", 404, "", "", ""}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - rec := get(t, s, tc.target) - if rec.Code != tc.code { - t.Errorf("code = %d, want %d (%s)", rec.Code, tc.code, rec.Body) - } - if tc.cache != "" && rec.Header().Get("Cache-Control") != tc.cache { - t.Errorf("Cache-Control = %q, want %q", rec.Header().Get("Cache-Control"), tc.cache) - } - if tc.contentType != "" && !strings.HasPrefix(rec.Header().Get("Content-Type"), tc.contentType) { - t.Errorf("Content-Type = %q, want %q", rec.Header().Get("Content-Type"), tc.contentType) - } - if !strings.Contains(rec.Body.String(), tc.body) { - t.Errorf("body %q missing %q", rec.Body, tc.body) - } - }) - } -} - -// Payloads are served verbatim, and today is clamped to the bundle's range. -func TestServerPayloadClampAndVerbatim(t *testing.T) { - requireSymlinks(t) - s, p := newTestServer(t, midRange) - b := newBundle("2026-09-24", 3, "2026-09-24T14:02:11Z") - b.payloads["2026-09-26"] = []byte("{\"day\":\"2026-09-26\", \"weather\":null}\n") - if _, err := InstallBundle(p, b.write(t, t.TempDir()), testDevice); err != nil { - t.Fatal(err) - } - for _, tc := range []struct { - now time.Time - want string - }{ - {time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC), `"day":"2026-09-24"`}, - {time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC), "{\"day\":\"2026-09-26\", \"weather\":null}\n"}, - } { - s.now = func() time.Time { return tc.now } - rec := get(t, s, "/api/musallah/payload?deviceId="+testDevice) - if !strings.Contains(rec.Body.String(), tc.want) { - t.Errorf("at %s got %q, want %q", tc.now, rec.Body, tc.want) - } - } -} - -// A new install is served from the next request, without a restart. -func TestServerPicksUpNewBundle(t *testing.T) { - requireSymlinks(t) - s, p := newTestServer(t, midRange) - src := t.TempDir() - if _, err := InstallBundle(p, newBundle("2026-09-24", 7, "2026-09-24T00:00:00Z").write(t, src), testDevice); err != nil { - t.Fatal(err) - } - if rec := get(t, s, "/api/local/status"); !strings.Contains(rec.Body.String(), `"lastDay":"2026-09-30"`) { - t.Fatalf("first status: %s", rec.Body) - } - if _, err := InstallBundle(p, newBundle("2026-09-25", 14, "2026-09-25T00:00:00Z").write(t, src), testDevice); err != nil { - t.Fatal(err) - } - if rec := get(t, s, "/api/local/status"); !strings.Contains(rec.Body.String(), `"lastDay":"2026-10-08"`) { - t.Errorf("status after reinstall still old: %s", rec.Body) - } - if _, m, _ := s.current(); m.FirstDay != "2026-09-25" { - t.Errorf("server manifest firstDay = %s", m.FirstDay) - } -} - -func TestServerSPA(t *testing.T) { - s, _ := newTestServer(t, midRange) - cases := []struct { - name, target string - code int - cache string - contentType string - body string - }{ - {"root", "/", 200, "no-cache", "text/html", "board"}, - {"index", "/index.html", 200, "no-cache", "text/html", "board"}, - {"query is ignored", "/?deviceId=x&mode=offline", 200, "no-cache", "text/html", "board"}, - {"hashed asset", "/assets/app-3f2a1b.js", 200, "public, max-age=31536000, immutable", "text/javascript", "console.log"}, - {"font", "/assets/font-9c.woff2", 200, "public, max-age=31536000, immutable", "font/woff2", "wOF2"}, - {"top-level file", "/favicon.ico", 200, "no-cache", "image/x-icon", "ico"}, - {"client route falls back", "/diagnostics/panel", 200, "no-cache", "text/html", "board"}, - {"missing asset is a 404", "/assets/app-old.js", 404, "", "", ""}, - {"traversal falls back, never escapes", "/../../etc/passwd", 200, "no-cache", "text/html", "board"}, - {"directory falls back", "/assets", 200, "no-cache", "text/html", "board"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - rec := get(t, s, tc.target) - if rec.Code != tc.code { - t.Fatalf("code = %d, want %d", rec.Code, tc.code) - } - if tc.cache != "" && rec.Header().Get("Cache-Control") != tc.cache { - t.Errorf("Cache-Control = %q, want %q", rec.Header().Get("Cache-Control"), tc.cache) - } - if tc.contentType != "" && !strings.HasPrefix(rec.Header().Get("Content-Type"), tc.contentType) { - t.Errorf("Content-Type = %q, want %q", rec.Header().Get("Content-Type"), tc.contentType) - } - if !strings.Contains(rec.Body.String(), tc.body) { - t.Errorf("body = %q", rec.Body) - } - }) - } -} - -func TestServerNoSPA(t *testing.T) { - s, p := newTestServer(t, midRange) - if err := os.RemoveAll(p.SPA); err != nil { - t.Fatal(err) - } - if rec := get(t, s, "/"); rec.Code != http.StatusServiceUnavailable { - t.Errorf("code = %d, want 503", rec.Code) - } -} - -func TestServerReadOnly(t *testing.T) { - s, _ := newTestServer(t, midRange) - for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} { - rec := httptest.NewRecorder() - s.ServeHTTP(rec, httptest.NewRequest(m, "/api/musallah/payload", nil)) - if rec.Code != http.StatusMethodNotAllowed { - t.Errorf("%s = %d, want 405", m, rec.Code) - } - } - rec := httptest.NewRecorder() - s.ServeHTTP(rec, httptest.NewRequest(http.MethodHead, "/", nil)) - if rec.Code != 200 { - t.Errorf("HEAD / = %d", rec.Code) - } -} - -// Serve answers on a real listener and returns nil once ctx is cancelled. -func TestServeLifecycle(t *testing.T) { - s, _ := newTestServer(t, midRange) - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan error, 1) - go func() { done <- s.Serve(ctx, ln) }() - - resp, err := http.Get("http://" + ln.Addr().String() + "/api/local/status") - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if resp.StatusCode != 200 { - t.Errorf("status = %d", resp.StatusCode) - } - - cancel() - select { - case err := <-done: - if err != nil { - t.Errorf("Serve returned %v", err) - } - case <-time.After(5 * time.Second): - t.Fatal("Serve did not return after cancel") - } -} diff --git a/internal/offline/status.go b/internal/offline/status.go deleted file mode 100644 index 74b4943..0000000 --- a/internal/offline/status.go +++ /dev/null @@ -1,67 +0,0 @@ -package offline - -import ( - "errors" - "time" -) - -// Status is the /api/local/status object from Contract 2. The CLI's -// `status --json` prints the same object with clock and rtc added. -type Status struct { - Mode string `json:"mode"` - DeviceID string `json:"deviceId"` - Bundle *BundleInfo `json:"bundle"` - // Today is in the bundle's timezone, or the system's when no bundle is - // installed (there is no other zone to use). - Today string `json:"today"` - // The rest describe the installed bundle and are null without one. - ServingDay *string `json:"servingDay"` - DaysRemaining *int `json:"daysRemaining"` - StaleDays *int `json:"staleDays"` - // Error is set when a bundle is installed but could not be read. Not in - // the contract; additive, and absent in the normal case. - Error string `json:"error,omitempty"` -} - -// BundleInfo summarises the installed bundle's manifest. -type BundleInfo struct { - FirstDay string `json:"firstDay"` - LastDay string `json:"lastDay"` - GeneratedAt string `json:"generatedAt"` - Timezone string `json:"timezone"` -} - -// StatusFor builds the status for manifest m (nil when nothing is installed) -// at instant now. -func StatusFor(mode, deviceID string, m *Manifest, now time.Time) Status { - s := Status{Mode: mode, DeviceID: deviceID} - if m == nil { - s.Today = now.Format(dateLayout) - return s - } - d := PickDay(m, now) - s.Bundle = &BundleInfo{ - FirstDay: m.FirstDay, - LastDay: m.LastDay, - GeneratedAt: m.GeneratedAt, - Timezone: m.Timezone, - } - s.Today = d.Today - s.ServingDay = &d.Serving - s.DaysRemaining = &d.DaysRemaining - s.StaleDays = &d.StaleDays - return s -} - -// ReadStatus reads the current bundle from p and builds its status. Failure -// to read an installed bundle is reported in Status.Error, not returned, so -// callers always have something to show. -func ReadStatus(p Paths, mode, deviceID string, now time.Time) Status { - _, m, err := CurrentBundle(p) - if err != nil && !errors.Is(err, ErrNoBundle) { - s := StatusFor(mode, deviceID, nil, now) - s.Error = err.Error() - return s - } - return StatusFor(mode, deviceID, m, now) -} diff --git a/internal/offline/today.go b/internal/offline/today.go deleted file mode 100644 index 77bb030..0000000 --- a/internal/offline/today.go +++ /dev/null @@ -1,50 +0,0 @@ -package offline - -import "time" - -// Day is the answer to "what should the board show right now". -type Day struct { - // Today is the current date in the bundle's timezone. - Today string - // Serving is the payload date served: Today clamped to firstDay..lastDay. - Serving string - // DaysRemaining is lastDay - today, floored at 0 (0 on the last day and - // once the bundle has run out). - DaysRemaining int - // StaleDays is max(0, today - lastDay). - StaleDays int -} - -// PickDay applies the "Picking today's payload" rule to m at instant now. -func PickDay(m *Manifest, now time.Time) Day { - today := civil(now.In(m.loc)) - - serving := today - switch { - case today.Before(m.first): - serving = m.first - case today.After(m.last): - serving = m.last - } - - remaining := daysBetween(today, m.last) - return Day{ - Today: today.Format(dateLayout), - Serving: serving.Format(dateLayout), - DaysRemaining: max(0, remaining), - StaleDays: max(0, -remaining), - } -} - -// civil drops the clock from t, keeping its wall-clock date, as midnight UTC. -// Every date comparison in this package is done on these, where a day is -// always exactly 24h long. -func civil(t time.Time) time.Time { - y, m, d := t.Date() - return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) -} - -// daysBetween returns b - a in whole days for two civil dates. -func daysBetween(a, b time.Time) int { - return int(b.Sub(a).Hours() / 24) -} diff --git a/internal/offline/today_test.go b/internal/offline/today_test.go deleted file mode 100644 index d3579f8..0000000 --- a/internal/offline/today_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package offline - -import ( - "encoding/json" - "testing" - "time" -) - -func mustManifest(t *testing.T, tz, first, last string) *Manifest { - t.Helper() - raw, _ := json.Marshal(Manifest{ - FormatVersion: 1, DeviceID: testDevice, Timezone: tz, - GeneratedAt: "2026-09-24T14:02:11Z", FirstDay: first, LastDay: last, - }) - m, err := parseManifest(raw) - if err != nil { - t.Fatal(err) - } - return m -} - -func TestPickDay(t *testing.T) { - at := func(s string) time.Time { - v, err := time.Parse(time.RFC3339, s) - if err != nil { - t.Fatal(err) - } - return v - } - const first, last = "2026-09-24", "2026-10-07" - - cases := []struct { - name string - tz string - first string - last string - now string - today string - serving string - remaining int - stale int - }{ - {"mid-range", "America/Toronto", first, last, "2026-09-30T16:00:00Z", "2026-09-30", "2026-09-30", 7, 0}, - // 23:59 EDT on the 23rd is already the 24th in UTC; the zone decides. - {"before first, zone decides", "America/Toronto", first, last, "2026-09-24T03:59:00Z", "2026-09-23", first, 14, 0}, - {"first day at local midnight", "America/Toronto", first, last, "2026-09-24T04:00:00Z", first, first, 13, 0}, - {"well before first", "America/Toronto", first, last, "2026-09-01T12:00:00Z", "2026-09-01", first, 36, 0}, - {"last day, evening", "America/Toronto", first, last, "2026-10-08T03:59:00Z", last, last, 0, 0}, - {"one day stale", "America/Toronto", first, last, "2026-10-08T04:00:00Z", "2026-10-08", last, 0, 1}, - {"long stale", "America/Toronto", first, last, "2026-10-20T12:00:00Z", "2026-10-20", last, 0, 13}, - - // The same instant is a different date in different zones. - {"UTC+14 is a day ahead", "Pacific/Kiritimati", first, last, "2026-09-30T11:00:00Z", "2026-10-01", "2026-10-01", 6, 0}, - {"UTC-11 is a day behind", "Pacific/Pago_Pago", first, last, "2026-09-30T09:00:00Z", "2026-09-29", "2026-09-29", 8, 0}, - {"half-hour zone", "Asia/Kolkata", first, last, "2026-10-07T18:29:00Z", last, last, 0, 0}, - {"half-hour zone rolls over", "Asia/Kolkata", first, last, "2026-10-07T18:30:00Z", "2026-10-08", last, 0, 1}, - - // Across the Nov 1 fall-back, when one local day is 25 hours long. - {"across DST end", "America/Toronto", "2026-10-25", "2026-11-07", "2026-11-02T12:00:00Z", "2026-11-02", "2026-11-02", 5, 0}, - {"on DST end day, late", "America/Toronto", "2026-10-25", "2026-11-07", "2026-11-02T04:30:00Z", "2026-11-01", "2026-11-01", 6, 0}, - // And the spring-forward day, 23 hours long. - {"across DST start", "America/Toronto", "2026-03-01", "2026-03-14", "2026-03-09T03:30:00Z", "2026-03-08", "2026-03-08", 6, 0}, - - {"single-day bundle", "America/Toronto", first, first, "2026-09-24T12:00:00Z", first, first, 0, 0}, - {"single-day bundle, next day", "America/Toronto", first, first, "2026-09-25T12:00:00Z", "2026-09-25", first, 0, 1}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - m := mustManifest(t, tc.tz, tc.first, tc.last) - got := PickDay(m, at(tc.now)) - want := Day{Today: tc.today, Serving: tc.serving, DaysRemaining: tc.remaining, StaleDays: tc.stale} - if got != want { - t.Errorf("PickDay = %+v, want %+v", got, want) - } - }) - } -} - -// The instant's own zone must not matter, only the manifest's. -func TestPickDayIgnoresCallerZone(t *testing.T) { - m := mustManifest(t, "America/Toronto", "2026-09-24", "2026-10-07") - utc := time.Date(2026, 9, 24, 3, 0, 0, 0, time.UTC) - tokyo, _ := time.LoadLocation("Asia/Tokyo") - if a, b := PickDay(m, utc), PickDay(m, utc.In(tokyo)); a != b { - t.Errorf("%+v != %+v", a, b) - } -} - -func TestStatusFor(t *testing.T) { - now := time.Date(2026, 9, 30, 16, 0, 0, 0, time.UTC) - - s := StatusFor("offline", testDevice, nil, now) - raw, _ := json.Marshal(s) - want := `{"mode":"offline","deviceId":"` + testDevice + `","bundle":null,"today":"2026-09-30","servingDay":null,"daysRemaining":null,"staleDays":null}` - if string(raw) != want { - t.Errorf("no-bundle status:\n got %s\nwant %s", raw, want) - } - - s = StatusFor("offline", testDevice, mustManifest(t, "America/Toronto", "2026-09-24", "2026-10-07"), now) - raw, _ = json.Marshal(s) - want = `{"mode":"offline","deviceId":"` + testDevice + `","bundle":{"firstDay":"2026-09-24","lastDay":"2026-10-07","generatedAt":"2026-09-24T14:02:11Z","timezone":"America/Toronto"},"today":"2026-09-30","servingDay":"2026-09-30","daysRemaining":7,"staleDays":0}` - if string(raw) != want { - t.Errorf("status:\n got %s\nwant %s", raw, want) - } -} diff --git a/internal/selfupdate/open_unix.go b/internal/selfupdate/open_unix.go new file mode 100644 index 0000000..ca8519e --- /dev/null +++ b/internal/selfupdate/open_unix.go @@ -0,0 +1,29 @@ +//go:build !windows + +package selfupdate + +import ( + "fmt" + "os" + "syscall" +) + +// openRegularNoFollow opens path for reading without following a final +// symlink and without blocking on a FIFO, then insists on a regular file. +func openRegularNoFollow(path string) (*os.File, error) { + fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK|syscall.O_CLOEXEC, 0) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + f := os.NewFile(uintptr(fd), path) + fi, err := f.Stat() + if err != nil { + f.Close() + return nil, err + } + if !fi.Mode().IsRegular() { + f.Close() + return nil, fmt.Errorf("%s is not a regular file", path) + } + return f, nil +} diff --git a/internal/selfupdate/open_windows.go b/internal/selfupdate/open_windows.go new file mode 100644 index 0000000..52c3851 --- /dev/null +++ b/internal/selfupdate/open_windows.go @@ -0,0 +1,21 @@ +//go:build windows + +package selfupdate + +import ( + "fmt" + "os" +) + +// The self-updater only runs on Linux; this keeps the package building on a +// Windows dev box. +func openRegularNoFollow(path string) (*os.File, error) { + fi, err := os.Lstat(path) + if err != nil { + return nil, err + } + if !fi.Mode().IsRegular() { + return nil, fmt.Errorf("%s is not a regular file", path) + } + return os.Open(path) +} diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go new file mode 100644 index 0000000..7d22e86 --- /dev/null +++ b/internal/selfupdate/selfupdate.go @@ -0,0 +1,392 @@ +// Package selfupdate is the root side of an agent update +// (docs/architecture.md, section 12). +// +// The daemon runs unprivileged. When it has verified an agent package it only +// stages it (agent/staged/package.mbu plus a "ready" marker) in a directory it +// owns; a systemd path unit then starts `musallahboard-agent selfupdate apply` +// as root. Because everything in that directory is writable by the daemon, +// nothing there is trusted: the updater copies the package into a private +// directory first, verifies that copy from scratch against keys only root +// controls (trust.json and the keys compiled into the binary running now), +// and only then replaces /usr/bin/musallahboard-agent. A compromised daemon +// can therefore at worst make the updater refuse something. +// +// A new agent that does not come up and report its version within two +// minutes is rolled back, and its version is recorded so the board never +// tries it again. +package selfupdate + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + "time" + + "github.com/LensBridge/agent/internal/fsutil" + "github.com/LensBridge/agent/internal/mbu" + "github.com/LensBridge/agent/internal/state" + "github.com/LensBridge/agent/internal/store" + "github.com/LensBridge/agent/internal/trust" +) + +// Production paths and names. +const ( + DefaultBinary = "/usr/bin/musallahboard-agent" + DefaultNewBinary = "/usr/bin/.musallahboard-agent.new" + DefaultPrev = "/usr/lib/musallahboard/agent.prev" + AgentUnit = "musallahboard-agent.service" + StatusURL = "http://127.0.0.1:8080/api/local/status" + HealthTimeout = 120 * time.Second +) + +// Outcome statuses written to last-update.json. +const ( + StatusOK = "ok" + StatusRolledBack = "rolled-back" + StatusRefused = "refused" +) + +// Outcome is agent/last-update.json. +type Outcome struct { + From string `json:"from"` + To string `json:"to"` + At string `json:"at"` + Status string `json:"status"` + Message string `json:"message"` +} + +// Updater applies a staged agent package. Every outside effect is a field so +// the whole sequence can be tested in a temp directory without root. +type Updater struct { + Layout store.Layout + TrustPath string + Binary string // the installed agent + NewBinary string // where the new binary is extracted before the swap + Prev string // the previous agent, kept for rollback + // TempRoot is where the private working directory is made ("" is the + // system temp directory; under systemd's PrivateTmp that is private too). + TempRoot string + + Arch string + CurrentVersion string + + // Run executes a command and returns its combined output. + Run func(ctx context.Context, name string, args ...string) (string, error) + // Healthy waits until the restarted daemon reports version, or fails. + Healthy func(ctx context.Context, version string) error + Now func() time.Time + // Logf reports progress (to the journal, via stdout). + Logf func(format string, args ...any) +} + +// New returns an Updater with the production paths, for the agent version +// running now. +func New(currentVersion string) *Updater { + return &Updater{ + Layout: store.Default(), + TrustPath: trust.DefaultPath, + Binary: DefaultBinary, + NewBinary: DefaultNewBinary, + Prev: DefaultPrev, + Arch: runtime.GOARCH, + CurrentVersion: currentVersion, + Run: runCommand, + Healthy: func(ctx context.Context, v string) error { return WaitHealthy(ctx, StatusURL, v, HealthTimeout) }, + Now: time.Now, + Logf: func(f string, a ...any) { fmt.Printf(f+"\n", a...) }, + } +} + +// ErrNothingStaged means there was no staged package to apply. +var ErrNothingStaged = errors.New("no agent update is staged") + +// refusal is a reason not to install that leaves the running agent alone. +type refusal struct{ msg string } + +func (r *refusal) Error() string { return r.msg } + +func refuse(format string, args ...any) error { return &refusal{fmt.Sprintf(format, args...)} } + +// Apply installs the staged package, health-checks it and rolls back on +// failure. The returned Outcome has been written to last-update.json (unless +// the error is ErrNothingStaged). +func (u *Updater) Apply(ctx context.Context) (Outcome, error) { + out := Outcome{From: u.CurrentVersion} + // The marker goes first: whatever happens next, the path unit must not + // start this again for the same staging. + if err := os.Remove(u.Layout.StagedReady()); err != nil && !errors.Is(err, fs.ErrNotExist) { + u.Logf("could not remove %s: %v", u.Layout.StagedReady(), err) + } + if _, err := os.Lstat(u.Layout.StagedPackage()); errors.Is(err, fs.ErrNotExist) { + return out, ErrNothingStaged + } + + err := u.apply(ctx, &out) + var r *refusal + switch { + case err == nil: + out.Status = StatusOK + out.Message = fmt.Sprintf("Updated the agent from %s to %s", out.From, out.To) + case errors.As(err, &r): + out.Status = StatusRefused + out.Message = r.msg + default: + out.Status = StatusRolledBack + out.Message = err.Error() + } + out.At = u.Now().UTC().Format(time.RFC3339) + u.Logf("%s: %s", out.Status, out.Message) + if werr := u.writeOutcome(out); werr != nil { + u.Logf("could not write %s: %v", u.Layout.AgentLastUpdate(), werr) + } + return out, err +} + +func (u *Updater) apply(ctx context.Context, out *Outcome) error { + work, err := os.MkdirTemp(u.TempRoot, "musallahboard-selfupdate-") + if err != nil { + return refuse("cannot make a private working directory: %v", err) + } + defer os.RemoveAll(work) + if err := os.Chmod(work, 0o700); err != nil { + return refuse("cannot secure the working directory: %v", err) + } + + // Copy first, verify the copy: the daemon owns the staged file and could + // swap it between a check and a use. + pkgPath := filepath.Join(work, "package.mbu") + if err := copyStaged(u.Layout.StagedPackage(), pkgPath); err != nil { + return refuse("cannot read the staged package: %v", err) + } + // Consumed; a leftover would only waste space. + _ = os.Remove(u.Layout.StagedPackage()) + + ring, err := trust.LoadRingRootOwned(u.TrustPath) + if err != nil { + return refuse("cannot read the trust store: %v", err) + } + pkg, err := mbu.Open(pkgPath, ring, mbu.OpenOptions{}) + if err != nil { + return refuse("the staged package did not verify: %v", err) + } + defer pkg.Close() + m := pkg.Manifest + out.To = m.Version + if m.Type != mbu.TypeAgent { + return refuse("the staged package is a %s package, not an agent", m.Type) + } + if m.Agent.Arch != u.Arch { + return refuse("agent %s is built for %s; this board is %s", m.Version, m.Agent.Arch, u.Arch) + } + if mbu.CompareVersions(m.Version, u.CurrentVersion) <= 0 { + return refuse("agent %s is not newer than the installed %s", m.Version, u.CurrentVersion) + } + st, err := u.Layout.State().Load() + if err != nil { + return refuse("cannot read %s: %v", u.Layout.State().Path, err) + } + if st.AgentRejected(m.Version) { + return refuse("agent %s failed to start on this board before; it will not be retried", m.Version) + } + + u.Logf("verified agent %s (signed by %s); installing", m.Version, pkg.SignedBy) + if err := u.extract(pkg, m.Agent.Binary); err != nil { + os.Remove(u.NewBinary) + return refuse("cannot write the new agent: %v", err) + } + vout, err := u.Run(ctx, u.NewBinary, "version") + if err != nil || !strings.Contains(vout, m.Version) { + os.Remove(u.NewBinary) + return refuse("the new agent does not run on this board (`version` said %q, error %v)", firstLine(vout), err) + } + + if err := os.MkdirAll(filepath.Dir(u.Prev), 0o755); err != nil { + os.Remove(u.NewBinary) + return refuse("cannot keep the current agent for rollback: %v", err) + } + if err := copyFileAtomic(u.Binary, u.Prev, 0o755); err != nil { + os.Remove(u.NewBinary) + return refuse("cannot keep the current agent for rollback: %v", err) + } + if err := os.Rename(u.NewBinary, u.Binary); err != nil { + os.Remove(u.NewBinary) + return refuse("cannot put the new agent in place: %v", err) + } + _ = fsutil.SyncDir(filepath.Dir(u.Binary)) + + u.Logf("restarting %s", AgentUnit) + if o, err := u.Run(ctx, "systemctl", "restart", AgentUnit); err != nil { + return u.rollback(ctx, m.Version, fmt.Sprintf("restarting the agent failed: %v %s", err, firstLine(o))) + } + if err := u.Healthy(ctx, m.Version); err != nil { + return u.rollback(ctx, m.Version, fmt.Sprintf("agent %s did not come up healthy: %v", m.Version, err)) + } + return nil +} + +// rollback restores the previous agent, restarts it and records the version +// as rejected so the release channel or a USB stick cannot loop on it. +func (u *Updater) rollback(ctx context.Context, version, reason string) error { + u.Logf("%s; rolling back to %s", reason, u.CurrentVersion) + msg := reason + "; rolled back to " + u.CurrentVersion + if err := copyFileAtomic(u.Prev, u.Binary, 0o755); err != nil { + msg += fmt.Sprintf(" (restoring the previous binary FAILED: %v)", err) + } + if _, err := u.Layout.State().Update(func(s *state.State) error { + if !slices.Contains(s.AgentVersionsRejected, version) { + s.AgentVersionsRejected = append(s.AgentVersionsRejected, version) + } + return nil + }); err != nil { + msg += fmt.Sprintf(" (could not record %s as rejected: %v)", version, err) + } + if o, err := u.Run(ctx, "systemctl", "restart", AgentUnit); err != nil { + msg += fmt.Sprintf(" (restarting the previous agent failed: %v %s)", err, firstLine(o)) + } + return errors.New(msg) +} + +func (u *Updater) extract(pkg *mbu.Package, binary string) error { + os.Remove(u.NewBinary) + f, err := os.OpenFile(u.NewBinary, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o700) + if err != nil { + return err + } + if err := pkg.CopyFile(binary, f); err != nil { + f.Close() + return err + } + if err := f.Sync(); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + // Executable by everyone only once it is complete and verified. + return os.Chmod(u.NewBinary, 0o755) +} + +func (u *Updater) writeOutcome(o Outcome) error { + raw, err := json.MarshalIndent(o, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(u.Layout.AgentLastUpdate()), 0o755); err != nil { + return err + } + return fsutil.WriteAtomic(u.Layout.AgentLastUpdate(), append(raw, '\n'), 0o644) +} + +// copyStaged copies the daemon-owned staged package, refusing anything that +// is not a plain file (a symlink or FIFO planted by the daemon), and at most +// one byte more than a package may be. +func copyStaged(src, dst string) error { + in, err := openRegularNoFollow(src) + if err != nil { + return err + } + defer in.Close() + n, err := fsutil.CopyFileSync(dst, in, mbu.MaxPackageBytes+1, 0o600) + if err != nil { + return err + } + if n > mbu.MaxPackageBytes { + return fmt.Errorf("the package is larger than %d MiB", mbu.MaxPackageBytes>>20) + } + return nil +} + +// copyFileAtomic copies src over dst via a temp file beside dst and a rename, +// so dst is always either the old file or the complete new one. A running +// binary can be replaced this way; writing into it in place cannot. +func copyFileAtomic(src, dst string, mode os.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + tmp := filepath.Join(filepath.Dir(dst), "."+filepath.Base(dst)+".tmp") + os.Remove(tmp) + if _, err := fsutil.CopyFileSync(tmp, in, 1<<40, mode); err != nil { + os.Remove(tmp) + return err + } + if err := os.Chmod(tmp, mode); err != nil { + os.Remove(tmp) + return err + } + if err := os.Rename(tmp, dst); err != nil { + os.Remove(tmp) + return err + } + return fsutil.SyncDir(filepath.Dir(dst)) +} + +func runCommand(ctx context.Context, name string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, 90*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, name, args...).CombinedOutput() + return strings.TrimSpace(string(out)), err +} + +func firstLine(s string) string { + s, _, _ = strings.Cut(strings.TrimSpace(s), "\n") + return s +} + +// WaitHealthy polls statusURL until it reports agentVersion == version, or +// timeout passes. The daemon needs a few seconds to start its local server; +// connection errors until then are expected. +func WaitHealthy(ctx context.Context, statusURL, version string, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + client := &http.Client{Timeout: 5 * time.Second} + last := "no answer" + for { + if v, err := probe(ctx, client, statusURL); err != nil { + last = err.Error() + } else if v == version { + return nil + } else { + last = "it reports version " + v + } + select { + case <-ctx.Done(): + return fmt.Errorf("after %s: %s", timeout, last) + case <-time.After(2 * time.Second): + } + } +} + +func probe(ctx context.Context, client *http.Client, statusURL string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil) + if err != nil { + return "", err + } + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("status returned %d", resp.StatusCode) + } + var st struct { + AgentVersion string `json:"agentVersion"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&st); err != nil { + return "", err + } + return st.AgentVersion, nil +} diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go new file mode 100644 index 0000000..a14f002 --- /dev/null +++ b/internal/selfupdate/selfupdate_test.go @@ -0,0 +1,256 @@ +package selfupdate + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/LensBridge/agent/internal/mbu" + "github.com/LensBridge/agent/internal/state" + "github.com/LensBridge/agent/internal/store" + "github.com/LensBridge/agent/internal/trust" +) + +var ( + releaseKey = ed25519.NewKeyFromSeed(bytes.Repeat([]byte{2}, 32)) + otherKey = ed25519.NewKeyFromSeed(bytes.Repeat([]byte{9}, 32)) +) + +const oldBinary = "old agent binary" + +type harness struct { + u *Updater + cmds []string + versionO string + healthy error +} + +func newHarness(t *testing.T, withContentKey bool) *harness { + t.Helper() + dir := t.TempDir() + h := &harness{} + l := store.Layout{Root: filepath.Join(dir, "var")} + os.MkdirAll(l.AgentStagedDir(), 0o750) + bin := filepath.Join(dir, "usr", "bin", "musallahboard-agent") + os.MkdirAll(filepath.Dir(bin), 0o755) + os.WriteFile(bin, []byte(oldBinary), 0o755) + + ts := &trust.Store{} + ts.Add(trust.RoleRelease, releaseKey.Public().(ed25519.PublicKey), "ci") + if withContentKey { + ts.Add(trust.RoleContent, otherKey.Public().(ed25519.PublicKey), "lensbridge") + } + trustPath := filepath.Join(dir, "trust.json") + if err := ts.Save(trustPath); err != nil { + t.Fatal(err) + } + + h.u = &Updater{ + Layout: l, + TrustPath: trustPath, + Binary: bin, + NewBinary: filepath.Join(filepath.Dir(bin), ".musallahboard-agent.new"), + Prev: filepath.Join(dir, "usr", "lib", "musallahboard", "agent.prev"), + TempRoot: dir, + Arch: "arm64", + CurrentVersion: "0.3.0", + Run: func(ctx context.Context, name string, args ...string) (string, error) { + h.cmds = append(h.cmds, strings.TrimSpace(filepath.Base(name)+" "+strings.Join(args, " "))) + if len(args) == 1 && args[0] == "version" { + return h.versionO, nil + } + return "", nil + }, + Healthy: func(ctx context.Context, v string) error { return h.healthy }, + Now: func() time.Time { return time.Date(2026, 9, 24, 12, 0, 0, 0, time.UTC) }, + Logf: t.Logf, + } + return h +} + +// stage writes a signed agent package and the ready marker. +func (h *harness) stage(t *testing.T, version, arch string, key ed25519.PrivateKey) { + t.Helper() + m := mbu.Manifest{Type: mbu.TypeAgent, Version: version, Agent: &mbu.AgentInfo{Arch: arch, Binary: "musallahboard-agent"}} + var buf bytes.Buffer + if err := mbu.Build(&buf, m, []mbu.Source{{Path: "musallahboard-agent", Data: []byte("new agent " + version)}}, + []ed25519.PrivateKey{key}); err != nil { + t.Fatal(err) + } + os.WriteFile(h.u.Layout.StagedPackage(), buf.Bytes(), 0o640) + os.WriteFile(h.u.Layout.StagedReady(), []byte("now\n"), 0o640) + h.versionO = "musallahboard-agent " + version +} + +func read(t *testing.T, p string) string { + b, err := os.ReadFile(p) + if err != nil { + t.Fatalf("read %s: %v", p, err) + } + return string(b) +} + +func lastUpdate(t *testing.T, h *harness) Outcome { + var o Outcome + if err := json.Unmarshal([]byte(read(t, h.u.Layout.AgentLastUpdate())), &o); err != nil { + t.Fatal(err) + } + return o +} + +func TestApplyOK(t *testing.T) { + h := newHarness(t, false) + h.stage(t, "0.4.0", "arm64", releaseKey) + out, err := h.u.Apply(context.Background()) + if err != nil { + t.Fatalf("Apply: %v", err) + } + if out.Status != StatusOK || out.From != "0.3.0" || out.To != "0.4.0" { + t.Fatalf("outcome = %+v", out) + } + if got := read(t, h.u.Binary); got != "new agent 0.4.0" { + t.Errorf("binary = %q", got) + } + if fi, _ := os.Stat(h.u.Binary); fi.Mode().Perm() != 0o755 { + t.Errorf("binary mode = %v", fi.Mode()) + } + if got := read(t, h.u.Prev); got != oldBinary { + t.Errorf("prev = %q", got) + } + for _, p := range []string{h.u.Layout.StagedReady(), h.u.Layout.StagedPackage(), h.u.NewBinary} { + if _, err := os.Stat(p); err == nil { + t.Errorf("%s left behind", p) + } + } + want := []string{".musallahboard-agent.new version", "systemctl restart musallahboard-agent.service"} + if fmt.Sprint(h.cmds) != fmt.Sprint(want) { + t.Errorf("commands = %q, want %q", h.cmds, want) + } + if o := lastUpdate(t, h); o.Status != StatusOK || o.At == "" { + t.Errorf("last-update = %+v", o) + } + // No temp directories left in TempRoot. + if m, _ := filepath.Glob(filepath.Join(h.u.TempRoot, "musallahboard-selfupdate-*")); len(m) != 0 { + t.Errorf("work dirs left: %v", m) + } +} + +func TestApplyRollsBack(t *testing.T) { + h := newHarness(t, true) + h.stage(t, "0.4.0", "arm64", releaseKey) + h.healthy = errors.New("it reports version 0.3.0") + out, err := h.u.Apply(context.Background()) + if err == nil || out.Status != StatusRolledBack { + t.Fatalf("outcome = %+v, err = %v", out, err) + } + if got := read(t, h.u.Binary); got != oldBinary { + t.Errorf("binary after rollback = %q", got) + } + st, _ := h.u.Layout.State().Load() + if !st.AgentRejected("0.4.0") { + t.Errorf("0.4.0 not recorded as rejected: %+v", st) + } + restarts := 0 + for _, c := range h.cmds { + if c == "systemctl restart musallahboard-agent.service" { + restarts++ + } + } + if restarts != 2 { + t.Errorf("restarts = %d, want 2 (%q)", restarts, h.cmds) + } + if o := lastUpdate(t, h); o.Status != StatusRolledBack || !strings.Contains(o.Message, "rolled back") { + t.Errorf("last-update = %+v", o) + } +} + +func TestApplyRefuses(t *testing.T) { + cases := []struct { + name string + setup func(t *testing.T, h *harness) + wantMsg string + }{ + {"older", func(t *testing.T, h *harness) { h.stage(t, "0.2.0", "arm64", releaseKey) }, "not newer"}, + {"equal", func(t *testing.T, h *harness) { h.stage(t, "0.3.0", "arm64", releaseKey) }, "not newer"}, + {"wrong arch", func(t *testing.T, h *harness) { h.stage(t, "0.4.0", "amd64", releaseKey) }, "built for amd64"}, + {"untrusted key", func(t *testing.T, h *harness) { h.stage(t, "0.4.0", "arm64", otherKey) }, "did not verify"}, + {"rejected before", func(t *testing.T, h *harness) { + h.stage(t, "0.4.0", "arm64", releaseKey) + h.u.Layout.State().Update(func(s *state.State) error { s.AgentVersionsRejected = []string{"0.4.0"}; return nil }) + }, "failed to start"}, + {"binary does not run", func(t *testing.T, h *harness) { + h.stage(t, "0.4.0", "arm64", releaseKey) + h.versionO = "exec format error" + }, "does not run"}, + {"symlinked package", func(t *testing.T, h *harness) { + h.stage(t, "0.4.0", "arm64", releaseKey) + real := h.u.Layout.StagedPackage() + ".real" + os.Rename(h.u.Layout.StagedPackage(), real) + os.Symlink(real, h.u.Layout.StagedPackage()) + }, "cannot read the staged package"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h := newHarness(t, true) + tc.setup(t, h) + out, err := h.u.Apply(context.Background()) + if err == nil || out.Status != StatusRefused || !strings.Contains(out.Message, tc.wantMsg) { + t.Fatalf("outcome = %+v, err = %v; want refused with %q", out, err, tc.wantMsg) + } + if got := read(t, h.u.Binary); got != oldBinary { + t.Errorf("binary changed on refusal: %q", got) + } + for _, c := range h.cmds { + if strings.HasPrefix(c, "systemctl") { + t.Errorf("service restarted on refusal: %q", h.cmds) + } + } + if _, err := os.Stat(h.u.NewBinary); err == nil { + t.Error("new binary left behind") + } + if o := lastUpdate(t, h); o.Status != StatusRefused { + t.Errorf("last-update = %+v", o) + } + }) + } +} + +func TestApplyNothingStaged(t *testing.T) { + h := newHarness(t, true) + if _, err := h.u.Apply(context.Background()); !errors.Is(err, ErrNothingStaged) { + t.Fatalf("err = %v", err) + } + if _, err := os.Stat(h.u.Layout.AgentLastUpdate()); err == nil { + t.Fatal("last-update written with nothing staged") + } +} + +func TestWaitHealthy(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + v := "0.3.0" + if calls.Add(1) >= 2 { + v = "0.4.0" + } + fmt.Fprintf(w, `{"localApi":2,"agentVersion":%q}`, v) + })) + defer srv.Close() + if err := WaitHealthy(context.Background(), srv.URL, "0.4.0", 10*time.Second); err != nil { + t.Fatal(err) + } + err := WaitHealthy(context.Background(), srv.URL, "9.9.9", 100*time.Millisecond) + if err == nil || !strings.Contains(err.Error(), "reports version 0.4.0") { + t.Fatalf("err = %v", err) + } +} diff --git a/internal/state/lock_unix.go b/internal/state/lock_unix.go new file mode 100644 index 0000000..08d5023 --- /dev/null +++ b/internal/state/lock_unix.go @@ -0,0 +1,22 @@ +//go:build !windows + +package state + +import ( + "os" + "syscall" +) + +// lockBlocking takes an exclusive flock on path, waiting for it. The kernel +// releases it if the process dies. +func lockBlocking(path string) (func(), error) { + f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o640) + if err != nil { + return nil, err + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { + f.Close() + return nil, err + } + return func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN); f.Close() }, nil +} diff --git a/internal/state/lock_windows.go b/internal/state/lock_windows.go new file mode 100644 index 0000000..f124e3a --- /dev/null +++ b/internal/state/lock_windows.go @@ -0,0 +1,5 @@ +//go:build windows + +package state + +func lockBlocking(string) (func(), error) { return func() {}, nil } diff --git a/internal/state/state.go b/internal/state/state.go new file mode 100644 index 0000000..2054e15 --- /dev/null +++ b/internal/state/state.go @@ -0,0 +1,124 @@ +// Package state persists the board's high-water marks (docs/architecture.md, +// section 6): the newest content sequence and app version ever installed, the +// agent versions that failed to start, and the clock floor. +// +// These live in their own file, not only in the installed directories, so +// that deleting a bundle can never reopen a rollback. The daemon and the root +// self-updater both write it; every write is a locked read-modify-write and an +// atomic replace. +package state + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + + "github.com/LensBridge/agent/internal/fsutil" +) + +// State is state.json. +type State struct { + ContentSequence int64 `json:"contentSequence"` + AppVersion string `json:"appVersion,omitempty"` + AgentVersionsRejected []string `json:"agentVersionsRejected,omitempty"` + // ClockFloor is Unix seconds; the board's clock is never allowed behind it. + ClockFloor int64 `json:"clockFloor"` + // SignedClockFloor is the newest createdAt of any verified package: the + // part of the floor that is proven rather than remembered. + SignedClockFloor int64 `json:"signedClockFloor,omitempty"` +} + +// MaxWallAheadOfSigned bounds how far a remembered wall clock may raise the +// floor past the newest signed time. Without it, a clock once set far in the +// future (a bad manual date, a broken time source) would raise the floor for +// good, and nothing could bring the board back. +const MaxWallAheadOfSigned = 400 * 24 * 60 * 60 + +// File is state.json at a path. +type File struct{ Path string } + +// Load reads the state. A missing file is the zero state. +func (f File) Load() (State, error) { + var s State + raw, err := os.ReadFile(f.Path) + if errors.Is(err, fs.ErrNotExist) { + return s, nil + } + if err != nil { + return s, err + } + if err := json.Unmarshal(raw, &s); err != nil { + return s, fmt.Errorf("%s is corrupt: %w", f.Path, err) + } + return s, nil +} + +// Update applies fn to the current state under an exclusive lock and writes +// the result atomically. fn's error aborts without writing. +func (f File) Update(fn func(*State) error) (State, error) { + dir := filepath.Dir(f.Path) + if err := os.MkdirAll(dir, 0o750); err != nil { + return State{}, err + } + unlock, err := lockBlocking(filepath.Join(dir, ".state.lock")) + if err != nil { + return State{}, err + } + defer unlock() + s, err := f.Load() + if err != nil { + return s, err + } + if err := fn(&s); err != nil { + return s, err + } + raw, err := json.MarshalIndent(s, "", " ") + if err != nil { + return s, err + } + mode := os.FileMode(0o640) + if fi, err := os.Stat(f.Path); err == nil { + mode = fi.Mode().Perm() + } + return s, fsutil.WriteAtomic(f.Path, append(raw, '\n'), mode) +} + +// RaiseClockFloor moves the floor up to a remembered wall-clock time unix, +// never down, and never more than MaxWallAheadOfSigned past the newest signed +// time (when there is one). +func (f File) RaiseClockFloor(unix int64) error { + _, err := f.Update(func(s *State) error { + if s.SignedClockFloor > 0 && unix > s.SignedClockFloor+MaxWallAheadOfSigned { + unix = s.SignedClockFloor + MaxWallAheadOfSigned + } + if unix > s.ClockFloor { + s.ClockFloor = unix + } + return nil + }) + return err +} + +// RaiseSignedClockFloor records a verified package's createdAt, which also +// raises the floor. +func (f File) RaiseSignedClockFloor(unix int64) error { + _, err := f.Update(func(s *State) error { + if unix > s.SignedClockFloor { + s.SignedClockFloor = unix + } + if unix > s.ClockFloor { + s.ClockFloor = unix + } + return nil + }) + return err +} + +// AgentRejected reports whether version failed a self-update before. +func (s State) AgentRejected(version string) bool { + return slices.Contains(s.AgentVersionsRejected, version) +} diff --git a/internal/state/state_test.go b/internal/state/state_test.go new file mode 100644 index 0000000..e497a9d --- /dev/null +++ b/internal/state/state_test.go @@ -0,0 +1,27 @@ +package state + +import ( + "path/filepath" + "testing" +) + +func TestWallClockCannotRatchetFarPastSignedTime(t *testing.T) { + f := File{Path: filepath.Join(t.TempDir(), "state.json")} + signed := int64(1_790_000_000) + if err := f.RaiseSignedClockFloor(signed); err != nil { + t.Fatal(err) + } + // A clock set ten years ahead once. + if err := f.RaiseClockFloor(signed + 10*365*86400); err != nil { + t.Fatal(err) + } + s, _ := f.Load() + if s.ClockFloor != signed+MaxWallAheadOfSigned { + t.Fatalf("floor = %d, want capped at %d", s.ClockFloor, signed+MaxWallAheadOfSigned) + } + // Never down. + f.RaiseClockFloor(signed - 100) + if s2, _ := f.Load(); s2.ClockFloor != s.ClockFloor { + t.Fatal("floor went down") + } +} diff --git a/internal/store/app.go b/internal/store/app.go new file mode 100644 index 0000000..f1c2241 --- /dev/null +++ b/internal/store/app.go @@ -0,0 +1,132 @@ +package store + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/LensBridge/agent/internal/fsutil" + "github.com/LensBridge/agent/internal/mbu" +) + +// App is an installed board app release. +type App struct { + Dir string + Manifest *mbu.Manifest +} + +// CurrentApp loads the release current points at. +func (l Layout) CurrentApp() (*App, error) { + dir, err := fsutil.ReadLinkAbs(l.AppCurrent()) + if errors.Is(err, os.ErrNotExist) { + return nil, ErrNoApp + } + if err != nil { + return nil, err + } + raw, err := os.ReadFile(filepath.Join(dir, ".mbu", mbu.ManifestName)) + if err != nil { + return nil, fmt.Errorf("installed app %s is unreadable: %w", filepath.Base(dir), err) + } + m, err := mbu.ParseManifest(raw) + if err != nil { + return nil, fmt.Errorf("installed app %s is unreadable: %w", filepath.Base(dir), err) + } + return &App{Dir: dir, Manifest: m}, nil +} + +// InstallApp installs an authenticated app package and makes it current. +// The release's own manifest is kept in /.mbu/, a name no package +// path can have (segments may not start with a dot), so it cannot collide +// with a build file or be served. +func (l Layout) InstallApp(pkg *mbu.Package, source string, now time.Time) error { + if err := os.MkdirAll(l.AppReleases(), 0o750); err != nil { + return err + } + unlock, err := fsutil.LockDir(l.appDir()) + if err != nil { + return err + } + defer unlock() + + stage, err := os.MkdirTemp(l.AppReleases(), ".staging-") + if err != nil { + return err + } + committed := false + defer func() { + if !committed { + os.RemoveAll(stage) + } + }() + if err := os.Chmod(stage, 0o750); err != nil { + return err + } + + dirs := map[string]bool{stage: true} + for _, f := range pkg.Manifest.Files { + // f.Path passed mbu.ValidPath, so the join stays inside stage. + dst := filepath.Join(stage, filepath.FromSlash(f.Path)) + if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil { + return err + } + dirs[filepath.Dir(dst)] = true + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640) + if err != nil { + return err + } + if err := pkg.CopyFile(f.Path, out); err != nil { + out.Close() + return err + } + if err := out.Sync(); err != nil { + out.Close() + return err + } + if err := out.Close(); err != nil { + return err + } + } + meta := filepath.Join(stage, ".mbu") + if err := os.Mkdir(meta, 0o750); err != nil { + return err + } + dirs[meta] = true + info, _ := json.Marshal(InstallInfo{Source: source, InstalledAt: now.UTC().Format(time.RFC3339)}) + for name, data := range map[string][]byte{ + mbu.ManifestName: pkg.RawManifest, mbu.SignatureName: pkg.RawSig, installInfoName: info, + } { + if err := fsutil.WriteFileSync(filepath.Join(meta, name), data, 0o640); err != nil { + return err + } + } + for d := range dirs { + if err := fsutil.SyncDir(d); err != nil { + return err + } + } + + prev, _ := l.CurrentApp() + name := fsutil.UniqueName(l.AppReleases(), pkg.Manifest.Version) + if err := os.Rename(stage, filepath.Join(l.AppReleases(), name)); err != nil { + return err + } + committed = true + if err := fsutil.SyncDir(l.AppReleases()); err != nil { + return err + } + if err := fsutil.SwapSymlink(l.AppCurrent(), filepath.Join("releases", name)); err != nil { + return err + } + prevName := "" + if prev != nil { + prevName = filepath.Base(prev.Dir) + } + if _, err := fsutil.Prune(l.AppReleases(), name, prevName); err != nil { + return fmt.Errorf("app installed, but removing old releases failed: %w", err) + } + return nil +} diff --git a/internal/store/content.go b/internal/store/content.go new file mode 100644 index 0000000..d5fdf36 --- /dev/null +++ b/internal/store/content.go @@ -0,0 +1,380 @@ +package store + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/LensBridge/agent/internal/fsutil" + "github.com/LensBridge/agent/internal/mbu" +) + +// Content is an installed content bundle. +type Content struct { + Dir string + Manifest *mbu.Manifest + // Info is the bundle's install record (source, time). + Info InstallInfo + loc *time.Location +} + +// InstallInfo records how a bundle or release arrived. +type InstallInfo struct { + Source string `json:"source"` + InstalledAt string `json:"installedAt"` +} + +const installInfoName = "installed.json" + +// Location is the bundle's timezone. +func (c *Content) Location() *time.Location { return c.loc } + +// PayloadPath is the payload file for date (YYYY-MM-DD, already validated by +// the caller as one of the bundle's days). +func (c *Content) PayloadPath(date string) string { + return filepath.Join(c.Dir, "payloads", date+".json") +} + +// contentCache avoids re-parsing a bundle's manifest on every request: +// installed bundle directories never change after their rename into place. +var contentCache struct { + sync.Mutex + dir string + c *Content +} + +// CurrentContent loads the bundle current points at. +func (l Layout) CurrentContent() (*Content, error) { + dir, err := fsutil.ReadLinkAbs(l.ContentCurrent()) + if errors.Is(err, os.ErrNotExist) { + return nil, ErrNoContent + } + if err != nil { + return nil, err + } + contentCache.Lock() + defer contentCache.Unlock() + if contentCache.dir == dir && contentCache.c != nil { + return contentCache.c, nil + } + c, err := readContent(dir) + if err != nil { + return nil, fmt.Errorf("installed content %s is unreadable: %w", filepath.Base(dir), err) + } + contentCache.dir, contentCache.c = dir, c + return c, nil +} + +func readContent(dir string) (*Content, error) { + raw, err := os.ReadFile(filepath.Join(dir, mbu.ManifestName)) + if err != nil { + return nil, err + } + m, err := mbu.ParseManifest(raw) + if err != nil { + return nil, err + } + if m.Type != mbu.TypeContent { + return nil, fmt.Errorf("not a content bundle") + } + loc, err := time.LoadLocation(m.Content.Timezone) + if err != nil { + return nil, err + } + c := &Content{Dir: dir, Manifest: m, loc: loc} + if raw, err := os.ReadFile(filepath.Join(dir, installInfoName)); err == nil { + _ = json.Unmarshal(raw, &c.Info) + } + return c, nil +} + +// MediaPath is where the media file with base name file lives in the store. +func (l Layout) MediaPath(file string) string { return filepath.Join(l.MediaDir(), file) } + +// HaveMedia reports whether the store holds a media file with this sha256. +func (l Layout) HaveMedia(sha string) bool { + matches, _ := filepath.Glob(filepath.Join(l.MediaDir(), sha+".*")) + for _, m := range matches { + if mbu.MediaFileRE.MatchString(filepath.Base(m)) { + return true + } + } + return false +} + +// MediaHashes lists the sha256 of every media file in the store. +func (l Layout) MediaHashes() []string { + entries, _ := os.ReadDir(l.MediaDir()) + var out []string + for _, e := range entries { + if mbu.MediaFileRE.MatchString(e.Name()) { + out = append(out, strings.SplitN(e.Name(), ".", 2)[0]) + } + } + sort.Strings(out) + return out +} + +// InstallContent installs an authenticated content package and makes it +// current. changed reports whether its payloads differ from what was current +// before (a sync that only moved the window forward by nothing, or re-sent +// the same content, is not worth a repaint). +func (l Layout) InstallContent(pkg *mbu.Package, source string, now time.Time) (changed bool, err error) { + m := pkg.Manifest + for _, d := range []string{l.BundlesDir(), l.MediaDir()} { + if err := os.MkdirAll(d, 0o750); err != nil { + return false, err + } + } + unlock, err := fsutil.LockDir(l.contentDir()) + if err != nil { + return false, err + } + defer unlock() + + // Media first: into the shared store, each file verified as it is + // written and only then renamed to its hash name. + media := make(map[string]bool) + for _, f := range m.Files { + if !strings.HasPrefix(f.Path, "media/") { + continue + } + name := strings.TrimPrefix(f.Path, "media/") + media[name] = true + if _, err := os.Stat(l.MediaPath(name)); err == nil { + continue + } + if !pkg.Has(f.Path) { + return false, fmt.Errorf("%s is neither in the package nor on this board", f.Path) + } + if err := l.storeMedia(pkg, f.Path, name); err != nil { + return false, err + } + } + if err := fsutil.SyncDir(l.MediaDir()); err != nil { + return false, err + } + + stage, err := os.MkdirTemp(l.BundlesDir(), ".staging-") + if err != nil { + return false, err + } + committed := false + defer func() { + if !committed { + os.RemoveAll(stage) + } + }() + if err := os.Chmod(stage, 0o750); err != nil { + return false, err + } + if err := os.Mkdir(filepath.Join(stage, "payloads"), 0o750); err != nil { + return false, err + } + days, _ := m.Content.Days() + for _, d := range days { + p := "payloads/" + d + ".json" + raw, err := pkg.ReadFile(p, mbu.MaxPayloadBytes) + if err != nil { + return false, err + } + if err := CheckPayload(raw, media); err != nil { + return false, fmt.Errorf("%s: %w", p, err) + } + if err := fsutil.WriteFileSync(filepath.Join(stage, "payloads", d+".json"), raw, 0o640); err != nil { + return false, err + } + } + info, _ := json.Marshal(InstallInfo{Source: source, InstalledAt: now.UTC().Format(time.RFC3339)}) + for name, data := range map[string][]byte{ + mbu.ManifestName: pkg.RawManifest, + mbu.SignatureName: pkg.RawSig, + installInfoName: info, + } { + if err := fsutil.WriteFileSync(filepath.Join(stage, name), data, 0o640); err != nil { + return false, err + } + } + for _, d := range []string{filepath.Join(stage, "payloads"), stage} { + if err := fsutil.SyncDir(d); err != nil { + return false, err + } + } + + prev, _ := l.CurrentContent() + name := fsutil.UniqueName(l.BundlesDir(), strconv.FormatInt(m.Sequence, 10)) + if err := os.Rename(stage, filepath.Join(l.BundlesDir(), name)); err != nil { + return false, err + } + committed = true + if err := fsutil.SyncDir(l.BundlesDir()); err != nil { + return false, err + } + if err := fsutil.SwapSymlink(l.ContentCurrent(), filepath.Join("bundles", name)); err != nil { + return false, err + } + + changed = prev == nil || payloadsDiffer(prev.Manifest, m) + prevName := "" + if prev != nil { + prevName = filepath.Base(prev.Dir) + } + if _, err := fsutil.Prune(l.BundlesDir(), name, prevName); err != nil { + return changed, fmt.Errorf("content installed, but removing old bundles failed: %w", err) + } + if err := l.gcMedia(); err != nil { + return changed, fmt.Errorf("content installed, but removing unused media failed: %w", err) + } + return changed, nil +} + +func (l Layout) storeMedia(pkg *mbu.Package, path, name string) error { + tmp, err := os.CreateTemp(l.MediaDir(), ".incoming-") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if err := pkg.CopyFile(path, tmp); err != nil { + tmp.Close() + return err + } + if err := tmp.Chmod(0o640); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmp.Name(), l.MediaPath(name)) +} + +// gcMedia deletes media no kept bundle references. Callers hold the content +// lock. +func (l Layout) gcMedia() error { + used := map[string]bool{} + bundles, err := os.ReadDir(l.BundlesDir()) + if err != nil { + return err + } + for _, b := range bundles { + c, err := readContent(filepath.Join(l.BundlesDir(), b.Name())) + if err != nil { + // An unreadable kept bundle: keep every file rather than guess. + return nil + } + for _, f := range c.Manifest.Files { + if strings.HasPrefix(f.Path, "media/") { + used[strings.TrimPrefix(f.Path, "media/")] = true + } + } + } + entries, err := os.ReadDir(l.MediaDir()) + if err != nil { + return err + } + for _, e := range entries { + if used[e.Name()] || e.Name() == ".lock" { + continue + } + if err := os.RemoveAll(filepath.Join(l.MediaDir(), e.Name())); err != nil { + return err + } + } + return nil +} + +func payloadsDiffer(a, b *mbu.Manifest) bool { + hashes := func(m *mbu.Manifest) map[string]string { + out := map[string]string{} + for _, f := range m.Files { + if strings.HasPrefix(f.Path, "payloads/") { + out[f.Path] = f.SHA256 + } + } + return out + } + ha, hb := hashes(a), hashes(b) + if len(ha) != len(hb) { + return true + } + for k, v := range ha { + if hb[k] != v { + return true + } + } + return false +} + +// CheckPayload requires raw to be one JSON object and every non-empty +// posterUrl anywhere in it to name a media file of the bundle. The walk is +// structural rather than tied to the payload schema, so a new frame type that +// carries a poster is covered without a change here. A posterUrl pointing +// anywhere else would be a blank tile on a board with no internet, or a +// request to an address the content key's holder chose. +func CheckPayload(raw []byte, media map[string]bool) error { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + var v any + if err := dec.Decode(&v); err != nil { + return fmt.Errorf("not valid JSON: %w", err) + } + if _, err := dec.Token(); err != io.EOF { + return fmt.Errorf("not valid JSON: trailing data after the object") + } + if _, ok := v.(map[string]any); !ok { + return fmt.Errorf("not a JSON object") + } + return walkPosterURLs(v, media) +} + +func walkPosterURLs(v any, media map[string]bool) error { + switch t := v.(type) { + case map[string]any: + keys := make([]string, 0, len(t)) + for k := range t { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if k == "posterUrl" { + if s, ok := t[k].(string); ok && s != "" { + file, ok := strings.CutPrefix(s, "/media/") + if !ok || !media[file] { + return fmt.Errorf("posterUrl %q is not a media file of this bundle", s) + } + } + } + if err := walkPosterURLs(t[k], media); err != nil { + return err + } + } + case []any: + for _, c := range t { + if err := walkPosterURLs(c, media); err != nil { + return err + } + } + } + return nil +} + +// sha256Hex is used by tests and by callers checking stored media. +func sha256Hex(b []byte) string { + s := sha256.Sum256(b) + return hex.EncodeToString(s[:]) +} diff --git a/internal/store/day.go b/internal/store/day.go new file mode 100644 index 0000000..4ec46e0 --- /dev/null +++ b/internal/store/day.go @@ -0,0 +1,47 @@ +package store + +import ( + "time" + + "github.com/LensBridge/agent/internal/mbu" +) + +// Day is the answer to "which payload should the board show right now". +type Day struct { + // Today is the current date in the bundle's timezone. + Today string + // Serving is Today clamped to firstDay..lastDay. + Serving string + // DaysRemaining is lastDay - today, floored at 0. + DaysRemaining int + // StaleDays is max(0, today - lastDay). + StaleDays int +} + +// PickDay applies the section 7 rule to the bundle at instant now. +func (c *Content) PickDay(now time.Time) Day { + ci := c.Manifest.Content + first, _ := time.Parse(mbu.DateLayout, ci.FirstDay) + last, _ := time.Parse(mbu.DateLayout, ci.LastDay) + y, mo, d := now.In(c.loc).Date() + today := time.Date(y, mo, d, 0, 0, 0, 0, time.UTC) + + serving := today + switch { + case today.Before(first): + serving = first + case today.After(last): + serving = last + } + return Day{ + Today: today.Format(mbu.DateLayout), + Serving: serving.Format(mbu.DateLayout), + DaysRemaining: max(0, daysBetween(today, last)), + StaleDays: max(0, daysBetween(last, today)), + } +} + +// daysBetween counts civil days from a to b (both at 00:00 UTC). +func daysBetween(a, b time.Time) int { + return int(b.Sub(a).Hours() / 24) +} diff --git a/internal/store/layout.go b/internal/store/layout.go new file mode 100644 index 0000000..03b2fc6 --- /dev/null +++ b/internal/store/layout.go @@ -0,0 +1,49 @@ +// Package store is the board's installed state on disk (docs/architecture.md, +// section 6): the content bundles and their media store, the app releases, +// and the paths the rest of the agent shares (inbox, staged agent, state). +// +// Installs here take packages that mbu.Open has already authenticated; the +// importer decides whether they may be installed at all. Every install stages, +// fsyncs, renames and swaps a symlink, so a power cut leaves the previous +// state serving. +package store + +import ( + "errors" + "path/filepath" + + "github.com/LensBridge/agent/internal/state" +) + +// DefaultRoot is the daemon's StateDirectory. +const DefaultRoot = "/var/lib/musallahboard" + +var ( + // ErrNoContent means no content bundle is installed. + ErrNoContent = errors.New("no content installed") + // ErrNoApp means no app release is installed. + ErrNoApp = errors.New("no board app installed") +) + +// Layout locates everything under one root, so tests can use a temp dir. +type Layout struct{ Root string } + +// Default is the production layout. +func Default() Layout { return Layout{Root: DefaultRoot} } + +func (l Layout) State() state.File { return state.File{Path: filepath.Join(l.Root, "state.json")} } +func (l Layout) contentDir() string { return filepath.Join(l.Root, "content") } +func (l Layout) MediaDir() string { return filepath.Join(l.contentDir(), "media") } +func (l Layout) BundlesDir() string { return filepath.Join(l.contentDir(), "bundles") } +func (l Layout) ContentCurrent() string { return filepath.Join(l.contentDir(), "current") } +func (l Layout) appDir() string { return filepath.Join(l.Root, "app") } +func (l Layout) AppReleases() string { return filepath.Join(l.appDir(), "releases") } +func (l Layout) AppCurrent() string { return filepath.Join(l.appDir(), "current") } +func (l Layout) AgentStagedDir() string { return filepath.Join(l.Root, "agent", "staged") } +func (l Layout) AgentLastUpdate() string { return filepath.Join(l.Root, "agent", "last-update.json") } +func (l Layout) Inbox() string { return filepath.Join(l.Root, "inbox") } +func (l Layout) InboxResults() string { return filepath.Join(l.Inbox(), "results") } + +// StagedPackage and StagedReady are the self-update hand-off (section 12). +func (l Layout) StagedPackage() string { return filepath.Join(l.AgentStagedDir(), "package.mbu") } +func (l Layout) StagedReady() string { return filepath.Join(l.AgentStagedDir(), "ready") } diff --git a/internal/trust/owner_unix.go b/internal/trust/owner_unix.go new file mode 100644 index 0000000..c58b926 --- /dev/null +++ b/internal/trust/owner_unix.go @@ -0,0 +1,41 @@ +//go:build !windows + +package trust + +import ( + "errors" + "fmt" + "io/fs" + "os" + "syscall" +) + +// checkRootOwned refuses a trust store the daemon could have planted. +// /etc/musallahboard is writable by the daemon (it writes kiosk-url), so it +// could unlink trust.json and put its own file there; that file would be +// owned by the daemon, not root. Hard links to someone else's file are +// caught by the link count. Run unprivileged (tests, a dev box) there is no +// root to protect, so ownership is not checked. +func checkRootOwned(path string) error { + fi, err := os.Lstat(path) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return err + } + st, ok := fi.Sys().(*syscall.Stat_t) + switch { + case !fi.Mode().IsRegular(): + return fmt.Errorf("%s is not a regular file", path) + case !ok: + return nil + case st.Uid != 0 && os.Geteuid() == 0: + return fmt.Errorf("%s is owned by uid %d, not root; refusing to trust it (restore it with `sudo musallahboard-agent trust fetch` after deleting it)", path, st.Uid) + case st.Nlink != 1: + return fmt.Errorf("%s has %d hard links; refusing to trust it", path, st.Nlink) + case fi.Mode().Perm()&0o022 != 0: + return fmt.Errorf("%s is writable by others than root; refusing to trust it", path) + } + return nil +} diff --git a/internal/trust/owner_windows.go b/internal/trust/owner_windows.go new file mode 100644 index 0000000..cf5472a --- /dev/null +++ b/internal/trust/owner_windows.go @@ -0,0 +1,5 @@ +//go:build windows + +package trust + +func checkRootOwned(string) error { return nil } diff --git a/internal/trust/trust.go b/internal/trust/trust.go new file mode 100644 index 0000000..011e1b9 --- /dev/null +++ b/internal/trust/trust.go @@ -0,0 +1,287 @@ +// Package trust holds the public keys a board accepts packages from +// (docs/architecture.md, section 5). +// +// There are two roles. Content keys belong to the LensBridge backend and may +// only sign content packages; they are pinned at enrollment. Release keys sign +// software (the board app and the agent itself) and never live on the backend; +// they are compiled into the agent and may be extended in trust.json. Keeping +// the roles apart is what limits a compromised backend to the wrong posters on +// a screen rather than code running on every board. +// +// The file is root-owned and the daemon only reads it. The root self-updater +// trusts nothing but this file and the keys compiled into its own binary. +package trust + +import ( + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "strings" + + "github.com/LensBridge/agent/internal/fsutil" +) + +// DefaultPath is the trust store on a board. +const DefaultPath = "/etc/musallahboard/trust.json" + +// BuiltinReleaseKeys is a comma-separated list of base64 release public keys, +// set at build time: +// +// -ldflags "-X github.com/LensBridge/agent/internal/trust.BuiltinReleaseKeys=[,]" +// +// A build without it (a developer build) accepts only release keys listed in +// trust.json. +var BuiltinReleaseKeys = "" + +// Role is which kind of package a key may sign. +type Role string + +const ( + RoleContent Role = "content" + RoleRelease Role = "release" +) + +// ParseRole accepts "content" or "release". +func ParseRole(s string) (Role, error) { + switch Role(s) { + case RoleContent, RoleRelease: + return Role(s), nil + } + return "", fmt.Errorf("unknown key role %q (want %q or %q)", s, RoleContent, RoleRelease) +} + +// Key is one trusted public key as stored in trust.json. +type Key struct { + KeyID string `json:"keyId"` + PublicKey string `json:"publicKey"` + Note string `json:"note,omitempty"` +} + +// Store is trust.json. +type Store struct { + Content []Key `json:"content"` + Release []Key `json:"release"` +} + +// KeyID is the lowercase hex of the first 8 bytes of SHA-256 of the raw +// public key. The backend, mbpack and the frontend packer derive it the same +// way. +func KeyID(pub ed25519.PublicKey) string { + sum := sha256.Sum256(pub) + return hex.EncodeToString(sum[:8]) +} + +// ParsePublicKey decodes a base64 raw 32-byte Ed25519 public key. +func ParsePublicKey(b64 string) (ed25519.PublicKey, error) { + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64)) + if err != nil { + return nil, fmt.Errorf("public key is not base64: %w", err) + } + if len(raw) != ed25519.PublicKeySize { + return nil, fmt.Errorf("public key is %d bytes, want %d", len(raw), ed25519.PublicKeySize) + } + return ed25519.PublicKey(raw), nil +} + +// ParsePrivateSeed decodes a base64 32-byte Ed25519 seed into a private key. +func ParsePrivateSeed(b64 string) (ed25519.PrivateKey, error) { + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64)) + if err != nil { + return nil, fmt.Errorf("signing key is not base64: %w", err) + } + if len(raw) != ed25519.SeedSize { + return nil, fmt.Errorf("signing key is %d bytes, want a %d-byte Ed25519 seed", len(raw), ed25519.SeedSize) + } + return ed25519.NewKeyFromSeed(raw), nil +} + +// NewKey describes pub for storage. +func NewKey(pub ed25519.PublicKey, note string) Key { + return Key{KeyID: KeyID(pub), PublicKey: base64.StdEncoding.EncodeToString(pub), Note: note} +} + +// Load reads the store at path. A missing file is an empty store, not an +// error: an unenrolled board has none yet. +func Load(path string) (*Store, error) { + raw, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return &Store{}, nil + } + if err != nil { + return nil, err + } + var s Store + if err := json.Unmarshal(raw, &s); err != nil { + return nil, fmt.Errorf("%s is not valid JSON: %w", path, err) + } + for _, list := range [][]Key{s.Content, s.Release} { + for _, k := range list { + pub, err := ParsePublicKey(k.PublicKey) + if err != nil { + return nil, fmt.Errorf("%s: key %s: %w", path, k.KeyID, err) + } + if KeyID(pub) != k.KeyID { + return nil, fmt.Errorf("%s: key id %s does not match its public key (%s)", path, k.KeyID, KeyID(pub)) + } + } + } + return &s, nil +} + +// Save writes the store atomically, world-readable (it holds public keys +// only) and owned by whoever runs this, which is root. +func (s *Store) Save(path string) error { + raw, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + return fsutil.WriteAtomic(path, append(raw, '\n'), 0o644) +} + +// Add trusts pub for role. It reports false if it was already trusted. +func (s *Store) Add(role Role, pub ed25519.PublicKey, note string) bool { + list := s.list(role) + id := KeyID(pub) + for _, k := range *list { + if k.KeyID == id { + return false + } + } + *list = append(*list, NewKey(pub, note)) + return true +} + +// Remove drops the key with keyID from either role, reporting whether it +// was there. +func (s *Store) Remove(keyID string) bool { + found := false + for _, role := range []Role{RoleContent, RoleRelease} { + list := s.list(role) + kept := (*list)[:0] + for _, k := range *list { + if k.KeyID == keyID { + found = true + continue + } + kept = append(kept, k) + } + *list = kept + } + return found +} + +func (s *Store) list(role Role) *[]Key { + if role == RoleContent { + return &s.Content + } + return &s.Release +} + +// Ring is the set of keys packages are verified against: the store plus the +// release keys compiled into this binary. +type Ring struct { + keys map[Role]map[string]ed25519.PublicKey +} + +// Ring builds the verification key ring. Builtin release keys that fail to +// parse are a build mistake, reported as an error rather than ignored. +func (s *Store) Ring() (*Ring, error) { + r := &Ring{keys: map[Role]map[string]ed25519.PublicKey{ + RoleContent: {}, + RoleRelease: {}, + }} + for _, role := range []Role{RoleContent, RoleRelease} { + for _, k := range *s.list(role) { + pub, err := ParsePublicKey(k.PublicKey) + if err != nil { + return nil, err + } + r.keys[role][KeyID(pub)] = pub + } + } + builtin, err := Builtin() + if err != nil { + return nil, err + } + for _, pub := range builtin { + r.keys[RoleRelease][KeyID(pub)] = pub + } + return r, nil +} + +// Builtin returns the release keys compiled into this binary. +func Builtin() ([]ed25519.PublicKey, error) { + var out []ed25519.PublicKey + for _, b64 := range strings.Split(BuiltinReleaseKeys, ",") { + if strings.TrimSpace(b64) == "" { + continue + } + pub, err := ParsePublicKey(b64) + if err != nil { + return nil, fmt.Errorf("builtin release key: %w", err) + } + out = append(out, pub) + } + return out, nil +} + +// NewRing builds a ring from explicit keys, for tools and tests. +func NewRing(content, release []ed25519.PublicKey) *Ring { + r := &Ring{keys: map[Role]map[string]ed25519.PublicKey{RoleContent: {}, RoleRelease: {}}} + for _, k := range content { + r.keys[RoleContent][KeyID(k)] = k + } + for _, k := range release { + r.keys[RoleRelease][KeyID(k)] = k + } + return r +} + +// Keys returns the keys trusted for role, by key id. +func (r *Ring) Keys(role Role) map[string]ed25519.PublicKey { + if r == nil { + return nil + } + return r.keys[role] +} + +// Empty reports whether no key is trusted for role. +func (r *Ring) Empty(role Role) bool { return len(r.Keys(role)) == 0 } + +// LoadRing loads the store at path and builds its ring. +func LoadRing(path string) (*Ring, error) { + s, err := Load(path) + if err != nil { + return nil, err + } + return s.Ring() +} + +// LoadRootOwned is Load for code running as root (the self-updater, the +// trust CLI): the file must be a regular, root-owned, singly linked file +// nobody else can write, or it is refused. +func LoadRootOwned(path string) (*Store, error) { + if err := checkRootOwned(path); err != nil { + return nil, err + } + return Load(path) +} + +// LoadRingRootOwned is LoadRing with LoadRootOwned's checks. +func LoadRingRootOwned(path string) (*Ring, error) { + s, err := LoadRootOwned(path) + if err != nil { + return nil, err + } + return s.Ring() +} + +// LoadForWrite is what root commands that rewrite the store use, so a key the +// daemon planted is never carried into the new root-owned file. +func LoadForWrite(path string) (*Store, error) { return LoadRootOwned(path) } diff --git a/internal/trust/trust_test.go b/internal/trust/trust_test.go new file mode 100644 index 0000000..0d2d528 --- /dev/null +++ b/internal/trust/trust_test.go @@ -0,0 +1,39 @@ +package trust + +import ( + "crypto/ed25519" + "os" + "path/filepath" + "testing" +) + +func TestStoreRoundTripAndRootOwnedChecks(t *testing.T) { + p := filepath.Join(t.TempDir(), "trust.json") + s, err := LoadRootOwned(p) + if err != nil || len(s.Content) != 0 { + t.Fatalf("missing file: %v", err) + } + pub := ed25519.NewKeyFromSeed(make([]byte, 32)).Public().(ed25519.PublicKey) + if !s.Add(RoleContent, pub, "t") || s.Add(RoleContent, pub, "t") { + t.Fatal("Add dedup") + } + if err := s.Save(p); err != nil { + t.Fatal(err) + } + r, err := LoadRingRootOwned(p) + if err != nil || len(r.Keys(RoleContent)) != 1 { + t.Fatalf("ring: %v", err) + } + // A second hard link is refused. + if err := os.Link(p, p+".2"); err == nil { + if _, err := LoadRootOwned(p); err == nil { + t.Fatal("hard-linked store accepted") + } + os.Remove(p + ".2") + } + // Group-writable is refused. + os.Chmod(p, 0o664) + if _, err := LoadRootOwned(p); err == nil { + t.Fatal("group-writable store accepted") + } +} diff --git a/internal/updatescreen/updatescreen.go b/internal/updatescreen/updatescreen.go new file mode 100644 index 0000000..1450394 --- /dev/null +++ b/internal/updatescreen/updatescreen.go @@ -0,0 +1,161 @@ +// Package updatescreen puts the kiosk on the "Working on updates" screen +// (update-anim/index.html, served by the local server at /_mb/updating) while +// the importer installs something a person brought to the board, and returns +// it to the board afterwards (docs/architecture.md, section 8). +// +// The screen is driven over CDP through its window.mbUpdate hook. It lives in +// the agent, not the board app, because an app update replaces the app while +// the screen is up, and an agent update restarts the server that served it: +// once loaded the page needs nothing from anyone. +// +// Every call is best effort. The kiosk may be restarting or absent (a board +// without a display attached still takes updates), and an update must never +// fail because the screen could not be shown. +package updatescreen + +import ( + "context" + "encoding/json" + "log/slog" + "strings" + "sync" + "time" + + "github.com/LensBridge/agent/internal/cdp" +) + +// Path is where the local server serves the screen. +const Path = "/_mb/updating" + +// callTimeout bounds one CDP round trip. +const callTimeout = 5 * time.Second + +// Screen implements importer.Screen. +type Screen struct { + cdp *cdp.Client + baseURL string // e.g. http://127.0.0.1:8080 + logger *slog.Logger + mu sync.Mutex + returnAt *time.Timer + active bool +} + +// New returns a Screen for the kiosk at the default CDP endpoint, whose board +// is served at baseURL. +func New(c *cdp.Client, baseURL string, logger *slog.Logger) *Screen { + return &Screen{cdp: c, baseURL: strings.TrimRight(baseURL, "/"), logger: logger} +} + +// Active reports whether the screen is up (for /api/local/status). +func (s *Screen) Active() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.active +} + +// Begin navigates the kiosk to the update screen and waits for its hook. +func (s *Screen) Begin(ctx context.Context) { + s.mu.Lock() + if s.returnAt != nil { + s.returnAt.Stop() + s.returnAt = nil + } + s.active = true + s.mu.Unlock() + + if err := s.navigate(ctx, s.baseURL+Path); err != nil { + s.logger.Debug("update screen: navigate failed", "err", err) + return + } + // The hook is defined at the end of ; poll rather than assume. + for range 25 { + var ready bool + cctx, cancel := context.WithTimeout(ctx, callTimeout) + err := s.cdp.EvaluateValue(cctx, `typeof window.mbUpdate === 'object'`, &ready) + cancel() + if err == nil && ready { + return + } + select { + case <-ctx.Done(): + return + case <-time.After(200 * time.Millisecond): + } + } +} + +// Caption sets the line under the headline. +func (s *Screen) Caption(ctx context.Context, text string) { + arg, _ := json.Marshal(text) + s.eval(ctx, "window.mbUpdate && window.mbUpdate.caption("+string(arg)+")") +} + +// Finish shows the outcome, then returns the kiosk to the board when the +// countdown ends. When restarting, the agent is about to be replaced; the new +// agent returns the kiosk to the board when it starts (see Resume). +func (s *Screen) Finish(ctx context.Context, ok bool, detail string, restarting bool) { + seconds := 5 + opts := map[string]any{"seconds": seconds, "caption": detail, "message": "Returning to MusallahBoard"} + switch { + case restarting: + opts["message"] = "Restarting MusallahBoard" + case !ok: + seconds = 10 + opts["seconds"] = seconds + opts["headline"] = "Update not installed" + } + arg, _ := json.Marshal(opts) + s.eval(ctx, "window.mbUpdate && window.mbUpdate.complete("+string(arg)+")") + if restarting { + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.returnAt = time.AfterFunc(time.Duration(seconds)*time.Second, func() { + s.returnToBoard(context.Background()) + }) +} + +// Resume is called once at agent startup. If the kiosk is still on the update +// screen (the previous agent was replaced mid-update, or crashed), it +// completes it and returns to the board. +func (s *Screen) Resume(ctx context.Context) { + cctx, cancel := context.WithTimeout(ctx, callTimeout) + target, err := s.cdp.FirstPageTarget(cctx) + cancel() + if err != nil || !strings.Contains(target.URL, Path) { + return + } + s.mu.Lock() + s.active = true + s.mu.Unlock() + s.Finish(ctx, true, "", false) +} + +func (s *Screen) returnToBoard(ctx context.Context) { + s.mu.Lock() + s.active = false + s.returnAt = nil + s.mu.Unlock() + if err := s.navigate(ctx, s.baseURL+"/"); err != nil { + s.logger.Debug("update screen: return to board failed", "err", err) + } +} + +func (s *Screen) navigate(ctx context.Context, url string) error { + cctx, cancel := context.WithTimeout(ctx, callTimeout) + defer cancel() + target, err := s.cdp.FirstPageTarget(cctx) + if err != nil { + return err + } + return s.cdp.Call(cctx, target, "Page.navigate", map[string]any{"url": url}, nil) +} + +func (s *Screen) eval(ctx context.Context, expr string) { + cctx, cancel := context.WithTimeout(ctx, callTimeout) + defer cancel() + if _, err := s.cdp.RuntimeEvaluate(cctx, expr); err != nil { + s.logger.Debug("update screen: evaluate failed", "err", err) + } +} diff --git a/internal/uploadserver/listen_linux.go b/internal/uploadserver/listen_linux.go new file mode 100644 index 0000000..5a0a7a9 --- /dev/null +++ b/internal/uploadserver/listen_linux.go @@ -0,0 +1,26 @@ +//go:build linux + +package uploadserver + +import ( + "context" + "net" + "syscall" +) + +// Listen binds ListenAddr with IP_FREEBIND, so the server is ready before +// NetworkManager has put 10.77.0.1 on eth0 (it only does when a cable is in), +// and keeps working across cable unplug and replug. +func Listen() (net.Listener, error) { + lc := net.ListenConfig{Control: func(_, _ string, c syscall.RawConn) error { + var serr error + err := c.Control(func(fd uintptr) { + serr = syscall.SetsockoptInt(int(fd), syscall.IPPROTO_IP, syscall.IP_FREEBIND, 1) + }) + if err != nil { + return err + } + return serr + }} + return lc.Listen(context.Background(), "tcp4", ListenAddr) +} diff --git a/internal/uploadserver/listen_other.go b/internal/uploadserver/listen_other.go new file mode 100644 index 0000000..885348f --- /dev/null +++ b/internal/uploadserver/listen_other.go @@ -0,0 +1,8 @@ +//go:build !linux + +package uploadserver + +import "net" + +// Listen binds ListenAddr. Only Linux can bind an address that is not up yet. +func Listen() (net.Listener, error) { return net.Listen("tcp4", ListenAddr) } diff --git a/internal/uploadserver/page.go b/internal/uploadserver/page.go new file mode 100644 index 0000000..fa8ab4c --- /dev/null +++ b/internal/uploadserver/page.go @@ -0,0 +1,64 @@ +package uploadserver + +// uploadPage is the whole browser client: nothing to install on the laptop. +// It is self-contained (no external requests) because the laptop is on a +// cable to a board with no internet. +const uploadPage = ` + + +MusallahBoard update + +

MusallahBoard update

Send signed update packages (.mbu) to this board.

+
+

This board

Status
loading…
+

Send updates

+
Drop .mbu files here, or click to choose
+
    +

    +

    Content packages come from LensBridge (Devices, the board, Download offline bundle). Board software packages come from the MusallahBoard releases. The board checks every signature itself and refuses anything not meant for it.

    +
    +` diff --git a/internal/uploadserver/server.go b/internal/uploadserver/server.go new file mode 100644 index 0000000..0e0a6ba --- /dev/null +++ b/internal/uploadserver/server.go @@ -0,0 +1,272 @@ +// Package uploadserver is the service-port upload server (docs/architecture.md, +// section 9.5): a laptop or phone plugged into the board's ethernet port opens +// http://10.77.0.1/ in a browser, or runs mbpush or the Android app, and +// uploads .mbu packages. +// +// There is no authentication, by design. Every package is signed, content is +// bound to this board and nothing installs backwards, so the most an +// anonymous uploader can do is make the board check a file and refuse it. +// Sizes and one-import-at-a-time bound that. What the server does guard +// against is a web page on the uploader's laptop reaching it through DNS +// rebinding: requests must name the board's own address in Host. +package uploadserver + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "mime" + "mime/multipart" + "net" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "time" + + "github.com/LensBridge/agent/internal/fsutil" + "github.com/LensBridge/agent/internal/importer" + "github.com/LensBridge/agent/internal/localserver" + "github.com/LensBridge/agent/internal/mbu" + "github.com/LensBridge/agent/internal/store" +) + +// ServiceIP is the board's address on the service port; setup.sh and the +// service-port CLI create it. +const ServiceIP = "10.77.0.1" + +// ListenAddr is where the server listens. +const ListenAddr = ServiceIP + ":80" + +const ( + maxParts = 8 + maxTotalBytes = 1 << 30 +) + +// ClockReport is what the clock keeper says about an uploader's time. +type ClockReport struct { + DriftSeconds int64 `json:"driftSeconds"` + Adjusted bool `json:"adjusted"` + Note string `json:"note"` +} + +// Deps is what the server needs. +type Deps struct { + Layout store.Layout + DeviceID string + AgentVersion string + Importer *importer.Importer + // ApplyClientTime is the clock keeper's decision on X-MB-Client-Time. + // May be nil. + ApplyClientTime func(clientUnix int64, b importer.Batch) ClockReport + RTCPresent func() bool + UpdateActive func() bool + Logger *slog.Logger + Now func() time.Time +} + +// Server is the upload server. +type Server struct{ d Deps } + +// New returns a Server. +func New(d Deps) *Server { + if d.Now == nil { + d.Now = time.Now + } + if d.Logger == nil { + d.Logger = slog.New(slog.DiscardHandler) + } + return &Server{d: d} +} + +// Serve accepts connections on ln until ctx ends. +func (s *Server) Serve(ctx context.Context, ln net.Listener) error { + srv := &http.Server{ + Handler: s, + ReadHeaderTimeout: 10 * time.Second, + IdleTimeout: 2 * time.Minute, + } + go func() { + <-ctx.Done() + sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(sctx) + }() + if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} + +var allowedHosts = map[string]bool{ + ServiceIP: true, ServiceIP + ":80": true, "musallahboard.local": true, "musallahboard.local:80": true, +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + if !allowedHosts[r.Host] { + writeJSON(w, http.StatusMisdirectedRequest, map[string]string{ + "message": "open this page as http://" + ServiceIP + "/", + }) + return + } + switch { + case r.URL.Path == "/" && (r.Method == http.MethodGet || r.Method == http.MethodHead): + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; form-action 'none'") + _, _ = io.WriteString(w, uploadPage) + case r.URL.Path == "/api/status" && r.Method == http.MethodGet: + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, s.status()) + case r.URL.Path == "/api/import" && r.Method == http.MethodPost: + s.handleImport(w, r) + default: + writeJSON(w, http.StatusNotFound, map[string]string{"message": "not found"}) + } +} + +// Status is GET /api/status. +type Status struct { + DeviceID string `json:"deviceId"` + AgentVersion string `json:"agentVersion"` + App *localserver.AppInfo `json:"app"` + Content *localserver.ContentInfo `json:"content"` + Today string `json:"today"` + DaysRemaining *int `json:"daysRemaining"` + StaleDays *int `json:"staleDays"` + Clock struct { + Unix int64 `json:"unix"` + Timezone string `json:"timezone"` + } `json:"clock"` + RTC bool `json:"rtc"` + Update localserver.UpdateInfo `json:"update"` +} + +func (s *Server) status() Status { + now := s.d.Now() + ls := localserver.BuildStatus(s.d.Layout, s.d.DeviceID, s.d.AgentVersion, now) + st := Status{DeviceID: ls.DeviceID, AgentVersion: ls.AgentVersion, App: ls.App, Content: ls.Content, + Today: ls.Today, DaysRemaining: ls.DaysRemaining, StaleDays: ls.StaleDays} + st.Clock.Unix = now.Unix() + st.Clock.Timezone, _ = now.Zone() + if s.d.RTCPresent != nil { + st.RTC = s.d.RTCPresent() + } + if s.d.UpdateActive != nil { + st.Update.Active = s.d.UpdateActive() + } + return st +} + +// ImportResponse is POST /api/import's body. +type ImportResponse struct { + Results []importer.Result `json:"results"` + Clock *ClockReport `json:"clock,omitempty"` + Message string `json:"message,omitempty"` +} + +var safeNameRE = regexp.MustCompile(`[^A-Za-z0-9._-]`) + +func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) { + // The uploader stamps its clock when it starts sending; receiving and + // installing can take minutes. Measure the offset now and apply it to the + // board's clock at the end, rather than the stale absolute time. + received := s.d.Now() + mt, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil || mt != "multipart/form-data" || params["boundary"] == "" { + writeJSON(w, http.StatusBadRequest, ImportResponse{Message: "send the packages as multipart/form-data, one 'package' part per file"}) + return + } + // Refuse early rather than receive a gigabyte and then say no. + if !s.idle() { + writeJSON(w, http.StatusConflict, ImportResponse{Message: "the board is already installing an update; try again in a minute"}) + return + } + inbox := s.d.Layout.Inbox() + if err := os.MkdirAll(inbox, 0o770); err != nil { + writeJSON(w, http.StatusInternalServerError, ImportResponse{Message: err.Error()}) + return + } + // A hidden directory, so the inbox runner does not pick the files up + // while they are still arriving. + dir, err := os.MkdirTemp(inbox, ".upload-") + if err != nil { + writeJSON(w, http.StatusInternalServerError, ImportResponse{Message: err.Error()}) + return + } + defer os.RemoveAll(dir) + + r.Body = http.MaxBytesReader(w, r.Body, maxTotalBytes+(1<<20)) + mr := multipart.NewReader(r.Body, params["boundary"]) + var files []string + for { + part, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + writeJSON(w, http.StatusBadRequest, ImportResponse{Message: "the upload was cut off or malformed: " + err.Error()}) + return + } + if part.FormName() != "package" { + part.Close() + continue + } + if len(files) == maxParts { + writeJSON(w, http.StatusRequestEntityTooLarge, ImportResponse{Message: fmt.Sprintf("at most %d packages per upload", maxParts)}) + return + } + name := safeNameRE.ReplaceAllString(filepath.Base(part.FileName()), "_") + if name == "" || name == "." || name[0] == '.' { + name = "package.mbu" + } + dst := filepath.Join(dir, fsutil.UniqueName(dir, name)) + n, err := fsutil.CopyFileSync(dst, part, mbu.MaxPackageBytes+1, 0o640) + part.Close() + if err != nil { + writeJSON(w, http.StatusBadRequest, ImportResponse{Message: "receiving " + name + " failed: " + err.Error()}) + return + } + if n > mbu.MaxPackageBytes { + writeJSON(w, http.StatusRequestEntityTooLarge, ImportResponse{Message: name + " is larger than the 512 MiB limit"}) + return + } + files = append(files, dst) + } + if len(files) == 0 { + writeJSON(w, http.StatusBadRequest, ImportResponse{Message: "no 'package' part in the upload"}) + return + } + + // The import outlives a client that gives up waiting: an install that + // has started should finish. + b, err := s.d.Importer.TryImport(context.WithoutCancel(r.Context()), importer.SourceUpload, files) + if errors.Is(err, importer.ErrBusy) { + writeJSON(w, http.StatusConflict, ImportResponse{Message: "the board is already installing an update; try again in a minute"}) + return + } + resp := ImportResponse{Results: b.Results} + if v := r.Header.Get("X-MB-Client-Time"); v != "" && s.d.ApplyClientTime != nil { + if unix, err := strconv.ParseInt(v, 10, 64); err == nil { + unix += int64(s.d.Now().Sub(received) / time.Second) + rep := s.d.ApplyClientTime(unix, b) + resp.Clock = &rep + } + } + writeJSON(w, http.StatusOK, resp) +} + +// idle is a cheap pre-check; TryImport is the authoritative one. +func (s *Server) idle() bool { return !s.d.Importer.Busy() } + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/internal/uploadserver/server_test.go b/internal/uploadserver/server_test.go new file mode 100644 index 0000000..f200fa2 --- /dev/null +++ b/internal/uploadserver/server_test.go @@ -0,0 +1,101 @@ +package uploadserver + +import ( + "bytes" + "crypto/ed25519" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + "github.com/LensBridge/agent/internal/importer" + "github.com/LensBridge/agent/internal/mbu" + "github.com/LensBridge/agent/internal/store" + "github.com/LensBridge/agent/internal/trust" +) + +var rk = ed25519.NewKeyFromSeed(bytes.Repeat([]byte{2}, 32)) + +func appPkg(t *testing.T, version string) []byte { + var buf bytes.Buffer + if err := mbu.Build(&buf, mbu.Manifest{Type: mbu.TypeApp, Version: version, App: &mbu.AppInfo{LocalAPI: 2}}, + []mbu.Source{{Path: "index.html", Data: []byte("x")}}, []ed25519.PrivateKey{rk}); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func server(t *testing.T) (*Server, *int64) { + l := store.Layout{Root: t.TempDir()} + ring := trust.NewRing(nil, []ed25519.PublicKey{rk.Public().(ed25519.PublicKey)}) + im := importer.New(importer.Deps{Layout: l, DeviceID: "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b", AgentVersion: "0.2.0", + Ring: func() (*trust.Ring, error) { return ring, nil }}) + var got int64 + return New(Deps{Layout: l, DeviceID: "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b", Importer: im, + ApplyClientTime: func(unix int64, b importer.Batch) ClockReport { + if b.Verified { + got = unix + } + return ClockReport{} + }}), &got +} + +func upload(t *testing.T, s *Server, host string, files map[string][]byte) (*httptest.ResponseRecorder, ImportResponse) { + var body bytes.Buffer + mw := multipart.NewWriter(&body) + for name, data := range files { + w, _ := mw.CreateFormFile("package", name) + w.Write(data) + } + mw.Close() + req := httptest.NewRequest(http.MethodPost, "/api/import", &body) + req.Host = host + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("X-MB-Client-Time", "1790000000") + w := httptest.NewRecorder() + s.ServeHTTP(w, req) + var r ImportResponse + json.Unmarshal(w.Body.Bytes(), &r) + return w, r +} + +func TestImport(t *testing.T) { + s, clock := server(t) + w, r := upload(t, s, ServiceIP, map[string][]byte{"../../etc/app.mbu": appPkg(t, "2.0.0")}) + if w.Code != 200 || len(r.Results) != 1 || r.Results[0].Action != importer.ActionInstalled { + t.Fatalf("%d %s", w.Code, w.Body) + } + if r.Results[0].File != "app.mbu" { + t.Errorf("upload name not sanitised: %q", r.Results[0].File) + } + if *clock != 1790000000 { + t.Errorf("client time not passed on after a verified import") + } +} + +func TestGarbageIsRefusedAndClockIgnored(t *testing.T) { + s, clock := server(t) + _, r := upload(t, s, ServiceIP, map[string][]byte{"x.mbu": []byte("not a zip")}) + if len(r.Results) != 1 || r.Results[0].Action != importer.ActionRejected { + t.Fatalf("%+v", r) + } + if *clock != 0 { + t.Error("clock applied without a verified package") + } +} + +func TestHostGuard(t *testing.T) { + s, _ := server(t) + w, _ := upload(t, s, "evil.example", map[string][]byte{"a.mbu": appPkg(t, "2.0.0")}) + if w.Code != http.StatusMisdirectedRequest { + t.Fatalf("rebinding host accepted: %d", w.Code) + } + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Host = ServiceIP + rec := httptest.NewRecorder() + s.ServeHTTP(rec, req) + if rec.Code != 200 || !bytes.Contains(rec.Body.Bytes(), []byte("MusallahBoard update")) { + t.Fatalf("upload page: %d", rec.Code) + } +} diff --git a/internal/usbimport/fs_unix.go b/internal/usbimport/fs_unix.go new file mode 100644 index 0000000..ac45e11 --- /dev/null +++ b/internal/usbimport/fs_unix.go @@ -0,0 +1,41 @@ +//go:build !windows + +package usbimport + +import ( + "fmt" + "os" + "syscall" +) + +// openRegularNoFollow opens path without following a final symlink and +// without blocking on a FIFO, then insists on a regular file. +func openRegularNoFollow(path string) (*os.File, error) { + fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK|syscall.O_CLOEXEC, 0) + if err != nil { + return nil, fmt.Errorf("open: %w", err) + } + f := os.NewFile(uintptr(fd), path) + fi, err := f.Stat() + if err != nil { + f.Close() + return nil, err + } + if !fi.Mode().IsRegular() { + f.Close() + return nil, fmt.Errorf("not a regular file") + } + return f, nil +} + +// chownToDirOwner gives path the owner and group of dir. Best effort: the +// helper runs as root, and the inbox belongs to the daemon's user. +func chownToDirOwner(path, dir string) { + fi, err := os.Stat(dir) + if err != nil { + return + } + if st, ok := fi.Sys().(*syscall.Stat_t); ok { + _ = os.Chown(path, int(st.Uid), int(st.Gid)) + } +} diff --git a/internal/usbimport/fs_windows.go b/internal/usbimport/fs_windows.go new file mode 100644 index 0000000..cc505b5 --- /dev/null +++ b/internal/usbimport/fs_windows.go @@ -0,0 +1,24 @@ +//go:build windows + +package usbimport + +import ( + "fmt" + "os" +) + +// The USB helper only runs on Linux; these keep the package building on a +// Windows dev box. + +func openRegularNoFollow(path string) (*os.File, error) { + fi, err := os.Lstat(path) + if err != nil { + return nil, err + } + if !fi.Mode().IsRegular() { + return nil, fmt.Errorf("not a regular file") + } + return os.Open(path) +} + +func chownToDirOwner(string, string) {} diff --git a/internal/usbimport/usbimport.go b/internal/usbimport/usbimport.go new file mode 100644 index 0000000..ff6f334 --- /dev/null +++ b/internal/usbimport/usbimport.go @@ -0,0 +1,397 @@ +// Package usbimport is the root helper that takes update packages off a USB +// stick (docs/architecture.md, section 9.6). +// +// udev starts `musallahboard-agent usb-import ` as root, with no +// network, when a USB block device with a filesystem appears. The helper +// treats the stick as hostile: it accepts only a few ordinary filesystems, +// mounts read-only with nosuid, nodev and noexec, follows no symlinks, and +// copies at most a handful of size-capped *.mbu files into the daemon's inbox. +// It verifies nothing itself. The daemon verifies every package exactly as it +// would from any other source, and the helper just waits for its results so +// they end up in the journal next to the insertion. +package usbimport + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "github.com/LensBridge/agent/internal/importer" + "github.com/LensBridge/agent/internal/mbu" + "github.com/LensBridge/agent/internal/store" +) + +const ( + // MaxFiles is the most packages taken from one stick. + MaxFiles = 16 + // DefaultMountRoot holds one mount point per device. + DefaultMountRoot = "/run/musallahboard/usb" + // WaitTimeout bounds how long the helper waits for the daemon. An agent + // update restarts the daemon, and a large content package takes a while + // on a Pi's SD card. + WaitTimeout = 15 * time.Minute + // FolderName is the optional folder on the stick, matched ignoring case. + FolderName = "MusallahBoard" + // MountOptions are fixed: nothing on the stick is executed, no device + // node or setuid bit on it means anything, and it is never written to. + MountOptions = "ro,nosuid,nodev,noexec,noatime" +) + +// NameRE is what a USB disk or partition's kernel name looks like. Anything +// else (an SD card, a loop device, a path) is refused before it reaches a +// command line. +var NameRE = regexp.MustCompile(`^sd[a-z]+[0-9]*$`) + +// Helper does one stick. Every outside effect is a field so tests can run it +// in a temp directory. +type Helper struct { + Layout store.Layout + MountRoot string + DevDir string + // Exec runs a command and returns its combined output. + Exec func(ctx context.Context, name string, args ...string) (string, error) + // NTFS3 reports whether the kernel's ntfs3 driver can be used. + NTFS3 func() bool + // Chown gives a copied file the inbox directory's owner, so the + // unprivileged daemon can read it and delete it once processed. + Chown func(path, dir string) + // Logf writes one plain line; journald captures stdout. + Logf func(format string, args ...any) + WaitTimeout time.Duration + Poll time.Duration +} + +// New returns a Helper with the production paths and commands. +func New() *Helper { + h := &Helper{ + Layout: store.Default(), + MountRoot: DefaultMountRoot, + DevDir: "/dev", + Exec: runCommand, + Chown: chownToDirOwner, + Logf: func(f string, a ...any) { fmt.Printf(f+"\n", a...) }, + WaitTimeout: WaitTimeout, + Poll: time.Second, + } + h.NTFS3 = h.ntfs3Available + return h +} + +// ErrUnsupported means the device was left alone on purpose (unknown +// filesystem). It is not a failure worth a failed unit. +var ErrUnsupported = errors.New("not a supported USB filesystem") + +// Run imports from the device with kernel name name. +func (h *Helper) Run(ctx context.Context, name string) error { + if !NameRE.MatchString(name) { + return fmt.Errorf("%q is not a USB disk name (want sdX or sdXN)", name) + } + dev := filepath.Join(h.DevDir, name) + fsType, err := h.Exec(ctx, "blkid", "-o", "value", "-s", "TYPE", dev) + fsType = strings.TrimSpace(fsType) + if err != nil || fsType == "" { + h.Logf("%s: no filesystem found (%v); ignoring it", name, err) + return ErrUnsupported + } + mountType, ok := h.mountType(fsType) + if !ok { + h.Logf("%s: filesystem %q is not accepted (use FAT32, exFAT, NTFS or ext4); ignoring it", name, fsType) + return ErrUnsupported + } + + mp := filepath.Join(h.MountRoot, name) + if err := os.MkdirAll(mp, 0o700); err != nil { + return fmt.Errorf("cannot create mount point %s: %w", mp, err) + } + if out, err := h.Exec(ctx, "mount", "-t", mountType, "-o", MountOptions, dev, mp); err != nil { + os.Remove(mp) + return fmt.Errorf("could not mount %s (%s): %v %s", name, fsType, err, strings.TrimSpace(out)) + } + h.Logf("%s: mounted read-only (%s)", name, fsType) + + files, notes := Scan(mp) + for _, n := range notes { + h.Logf("%s: %s", name, n) + } + var queued []queuedFile + if len(files) == 0 { + h.Logf("%s: no .mbu packages found (put them at the top of the stick or in a %s folder)", name, FolderName) + } else { + queued = h.copyAll(name, files) + } + // Unmount before waiting, so the stick can be pulled as soon as the copy + // is done, whatever the daemon then makes of it. + h.unmount(ctx, name, mp) + + if len(queued) == 0 { + return nil + } + h.Logf("%s: copied %d package(s); waiting for the board to check and install them", name, len(queued)) + h.wait(ctx, queued) + return nil +} + +// mountType maps blkid's type to the mount -t value, and whether it is +// accepted at all. NTFS goes through the kernel's ntfs3 driver when there is +// one; the FUSE ntfs-3g driver is not used, it runs a userspace process over +// the stick's contents. +func (h *Helper) mountType(fsType string) (string, bool) { + switch fsType { + case "vfat", "exfat", "ext4", "ntfs3": + return fsType, true + case "ntfs": + if h.NTFS3 != nil && h.NTFS3() { + return "ntfs3", true + } + } + return "", false +} + +func (h *Helper) ntfs3Available() bool { + if b, err := os.ReadFile("/proc/filesystems"); err == nil && hasFS(string(b), "ntfs3") { + return true + } + _, err := h.Exec(context.Background(), "modprobe", "ntfs3") + return err == nil +} + +func hasFS(procFilesystems, name string) bool { + for _, line := range strings.Split(procFilesystems, "\n") { + f := strings.Fields(line) + if len(f) > 0 && f[len(f)-1] == name { + return true + } + } + return false +} + +func (h *Helper) unmount(ctx context.Context, name, mp string) { + if _, err := h.Exec(ctx, "umount", mp); err != nil { + // Something still holds it (it should not); detach it anyway so a + // pulled stick leaves nothing behind. + if out, err := h.Exec(ctx, "umount", "-l", mp); err != nil { + h.Logf("%s: could not unmount %s: %v %s", name, mp, err, strings.TrimSpace(out)) + return + } + } + os.Remove(mp) + h.Logf("%s: unmounted; the stick can be removed once the screen says the update is complete", name) +} + +// Candidate is a package file found on the stick. +type Candidate struct { + Path string + Size int64 +} + +// Scan lists the packages to take from the stick mounted at root: regular +// files (never symlinks) named *.mbu in any case, at the top level and in a +// top-level folder named MusallahBoard in any case, at most MaxFiles, each at +// most mbu.MaxPackageBytes. notes explains anything skipped. +func Scan(root string) (files []Candidate, notes []string) { + dirs := []string{root} + if entries, err := os.ReadDir(root); err == nil { + for _, e := range entries { + if strings.EqualFold(e.Name(), FolderName) && e.Type().IsDir() { + dirs = append(dirs, filepath.Join(root, e.Name())) + } + } + } + var all []Candidate + for _, d := range dirs { + entries, err := os.ReadDir(d) + if err != nil { + notes = append(notes, fmt.Sprintf("cannot read %s: %v", rel(root, d), err)) + continue + } + for _, e := range entries { + n := e.Name() + // "._name.mbu" is macOS metadata written beside every file on + // a FAT stick; it is not a package. + if strings.HasPrefix(n, ".") || !strings.HasSuffix(strings.ToLower(n), ".mbu") { + continue + } + p := filepath.Join(d, n) + fi, err := os.Lstat(p) + if err != nil { + notes = append(notes, fmt.Sprintf("cannot read %s: %v", rel(root, p), err)) + continue + } + if !fi.Mode().IsRegular() { + notes = append(notes, fmt.Sprintf("skipping %s: not a regular file", rel(root, p))) + continue + } + if fi.Size() > mbu.MaxPackageBytes { + notes = append(notes, fmt.Sprintf("skipping %s: %d MiB is larger than the %d MiB a package may be", + rel(root, p), fi.Size()>>20, mbu.MaxPackageBytes>>20)) + continue + } + all = append(all, Candidate{Path: p, Size: fi.Size()}) + } + } + sort.Slice(all, func(i, j int) bool { return all[i].Path < all[j].Path }) + if len(all) > MaxFiles { + for _, c := range all[MaxFiles:] { + notes = append(notes, fmt.Sprintf("skipping %s: at most %d packages are taken from one stick", rel(root, c.Path), MaxFiles)) + } + all = all[:MaxFiles] + } + return all, notes +} + +func rel(root, p string) string { + if r, err := filepath.Rel(root, p); err == nil { + return r + } + return p +} + +type queuedFile struct { + orig string // name on the stick, for the log + inbox string // base name in the inbox +} + +// copyAll copies every candidate to a .part name first, and only renames +// them to .mbu once all are copied, so the daemon's next inbox scan sees the +// whole stick as one batch (one update screen, agent first). +func (h *Helper) copyAll(dev string, files []Candidate) []queuedFile { + inbox := h.Layout.Inbox() + if err := os.MkdirAll(inbox, 0o770); err != nil { + h.Logf("%s: cannot use the inbox %s: %v", dev, inbox, err) + return nil + } + type part struct { + q queuedFile + path string + } + var parts []part + for i, c := range files { + base := inboxName(dev, i+1, filepath.Base(c.Path)) + // A result left from an earlier insertion of the same stick would + // otherwise be read as this one's. + os.Remove(filepath.Join(h.Layout.InboxResults(), base+".json")) + tmp := filepath.Join(inbox, strings.TrimSuffix(base, ".mbu")+".part") + if err := copyCapped(c.Path, tmp); err != nil { + os.Remove(tmp) + h.Logf("%s: could not copy %s: %v", dev, filepath.Base(c.Path), err) + continue + } + if h.Chown != nil { + h.Chown(tmp, inbox) + } + parts = append(parts, part{queuedFile{orig: filepath.Base(c.Path), inbox: base}, tmp}) + } + var out []queuedFile + for _, p := range parts { + if err := os.Rename(p.path, filepath.Join(inbox, p.q.inbox)); err != nil { + os.Remove(p.path) + h.Logf("%s: could not queue %s: %v", dev, p.q.orig, err) + continue + } + out = append(out, p.q) + } + return out +} + +// inboxName is usb---.mbu. The "usb-" prefix is how the inbox +// runner knows the source; the stem is reduced to safe characters because it +// came off a stick. +func inboxName(dev string, n int, orig string) string { + stem := orig[:len(orig)-len(filepath.Ext(orig))] + var b strings.Builder + for _, c := range stem { + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '.', c == '-', c == '_': + b.WriteRune(c) + default: + b.WriteByte('_') + } + } + s := strings.TrimLeft(b.String(), ".") + if len(s) > 100 { + s = s[:100] + } + if s == "" { + s = "package" + } + return fmt.Sprintf("usb-%s-%d-%s.mbu", dev, n, s) +} + +// copyCapped copies src (on the stick) to a new file dst, refusing a symlink +// swapped in after the scan and anything over the package size limit. +func copyCapped(src, dst string) error { + in, err := openRegularNoFollow(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640) + if err != nil { + return err + } + n, err := io.Copy(out, io.LimitReader(in, mbu.MaxPackageBytes+1)) + if err == nil && n > mbu.MaxPackageBytes { + err = fmt.Errorf("larger than %d MiB", mbu.MaxPackageBytes>>20) + } + if err == nil { + err = out.Sync() + } + if cerr := out.Close(); err == nil { + err = cerr + } + return err +} + +// wait logs each queued file's result as the daemon writes it. +func (h *Helper) wait(ctx context.Context, queued []queuedFile) { + ctx, cancel := context.WithTimeout(ctx, h.WaitTimeout) + defer cancel() + pending := append([]queuedFile(nil), queued...) + for len(pending) > 0 { + var still []queuedFile + for _, q := range pending { + raw, err := os.ReadFile(filepath.Join(h.Layout.InboxResults(), q.inbox+".json")) + if err != nil { + still = append(still, q) + continue + } + var r importer.InboxResult + if err := json.Unmarshal(raw, &r); err != nil { + // Written atomically, so this is a genuinely bad file. + h.Logf("%s: unreadable result: %v", q.orig, err) + continue + } + h.Logf("%s: %s: %s", q.orig, r.Action, r.Message) + } + pending = still + if len(pending) == 0 { + break + } + select { + case <-ctx.Done(): + for _, q := range pending { + h.Logf("%s: no result after %s; the board may still be working on it (see `journalctl -u musallahboard-agent`)", + q.orig, h.WaitTimeout) + } + return + case <-time.After(h.Poll): + } + } + h.Logf("all packages from the stick have been handled") +} + +func runCommand(ctx context.Context, name string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + out, err := exec.CommandContext(ctx, name, args...).CombinedOutput() + return string(out), err +} diff --git a/internal/usbimport/usbimport_test.go b/internal/usbimport/usbimport_test.go new file mode 100644 index 0000000..cb8b696 --- /dev/null +++ b/internal/usbimport/usbimport_test.go @@ -0,0 +1,286 @@ +package usbimport + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/LensBridge/agent/internal/importer" + "github.com/LensBridge/agent/internal/store" +) + +func touch(t *testing.T, p string, size int) { + t.Helper() + os.MkdirAll(filepath.Dir(p), 0o755) + if err := os.WriteFile(p, make([]byte, size), 0o644); err != nil { + t.Fatal(err) + } +} + +func names(c []Candidate, root string) []string { + var out []string + for _, x := range c { + out = append(out, rel(root, x.Path)) + } + return out +} + +func TestScan(t *testing.T) { + root := t.TempDir() + touch(t, filepath.Join(root, "a.mbu"), 10) + touch(t, filepath.Join(root, "B.MBU"), 10) + touch(t, filepath.Join(root, "notes.txt"), 10) + touch(t, filepath.Join(root, "._a.mbu"), 10) + touch(t, filepath.Join(root, "musallahBOARD", "c.mbu"), 10) + touch(t, filepath.Join(root, "musallahBOARD", "deeper", "d.mbu"), 10) + touch(t, filepath.Join(root, "other", "e.mbu"), 10) + os.Symlink(filepath.Join(root, "a.mbu"), filepath.Join(root, "link.mbu")) + os.Mkdir(filepath.Join(root, "dir.mbu"), 0o755) + + files, notes := Scan(root) + got := strings.Join(names(files, root), ",") + if got != "B.MBU,a.mbu,musallahBOARD/c.mbu" { + t.Fatalf("files = %s", got) + } + joined := strings.Join(notes, "\n") + if !strings.Contains(joined, "link.mbu: not a regular file") || !strings.Contains(joined, "dir.mbu: not a regular file") { + t.Errorf("notes = %q", notes) + } +} + +func TestScanSymlinkedFolderIgnored(t *testing.T) { + root := t.TempDir() + elsewhere := t.TempDir() + touch(t, filepath.Join(elsewhere, "x.mbu"), 1) + os.Symlink(elsewhere, filepath.Join(root, "MusallahBoard")) + if files, _ := Scan(root); len(files) != 0 { + t.Fatalf("followed a symlinked folder: %v", files) + } +} + +func TestScanLimits(t *testing.T) { + root := t.TempDir() + for i := range MaxFiles + 3 { + touch(t, filepath.Join(root, fmt.Sprintf("p%02d.mbu", i)), 1) + } + files, notes := Scan(root) + if len(files) != MaxFiles { + t.Fatalf("%d files, want %d", len(files), MaxFiles) + } + if len(notes) != 3 { + t.Fatalf("notes = %q", notes) + } + + // A file over the package limit is skipped without reading it (sparse). + root = t.TempDir() + big := filepath.Join(root, "big.mbu") + f, _ := os.Create(big) + f.Truncate(512<<20 + 1) + f.Close() + files, notes = Scan(root) + if len(files) != 0 || len(notes) != 1 || !strings.Contains(notes[0], "larger than") { + t.Fatalf("files=%v notes=%q", files, notes) + } +} + +func TestInboxName(t *testing.T) { + for in, want := range map[string]string{ + "musallahboard-app-2.1.0.mbu": "usb-sda1-1-musallahboard-app-2.1.0.mbu", + "My Board (copy).MBU": "usb-sda1-1-My_Board__copy_.mbu", + "...mbu": "usb-sda1-1-package.mbu", + } { + if got := inboxName("sda1", 1, in); got != want { + t.Errorf("inboxName(%q) = %q, want %q", in, got, want) + } + } +} + +type fakeSys struct { + mu sync.Mutex + cmds []string + fs string + stick map[string]int // files the fake mount puts on the stick + failU bool +} + +func (f *fakeSys) exec(ctx context.Context, name string, args ...string) (string, error) { + f.mu.Lock() + f.cmds = append(f.cmds, name+" "+strings.Join(args, " ")) + f.mu.Unlock() + switch name { + case "blkid": + if f.fs == "" { + return "", errors.New("exit status 2") + } + return f.fs + "\n", nil + case "mount": + mp := args[len(args)-1] + for p, size := range f.stick { + full := filepath.Join(mp, p) + os.MkdirAll(filepath.Dir(full), 0o755) + os.WriteFile(full, make([]byte, size), 0o644) + } + return "", nil + case "umount": + if f.failU && args[0] != "-l" { + return "busy", errors.New("exit status 32") + } + // Unmounted: the mount point is an empty directory again. + mp := args[len(args)-1] + entries, _ := os.ReadDir(mp) + for _, e := range entries { + os.RemoveAll(filepath.Join(mp, e.Name())) + } + return "", nil + } + return "", fmt.Errorf("unexpected command %s", name) +} + +func newHelper(t *testing.T, f *fakeSys) (*Helper, *[]string) { + dir := t.TempDir() + var log []string + var mu sync.Mutex + h := &Helper{ + Layout: store.Layout{Root: filepath.Join(dir, "state")}, + MountRoot: filepath.Join(dir, "usb"), + DevDir: "/dev", + Exec: f.exec, + NTFS3: func() bool { return false }, + Logf: func(format string, a ...any) { mu.Lock(); log = append(log, fmt.Sprintf(format, a...)); mu.Unlock() }, + WaitTimeout: 2 * time.Second, + Poll: 10 * time.Millisecond, + } + return h, &log +} + +// fakeDaemon answers every .mbu in the inbox, as RunInbox would. +func fakeDaemon(ctx context.Context, l store.Layout) { + for ctx.Err() == nil { + entries, _ := os.ReadDir(l.Inbox()) + for _, e := range entries { + if !strings.HasSuffix(e.Name(), ".mbu") { + continue + } + os.MkdirAll(l.InboxResults(), 0o770) + raw, _ := json.Marshal(importer.InboxResult{Result: importer.Result{File: e.Name(), Action: "installed", Message: "Installed " + e.Name()}}) + os.WriteFile(filepath.Join(l.InboxResults(), e.Name()+".json"), raw, 0o640) + os.Remove(filepath.Join(l.Inbox(), e.Name())) + } + time.Sleep(5 * time.Millisecond) + } +} + +func TestRunCopiesAndWaits(t *testing.T) { + f := &fakeSys{fs: "vfat", stick: map[string]int{"a.mbu": 5, "MusallahBoard/b.mbu": 7, "readme.txt": 1}} + h, log := newHelper(t, f) + // A stale result from an earlier insertion must not be taken as this one's. + os.MkdirAll(h.Layout.InboxResults(), 0o770) + os.WriteFile(filepath.Join(h.Layout.InboxResults(), "usb-sdb1-1-MusallahBoard_b.mbu.json"), []byte(`{"action":"rejected"}`), 0o640) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go fakeDaemon(ctx, h.Layout) + if err := h.Run(context.Background(), "sdb1"); err != nil { + t.Fatal(err) + } + all := strings.Join(*log, "\n") + for _, want := range []string{"a.mbu: installed", "b.mbu: installed", "all packages from the stick have been handled"} { + if !strings.Contains(all, want) { + t.Errorf("log lacks %q:\n%s", want, all) + } + } + if strings.Contains(all, "rejected") { + t.Errorf("stale result was reported:\n%s", all) + } + cmds := strings.Join(f.cmds, "\n") + if !strings.Contains(cmds, "mount -t vfat -o ro,nosuid,nodev,noexec,noatime /dev/sdb1 "+filepath.Join(h.MountRoot, "sdb1")) { + t.Errorf("mount command wrong:\n%s", cmds) + } + if !strings.Contains(cmds, "umount "+filepath.Join(h.MountRoot, "sdb1")) { + t.Errorf("not unmounted:\n%s", cmds) + } + if _, err := os.Stat(filepath.Join(h.MountRoot, "sdb1")); err == nil { + t.Error("mount point left behind") + } +} + +func TestRunLazyUnmountFallback(t *testing.T) { + f := &fakeSys{fs: "exfat", stick: map[string]int{}, failU: true} + h, _ := newHelper(t, f) + if err := h.Run(context.Background(), "sdc"); err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(f.cmds, "\n"), "umount -l") { + t.Fatalf("no lazy unmount: %q", f.cmds) + } +} + +func TestRunTimesOutWithoutDaemon(t *testing.T) { + f := &fakeSys{fs: "ext4", stick: map[string]int{"a.mbu": 1}} + h, log := newHelper(t, f) + h.WaitTimeout = 50 * time.Millisecond + if err := h.Run(context.Background(), "sda"); err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(*log, "\n"), "a.mbu: no result after") { + t.Fatalf("log = %q", *log) + } + // The file was queued under a .mbu name, not left as .part. + entries, _ := os.ReadDir(h.Layout.Inbox()) + if len(entries) != 1 || entries[0].Name() != "usb-sda-1-a.mbu" { + t.Fatalf("inbox = %v", entries) + } +} + +func TestRunRefusals(t *testing.T) { + cases := []struct { + name, dev, fs string + ntfs3 bool + unsupported bool + mounted string + }{ + {"bad name", "mmcblk0p1", "vfat", false, false, ""}, + {"path", "../sda1", "vfat", false, false, ""}, + {"no filesystem", "sda1", "", false, true, ""}, + {"iso", "sda1", "iso9660", false, true, ""}, + {"ntfs without ntfs3", "sda1", "ntfs", false, true, ""}, + {"ntfs with ntfs3", "sda1", "ntfs", true, false, "-t ntfs3"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := &fakeSys{fs: tc.fs, stick: map[string]int{}} + h, _ := newHelper(t, f) + h.NTFS3 = func() bool { return tc.ntfs3 } + err := h.Run(context.Background(), tc.dev) + cmds := strings.Join(f.cmds, "\n") + switch { + case tc.mounted != "": + if err != nil || !strings.Contains(cmds, tc.mounted) { + t.Fatalf("err=%v cmds=%s", err, cmds) + } + case tc.unsupported: + if !errors.Is(err, ErrUnsupported) || strings.Contains(cmds, "mount ") { + t.Fatalf("err=%v cmds=%s", err, cmds) + } + default: + if err == nil || len(f.cmds) != 0 { + t.Fatalf("err=%v cmds=%s", err, cmds) + } + } + }) + } +} + +func TestHasFS(t *testing.T) { + proc := "nodev\tsysfs\n\text4\n\tvfat\n\tntfs3\n" + if !hasFS(proc, "ntfs3") || hasFS(proc, "exfat") { + t.Fatal("hasFS") + } +} diff --git a/internal/wsclient/wsclient.go b/internal/wsclient/wsclient.go index 15789bd..96e061d 100644 --- a/internal/wsclient/wsclient.go +++ b/internal/wsclient/wsclient.go @@ -204,7 +204,7 @@ func (c *Client) runOnce(ctx context.Context) (authed bool, err error) { // server's clock would defeat the replay protection the timestamp exists for. func (c *Client) warnOnClockSkew(serverTimeMs, localTimeMs int64) { if serverTimeMs == 0 { - return // older backend, or a hello without serverTime + return // a hello without serverTime } skew := time.Duration(localTimeMs-serverTimeMs) * time.Millisecond if skew < 0 { diff --git a/packaging/install.sh b/packaging/install.sh index 4cc8783..4105bc5 100644 --- a/packaging/install.sh +++ b/packaging/install.sh @@ -13,7 +13,9 @@ # # - switching the default boot target / disabling display managers # → packaging/appliance-policy.sh -# - writing /etc/musallahboard/board-url → provisioner or firstboot +# → provisioner or firstboot +# - the ethernet service port (NetworkManager profiles, ufw rules) +# → setup.sh --service-port # - enrollment → firstboot, or by hand # - hostname, SSH, UFW, watchdog, timezone, LUKS → common.sh / setup.sh # @@ -82,6 +84,16 @@ KIOSK_WATCH_DEST=${UNIT_DIR}/musallahboard-kiosk-watch.path KIOSK_RELOAD_DEST=${UNIT_DIR}/musallahboard-kiosk-reload.service KIOSK_LAUNCHER_DEST=/usr/bin/start-kiosk.sh KIOSK_SHARE_DIR=/usr/share/musallahboard +# The root self-updater, the USB import helper and the udev rules that feed +# them (docs/architecture.md, sections 9.6 and 12). +UPDATE_PATH_DEST=${UNIT_DIR}/musallahboard-agent-update.path +UPDATE_SERVICE_DEST=${UNIT_DIR}/musallahboard-agent-update.service +USB_SERVICE_DEST=${UNIT_DIR}/musallahboard-usb-import@.service +UDEV_DIR=/etc/udev/rules.d +UDEV_RULES=(90-musallahboard-usb.rules 90-musallahboard-rtc.rules) +INBOX_DIR=${STATE_DIR}/inbox +LIB_DIR=/usr/lib/musallahboard +TRUST_PATH=${CONFIG_DIR}/trust.json # ── Arg parsing ─────────────────────────────────────────────────────────────── SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -97,14 +109,6 @@ while [[ $# -gt 0 ]]; do --backend) BACKEND_URL="$2"; shift 2 ;; --backend=*) BACKEND_URL="${1#--backend=}"; shift ;; --kiosk) WANT_KIOSK=yes; shift ;; - # Accepted only to fail loudly: the account is fixed now, and silently - # ignoring a name here would install a kiosk running as the wrong user. - --kiosk-user|--kiosk-user=*) - error "--kiosk-user is gone; the display account is always '$KIOSK_USER'. Use --kiosk." ;; - # Per-device state, not payload. The provisioner writes board-url - # (common.sh), or firstboot does from musallahboard.conf. - --board-url|--board-url=*) - error "--board-url is gone; write /etc/musallahboard/board-url from the provisioner or musallahboard.conf." ;; --*) error "Unknown flag: $1" ;; *) [[ -n "$AGENT_DIR" ]] && error "Unexpected argument: $1" @@ -153,9 +157,17 @@ done SUDOERS_SRC="${AGENT_DIR}/packaging/sudoers.d/musallahboard-agent" SERVICE_SRC="${AGENT_DIR}/packaging/musallahboard-agent.service" +UPDATE_PATH_SRC="${AGENT_DIR}/packaging/musallahboard-agent-update.path" +UPDATE_SERVICE_SRC="${AGENT_DIR}/packaging/musallahboard-agent-update.service" +USB_SERVICE_SRC="${AGENT_DIR}/packaging/musallahboard-usb-import@.service" +UDEV_SRC_DIR="${AGENT_DIR}/packaging/udev" -[[ -f "$SUDOERS_SRC" ]] || error "Missing: $SUDOERS_SRC" -[[ -f "$SERVICE_SRC" ]] || error "Missing: $SERVICE_SRC" +for f in "$SUDOERS_SRC" "$SERVICE_SRC" "$UPDATE_PATH_SRC" "$UPDATE_SERVICE_SRC" "$USB_SERVICE_SRC"; do + [[ -f "$f" ]] || error "Missing: $f" +done +for r in "${UDEV_RULES[@]}"; do + [[ -f "$UDEV_SRC_DIR/$r" ]] || error "Missing: $UDEV_SRC_DIR/$r" +done info "Agent directory : $AGENT_DIR" info "Binary : $BINARY (arch: $ARCH_SUFFIX)" @@ -210,12 +222,45 @@ mkdir -p "$CONFIG_DIR" # rather than root. chown "$SERVICE_USER:$SERVICE_USER" "$CONFIG_DIR" # 0751 (not 0750): the unprivileged kiosk user must traverse this dir to read -# the agent-written board-url / kiosk-url files. 0751 grants traverse-by-path +# the agent-written kiosk-url file. 0751 grants traverse-by-path # without directory listing; agent.key stays 0600 so it is unreadable anyway. chmod 0751 "$CONFIG_DIR" info "Created $CONFIG_DIR ($SERVICE_USER 0751)" -info "$STATE_DIR + $LOG_DIR: created by systemd at first start" + +# The state dir is also StateDirectory= in the unit, but the inbox inside it is +# written by root tools (the USB helper, `musallahboard-agent import`) before +# the daemon may ever have started, so both are created here. The owner must +# match User= exactly: systemd recursively chowns a StateDirectory= whose top +# directory has the wrong owner, which would also hand the daemon the +# root-owned agent/last-update.json. +install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0750 "$STATE_DIR" +install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0770 "$INBOX_DIR" +info "Created $INBOX_DIR ($SERVICE_USER 0770)" +info "$LOG_DIR: created by systemd at first start" + +# Where the root self-updater keeps the previous agent binary for rollback. +install -d -o root -g root -m 0755 "$LIB_DIR" +info "Created $LIB_DIR (root 0755)" + +# ── Trust store ─────────────────────────────────────────────────────────────── +section "Trust store" + +# Root-owned and world-readable: the daemon reads it, only root changes it +# (`musallahboard-agent trust ...`, enrollment, the self-updater). Never +# overwritten: it holds the content keys pinned at enrollment, and release +# keys an admin may have added. An empty store is fine; release keys are also +# compiled into the agent, and content keys arrive with enrollment or +# `trust fetch`. +if [[ -e "$TRUST_PATH" ]]; then + info "Keeping existing $TRUST_PATH" +else + printf '{"content":[],"release":[]}\n' > "$TRUST_PATH.tmp" + chown root:root "$TRUST_PATH.tmp" + chmod 0644 "$TRUST_PATH.tmp" + mv -f "$TRUST_PATH.tmp" "$TRUST_PATH" + info "Created $TRUST_PATH (root 0644, no keys yet)" +fi # ── Binary ──────────────────────────────────────────────────────────────────── section "Binary" @@ -238,9 +283,35 @@ info "Installed $SUDOERS_DEST" section "Systemd service" install -o root -g root -m 0644 "$SERVICE_SRC" "$SERVICE_DEST" + +# Root self-updater (docs/architecture.md section 12): the .path unit watches +# for agent/staged/ready and starts the oneshot. Only the .path is enabled. +install -o root -g root -m 0644 "$UPDATE_PATH_SRC" "$UPDATE_PATH_DEST" +install -o root -g root -m 0644 "$UPDATE_SERVICE_SRC" "$UPDATE_SERVICE_DEST" +# USB import helper (section 9.6): a template started by udev, never enabled. +install -o root -g root -m 0644 "$USB_SERVICE_SRC" "$USB_SERVICE_DEST" + systemd_running && systemctl daemon-reload systemctl enable musallahboard-agent.service -info "Service installed and enabled" +systemctl enable musallahboard-agent-update.path +info "Service, self-updater and USB import units installed and enabled" + +# ── udev rules ──────────────────────────────────────────────────────────────── +section "udev rules" + +# USB sticks -> musallahboard-usb-import@.service, and rtc0 writable by +# the agent's group so it can save a corrected clock to the RTC. +mkdir -p "$UDEV_DIR" +for r in "${UDEV_RULES[@]}"; do + install -o root -g root -m 0644 "$UDEV_SRC_DIR/$r" "$UDEV_DIR/$r" +done +if systemd_running && command -v udevadm >/dev/null; then + udevadm control --reload || true + # Apply the rtc0 permissions now rather than at the next boot. Not a + # block-device trigger: that would import from a stick already plugged in. + udevadm trigger --subsystem-match=rtc --action=change || true +fi +info "Installed ${UDEV_RULES[*]} in $UDEV_DIR" # ── Kiosk (cage + Chromium) ─────────────────────────────────────────────────── if [[ "$WANT_KIOSK" == "yes" ]]; then @@ -334,15 +405,22 @@ if [[ -n "$ENROLL_TOKEN" ]]; then warn "Config already exists at $CONFIG_PATH — skipping enrollment." warn "Delete it and re-run with --token to re-enroll." else - # Run enroll as the service user so files end up owned by it directly. - # (The agent's enroll path also chowns to the parent dir's owner as a - # belt-and-suspenders measure when run via `sudo`.) - sudo -u "$SERVICE_USER" "$BINARY_DEST" enroll \ + # Run as root: enrollment also pins the backend's content signing keys + # in the root-owned trust.json. agent.toml and agent.key still end up + # owned by $SERVICE_USER, because enroll chowns the files it creates to + # the owner of the config directory. + "$BINARY_DEST" enroll \ --token="$ENROLL_TOKEN" \ --backend="$BACKEND_URL" \ --config="$CONFIG_PATH" info "Device enrolled. Config: $CONFIG_PATH" fi + + # Whatever wrote it, trust.json is root's. + if [[ -f "$TRUST_PATH" ]]; then + chown root:root "$TRUST_PATH" + chmod 0644 "$TRUST_PATH" + fi fi # ── Start the service ───────────────────────────────────────────────────────── @@ -362,6 +440,8 @@ else sleep 1 systemctl status musallahboard-agent.service --no-pager --lines=5 + systemctl start musallahboard-agent-update.path + # Kiosk shows the splash immediately and the .path watcher swaps it for # the board when the agent writes kiosk-url — safe to start either way. if [[ "$WANT_KIOSK" == "yes" ]]; then @@ -387,10 +467,15 @@ cat << EOF Config : $CONFIG_PATH State : $STATE_DIR Service : musallahboard-agent.service + Trust : $TRUST_PATH + Updates : USB stick, upload on the service port, or the release channel + (docs/architecture.md) Useful commands: sudo systemctl status musallahboard-agent sudo journalctl -u musallahboard-agent -f sudo musallahboard-agent enroll --token=X --backend=Y + sudo musallahboard-agent status + sudo musallahboard-agent import EOF diff --git a/packaging/lib/common.sh b/packaging/lib/common.sh index b5302cc..949dfba 100644 --- a/packaging/lib/common.sh +++ b/packaging/lib/common.sh @@ -53,7 +53,7 @@ NTP_SYNC_TIMEOUT_SEC="${NTP_SYNC_TIMEOUT_SEC:-90}" # person — a bare `read` there consumes the rest of the script instead of an # answer, and does it silently. # -# MB_HOSTNAME MB_ADMIN_USER MB_BOARD_URL MB_TIMEZONE +# MB_HOSTNAME MB_ADMIN_USER MB_TIMEZONE # MB_ADMIN_SSH_KEY # MB_ASSUME_YES=1 take the default for anything unset, confirm nothing # MB_REBOOT=auto|never|ask @@ -61,9 +61,8 @@ NTP_SYNC_TIMEOUT_SEC="${NTP_SYNC_TIMEOUT_SEC:-90}" # Anything left unset is still asked for interactively, so `bash setup.sh` by # hand behaves exactly as it always has. # -# MB_KIOSK_USER is gone: the display account is fixed at `musallahkiosk` -# because musallahboard-kiosk.service names it in User= and ExecStopPost, and -# a .deb ships that unit as a static file. +# The display account is fixed at `musallahkiosk`: musallahboard-kiosk.service +# names it in User= and ExecStopPost, and a .deb ships that unit as a static file. MB_ASSUME_YES="${MB_ASSUME_YES:-0}" MB_REBOOT="${MB_REBOOT:-ask}" @@ -147,12 +146,13 @@ _prompt_config() { # an answer somebody supplied and never ask the question. HOSTNAME="${MB_HOSTNAME-}" ADMIN_USER="${MB_ADMIN_USER-}" - KIOSK_URL="${MB_BOARD_URL-}" TIMEZONE="${MB_TIMEZONE-}" + # The kiosk shows the board served by the local agent + # (docs/architecture.md section 14), so there is no URL to ask for. + KIOSK_URL="http://127.0.0.1:8080/" _ask HOSTNAME "Hostname for this board" "musallahboard" _ask ADMIN_USER "Admin username (SSH/sudo)" "ibra" - _ask KIOSK_URL "Kiosk URL" "https://board.lensbridge.tech" _ask TIMEZONE "Timezone" "America/Toronto" # Three accounts, three trust levels, no overlap: @@ -199,7 +199,7 @@ _prompt_config() { printf " %-18s %s\n" "Hostname:" "$HOSTNAME" printf " %-18s %s\n" "Admin user:" "$ADMIN_USER (SSH key auth, passwordless sudo)" printf " %-18s %s\n" "Kiosk user:" "$KIOSK_USER (auto-login, browser only, no sudo/SSH/shell)" - printf " %-18s %s\n" "Kiosk URL:" "$KIOSK_URL" + printf " %-18s %s\n" "Kiosk URL:" "$KIOSK_URL (served by the agent)" printf " %-18s %s\n" "Timezone:" "$TIMEZONE" [[ -z "$SSH_PUB_KEY" ]] && warn " No SSH key — password auth stays enabled; harden after testing." echo @@ -330,15 +330,6 @@ EOF sudo rm -f /etc/musallahboard/kiosk.env info "Bare-metal host — keeping hardware GL (no kiosk.env)" fi - - # Persist the board base URL. This is the only writer on the setup.sh path - # — install.sh no longer takes --board-url, because a base URL is per-device - # state rather than package payload. Without it the agent composes an empty - # kiosk-url and the kiosk waits forever. The agent owns appending - # ?deviceId= → /etc/musallahboard/kiosk-url. - printf '%s\n' "$KIOSK_URL" | sudo tee /etc/musallahboard/board-url > /dev/null - sudo chmod 0644 /etc/musallahboard/board-url - info "Wrote /etc/musallahboard/board-url ($KIOSK_URL)" } # ── Disable tty1 autologin ──────────────────────────────────────────────────── @@ -479,8 +470,7 @@ _install_agent() { info "Agent binary : $AGENT_BINARY" # install.sh is payload only: files, accounts, unit enablement. It does not - # touch the boot target — that is appliance-policy.sh, below. board-url was - # already written unconditionally by _setup_display_stack. + # touch the boot target — that is appliance-policy.sh, below. sudo bash "$REPO_ROOT/packaging/install.sh" "$REPO_ROOT" --kiosk sudo bash "$REPO_ROOT/packaging/appliance-policy.sh" AGENT_INSTALLED="yes" @@ -497,9 +487,7 @@ _print_summary() { Hostname : $HOSTNAME Admin SSH : ssh $ADMIN_USER@ - Kiosk URL : $KIOSK_URL - Change later: sudo nano /etc/musallahboard/board-url - then: sudo systemctl restart musallahboard-agent + Kiosk URL : $KIOSK_URL (served by the agent) EOF @@ -511,8 +499,8 @@ EOF --token= --backend= Until then the kiosk shows the local "waiting" splash. On enrollment the - agent composes ?deviceId= and the board loads automatically - — no reboot needed. + kiosk switches to the board served by the agent, which starts syncing + content right away, with no reboot needed. ✓ Boots multi-user → musallahboard-kiosk.service → cage → browser ✓ SSH as $ADMIN_USER to manage the system diff --git a/packaging/musallahboard-agent-update.path b/packaging/musallahboard-agent-update.path new file mode 100644 index 0000000..ece189a --- /dev/null +++ b/packaging/musallahboard-agent-update.path @@ -0,0 +1,17 @@ +[Unit] +Description=Watch for a staged MusallahBoard agent update +Documentation=https://github.com/LensBridge/agent/blob/main/docs/architecture.md +# The daemon (unprivileged) verifies an agent package, writes it to +# agent/staged/package.mbu and then creates agent/staged/ready. This starts the +# root updater, which removes `ready` first thing, re-verifies the package from +# a private copy against root-trusted keys only, swaps the binary and +# health-checks the new agent (docs/architecture.md section 12). + +[Path] +# PathExists, not PathModified: a `ready` left behind by a power cut between +# staging and the updater starting still triggers the update on next boot. +PathExists=/var/lib/musallahboard/agent/staged/ready +Unit=musallahboard-agent-update.service + +[Install] +WantedBy=multi-user.target diff --git a/packaging/musallahboard-agent-update.service b/packaging/musallahboard-agent-update.service new file mode 100644 index 0000000..ffda7ac --- /dev/null +++ b/packaging/musallahboard-agent-update.service @@ -0,0 +1,42 @@ +[Unit] +Description=Apply a staged MusallahBoard agent update +Documentation=https://github.com/LensBridge/agent/blob/main/docs/architecture.md +# Started only by musallahboard-agent-update.path. Runs as root: it replaces +# /usr/bin/musallahboard-agent, keeps the previous binary in +# /usr/lib/musallahboard/agent.prev, restarts musallahboard-agent.service and +# rolls back if the new agent does not report its version on +# 127.0.0.1:8080 within 120 s. Nothing the daemon can write decides trust +# here: the updater re-verifies against /etc/musallahboard/trust.json +# (root-owned) and the keys compiled into this binary. +After=musallahboard-agent.service + +[Service] +Type=oneshot +ExecStart=/usr/bin/musallahboard-agent selfupdate apply +# Verify + swap + restart + a 120 s health check, with room to spare. Killing +# it mid-way is safe (every step is a rename), but it would leave the version +# unjudged, so the limit is generous. +TimeoutStartSec=5min + +# Hardening. It needs to write /usr/bin (the binary), /usr/lib/musallahboard +# (agent.prev), /var/lib/musallahboard (state.json, agent/last-update.json) +# and /etc/musallahboard (trust.json, when it runs `trust fetch` because no +# content key is pinned yet). It needs the network for that fetch and for +# the loopback health check, and it talks to PID 1 over its private socket +# to restart the agent, which a read-only /run does not prevent. +ProtectSystem=strict +ReadWritePaths=/usr/bin -/usr/lib/musallahboard /var/lib/musallahboard /etc/musallahboard +ProtectHome=yes +PrivateTmp=yes +NoNewPrivileges=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectHostname=yes +RestrictSUIDSGID=yes +RestrictRealtime=yes +RestrictNamespaces=yes +LockPersonality=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +UMask=0022 diff --git a/packaging/musallahboard-agent.service b/packaging/musallahboard-agent.service index 5b0f93a..d67e70d 100644 --- a/packaging/musallahboard-agent.service +++ b/packaging/musallahboard-agent.service @@ -3,6 +3,12 @@ Description=MusallahBoard device agent Documentation=https://github.com/LensBridge/agent After=network-online.target Wants=network-online.target +# Crash-loop guard: if the agent fails 5 times in 60s, stop trying. These are +# [Unit] settings; systemd ignores StartLimitIntervalSec= under [Service]. +# A bad self-update is caught earlier than this by the updater's health check +# and rollback (musallahboard-agent-update.service). +StartLimitBurst=5 +StartLimitIntervalSec=60 [Service] Type=notify @@ -12,16 +18,22 @@ ExecStart=/usr/bin/musallahboard-agent run Restart=always RestartSec=5 -# Crash-loop guard: if the agent fails 5 times in 60s, stop trying. -# Prevents a bad agent update from death-looping across the fleet. -StartLimitBurst=5 -StartLimitIntervalSec=60 - # sd_notify watchdog: agent must call WATCHDOG=1 every 30s or systemd # kills it. Catches goroutine deadlocks where the process is alive but # not making progress. WatchdogSec=60s +# Capabilities (docs/architecture.md sections 9.5 and 10). The daemon runs as +# an unprivileged user but holds exactly these two: +# CAP_SYS_TIME step the clock forward to a signed package's +# createdAt, and write the RTC with hwclock (rtc0 is +# group musallahdaemon via 90-musallahboard-rtc.rules) +# CAP_NET_BIND_SERVICE the upload server on the service port, 10.77.0.1:80 +# Ambient, so they survive exec into hwclock. They do not survive exec into +# sudo (setuid clears the ambient set), so the allow-list below is unaffected. +# ProtectClock= must stay unset for the same reason CAP_SYS_TIME is here. +AmbientCapabilities=CAP_SYS_TIME CAP_NET_BIND_SERVICE + # Hardening # NoNewPrivileges is intentionally NOT set: the agent's kiosk.restart and # system.reboot commands invoke `sudo` against a tightly-scoped allow-list @@ -43,7 +55,19 @@ LogsDirectoryMode=0750 # Redundant with the two directives above (systemd makes those writable even # under ProtectSystem=strict) but kept explicit for /etc/musallahboard, which -# is not managed by a *Directory= directive. +# is not managed by a *Directory= directive. The daemon writes kiosk-url there +# (write .tmp, rename), so the directory has to stay writable. +# +# Writable here means "not read-only mounted", nothing more: ordinary +# permissions still apply. trust.json in the same directory is root:root 0644, +# so the daemon cannot write it. It is only read here; `trust add|remove|fetch` +# and the root self-updater run as root, outside this unit. Note that because +# the directory itself is owned by musallahdaemon, the daemon can still unlink +# or rename directory entries, including trust.json, so root code must check +# that trust.json is a root-owned regular file before trusting it. +# +# /var/lib/musallahboard covers the content and app stores, state.json, the +# inbox and agent/staged/, which the root updater watches. ReadWritePaths=/etc/musallahboard /var/lib/musallahboard /var/log/musallahboard ProtectKernelTunables=yes ProtectKernelModules=yes diff --git a/packaging/musallahboard-kiosk.service b/packaging/musallahboard-kiosk.service index 41edc59..a7221ba 100644 --- a/packaging/musallahboard-kiosk.service +++ b/packaging/musallahboard-kiosk.service @@ -6,9 +6,16 @@ Documentation=https://github.com/LensBridge/agent # kiosk-url is absent/empty). There is deliberately NO ExecStartPre gate any # more — gating before ExecStart left tty1 black until enrollment. Instead, # musallahboard-kiosk-watch.path watches kiosk-url and restarts this unit the -# moment the agent composes it (on enrollment, or a board-url change), so the -# board replaces the splash within ~2s with no operator action. +# moment the agent writes it on enrollment, so the board replaces the splash +# within ~2s with no operator action. After=systemd-user-sessions.service +# The kiosk loads http://127.0.0.1:8080/, served by the agent, and the agent +# reports READY=1 (Type=notify) only once that server is listening. So ordering +# after it means Chromium never starts against a closed port +# (docs/architecture.md section 14). Wants=, not Requires=: a crashing agent +# must not take the screen down with it. +After=musallahboard-agent.service +Wants=musallahboard-agent.service # We take over the console getty would otherwise own. After=getty@tty1.service Conflicts=getty@tty1.service diff --git a/packaging/musallahboard-usb-import@.service b/packaging/musallahboard-usb-import@.service new file mode 100644 index 0000000..d224202 --- /dev/null +++ b/packaging/musallahboard-usb-import@.service @@ -0,0 +1,58 @@ +[Unit] +Description=Import MusallahBoard packages from USB stick %I +Documentation=https://github.com/LensBridge/agent/blob/main/docs/architecture.md +# Started by udev (/etc/udev/rules.d/90-musallahboard-usb.rules) for a USB +# block device that carries a filesystem; %I is its kernel name, e.g. sda1. +# The helper re-checks the name and the filesystem type, mounts it read-only +# with nosuid,nodev,noexec at /run/musallahboard/usb/%I, copies at most 16 +# *.mbu regular files into the inbox, unmounts, and then waits for the +# daemon's results so they land in this unit's journal +# (docs/architecture.md section 9.6). `usb_import = false` in agent.toml makes +# it exit at once. +# +# No BindsTo= on the device: once the files are copied the stick may be pulled +# while the daemon is still installing, and the helper should keep waiting for +# the results rather than be stopped. + +[Service] +Type=oneshot +ExecStart=/usr/bin/musallahboard-agent usb-import %I +# Copying up to 16 x 512 MiB from a slow stick, then up to 15 min waiting for +# the daemon. +TimeoutStartSec=20min + +# The mount point. systemd creates it before start and removes it after. +RuntimeDirectory=musallahboard/usb/%I +RuntimeDirectoryMode=0700 + +# Hardening. Root is needed to mount, but a hostile stick gets as little as +# possible to work with: no network at all, a read-only view of the system +# except the inbox, and its own mount namespace (from ProtectSystem= and +# PrivateTmp=), so the stick's mount is not propagated to the rest of the +# system and goes away with the unit even if the helper dies before +# unmounting. +# +# Deliberately NOT set, because mount needs them: PrivateDevices= (it hides +# /dev/sdX), DevicePolicy=/DeviceAllow= (FUSE-backed ntfs would also need +# /dev/fuse), SystemCallFilter=@mount exclusions, and a CapabilityBoundingSet +# without CAP_SYS_ADMIN. ProtectKernelModules= is fine: the kernel still +# autoloads the exfat/ntfs3 module on mount. +PrivateNetwork=yes +ProtectSystem=strict +ReadWritePaths=/var/lib/musallahboard/inbox +ProtectHome=yes +PrivateTmp=yes +NoNewPrivileges=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHostname=yes +RestrictSUIDSGID=yes +RestrictRealtime=yes +RestrictNamespaces=yes +LockPersonality=yes +RestrictAddressFamilies=AF_UNIX +CapabilityBoundingSet=CAP_SYS_ADMIN CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH CAP_CHOWN CAP_FOWNER +UMask=0022 diff --git a/packaging/start-kiosk.sh b/packaging/start-kiosk.sh index c6a1e08..babb85b 100644 --- a/packaging/start-kiosk.sh +++ b/packaging/start-kiosk.sh @@ -8,11 +8,10 @@ # at which point systemd (Restart=always) bounces us. This replaces the old # lightdm + labwc autologin stack: one systemd unit == the whole display. # -# The board URL (with ?deviceId=) is written by the agent to -# KIOSK_URL_FILE. The systemd unit's ExecStartPre blocks until that file -# exists, so by the time we run the device is enrolled. We still re-read it -# here and fall back to a local splash if it vanished between the check and -# now (e.g. a mid-write crash). +# The agent writes the URL to KIOSK_URL_FILE once the device is enrolled: +# its own local server, http://127.0.0.1:8080/. Until then the file is +# absent and we show the local "waiting for enrollment" splash; the kiosk +# .path watcher restarts us when the agent writes it. # # Browser: we must NOT use a snap. Snap Chromium runs in its own snapd cgroup # scope, so it survives `systemctl restart` diff --git a/packaging/udev/90-musallahboard-rtc.rules b/packaging/udev/90-musallahboard-rtc.rules new file mode 100644 index 0000000..09e6d0a --- /dev/null +++ b/packaging/udev/90-musallahboard-rtc.rules @@ -0,0 +1,5 @@ +# MusallahBoard: let the agent (group musallahdaemon) write the RTC with +# hwclock after it corrects the system clock. docs/architecture.md section 10. +# Setting the RTC also needs CAP_SYS_TIME, which the agent's unit grants. +# Harmless on a board with no RTC fitted: the rule never matches. +SUBSYSTEM=="rtc", KERNEL=="rtc0", GROUP="musallahdaemon", MODE="0660" diff --git a/packaging/udev/90-musallahboard-usb.rules b/packaging/udev/90-musallahboard-usb.rules new file mode 100644 index 0000000..5d3b179 --- /dev/null +++ b/packaging/udev/90-musallahboard-usb.rules @@ -0,0 +1,9 @@ +# MusallahBoard: import signed update packages (*.mbu) from a USB stick. +# docs/architecture.md section 9.6. +# +# For a USB disk or partition with a filesystem on it, start the root oneshot +# musallahboard-usb-import@.service. SYSTEMD_WANTS rather than +# RUN+=: udev kills RUN programs after a few seconds and forbids mounting from +# them; a unit has neither problem. The filesystem allow-list here is only a +# first filter; the helper checks the name and type again with blkid. +ACTION=="add", SUBSYSTEM=="block", KERNEL=="sd[a-z]*", ENV{ID_BUS}=="usb", ENV{ID_FS_USAGE}=="filesystem", ENV{ID_FS_TYPE}=="vfat|exfat|ext4|ntfs|ntfs3", TAG+="systemd", ENV{SYSTEMD_WANTS}+="musallahboard-usb-import@%k.service" diff --git a/packaging/uninstall.sh b/packaging/uninstall.sh index 55bf203..661c28f 100644 --- a/packaging/uninstall.sh +++ b/packaging/uninstall.sh @@ -10,19 +10,21 @@ # # (irreversible — the Ed25519 key is gone) # # What is removed in the default mode: -# - /lib/systemd/system/musallahboard-*.{service,path} (stopped, disabled) +# - /lib/systemd/system/musallahboard-*.{service,path} (stopped, disabled), +# including the self-updater and the USB import template +# - /etc/udev/rules.d/90-musallahboard-{usb,rtc}.rules # - /etc/sudoers.d/musallahboard-agent -# - /usr/bin/musallahboard-agent -# - /var/lib/musallahboard +# - /usr/bin/musallahboard-agent, /usr/lib/musallahboard (rollback binary) +# - /var/lib/musallahboard (content, app, inbox, state) # - /var/log/musallahboard -# - offline mode, if set up: the musallahboard-service-port and -# musallahboard-lan NetworkManager connections, the service port's ufw -# rules, the RTC boot-time clock unit, and the 'mbpush' push account -# (user, sudoers rule, sshd settings) +# - the service port, if set up: the musallahboard-service-port and +# musallahboard-lan NetworkManager connections, its ufw rules, and the +# RTC boot-time clock unit # # What is preserved (unless --purge): # - /etc/musallahboard/agent.toml (device id, backend url) # - /etc/musallahboard/agent.key (Ed25519 private key) +# - /etc/musallahboard/trust.json (pinned signing keys) # - The service user account ('musallahdaemon') # # Re-installing later (without --purge) keeps the device's existing enrollment. @@ -40,7 +42,7 @@ PURGE=no while [[ $# -gt 0 ]]; do case "$1" in --purge) PURGE=yes; shift ;; - -h|--help) sed -n '2,25p' "$0"; exit 0 ;; + -h|--help) sed -n '2,31p' "$0"; exit 0 ;; *) error "Unknown flag: $1" ;; esac done @@ -61,10 +63,21 @@ section "Removing files" if systemctl list-unit-files musallahboard-kiosk-watch.path &>/dev/null; then systemctl disable --now musallahboard-kiosk-watch.path 2>/dev/null || true fi +# The self-updater first: it must not fire while the files below disappear. +systemctl disable --now musallahboard-agent-update.path 2>/dev/null || true +systemctl stop musallahboard-agent-update.service 2>/dev/null || true +systemctl stop 'musallahboard-usb-import@*.service' 2>/dev/null || true +# udev before the units, so a stick plugged in now starts nothing. +rm -f /etc/udev/rules.d/90-musallahboard-usb.rules +rm -f /etc/udev/rules.d/90-musallahboard-rtc.rules +udevadm control --reload 2>/dev/null || true rm -f /lib/systemd/system/musallahboard-agent.service rm -f /lib/systemd/system/musallahboard-kiosk.service rm -f /lib/systemd/system/musallahboard-kiosk-watch.path rm -f /lib/systemd/system/musallahboard-kiosk-reload.service +rm -f /lib/systemd/system/musallahboard-agent-update.path +rm -f /lib/systemd/system/musallahboard-agent-update.service +rm -f '/lib/systemd/system/musallahboard-usb-import@.service' # Pre-/lib layout — harmless if absent, and leaving one behind would shadow a # reinstall's unit, since /etc/systemd/system takes precedence over /lib. rm -f /etc/systemd/system/musallahboard-agent.service @@ -73,19 +86,22 @@ rm -f /etc/systemd/system/musallahboard-kiosk-watch.path rm -f /etc/systemd/system/musallahboard-kiosk-reload.service rm -f /etc/sudoers.d/musallahboard-agent rm -f /usr/bin/musallahboard-agent +rm -f /usr/bin/.musallahboard-agent.new +rm -rf /usr/lib/musallahboard rm -f /usr/bin/start-kiosk.sh rm -rf /usr/share/musallahboard -info "Removed binary, units (agent + kiosk watcher), launcher, splash, sudoers" +info "Removed binary, units (agent, self-updater, USB import, kiosk watcher), udev rules, launcher, splash, sudoers" # State / logs: never preserved — they don't contain identity, just runtime crud. -# Includes offline mode's installed bundles (/var/lib/musallahboard/offline). +# Includes installed content and app releases. rm -rf /var/lib/musallahboard rm -rf /var/log/musallahboard info "Removed /var/lib/musallahboard and /var/log/musallahboard" -# Offline mode (setup.sh --offline). Left behind, the service-port connection -# would keep eth0 able to act as a DHCP server with no agent to switch it off. -section "Removing offline mode" +# The service port (setup.sh --service-port). Left behind, the +# service-port connection would keep eth0 able to act as a DHCP server with no +# agent to switch it off. +section "Removing the service port" if command -v nmcli &>/dev/null; then for conn in musallahboard-service-port musallahboard-lan; do if nmcli -g NAME connection show 2>/dev/null | grep -qxF "$conn"; then @@ -100,7 +116,8 @@ fi if command -v ufw &>/dev/null; then ufw delete allow in on eth0 to any port 67 proto udp >/dev/null 2>&1 || true ufw delete allow in on eth0 to any port 53 >/dev/null 2>&1 || true - info "Removed the service port's DHCP/DNS firewall rules (if present)" + ufw delete allow in on eth0 from 10.77.0.0/24 to any port 80 proto tcp >/dev/null 2>&1 || true + info "Removed the service port's DHCP/DNS/upload firewall rules (if present)" fi if [[ -e /etc/udev/rules.d/85-musallahboard-rtc.rules || -e /lib/systemd/system/musallahboard-rtc.service ]]; then rm -f /etc/udev/rules.d/85-musallahboard-rtc.rules @@ -110,25 +127,6 @@ if [[ -e /etc/udev/rules.d/85-musallahboard-rtc.rules || -e /lib/systemd/system/ # harmless), and fake-hwclock is not reinstalled. info "Removed the RTC boot-time clock unit" fi -# The push account. Its only permission was running the agent's gate, which -# is gone now, but a login nobody uses should not outlive its purpose. -if id mbpush &>/dev/null || [[ -e /etc/sudoers.d/musallahboard-push || -e /etc/ssh/sshd_config.d/99-musallahboard-push.conf ]]; then - rm -f /etc/sudoers.d/musallahboard-push - rm -f /etc/ssh/sshd_config.d/99-musallahboard-push.conf - if [[ -f /etc/ssh/sshd_config.d/kiosk-hardening.conf ]]; then - sed -i -E 's/^(AllowUsers\b.*)[[:space:]]mbpush([[:space:]]|$)/\1\2/' /etc/ssh/sshd_config.d/kiosk-hardening.conf - fi - if /usr/sbin/sshd -t 2>/dev/null; then - systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || true - else - warn "sshd's configuration does not validate after removing the push account; check /etc/ssh before logging out" - fi - if id mbpush &>/dev/null; then - userdel --remove mbpush 2>/dev/null || userdel mbpush || true - fi - info "Removed the push account (mbpush), its sudo rule and sshd settings" -fi - if [[ "$PURGE" == "yes" ]]; then warn "--purge: deleting device identity at /etc/musallahboard" warn " (the Ed25519 private key will be lost — re-enrollment required)" diff --git a/packaging/update.sh b/packaging/update.sh index 05a40b9..22ef4d5 100644 --- a/packaging/update.sh +++ b/packaging/update.sh @@ -3,8 +3,16 @@ # MusallahBoard Agent Update Script # ===================================================== # Replaces the agent binary in place and restarts the service. Preserves -# /etc/musallahboard (device identity) and the systemd unit / sudoers config — -# use install.sh instead if you also need to update those. +# /etc/musallahboard (device identity and trust.json) and the systemd unit / +# sudoers config — use install.sh instead if you also need to update those. +# +# This is the manual, admin-over-SSH path. Boards normally update themselves +# from signed agent packages (.mbu) instead: online through the release +# channel, offline from a USB stick or an upload on the service port. Those go +# through the root self-updater, which verifies signatures and rolls back a +# release that does not come up. This script does neither: it trusts the +# binary you give it, and it does not touch the version high-water mark, so it +# can also downgrade. # # Usage: # sudo bash update.sh # uses ./musallahboard-agent next to script @@ -29,6 +37,8 @@ error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; } SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" BINARY_DEST=/usr/bin/musallahboard-agent SERVICE=musallahboard-agent.service +LIB_DIR=/usr/lib/musallahboard +PREV_DEST=${LIB_DIR}/agent.prev BINARY_SRC="${1:-}" if [[ -z "$BINARY_SRC" ]]; then @@ -57,6 +67,15 @@ info "Replacing $BINARY_DEST" info " old: ${OLD_VERSION:-}" info " new: $NEW_VERSION" +# Keep the running binary where the self-updater keeps its rollback copy, so +# a bad manual update can be undone the same way: +# sudo install -m 0755 /usr/lib/musallahboard/agent.prev /usr/bin/musallahboard-agent +if [[ -x "$BINARY_DEST" ]]; then + install -d -o root -g root -m 0755 "$LIB_DIR" + install -o root -g root -m 0755 "$BINARY_DEST" "$PREV_DEST" + info "Saved the previous binary as $PREV_DEST" +fi + # Atomic replace: install(1) handles temp file + rename. install -o root -g root -m 0755 "$BINARY_SRC" "$BINARY_DEST" diff --git a/setup.sh b/setup.sh index 4475efa..dc80f94 100755 --- a/setup.sh +++ b/setup.sh @@ -10,27 +10,38 @@ # # Or with pre-supplied answers (no prompts): # curl -fsSL ... | MB_HOSTNAME=lobby MB_ADMIN_USER=admin \ -# MB_BOARD_URL=https://board.example.com MB_TIMEZONE=America/Toronto \ +# MB_TIMEZONE=America/Toronto \ # MB_ADMIN_SSH_KEY="ssh-ed25519 AAAA..." bash # # After setup completes, enroll the device: # sudo musallahboard-agent enroll --token= --backend= # -# Offline boards (docs/offline.md): once the board is set up and enrolled, -# and while it still has internet, run -# bash setup.sh --offline --board-dist= [--rtc] -# to provision the ethernet service port and switch it to offline mode. -# # The kiosk shows a "waiting for enrollment" splash until then; once enrolled -# the board loads automatically with no reboot. +# it shows the board served by the local agent, with no reboot. Every board +# renders from its own disk and syncs content whenever it has internet +# (docs/architecture.md). +# +# Boards without internet: once the board is set up and enrolled, and while +# it still has internet, run +# bash setup.sh --service-port [--rtc] +# to provision the ethernet service port, where a laptop or phone plugged in +# can upload signed update packages. USB sticks work on every board. +# +# Re-running this script is safe. # ===================================================== set -euo pipefail # ── Version and download URLs ───────────────────────────────────────────────── -VERSION="${MB_VERSION:-0.1.2}" +# MB_VERSION pins a release tag; the default follows the latest release, since +# this script and the units it writes track the current agent. +VERSION="${MB_VERSION:-latest}" GITHUB_REPO="LensBridge/agent" -RELEASE_URL="https://github.com/${GITHUB_REPO}/releases/download/${VERSION}" +if [[ "$VERSION" == "latest" ]]; then + RELEASE_URL="https://github.com/${GITHUB_REPO}/releases/latest/download" +else + RELEASE_URL="https://github.com/${GITHUB_REPO}/releases/download/${VERSION}" +fi BINARY_URL="${RELEASE_URL}/musallahboard-agent-arm64" # ── Colour helpers ──────────────────────────────────────────────────────────── @@ -47,7 +58,7 @@ NTP_SYNC_TIMEOUT_SEC="${NTP_SYNC_TIMEOUT_SEC:-90}" # Every prompt can be answered in advance via environment variable. When piped # (stdin is the script itself), prompts cannot work — defaults are used. # -# MB_HOSTNAME MB_ADMIN_USER MB_BOARD_URL MB_TIMEZONE MB_ADMIN_SSH_KEY +# MB_HOSTNAME MB_ADMIN_USER MB_TIMEZONE MB_ADMIN_SSH_KEY # MB_ASSUME_YES=1 take the default for anything unset, confirm nothing # MB_REBOOT=auto|never|ask MB_ASSUME_YES="${MB_ASSUME_YES:-0}" @@ -63,24 +74,25 @@ CONFIG_DIR=/etc/musallahboard UNIT_DIR=/lib/systemd/system SUDOERS_DEST=/etc/sudoers.d/musallahboard-agent KIOSK_SHARE_DIR=/usr/share/musallahboard - -# Offline mode (setup.sh --offline). Connection names and the address are -# shared with cmd/agent (offline_cli.go) and cmd/mbpush — change them together. -OFFLINE=0 -OFFLINE_RTC=0 -BOARD_DIST="" +STATE_DIR=/var/lib/musallahboard +INBOX_DIR=/var/lib/musallahboard/inbox +LIB_DIR=/usr/lib/musallahboard +TRUST_PATH=/etc/musallahboard/trust.json +UDEV_DIR=/etc/udev/rules.d +# The kiosk always loads the agent's local server (docs/architecture.md, 14). +KIOSK_URL="http://127.0.0.1:8080/" + +# The ethernet service port (setup.sh --service-port). Connection names and +# the address are shared with cmd/agent (serviceport_cli.go) and cmd/mbpush — +# change them together. +SERVICE_PORT=0 +SERVICE_PORT_RTC=0 SERVICE_CONN=musallahboard-service-port LAN_CONN=musallahboard-lan SERVICE_IFACE=eth0 SERVICE_ADDR=10.77.0.1/24 -OFFLINE_DIR=/var/lib/musallahboard/offline +SERVICE_NET=10.77.0.0/24 RTC_OVERLAY="dtoverlay=i2c-rtc,ds3231" -# The restricted account mbpush and the Android app log in as. Shared with -# cmd/agent (gate.go) and cmd/mbpush — change them together. -PUSH_USER=mbpush -PUSH_KEYS=() -PUSH_SUDOERS=/etc/sudoers.d/musallahboard-push -PUSH_SSHD_CONF=/etc/ssh/sshd_config.d/99-musallahboard-push.conf SSHD_HARDENING=/etc/ssh/sshd_config.d/kiosk-hardening.conf # ── Argument parsing ────────────────────────────────────────────────────────── @@ -88,7 +100,6 @@ usage() { cat <<'USAGE' Usage: bash setup.sh [options] - --board-url=URL kiosk URL for this board --hostname=NAME hostname to set --admin-user=NAME SSH/sudo account --timezone=TZ e.g. America/Toronto @@ -97,38 +108,34 @@ Usage: bash setup.sh [options] --yes, -y answer every prompt with its default -h, --help -Offline mode (after a normal setup and enrollment, while still online): - --offline provision the ethernet service port and switch this - board to offline mode; skips the normal setup - --board-dist=PATH board app build (dist dir or .tar.gz) to serve offline; - required the first time +Service port (after a normal setup and enrollment, while still online): + --service-port provision the ethernet service port, where a laptop + or phone plugged in can upload update packages, and + turn it on; skips the normal setup --rtc a DS3231 RTC is fitted: enable it, remove fake-hwclock - --push-key=KEY public key allowed to push bundles as the restricted - 'mbpush' account (repeatable; re-run to add more). - That account can only run `musallahboard-agent gate`. + +Update packages (.mbu) are installed with a USB stick, an upload on the +service port, or: sudo musallahboard-agent import USAGE } for arg in "$@"; do case "$arg" in - --board-url=*) export MB_BOARD_URL="${arg#*=}" ;; --hostname=*) export MB_HOSTNAME="${arg#*=}" ;; --admin-user=*) export MB_ADMIN_USER="${arg#*=}" ;; --timezone=*) export MB_TIMEZONE="${arg#*=}" ;; --admin-ssh-key=*) export MB_ADMIN_SSH_KEY="${arg#*=}" ;; --reboot=*) export MB_REBOOT="${arg#*=}" ;; --yes|-y) export MB_ASSUME_YES=1 ;; - --offline) OFFLINE=1 ;; - --rtc) OFFLINE_RTC=1 ;; - --board-dist=*) BOARD_DIST="${arg#*=}" ;; - --push-key=*) PUSH_KEYS+=("${arg#*=}") ;; + --service-port) SERVICE_PORT=1 ;; + --rtc) SERVICE_PORT_RTC=1 ;; -h|--help) usage; exit 0 ;; *) echo "Unknown option: $arg" >&2; usage >&2; exit 1 ;; esac done -if [[ "$OFFLINE" != "1" ]] && { [[ "$OFFLINE_RTC" == "1" ]] || [[ -n "$BOARD_DIST" ]] || (( ${#PUSH_KEYS[@]} )); }; then - echo "--rtc, --board-dist and --push-key only apply with --offline" >&2 +if [[ "$SERVICE_PORT" != "1" && "$SERVICE_PORT_RTC" == "1" ]]; then + echo "--rtc only applies with --service-port" >&2 exit 1 fi @@ -197,16 +204,14 @@ prompt_config() { # HOSTNAME is set by bash to the machine's name; clear it so _ask works HOSTNAME="${MB_HOSTNAME-}" ADMIN_USER="${MB_ADMIN_USER-}" - KIOSK_URL="${MB_BOARD_URL-}" TIMEZONE="${MB_TIMEZONE-}" _ask HOSTNAME "Hostname for this board" "musallahboard" _ask ADMIN_USER "Admin username (SSH/sudo)" "ibra" - _ask KIOSK_URL "Kiosk URL" "https://board.lensbridge.tech" _ask TIMEZONE "Timezone" "America/Toronto" - # Validate admin user is not a reserved name - for _reserved in musallahdaemon "$KIOSK_USER" "$PUSH_USER"; do + # Validate admin user is not a reserved name. + for _reserved in musallahdaemon "$KIOSK_USER"; do [[ "$ADMIN_USER" == "$_reserved" ]] && \ error "'$_reserved' is reserved for a MusallahBoard service account." done @@ -228,7 +233,7 @@ prompt_config() { printf " %-18s %s\n" "Hostname:" "$HOSTNAME" printf " %-18s %s\n" "Admin user:" "$ADMIN_USER (SSH key auth, passwordless sudo)" printf " %-18s %s\n" "Kiosk user:" "$KIOSK_USER (auto-login, browser only, no sudo/SSH/shell)" - printf " %-18s %s\n" "Kiosk URL:" "$KIOSK_URL" + printf " %-18s %s\n" "Kiosk URL:" "$KIOSK_URL (served by the agent)" printf " %-18s %s\n" "Timezone:" "$TIMEZONE" echo if ! _confirm "Continue? (y/n): "; then @@ -390,7 +395,7 @@ BASHRC info "Kiosk user locked down (no sudo, no shell, video+render only)" } -# ── Display stack (config dir, board-url) ───────────────────────────────────── +# ── Display stack (config dir, kiosk.env) ────────────────────────── setup_display_stack() { section "Display stack" @@ -411,11 +416,6 @@ EOF sudo rm -f "$CONFIG_DIR/kiosk.env" info "Bare-metal host — no kiosk.env needed" fi - - # Persist the board base URL - printf '%s\n' "$KIOSK_URL" | sudo tee "$CONFIG_DIR/board-url" > /dev/null - sudo chmod 0644 "$CONFIG_DIR/board-url" - info "Wrote $CONFIG_DIR/board-url ($KIOSK_URL)" } # ── Disable tty1 autologin ──────────────────────────────────────────────────── @@ -535,7 +535,7 @@ install_agent_binary() { sudo install -o root -g root -m 0755 "$tmp_binary" "$BINARY_DEST" rm -f "$tmp_binary" - info "Installed $BINARY_DEST" + info "Installed $BINARY_DEST ($("$BINARY_DEST" version 2>/dev/null || echo 'version unknown'))" } # ── Install sudoers allow-list ──────────────────────────────────────────────── @@ -562,13 +562,20 @@ EOF install_systemd_units() { section "Systemd units" - # Agent service + # KEEP IN SYNC with packaging/*.service, *.path and packaging/udev/. This + # script is curl|bash'd and has no checkout to copy from, so the units are + # inlined here without their comments; the packaged files explain them. + + # Agent service. CAP_SYS_TIME sets the clock (and, with the RTC rule, the + # RTC); CAP_NET_BIND_SERVICE is for the upload server on port 80. sudo tee "$UNIT_DIR/musallahboard-agent.service" > /dev/null << 'EOF' [Unit] Description=MusallahBoard device agent Documentation=https://github.com/LensBridge/agent After=network-online.target Wants=network-online.target +StartLimitBurst=5 +StartLimitIntervalSec=60 [Service] Type=notify @@ -577,9 +584,8 @@ Group=musallahdaemon ExecStart=/usr/bin/musallahboard-agent run Restart=always RestartSec=5 -StartLimitBurst=5 -StartLimitIntervalSec=60 WatchdogSec=60s +AmbientCapabilities=CAP_SYS_TIME CAP_NET_BIND_SERVICE ProtectSystem=strict ProtectHome=yes PrivateTmp=yes @@ -605,6 +611,8 @@ EOF Description=MusallahBoard kiosk (cage + Chromium) Documentation=https://github.com/LensBridge/agent After=systemd-user-sessions.service +After=musallahboard-agent.service +Wants=musallahboard-agent.service After=getty@tty1.service Conflicts=getty@tty1.service @@ -655,11 +663,139 @@ After=musallahboard-kiosk.service [Service] Type=oneshot ExecStart=/bin/systemctl --no-block try-restart musallahboard-kiosk.service +EOF + + # Root self-updater: the daemon stages a verified agent package and + # creates staged/ready; this re-verifies it as root, swaps the binary and + # rolls back if the new agent does not come up. + sudo tee "$UNIT_DIR/musallahboard-agent-update.path" > /dev/null << 'EOF' +[Unit] +Description=Watch for a staged MusallahBoard agent update +Documentation=https://github.com/LensBridge/agent/blob/main/docs/architecture.md + +[Path] +PathExists=/var/lib/musallahboard/agent/staged/ready +Unit=musallahboard-agent-update.service + +[Install] +WantedBy=multi-user.target +EOF + + sudo tee "$UNIT_DIR/musallahboard-agent-update.service" > /dev/null << 'EOF' +[Unit] +Description=Apply a staged MusallahBoard agent update +Documentation=https://github.com/LensBridge/agent/blob/main/docs/architecture.md +After=musallahboard-agent.service + +[Service] +Type=oneshot +ExecStart=/usr/bin/musallahboard-agent selfupdate apply +TimeoutStartSec=5min +ProtectSystem=strict +ReadWritePaths=/usr/bin -/usr/lib/musallahboard /var/lib/musallahboard /etc/musallahboard +ProtectHome=yes +PrivateTmp=yes +NoNewPrivileges=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectHostname=yes +RestrictSUIDSGID=yes +RestrictRealtime=yes +RestrictNamespaces=yes +LockPersonality=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +UMask=0022 +EOF + + # USB import helper, started by udev for each USB filesystem. Root (it + # mounts), no network, read-only system except the inbox. + sudo tee "$UNIT_DIR/musallahboard-usb-import@.service" > /dev/null << 'EOF' +[Unit] +Description=Import MusallahBoard packages from USB stick %I +Documentation=https://github.com/LensBridge/agent/blob/main/docs/architecture.md + +[Service] +Type=oneshot +ExecStart=/usr/bin/musallahboard-agent usb-import %I +TimeoutStartSec=20min +RuntimeDirectory=musallahboard/usb/%I +RuntimeDirectoryMode=0700 +PrivateNetwork=yes +ProtectSystem=strict +ReadWritePaths=/var/lib/musallahboard/inbox +ProtectHome=yes +PrivateTmp=yes +NoNewPrivileges=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHostname=yes +RestrictSUIDSGID=yes +RestrictRealtime=yes +RestrictNamespaces=yes +LockPersonality=yes +RestrictAddressFamilies=AF_UNIX +CapabilityBoundingSet=CAP_SYS_ADMIN CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH CAP_CHOWN CAP_FOWNER +UMask=0022 EOF info "Installed systemd units" } +# ── Install udev rules ──────────────────────────────────────────────────────── +install_udev_rules() { + section "udev rules" + + # KEEP IN SYNC with packaging/udev/. + sudo mkdir -p "$UDEV_DIR" + sudo tee "$UDEV_DIR/90-musallahboard-usb.rules" > /dev/null << 'EOF' +# MusallahBoard: import signed update packages (*.mbu) from a USB stick. +ACTION=="add", SUBSYSTEM=="block", KERNEL=="sd[a-z]*", ENV{ID_BUS}=="usb", ENV{ID_FS_USAGE}=="filesystem", ENV{ID_FS_TYPE}=="vfat|exfat|ext4|ntfs|ntfs3", TAG+="systemd", ENV{SYSTEMD_WANTS}+="musallahboard-usb-import@%k.service" +EOF + sudo tee "$UDEV_DIR/90-musallahboard-rtc.rules" > /dev/null << 'EOF' +# MusallahBoard: let the agent (group musallahdaemon) write the RTC with hwclock. +SUBSYSTEM=="rtc", KERNEL=="rtc0", GROUP="musallahdaemon", MODE="0660" +EOF + sudo chmod 0644 "$UDEV_DIR/90-musallahboard-usb.rules" "$UDEV_DIR/90-musallahboard-rtc.rules" + + sudo udevadm control --reload || true + # Apply the rtc0 permissions now. Not the block subsystem: that would + # start an import from a stick that happens to be plugged in. + sudo udevadm trigger --subsystem-match=rtc --action=change || true + info "USB sticks start musallahboard-usb-import@.service; rtc0 is writable by $SERVICE_USER" +} + +# ── State directories and trust store ───────────────────────────────────────── +install_state_dirs() { + section "State directories and trust store" + + # Owned by the service user, as StateDirectory= in the unit expects: a + # mismatched owner there makes systemd chown the whole tree recursively. + sudo install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0750 "$STATE_DIR" + # Written by root tools (the USB helper, `musallahboard-agent import`) and + # consumed by the daemon. + sudo install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0770 "$INBOX_DIR" + # The self-updater's rollback copy of the previous agent. + sudo install -d -o root -g root -m 0755 "$LIB_DIR" + info "$INBOX_DIR ($SERVICE_USER 0770), $LIB_DIR (root 0755)" + + # Root-owned, world-readable, never overwritten: it holds the content keys + # pinned at enrollment and any release keys an admin added. + if sudo test -e "$TRUST_PATH"; then + info "Keeping existing $TRUST_PATH" + else + printf '{"content":[],"release":[]}\n' | sudo tee "$TRUST_PATH.tmp" > /dev/null + sudo chown root:root "$TRUST_PATH.tmp" + sudo chmod 0644 "$TRUST_PATH.tmp" + sudo mv -f "$TRUST_PATH.tmp" "$TRUST_PATH" + info "Created $TRUST_PATH (root 0644, no keys yet)" + fi +} + # ── Install kiosk launcher script ───────────────────────────────────────────── install_kiosk_launcher() { section "Kiosk launcher" @@ -841,9 +977,11 @@ enable_services() { sudo systemctl enable musallahboard-agent.service sudo systemctl enable musallahboard-kiosk.service sudo systemctl enable musallahboard-kiosk-watch.path + sudo systemctl enable musallahboard-agent-update.path - # Start the watcher now so it catches enrollment + # Start the watchers now: one catches enrollment, the other a staged update sudo systemctl start musallahboard-kiosk-watch.path + sudo systemctl start musallahboard-agent-update.path info "Services enabled" } @@ -879,7 +1017,7 @@ print_summary() { Hostname : $HOSTNAME Admin SSH : ssh $ADMIN_USER@ - Kiosk URL : $KIOSK_URL + Kiosk URL : $KIOSK_URL (served by the agent) FINAL STEP - enroll this device (once, with a token from the admin UI): @@ -888,8 +1026,11 @@ print_summary() { Until then the kiosk shows the local "waiting" splash, which prints this device's IP address on screen once it has one - that is the to SSH to. - On enrollment the agent composes ?deviceId= and the board - loads automatically. + On enrollment the kiosk switches to the board served by the agent, which + starts syncing content and fetches the board app right away. + + A board that will not have internet: after enrolling, while it still has + internet, run bash setup.sh --service-port * Boots multi-user -> musallahboard-kiosk.service -> cage -> browser * SSH as $ADMIN_USER to manage the system @@ -918,42 +1059,33 @@ EOF return 0 } -# ══ Offline mode (setup.sh --offline) ═════════════════════════════════════════ -# Everything below runs only with --offline, on a board that has already been -# set up and enrolled. See docs/offline.md, "Provisioning". Every step is -# idempotent: re-running it is safe, and is how you change --board-dist or add -# --rtc later. +# ══ Service port (setup.sh --service-port) ════════════════════════════════════ +# Everything below runs only with --service-port, on a board that has already +# been set up and enrolled. See docs/architecture.md, sections 9.5 and 13. +# Every step is idempotent: re-running it is safe, and is how you add --rtc +# later. -offline_preflight() { +service_port_preflight() { [[ $EUID -eq 0 ]] && error "Do not run as root. Run as a user with sudo access." # Not `sudo -v`: it prompts even under NOPASSWD when any other matching # sudoers entry (e.g. %sudo) needs a password, which breaks ssh 'setup.sh'. sudo -n true 2>/dev/null || sudo -v || error "This script requires sudo access." command -v nmcli >/dev/null || \ - error "nmcli not found. Offline mode needs NetworkManager (the Raspberry Pi OS default)." + error "nmcli not found. The service port needs NetworkManager (the Raspberry Pi OS default)." [[ -x "$BINARY_DEST" ]] || \ - error "$BINARY_DEST is not installed. Run the normal setup (without --offline) first." - "$BINARY_DEST" help 2>&1 | grep -q "mode online|offline" || \ - error "The installed agent predates offline mode. Update it first (packaging/update.sh, or make deploy PI=...)." + error "$BINARY_DEST is not installed. Run the normal setup (without --service-port) first." sudo test -s "$CONFIG_DIR/agent.toml" || \ error "This board is not enrolled yet. Enroll it online first: sudo musallahboard-agent enroll --token= --backend=" - - if [[ -n "$BOARD_DIST" ]]; then - [[ -e "$BOARD_DIST" ]] || error "--board-dist: $BOARD_DIST does not exist" - elif [[ ! -f "$KIOSK_SHARE_DIR/board/index.html" ]]; then - error "No board app is installed for offline use. Pass --board-dist= - (the MusallahBoard frontend build: npm run build, then its dist/ directory)." - fi } -offline_install_packages() { - section "Offline: packages" +service_port_install_packages() { + section "Service port: packages" # dnsmasq-base: NetworkManager's "shared" mode runs it for DHCP/DNS. - # util-linux-extra: hwclock, which the gate uses to save the time to the RTC. + # util-linux-extra: hwclock, which the agent uses to save the time to the RTC. local pkgs=(dnsmasq-base util-linux-extra) - # Skip apt when they are already there: re-running setup.sh --offline to - # add a --push-key happens on a board that has no internet any more. + # Skip apt when they are already there: re-running setup.sh --service-port + # to add --rtc may happen on a board that has no internet any more. if dpkg -s "${pkgs[@]}" &>/dev/null; then info "${pkgs[*]} already installed" return @@ -963,15 +1095,6 @@ offline_install_packages() { sudo apt-get install -y "${pkgs[@]}" } -offline_state_dir() { - section "Offline: state directory" - # Root installs bundles (bundle install runs under sudo); the agent only - # reads them, through its group. - sudo install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0750 /var/lib/musallahboard - sudo install -d -o root -g "$SERVICE_USER" -m 0750 "$OFFLINE_DIR" - info "$OFFLINE_DIR ready (root:$SERVICE_USER 0750)" -} - # Prints the name of a saved (not auto-generated) wired profile eth0 could # use, other than ours; fails if there is none. NetworkManager's automatic # "Wired connection 1" lives under /run and is not counted: it stops being @@ -992,15 +1115,15 @@ _saved_wired_profile() { _nm_has() { nmcli -g NAME connection show | grep -qxF "$1"; } -offline_service_port() { - section "Offline: ethernet service port" +service_port_profiles() { + section "Service port: ethernet profiles" - # Two profiles on eth0, tried in priority order (docs/offline.md): + # Two profiles on eth0, tried in priority order (docs/architecture.md, 13): # # 1. A DHCP client ($LAN_CONN, default priority). Plugged into a real # network, the board just gets an address there, as it does today. # A laptop runs no DHCP server, so with one plugged in this fails - # after ipv4.dhcp-timeout and, with the single retry `mode offline` + # after ipv4.dhcp-timeout and, with the single retry `service-port on` # gives it, NetworkManager moves on to... # 2. the service port ($SERVICE_CONN): shared mode, i.e. a DHCP server, # at the lowest priority NetworkManager has (-999). It only ever @@ -1024,15 +1147,15 @@ offline_service_port() { elif existing="$(_saved_wired_profile)"; then info "Keeping your saved wired profile '$existing' as eth0's first choice" warn "It keeps NetworkManager's default of 4 attempts, so a laptop may wait a few minutes" - warn "for an address. Delete it to use $LAN_CONN instead, then re-run setup.sh --offline." + warn "for an address. Delete it to use $LAN_CONN instead, then re-run setup.sh --service-port." else sudo nmcli connection add type ethernet con-name "$LAN_CONN" "${lan_props[@]}" info "Created NetworkManager connection $LAN_CONN (DHCP client, tried first)" fi - # autoconnect stays off here: `musallahboard-agent mode offline` below turns - # it on and `mode online` turns it off, so the mode alone decides whether - # this board can ever act as a DHCP server. + # autoconnect stays off here: `musallahboard-agent service-port on` below + # turns it on and `service-port off` turns it off, so that switch alone + # decides whether this board can ever act as a DHCP server. local svc_props=( connection.interface-name "$SERVICE_IFACE" connection.autoconnect no @@ -1051,165 +1174,20 @@ offline_service_port() { info "$SERVICE_IFACE: shared, ${SERVICE_ADDR}, IPv6 off — a laptop plugged in gets a 10.77.0.x address" } -offline_firewall() { - section "Offline: firewall" +service_port_firewall() { + section "Service port: firewall" # SSH is already allowed on every interface by the normal setup. ufw skips - # rules that already exist, so these are safe to re-add. + # rules that already exist, so these are safe to re-add. The upload server + # (port 80) is reachable only from the service port's own subnet on eth0, + # never from Wi-Fi or a building network. sudo ufw allow in on "$SERVICE_IFACE" to any port 67 proto udp comment 'musallahboard service port DHCP' sudo ufw allow in on "$SERVICE_IFACE" to any port 53 comment 'musallahboard service port DNS' - info "Firewall: DHCP and DNS allowed in on $SERVICE_IFACE (alongside SSH)" -} - -# The restricted account for pushing bundles (docs/offline.md, "Push account"). -# Every session is confined to `musallahboard-agent gate` twice over: by -# command="..." on each key, and by an sshd ForceCommand for the user, so a key -# added to authorized_keys by hand without the option is still confined. The -# account's only sudo right is running the gate. -offline_push_account() { - section "Offline: push account ($PUSH_USER)" - local force_cmd="sudo -n $BINARY_DEST gate" - local admin="${SUDO_USER:-$(id -un)}" - - "$BINARY_DEST" help 2>&1 | grep -q "gate " || \ - error "The installed agent has no 'gate' command. Update it first (packaging/update.sh, or make deploy PI=...)." - - if ! id "$PUSH_USER" &>/dev/null; then - sudo useradd --system --create-home --home-dir "/home/$PUSH_USER" --shell /bin/sh "$PUSH_USER" - info "Created $PUSH_USER" - fi - # '*' rather than a locked '!' password: no password can ever match, but - # sshd does not treat the account as locked and refuse its keys. - sudo usermod -p '*' "$PUSH_USER" - - # Keys. root owns the file, so the account could not change its own keys - # even if it could run anything. - local ssh_dir="/home/$PUSH_USER/.ssh" - local keys="$ssh_dir/authorized_keys" - sudo install -d -o root -g root -m 0755 "$ssh_dir" - sudo touch "$keys" - sudo chown root:root "$keys" - sudo chmod 0644 "$keys" - local key kt blob rest - for key in "${PUSH_KEYS[@]}"; do - read -r kt blob rest <<< "$key" - [[ "$kt" =~ ^(ssh-|ecdsa-|sk-) && -n "$blob" ]] || \ - error "--push-key is not a public key (expected 'ssh-ed25519 AAAA... name'): $key" - if sudo grep -qF " $kt $blob" "$keys"; then - info "Key ${rest:-$kt} already allowed" - else - echo "restrict,command=\"$force_cmd\" $kt $blob${rest:+ $rest}" | sudo tee -a "$keys" > /dev/null - info "Allowed key ${rest:-$kt} to push" - fi - done - - # sudo: the gate as root, nothing else. It needs SSH_ORIGINAL_COMMAND, - # which sudo would otherwise strip. - local tmp - tmp="$(mktemp)" - cat > "$tmp" << EOF -# MusallahBoard push account (setup.sh --offline): may run the gate, and nothing else. -Defaults:$PUSH_USER env_keep += "SSH_ORIGINAL_COMMAND" -$PUSH_USER ALL=(root) NOPASSWD: $BINARY_DEST gate -EOF - if ! sudo visudo -cf "$tmp" > /dev/null; then - rm -f "$tmp" - error "The generated sudoers rule for $PUSH_USER did not validate; nothing was installed." - fi - sudo install -o root -g root -m 0440 "$tmp" "$PUSH_SUDOERS" - rm -f "$tmp" - info "sudo: $PUSH_USER may run '$BINARY_DEST gate' only" - - _push_sshd "$admin" "$force_cmd" - - local count - count="$(sudo grep -c . "$keys" || true)" - if [[ "$count" == "0" ]]; then - warn "No push keys yet. Add one (re-running is safe):" - warn " bash setup.sh --offline --push-key=\"ssh-ed25519 AAAA... phone\"" - else - info "$count key(s) can push as $PUSH_USER" - fi + sudo ufw allow in on "$SERVICE_IFACE" from "$SERVICE_NET" to any port 80 proto tcp comment 'musallahboard upload server' + info "Firewall: DHCP, DNS and uploads (80/tcp from $SERVICE_NET) allowed in on $SERVICE_IFACE" } -# Admit the push account to sshd and confine it, without being able to lock -# the admin out: the admin's effective sshd settings are captured before and -# compared after, and any difference (or a config sshd rejects) restores the -# previous files before sshd is reloaded. -_push_sshd() { - local admin="$1" force_cmd="$2" - local probe="host=localhost,addr=127.0.0.1" - # allowusers is expected to change (it gains the push account), so it is - # left out of the comparison and checked separately below. - _effective() { - local cfg - cfg="$(sudo /usr/sbin/sshd -T -C "user=$1,$probe" 2>/dev/null)" || return 1 - grep -v '^allowusers ' <<< "$cfg" | sort - } - - local before - before="$(_effective "$admin")" || true - [[ -n "$before" ]] || error "Could not read sshd's effective configuration (sshd -T). Nothing was changed." - - local backup - backup="$(mktemp -d)" - [[ -f "$SSHD_HARDENING" ]] && sudo cp -p "$SSHD_HARDENING" "$backup/hardening" - [[ -f "$PUSH_SSHD_CONF" ]] && sudo cp -p "$PUSH_SSHD_CONF" "$backup/push" - _restore() { - if [[ -f "$backup/hardening" ]]; then sudo cp -p "$backup/hardening" "$SSHD_HARDENING"; fi - if [[ -f "$backup/push" ]]; then sudo cp -p "$backup/push" "$PUSH_SSHD_CONF"; else sudo rm -f "$PUSH_SSHD_CONF"; fi - rm -rf "$backup" - } - - # The normal setup's AllowUsers admits only the admin. - if sudo grep -qE '^AllowUsers\b' "$SSHD_HARDENING" 2>/dev/null && \ - ! sudo grep -qE "^AllowUsers\b.*[[:space:]]$PUSH_USER([[:space:]]|\$)" "$SSHD_HARDENING"; then - sudo sed -i -E "s/^(AllowUsers\b.*)\$/\1 $PUSH_USER/" "$SSHD_HARDENING" - fi - - sudo tee "$PUSH_SSHD_CONF" > /dev/null << EOF -# MusallahBoard push account (setup.sh --offline). Confines every session of -# $PUSH_USER to the gate, whatever its authorized_keys say. -Match User $PUSH_USER - ForceCommand $force_cmd - PermitTTY no - AllowTcpForwarding no - AllowStreamLocalForwarding no - AllowAgentForwarding no - X11Forwarding no - PermitTunnel no - PasswordAuthentication no -EOF - sudo chmod 0644 "$PUSH_SSHD_CONF" - - # Captured, not piped into grep -q: under pipefail an early grep exit can - # SIGPIPE sshd and turn a pass into a false failure. - local problem="" admin_cfg="" push_cfg="" - if sudo /usr/sbin/sshd -t 2> /dev/null; then - admin_cfg="$(sudo /usr/sbin/sshd -T -C "user=$admin,$probe" 2>/dev/null || true)" - push_cfg="$(sudo /usr/sbin/sshd -T -C "user=$PUSH_USER,$probe" 2>/dev/null || true)" - else - problem="sshd rejected the new configuration" - fi - if [[ -z "$problem" && "$(_effective "$admin" || true)" != "$before" ]]; then - problem="it would have changed sshd settings for $admin" - elif [[ -z "$problem" ]] && sudo grep -qE '^AllowUsers\b' "$SSHD_HARDENING" 2>/dev/null && \ - ! grep -qE "^allowusers .*\b$admin\b" <<< "$admin_cfg"; then - problem="$admin would no longer be allowed to log in" - elif [[ -z "$problem" ]] && ! grep -qxF "forcecommand $force_cmd" <<< "$push_cfg"; then - problem="sshd did not apply the ForceCommand to $PUSH_USER" - fi - if [[ -n "$problem" ]]; then - _restore - error "Not enabling SSH for $PUSH_USER: $problem. The previous sshd configuration was restored." - fi - rm -rf "$backup" - - sudo systemctl reload ssh 2>/dev/null || sudo systemctl reload sshd 2>/dev/null || true - info "sshd: $PUSH_USER allowed, confined to the gate; $admin unchanged" -} - -offline_rtc() { - section "Offline: RTC (DS3231)" +service_port_rtc() { + section "Service port: RTC (DS3231)" local config_txt=/boot/firmware/config.txt [[ -f "$config_txt" ]] || config_txt=/boot/config.txt @@ -1232,14 +1210,15 @@ offline_rtc() { # The ds3231 driver is a module, so the kernel's own boot-time read of the # RTC is over by the time it loads, and udev cannot set the clock itself # (systemd-udevd runs without CAP_SYS_TIME). Instead udev starts a oneshot - # unit when rtc0 appears, and that unit reads the RTC. + # unit when rtc0 appears, and that unit reads the RTC. Writing it back is + # the agent's job (90-musallahboard-rtc.rules gives it access). sudo tee /etc/udev/rules.d/85-musallahboard-rtc.rules > /dev/null << 'EOF' -# MusallahBoard offline mode: set the system clock from the RTC when it appears. +# MusallahBoard: set the system clock from the RTC when it appears. ACTION=="add", SUBSYSTEM=="rtc", KERNEL=="rtc0", TAG+="systemd", ENV{SYSTEMD_WANTS}+="musallahboard-rtc.service" EOF sudo tee "$UNIT_DIR/musallahboard-rtc.service" > /dev/null << 'EOF' [Unit] -Description=Set the system clock from the RTC (MusallahBoard offline mode) +Description=Set the system clock from the RTC (MusallahBoard) Documentation=https://github.com/LensBridge/agent DefaultDependencies=no Before=time-set.target @@ -1252,66 +1231,58 @@ EOF sudo systemctl daemon-reload sudo udevadm control --reload info "RTC read at boot via musallahboard-rtc.service" - warn "Reboot once with the RTC fitted, then run 'mbpush status' (or mbpush ) to set and save the time." -} - -offline_board_app() { - section "Offline: board app" - if [[ -n "$BOARD_DIST" ]]; then - sudo "$BINARY_DEST" app install "$BOARD_DIST" - else - info "Keeping the installed board app ($KIOSK_SHARE_DIR/board)" - fi + warn "Reboot once with the RTC fitted. The agent saves the time to it whenever it corrects the clock," + warn "for example on the next upload from a laptop (mbpush or http://10.77.0.1/)." } -offline_switch_mode() { - section "Offline: switch mode" - # If eth0 is on this network right now, `mode offline` leaves the service - # port stopped (it would serve DHCP here) and says so. - sudo "$BINARY_DEST" mode offline +service_port_switch_on() { + section "Service port: switch on" + # If eth0 is on a network right now, `service-port on` leaves the port + # stopped (it would serve DHCP there) and says so. It restarts the agent, + # which then also runs with the units installed above. + sudo "$BINARY_DEST" service-port on } -offline_summary() { - local admin="${SUDO_USER:-$(id -un)}" +service_port_summary() { cat << EOF +----------------------------------------------------------+ - | Offline mode is ON | + | The service port is ON | +----------------------------------------------------------+ - The board now serves its installed bundle and makes no network calls. + The board keeps showing what it has installed, and syncs from LensBridge + whenever it can reach it. To update it without internet: - To update it: plug a laptop into its ethernet port, then on the laptop - mbpush --user $admin musallahboard--.zip - (mbpush talks to $admin@${SERVICE_ADDR%/*} with the SSH key you use now. - Allow up to a minute after plugging in: the board first tries to get an - address from the laptop, and only then starts serving one.) + - USB stick: copy the .mbu files to it (root, or a MusallahBoard/ folder) + and plug it in. The screen says when it is done. + - Laptop or phone: plug into the board's ethernet port and open + http://${SERVICE_ADDR%/*}/ + or run mbpush ... (it also shows the board's clock). + Allow up to a minute after plugging in: the board first tries to get + an address from the laptop, and only then starts serving one. - Check it any time: sudo musallahboard-agent status - Back to online: sudo musallahboard-agent mode online + Check it any time: sudo musallahboard-agent status + Before joining a network: sudo musallahboard-agent service-port off EOF } -offline_main() { - offline_preflight - offline_install_packages - offline_state_dir - offline_service_port - offline_firewall - offline_push_account - if [[ "$OFFLINE_RTC" == "1" ]]; then - offline_rtc +service_port_main() { + service_port_preflight + service_port_install_packages + service_port_profiles + service_port_firewall + if [[ "$SERVICE_PORT_RTC" == "1" ]]; then + service_port_rtc fi - offline_board_app - offline_switch_mode - offline_summary + service_port_switch_on + service_port_summary } # ── Main ────────────────────────────────────────────────────────────────────── main() { - if [[ "$OFFLINE" == "1" ]]; then - offline_main + if [[ "$SERVICE_PORT" == "1" ]]; then + service_port_main return fi @@ -1336,7 +1307,9 @@ main() { install_agent_binary install_sudoers + install_state_dirs install_systemd_units + install_udev_rules install_kiosk_launcher install_splash_page enable_services diff --git a/uninstall.sh b/uninstall.sh index 0bf7ce3..dbd7d8e 100644 --- a/uninstall.sh +++ b/uninstall.sh @@ -34,10 +34,14 @@ systemctl stop musallahboard-agent.service 2>/dev/null || true systemctl stop musallahboard-kiosk.service 2>/dev/null || true systemctl stop musallahboard-kiosk-watch.path 2>/dev/null || true systemctl stop musallahboard-kiosk-reload.service 2>/dev/null || true +systemctl stop musallahboard-agent-update.path 2>/dev/null || true +systemctl stop musallahboard-agent-update.service 2>/dev/null || true +systemctl stop 'musallahboard-usb-import@*.service' 2>/dev/null || true systemctl disable musallahboard-agent.service 2>/dev/null || true systemctl disable musallahboard-kiosk.service 2>/dev/null || true systemctl disable musallahboard-kiosk-watch.path 2>/dev/null || true +systemctl disable musallahboard-agent-update.path 2>/dev/null || true info "Services stopped and disabled" @@ -48,14 +52,29 @@ rm -f /lib/systemd/system/musallahboard-agent.service rm -f /lib/systemd/system/musallahboard-kiosk.service rm -f /lib/systemd/system/musallahboard-kiosk-watch.path rm -f /lib/systemd/system/musallahboard-kiosk-reload.service +rm -f /lib/systemd/system/musallahboard-agent-update.path +rm -f /lib/systemd/system/musallahboard-agent-update.service +rm -f '/lib/systemd/system/musallahboard-usb-import@.service' +rm -f /lib/systemd/system/musallahboard-rtc.service systemctl daemon-reload info "Systemd units removed" +# ── Remove udev rules ───────────────────────────────────────────────────────── +section "Removing udev rules" + +rm -f /etc/udev/rules.d/90-musallahboard-usb.rules +rm -f /etc/udev/rules.d/90-musallahboard-rtc.rules +rm -f /etc/udev/rules.d/85-musallahboard-rtc.rules +udevadm control --reload 2>/dev/null || true +info "udev rules removed (USB import, RTC access, RTC boot-time read)" + # ── Remove binary and scripts ───────────────────────────────────────────────── section "Removing binaries and scripts" rm -f /usr/bin/musallahboard-agent +rm -f /usr/bin/.musallahboard-agent.new +rm -rf /usr/lib/musallahboard rm -f /usr/bin/start-kiosk.sh info "Binaries removed" @@ -65,6 +84,26 @@ section "Removing sudoers" rm -f /etc/sudoers.d/musallahboard-agent info "Sudoers removed" +# ── Remove the service port ─────────────────────────────────────────────────── +# setup.sh --service-port. Left behind, the service-port +# connection would keep eth0 able to act as a DHCP server with no agent to +# switch it off. +section "Removing the service port" + +if command -v nmcli &>/dev/null; then + for conn in musallahboard-service-port musallahboard-lan; do + if nmcli -g NAME connection show 2>/dev/null | grep -qxF "$conn"; then + nmcli connection delete "$conn" >/dev/null || warn "Could not delete NetworkManager connection $conn" + fi + done +fi +if command -v ufw &>/dev/null; then + ufw delete allow in on eth0 to any port 67 proto udp >/dev/null 2>&1 || true + ufw delete allow in on eth0 to any port 53 >/dev/null 2>&1 || true + ufw delete allow in on eth0 from 10.77.0.0/24 to any port 80 proto tcp >/dev/null 2>&1 || true +fi +info "Service port removed (if it was set up)" + # ── Remove config and state ─────────────────────────────────────────────────── section "Removing configuration and state" @@ -127,7 +166,8 @@ cat << 'EOF' +----------------------------------------------------------+ Removed: - - musallahboard-agent binary and service + - musallahboard-agent binary, service, self-updater and USB import + - udev rules and the service port (NetworkManager profiles, ufw rules) - musallahboard-kiosk service and launcher - Service users (musallahdaemon, musallahkiosk) - Configuration (/etc/musallahboard) @@ -139,7 +179,7 @@ cat << 'EOF' - Packages (cage, chromium, rpi-connect, ufw, etc.) - Admin user and SSH key - Hostname and timezone - - UFW rules + - UFW rules other than the service port's - Journal config - Unattended upgrades config diff --git a/update-anim/embed.go b/update-anim/embed.go new file mode 100644 index 0000000..88b0c06 --- /dev/null +++ b/update-anim/embed.go @@ -0,0 +1,12 @@ +// Package updateanim embeds the board's "Working on updates" screen +// (index.html beside this file) so the agent can serve it with no other files +// on disk. The page is driven over CDP through window.mbUpdate; see the +// comment at the bottom of index.html and internal/updatescreen. +package updateanim + +import _ "embed" + +// HTML is update-anim/index.html. +// +//go:embed index.html +var HTML []byte