From d977065cd7a5639d2b1b523795e0e0422e6c9ab7 Mon Sep 17 00:00:00 2001 From: madeofpendletonwool Date: Wed, 14 Oct 2026 15:13:15 -0500 Subject: [PATCH] feat: read-only root and /data partition - Overlay root via the raspi-config initramfs mechanism, enabled at build time; /boot/firmware ro except boxd's remount dance - Third partition (syncphony-data) appended post-export; first boot grows it, not root - Chromium profile, config.env, logs, identity, SSH keys, Wi-Fi persist on /data (docs/persistent-paths.md) - Storage-lost screen when /data can't mount; sudo syncphony-box chroot for maintenance - Smoke test: 3 partitions, overlay wired, persistent paths on /data; power-pull procedure in docs/testing.md --- README.md | 94 ++++++++--- boxd/internal/config/apply.go | 7 +- boxd/internal/config/config.go | 20 ++- boxd/internal/config/mounts.go | 114 ++++++++++++++ boxd/internal/config/mounts_test.go | 143 +++++++++++++++++ boxd/internal/daemon/bridge.go | 1 + boxd/internal/daemon/daemon.go | 13 ++ boxd/internal/daemon/daemon_test.go | 50 ++++++ boxd/internal/daemon/pages.go | 35 ++++ boxd/internal/daemon/screens.go | 22 +++ boxd/internal/daemon/setup.go | 7 +- build.sh | 8 + docs/adr/0006-pairing-persistence.md | 25 +-- docs/adr/0007-readonly-root-and-data.md | 145 +++++++++++++++++ docs/persistent-paths.md | 38 +++++ docs/testing.md | 81 ++++++++++ scripts/add-data-partition.sh | 117 ++++++++++++++ scripts/smoke-test.sh | 149 ++++++++++++++++-- stage-syncphony/01-kiosk/files/kiosk | 11 +- stage-syncphony/01-kiosk/files/syncphony-box | 78 +++++++-- stage-syncphony/06-readonly-root/00-run.sh | 117 ++++++++++++++ .../files/50-syncphony-journald.conf | 8 + .../06-readonly-root/files/data-persist | 143 +++++++++++++++++ .../06-readonly-root/files/grow-data | 99 ++++++++++++ .../06-readonly-root/files/overlay | 90 +++++++++++ .../files/syncphony-data-grow.service | 16 ++ .../files/syncphony-data-prepare.service | 17 ++ .../files/syncphony-data-save.service | 14 ++ .../files/syncphony-data-save.timer | 9 ++ tests/syncphony-box-data.bats | 112 +++++++++++++ tests/syncphony-box-profile.bats | 2 +- 31 files changed, 1719 insertions(+), 66 deletions(-) create mode 100644 boxd/internal/config/mounts.go create mode 100644 boxd/internal/config/mounts_test.go create mode 100644 docs/adr/0007-readonly-root-and-data.md create mode 100644 docs/persistent-paths.md create mode 100644 docs/testing.md create mode 100755 scripts/add-data-partition.sh create mode 100644 stage-syncphony/06-readonly-root/00-run.sh create mode 100644 stage-syncphony/06-readonly-root/files/50-syncphony-journald.conf create mode 100755 stage-syncphony/06-readonly-root/files/data-persist create mode 100755 stage-syncphony/06-readonly-root/files/grow-data create mode 100755 stage-syncphony/06-readonly-root/files/overlay create mode 100644 stage-syncphony/06-readonly-root/files/syncphony-data-grow.service create mode 100644 stage-syncphony/06-readonly-root/files/syncphony-data-prepare.service create mode 100644 stage-syncphony/06-readonly-root/files/syncphony-data-save.service create mode 100644 stage-syncphony/06-readonly-root/files/syncphony-data-save.timer create mode 100644 tests/syncphony-box-data.bats diff --git a/README.md b/README.md index b9c4af3..1a9a6ae 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,10 @@ The result lands in `deploy/`: `make clean` removes build output and the leftover pi-gen build container. `make test` runs the unit tests — the bats suite (`tests/`) that pins the -`syncphony.txt` behavior against a boxd binary built from the repo, plus -`go test` for boxd — no hardware or image build needed; install bats -(`apt install bats`) and Go first. `make lint` checks gofmt and go vet. +`syncphony.txt` behavior and the `/data` persistence script against +binaries built from the repo, plus `go test` for boxd — no hardware or +image build needed; install bats (`apt install bats`) and Go first. +`make lint` checks gofmt and go vet. CI runs them on every PR and push. ### Continuous integration @@ -47,8 +48,9 @@ GitHub Actions builds the image natively on arm64 runners bats and Go tests, runs the no-hardware smoke test (`scripts/smoke-test.sh` loop-mounts the built image and checks packages, enabled units, the locked first user, the first-boot seed files, the kiosk session, the boot -config file, the audio stack, and boxd), and uploads the image as a workflow -artifact. +config file, the audio stack, boxd, and the read-only-root layout: three +partitions, the overlay wired into the initramfs, and every persistent +path on /data), and uploads the image as a workflow artifact. ## boxd @@ -96,9 +98,9 @@ from there. The box's whole identity is its Chromium profile: the server's `syncphony_display` pairing cookie and the "I'm the speaker" localStorage flag live there, so that's what must survive reboots and power cuts. The -profile path is defined in one place (boxd's `DefaultProfileDir`; -stage 3 moves it to `/data` by changing that constant) and reaches the -kiosk wrapper through `config.env`. See +profile lives on the **/data partition** (`/data/chromium` — see below); +the path is defined in one place (boxd's `DefaultProfileDir`) and reaches +the kiosk wrapper through `config.env`. See [docs/adr/0006-pairing-persistence.md](docs/adr/0006-pairing-persistence.md). Two mechanisms keep it durable: @@ -123,6 +125,45 @@ sudo syncphony-box reset In a dev checkout, `make reset` does the same to a profile (point it with `SB_PROFILE=...`; it defaults to the box's path). +## Safe to unplug: read-only root and /data + +The box is meant to be power-cycled like a TV — pulled at any moment, +mid-song included. The image makes that structurally safe +([docs/adr/0007](docs/adr/0007-readonly-root-and-data.md)): + +- **Three partitions:** boot (FAT), root (ext4), and **data** (ext4, + label `syncphony-data`). On first boot the data partition grows to fill + the card; the root never grows. +- **The root filesystem is read-only behind an overlay** — the mechanism + raspi-config's "Overlay FS" option uses, enabled at build time: the + real root is the lower layer, every runtime write lands in a tmpfs + upper and evaporates at power-off. Nothing on the SD card is ever + half-written. +- **Everything that must persist lives on /data** — the pairing (the + Chromium profile), the applied config, logs, the SSH host keys, the + Imager-created user, Wi-Fi credentials. The maintained list is + [docs/persistent-paths.md](docs/persistent-paths.md). +- **/boot/firmware stays read-only** too; boxd remounts it rw for the + moment it writes `syncphony.txt` (the setup form) or a `resolution=` + change, then ro again. +- **A damaged /data never hangs the boot**: the box comes up unpaired + and the TV says the storage is lost (fix: check or re-flash the card). + +The power-pull test procedure (50 pulls: during boot, during playback, +seconds after pairing) is [docs/testing.md](docs/testing.md). + +**Maintaining the box.** Runtime writes under `/` are discarded by +design, so package updates go to the real root through a chroot (the +overlayroot-chroot role in this image): + +```sh +sudo syncphony-box chroot # shell on the real root, read-write +# (or: sudo syncphony-box chroot apt full-upgrade) +``` + +Reboot afterwards. Changes to the pairing, config and anything else on +`/data` need no chroot — that partition is writable at runtime. + ## Audio The box is the room's speaker: Chromium plays everything through the @@ -175,10 +216,10 @@ the box from booting; bad values are logged and fall back to defaults. | `cec` | `on`, `off` | `on` | switch the TV's input when music starts | On every boot, `syncphony-box-config.service` runs `boxd config apply`: -it parses the file into `/etc/syncphony-box/config.env` before the kiosk -starts; `resolution` and `rotate` also become a `video=HDMI-A-1:…D` kernel -argument (the `D` forces the output on, so a TV that's off at boot still -gets a picture later). +it parses the file into `config.env` (kept on `/data`; `/etc/syncphony-box/config.env` +is a symlink to it) before the kiosk starts; `resolution` and `rotate` +also become a `video=HDMI-A-1:…D` kernel argument (the `D` forces the +output on, so a TV that's off at boot still gets a picture later). Without a valid `server_url` the box shows its setup screen: a QR code you scan with a phone on the same network to set the server — no SD card @@ -186,7 +227,9 @@ editing needed. (Editing the card still works too.) ### On-Pi checklist -CI can't boot a Pi; verify these on hardware when one is at hand: +CI can't boot a Pi; verify these on hardware when one is at hand. The +destructive power-pull gauntlet (the done-when for the read-only root) +has its own page: [docs/testing.md](docs/testing.md). - Boots to the server's `/tv` fullscreen in under ~30 s on a Pi 5. - `sudo systemctl kill --kill-who=all syncphony-kiosk` (or killing @@ -194,13 +237,16 @@ CI can't boot a Pi; verify these on hardware when one is at hand: - No visible cursor — including with a mouse plugged in and wiggled. - No crash bubble, and no "restore pages" prompt after pulling power. - Pair → reboot → the box is back in the room and playing without a touch. -- Pair → pull the power at random points 20 times (some within 10 s of - pairing, some during playback, some during boot) → it comes back paired - every time. If one is ever lost, `journalctl -u boxd` should show the - `paired event; syncing disks` line for that boot — that's the finding - to report. - `sudo syncphony-box reset` wipes the pairing: the box shows a fresh pairing code afterwards, and re-pairs normally. +- Read-only root: `findmnt /` shows an overlay, `/boot/firmware` is `ro`, + `/data` is mounted, `touch /usr/bin/nope` survives only until a reboot, + and the setup form still saves (boxd remounts the boot partition around + its write). +- First boot grows `/data` to fill the card (`lsblk`), root stays at + image size. +- `sudo syncphony-box chroot apt update` writes the real root; the change + is still there after a reboot. - The `/tv` visualizer runs smoothly with GPU rasterization (check `chrome://gpu` on the box); if not, tune the GPU flags in the wrapper. - Edit `syncphony.txt` on a laptop (Notepad is fine), boot, and the server @@ -246,14 +292,20 @@ CI can't boot a Pi; verify these on hardware when one is at hand: ``` pi-gen/ pi-gen submodule (pinned, never edited) -stage-syncphony/ custom pi-gen stage (packages, kiosk session, ...) +stage-syncphony/ custom pi-gen stage (packages, kiosk session, + read-only root, ...) boxd/ the box's Go helper daemon (bridge, config, screens) config pi-gen build configuration -build.sh wraps pi-gen's build-docker.sh (builds boxd first) +build.sh wraps pi-gen's build-docker.sh (builds boxd first, + then appends the /data partition after export) Makefile make image / make test / make lint / make clean +scripts/add-data-partition.sh the /data partition post-export step scripts/smoke-test.sh no-hardware image checks (used by CI) -tests/ bats tests pinning the syncphony.txt behavior (used by CI) +tests/ bats tests pinning the syncphony.txt and /data + behavior (used by CI) docs/adr/ architecture decision records +docs/persistent-paths.md the list of everything that survives a power cut +docs/testing.md the hardware test procedures (power pulls & co) ``` ## License diff --git a/boxd/internal/config/apply.go b/boxd/internal/config/apply.go index 9b09dd0..22b384a 100644 --- a/boxd/internal/config/apply.go +++ b/boxd/internal/config/apply.go @@ -193,7 +193,12 @@ func syncCmdline(resolution, rotate string, p Paths, run RunCommand, log *log.Lo if next == current { return } - if err := os.WriteFile(p.Cmdline, []byte(next+"\n"), 0o644); err != nil { + // /boot/firmware stays read-only (ADR 0007): the write goes through the + // remount dance, so a power pull can never catch the FAT half-written. + err := WithMountWritable(p.Cmdline, run, log, func() error { + return os.WriteFile(p.Cmdline, []byte(next+"\n"), 0o644) + }) + if err != nil { log.Printf("boxd config: cannot write %s; display settings stay as they are: %v", p.Cmdline, err) return } diff --git a/boxd/internal/config/config.go b/boxd/internal/config/config.go index 4a11ea3..f913b03 100644 --- a/boxd/internal/config/config.go +++ b/boxd/internal/config/config.go @@ -16,15 +16,27 @@ import ( // apply path can be tested on a dev machine instead of a box. const ( DefaultTxtPath = "/boot/firmware/syncphony.txt" - DefaultEnvPath = "/etc/syncphony-box/config.env" DefaultCmdlinePath = "/boot/firmware/cmdline.txt" + // Everything that must survive a power pull lives on the /data + // partition (MAD-805, ADR 0007); the root filesystem behind its + // overlay is read-only. The complete list is docs/persistent-paths.md. + DefaultDataDir = "/data" + + // DefaultEnvPath is the applied config. It lives on /data (writable); + // /etc/syncphony-box/config.env is a symlink to it so the kiosk + // wrapper and the reset tool keep reading the path they always did. + // A missing /data only costs the write: apply logs it and the kiosk + // falls back to defaults. + DefaultEnvPath = "/data/syncphony-box/config.env" + // DefaultProfileDir is the Chromium profile: where the paired-display // cookie and the speaker localStorage flag live (MAD-804, ADR 0006). // It is the ONE place the path is defined — config.env carries it to - // the kiosk wrapper and the reset tool. Stage 3 moves it to /data by - // changing this constant. SB_PROFILE overrides it for dev and tests. - DefaultProfileDir = "/var/lib/syncphony-box/chromium" + // the kiosk wrapper and the reset tool, and it lives on /data so the + // pairing survives anything the overlay discards (MAD-805). SB_PROFILE + // overrides it for dev and tests. + DefaultProfileDir = "/data/chromium" ) // Config is the validated result of parsing syncphony.txt. Invalid values diff --git a/boxd/internal/config/mounts.go b/boxd/internal/config/mounts.go new file mode 100644 index 0000000..74ccbee --- /dev/null +++ b/boxd/internal/config/mounts.go @@ -0,0 +1,114 @@ +package config + +import ( + "log" + "os" + "strings" + "syscall" +) + +// The mounts table boxd consults: which filesystem a path lives on, and +// whether that filesystem is read-only. On the box this decides two things +// (MAD-805, ADR 0007): +// +// - writes to /boot/firmware (syncphony.txt, cmdline.txt) sit on a +// read-only vfat mount and need the remount dance of WithMountWritable; +// - /data being mounted is what makes the box's storage (and with it the +// pairing) healthy — see daemon.StorageHealthy. +// +// SB_MOUNTS points the parser at a fake table for tests, in the spirit of +// the other SB_* hooks. +const defaultMountsPath = "/proc/mounts" + +// mountsPath resolves the mounts table to read. +func mountsPath() string { + if p := os.Getenv("SB_MOUNTS"); p != "" { + return p + } + return defaultMountsPath +} + +// Mount is one entry of the mounts table that matters here. +type Mount struct { + Point string // mount point, octal escapes left as-is (no box path has them) + RO bool // mounted read-only +} + +// ReadMounts parses the mounts table; a missing or unreadable table yields +// nil (the callers then behave as "not on a read-only mount"). +func ReadMounts() []Mount { + data, err := os.ReadFile(mountsPath()) + if err != nil { + return nil + } + var mounts []Mount + for _, line := range strings.Split(string(data), "\n") { + // Format: device mountpoint fstype options ... + fields := strings.Fields(line) + if len(fields) < 4 { + continue + } + ro := false + for _, opt := range strings.Split(fields[3], ",") { + if opt == "ro" { + ro = true + } + } + mounts = append(mounts, Mount{Point: fields[1], RO: ro}) + } + return mounts +} + +// MountOf returns the deepest mount whose point contains path — the +// filesystem the path lives on. ok is false when no mount contains it (a +// plain file on a dev machine, say). +func MountOf(path string) (m Mount, ok bool) { + bestLen := -1 + for _, mount := range ReadMounts() { + if mount.Point == "/" || mount.Point == path || strings.HasPrefix(path, mount.Point+"/") { + if len(mount.Point) > bestLen { + m, ok, bestLen = mount, true, len(mount.Point) + } + } + } + return m, ok +} + +// Mounted reports whether the given mount point carries a filesystem. +func Mounted(point string) bool { + for _, mount := range ReadMounts() { + if mount.Point == point { + return true + } + } + return false +} + +// WithMountWritable runs fn while the filesystem holding path is writable, +// then puts it back: remount rw, run fn, sync, remount ro. The box keeps +// /boot/firmware read-only (ADR 0007) so a power pull can never catch the +// FAT partition half-written; the only writes that ever hit it go through +// this dance. +// +// A path on an already-writable filesystem — or on no mount at all (the +// SB_* test hooks point at plain files) — runs fn directly, so dev +// machines and tests never perform (or fail) a remount. +// +// Fail-safe bias: a fn error is returned even if the ro remount also fails +// (the write may have landed), and a failed remount back to ro is logged +// but never discarded silently — the box keeps running either way. +func WithMountWritable(path string, run RunCommand, log *log.Logger, fn func() error) error { + mount, ok := MountOf(path) + if !ok || !mount.RO { + return fn() + } + if err := run("mount", "-o", "remount,rw", mount.Point); err != nil { + return err + } + err := fn() + syscall.Sync() + if roErr := run("mount", "-o", "remount,ro", mount.Point); roErr != nil { + log.Printf("boxd: cannot remount %s read-only after writing: %v (leaving it writable)", mount.Point, roErr) + } + return err +} diff --git a/boxd/internal/config/mounts_test.go b/boxd/internal/config/mounts_test.go new file mode 100644 index 0000000..a10e9cf --- /dev/null +++ b/boxd/internal/config/mounts_test.go @@ -0,0 +1,143 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// fakeMounts writes a /proc/mounts-shaped file and points SB_MOUNTS at it. +func fakeMounts(t *testing.T, content string) { + t.Helper() + p := filepath.Join(t.TempDir(), "mounts") + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("SB_MOUNTS", p) +} + +func TestMountOfPicksTheDeepestReadOnlyMount(t *testing.T) { + fakeMounts(t, `/dev/mmcblk0p2 / ext4 rw,relatime 0 0 +/dev/mmcblk0p1 /boot/firmware vfat ro,relatime 0 0 +tmpfs /run tmpfs rw 0 0 +`) + m, ok := MountOf("/boot/firmware/syncphony.txt") + if !ok || m.Point != "/boot/firmware" || !m.RO { + t.Fatalf("boot file: %+v ok=%v", m, ok) + } + m, ok = MountOf("/etc/hostname") + if !ok || m.Point != "/" || m.RO { + t.Fatalf("etc file: %+v ok=%v", m, ok) + } +} + +func TestMountOfRootContainsEverything(t *testing.T) { + fakeMounts(t, "/dev/sda / ext4 ro 0 0\n") + if _, ok := MountOf("/anywhere/file"); !ok { + t.Fatal("/ should contain every absolute path") + } +} + +func TestMountOfMissingTable(t *testing.T) { + t.Setenv("SB_MOUNTS", filepath.Join(t.TempDir(), "nope")) + if _, ok := MountOf("/boot/firmware/cmdline.txt"); ok { + t.Fatal("no table means no mount found") + } +} + +func TestMounted(t *testing.T) { + fakeMounts(t, "/dev/mmcblk0p3 /data ext4 rw,relatime 0 0\n") + if !Mounted("/data") { + t.Fatal("/data should read as mounted") + } + if Mounted("/boot/firmware") { + t.Fatal("/boot/firmware is not in the table") + } +} + +func TestWithMountWritableOnReadOnlyMount(t *testing.T) { + fakeMounts(t, `/dev/mmcblk0p2 / overlay rw,relatime 0 0 +/dev/mmcblk0p1 /boot/firmware vfat ro,relatime 0 0 +`) + var calls []string + run := func(name string, args ...string) error { + calls = append(calls, name+" "+strings.Join(args, " ")) + return nil + } + wrote := false + err := WithMountWritable("/boot/firmware/syncphony.txt", run, testLogger(), func() error { + wrote = true + return nil + }) + if err != nil { + t.Fatal(err) + } + if !wrote { + t.Fatal("fn never ran") + } + want := []string{ + "mount -o remount,rw /boot/firmware", + "mount -o remount,ro /boot/firmware", + } + if strings.Join(calls, ";") != strings.Join(want, ";") { + t.Fatalf("remount dance = %v, want %v", calls, want) + } +} + +func TestWithMountWritableOnWritableMountSkipsTheDance(t *testing.T) { + fakeMounts(t, "/dev/mmcblk0p2 / overlay rw,relatime 0 0\n") + calls := 0 + run := func(name string, args ...string) error { + calls++ + return nil + } + if err := WithMountWritable("/etc/whatever", run, testLogger(), func() error { return nil }); err != nil { + t.Fatal(err) + } + if calls != 0 { + t.Fatalf("writable path triggered %d mount calls", calls) + } +} + +func TestWithMountWritableWithNoTableSkipsTheDance(t *testing.T) { + // The SB_* hooks point at plain files on a dev machine: nothing is + // mounted, nothing is read-only, so the write just happens. This is + // what keeps the bats apply tests (and a dev `boxd config apply`) + // working without a mount table to dance with. + t.Setenv("SB_MOUNTS", filepath.Join(t.TempDir(), "nope")) + calls := 0 + run := func(name string, args ...string) error { + calls++ + return nil + } + wrote := false + err := WithMountWritable("/tmp/cmdline.txt", run, testLogger(), func() error { + wrote = true + return nil + }) + if err != nil || !wrote { + t.Fatalf("err=%v wrote=%v", err, wrote) + } + if calls != 0 { + t.Fatalf("no-table path triggered %d mount calls", calls) + } +} + +func TestWithMountWritableFailsCleanlyWhenRemountFails(t *testing.T) { + fakeMounts(t, "/dev/mmcblk0p1 /boot/firmware vfat ro 0 0\n") + run := func(name string, args ...string) error { + return os.ErrPermission + } + ran := false + err := WithMountWritable("/boot/firmware/syncphony.txt", run, testLogger(), func() error { + ran = true + return nil + }) + if err == nil { + t.Fatal("a failed rw remount must surface") + } + if ran { + t.Fatal("fn must not run against a still-read-only filesystem") + } +} diff --git a/boxd/internal/daemon/bridge.go b/boxd/internal/daemon/bridge.go index 472ed7e..890cc5c 100644 --- a/boxd/internal/daemon/bridge.go +++ b/boxd/internal/daemon/bridge.go @@ -29,6 +29,7 @@ func (d *Daemon) bridgeMux() *http.ServeMux { mux.HandleFunc("GET /status", d.handleStatus) mux.HandleFunc("GET /setup", d.handleSetupScreen) mux.HandleFunc("GET /start", d.handleStartScreen) + mux.HandleFunc("GET /storage", d.handleStorageScreen) return mux } diff --git a/boxd/internal/daemon/daemon.go b/boxd/internal/daemon/daemon.go index ae8a136..ee7616d 100644 --- a/boxd/internal/daemon/daemon.go +++ b/boxd/internal/daemon/daemon.go @@ -349,6 +349,19 @@ func (d *Daemon) watch() { } } +// storageHealthy reports whether /data — the only partition that survives +// the overlay (MAD-805, ADR 0007) — is mounted. Without it the box boots +// anyway, but the pairing, the applied config and the logs are gone (they +// live on tmpfs for the boot), so the kiosk shows the storage-lost screen +// instead of pretending everything is fine. SB_STORAGE pins the answer in +// dev and tests ("ok"/"missing"), like the other SB_* hooks. +func storageHealthy() bool { + if v := os.Getenv("SB_STORAGE"); v != "" { + return v != "missing" + } + return config.Mounted(config.DefaultDataDir) +} + // boxIPs lists the box's LAN addresses (the offline screen shows them, so // whoever is setting the box up can reach the setup form by IP if mDNS // doesn't work on their phone). diff --git a/boxd/internal/daemon/daemon_test.go b/boxd/internal/daemon/daemon_test.go index 5404cfb..70d5597 100644 --- a/boxd/internal/daemon/daemon_test.go +++ b/boxd/internal/daemon/daemon_test.go @@ -27,6 +27,9 @@ func newTestDaemon(t *testing.T, txtContent string) (*Daemon, *recorder) { rec := &recorder{} t.Setenv("SB_TXT", txt) t.Setenv("SB_ENV", env) + // No dev machine has /data mounted: the storage hook pins "healthy" so + // the screens under test don't trip over the host's mount table. + t.Setenv("SB_STORAGE", "ok") d := New("test-version", log.New(log.Writer(), "", 0), rec.run) return d, rec } @@ -325,16 +328,63 @@ func TestStatusReportsServerAndTarget(t *testing.T) { Server string `json:"server"` Reachable bool `json:"reachable"` Target string `json:"target"` + Storage bool `json:"storage"` } json.Unmarshal(rec.Body.Bytes(), &st) if !st.Configured || st.Server != srv.URL || !st.Reachable { t.Fatalf("got %+v", st) } + if !st.Storage { + t.Fatalf("storage hook is ok but /status reports %+v", st) + } if want := config.TargetURL(srv.URL, "test-version", "Den TV"); st.Target != want { t.Fatalf("target %q, want %q", st.Target, want) } } +// --- storage-lost screen (MAD-805) ------------------------------------------- + +// TestStorageScreenTakesOverStartAndSetup: without /data the box still +// boots, but the kiosk must not pretend — the storage screen wins over +// both entry pages, whatever the config says. +func TestStorageScreenTakesOverStartAndSetup(t *testing.T) { + for _, tc := range []struct { + name, txt, path string + }{ + {"unconfigured setup", "", "/setup"}, + {"unconfigured start", "", "/start"}, + {"configured start", "server_url=https://s.example\n", "/start"}, + {"configured setup", "server_url=https://s.example\n", "/setup"}, + } { + t.Run(tc.name, func(t *testing.T) { + d, _ := newTestDaemon(t, tc.txt) + t.Setenv("SB_STORAGE", "missing") // helper pins ok; lose /data here + rec := get(t, d.bridgeMux(), tc.path) + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "lost its storage") { + t.Fatalf("no storage screen: %s", rec.Body) + } + }) + } +} + +func TestStorageScreenNamesWhatToDo(t *testing.T) { + d, _ := newTestDaemon(t, "server_url=https://s.example\nname=Den TV\n") + t.Setenv("SB_STORAGE", "missing") + rec := get(t, d.bridgeMux(), "/storage") + body := rec.Body.String() + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + for _, want := range []string{"Den TV", "SD card", "pairing"} { + if !strings.Contains(body, want) { + t.Errorf("storage screen should mention %q", want) + } + } +} + // --- LAN setup form ---------------------------------------------------------- func TestLANClosedWhenConfigured(t *testing.T) { diff --git a/boxd/internal/daemon/pages.go b/boxd/internal/daemon/pages.go index ee50d17..1e64c2a 100644 --- a/boxd/internal/daemon/pages.go +++ b/boxd/internal/daemon/pages.go @@ -82,6 +82,41 @@ type startScreenData struct { Network string } +type storageScreenData struct { + Name string + IPs []string +} + +// storageScreenHTML is the "the SD card's data partition is gone" screen +// (MAD-805): shown instead of setup or start when /data never mounted. +// There is nothing to retry on the TV — the fix is at the card — so the +// page just names it. Pairing-wise nothing is lost that wasn't already: +// the box re-pairs from scratch on the next boot with healthy storage. +func storageScreenHTML(d storageScreenData) string { + b := &strings.Builder{} + t := template.Must(template.New("storage").Parse(` + + + + +Syncphony Box — storage lost + + + +
+

{{.Name}} lost its storage

+

The card's data partition couldn't be read, so this box forgot its room + and its pairing. It is still running, but nothing survives a reboot.

+

Power it off, check or re-flash the SD card, and power it back + on — it will offer a fresh pairing code.

+ {{if .IPs}}

Reach it at {{range .IPs}}http://{{.}} {{end}}

{{end}} +
+ +`)) + t.Execute(b, d) + return b.String() +} + func startScreenHTML(d startScreenData) string { targetJS, err := json.Marshal(d.Target) if err != nil { diff --git a/boxd/internal/daemon/screens.go b/boxd/internal/daemon/screens.go index 0117327..0f4dcaa 100644 --- a/boxd/internal/daemon/screens.go +++ b/boxd/internal/daemon/screens.go @@ -23,6 +23,7 @@ func (d *Daemon) handleStatus(w http.ResponseWriter, r *http.Request) { "hostname": hostname(), "ips": boxIPs(), "network": networkState(), + "storage": storageHealthy(), }) } @@ -36,6 +37,12 @@ func hostname() string { // handleSetupScreen serves the TV setup page: a big QR code and // http://.local, which the phone opens to set the server URL. func (d *Daemon) handleSetupScreen(w http.ResponseWriter, r *http.Request) { + if !storageHealthy() { + // Setup makes no sense without /data: the pairing would die with + // the overlay at the next power cut anyway. Say so instead. + d.handleStorageScreen(w, r) + return + } if d.configured() { // The kiosk only lands here before the first navigate succeeds; if // the server URL is set, the start page is the right entry. @@ -60,6 +67,10 @@ func (d *Daemon) handleSetupScreen(w http.ResponseWriter, r *http.Request) { // shows Chromium's error page — it shows the offline screen and returns to // /tv on its own once /healthz answers. func (d *Daemon) handleStartScreen(w http.ResponseWriter, r *http.Request) { + if !storageHealthy() { + d.handleStorageScreen(w, r) + return + } if !d.configured() { http.Redirect(w, r, "/setup", http.StatusFound) return @@ -73,6 +84,17 @@ func (d *Daemon) handleStartScreen(w http.ResponseWriter, r *http.Request) { writePage(w, startScreenHTML(st)) } +// handleStorageScreen says the quiet part out loud when /data failed to +// mount: the box boots regardless (the overlay keeps it alive), but the +// pairing and everything else that used to live on /data is gone — this +// boot runs on throwaway tmpfs. The screen names it and says what to do. +func (d *Daemon) handleStorageScreen(w http.ResponseWriter, r *http.Request) { + writePage(w, storageScreenHTML(storageScreenData{ + Name: d.displayName(), + IPs: boxIPs(), + })) +} + func qrPNG(content string) (string, error) { png, err := qrcode.Encode(content, qrcode.Medium, 560) if err != nil { diff --git a/boxd/internal/daemon/setup.go b/boxd/internal/daemon/setup.go index 0e5da06..5dd59f2 100644 --- a/boxd/internal/daemon/setup.go +++ b/boxd/internal/daemon/setup.go @@ -65,7 +65,12 @@ func (d *Daemon) handleSetupSave(w http.ResponseWriter, r *http.Request) { if name != "" { pairs = append(pairs, config.KV{Key: "name", Value: name}) } - if err := config.SetKeys(d.paths.Txt, pairs...); err != nil { + // syncphony.txt sits on the read-only boot partition (ADR 0007): the + // save goes through the remount dance (rw, write, sync, ro). + err = config.WithMountWritable(d.paths.Txt, d.run, d.log, func() error { + return config.SetKeys(d.paths.Txt, pairs...) + }) + if err != nil { d.renderFormError(w, "Couldn't save the setting on the box's boot partition. Try again, or edit syncphony.txt on the SD card.") d.log.Printf("boxd: setup: save failed: %v", err) return diff --git a/build.sh b/build.sh index 93e469f..0bad7ae 100755 --- a/build.sh +++ b/build.sh @@ -6,6 +6,8 @@ # to syncphony-box--arm64.img.xz (+ .sha256) # - neutralizes stage2's EXPORT_IMAGE (pi-gen's documented SKIP_IMAGES # mechanism) so only stage-syncphony exports an image +# - adds the third (/data) partition after export (scripts/ +# add-data-partition.sh; pi-gen's export stage can only lay out two) # - bind-mounts stage-syncphony into the build container at /stage-syncphony # so the ../stage-syncphony entry of STAGE_LIST resolves (build-docker.sh # bakes only the pi-gen submodule into the container image) @@ -60,6 +62,12 @@ if [ ! -f "${IMAGE}" ]; then exit 1 fi +# pi-gen's export stage ships a two-partition image; the box's /data +# partition is appended now (needs sudo for losetup/mount/mke2fs, like +# the smoke test). See ADR 0007. +echo "Adding the /data partition..." +./scripts/add-data-partition.sh "${IMAGE}" + sha256sum "${IMAGE}" > "${IMAGE}.sha256" echo diff --git a/docs/adr/0006-pairing-persistence.md b/docs/adr/0006-pairing-persistence.md index f56796c..e721400 100644 --- a/docs/adr/0006-pairing-persistence.md +++ b/docs/adr/0006-pairing-persistence.md @@ -31,14 +31,14 @@ Chromium crashes. Two facts make that non-trivial: ### One place defines the profile path -`config.DefaultProfileDir` (`/var/lib/syncphony-box/chromium`) is the only -definition; the `SB_PROFILE` env hook overrides it for dev and tests. -`boxd config apply` writes it into `config.env` as `PROFILE_DIR=`, the -kiosk wrapper reads it from there (asking `boxd profile path` when -`config.env` is missing, with a last-resort literal the smoke test pins to -boxd's answer), and `sudo syncphony-box reset` uses the same value. When -stage 3 moves the profile to the `/data` partition (MAD-805), changing the -constant is the whole change. +`config.DefaultProfileDir` is the only definition; the `SB_PROFILE` env +hook overrides it for dev and tests. `boxd config apply` writes it into +`config.env` as `PROFILE_DIR=`, the kiosk wrapper reads it from there +(asking `boxd profile path` when `config.env` is missing, with a +last-resort literal the smoke test pins to boxd's answer), and `sudo +syncphony-box reset` uses the same value. MAD-805 (stage 3, ADR 0007) +moved the profile to the `/data` partition — `/data/chromium` — by +changing exactly that constant. ### The bridge `paired` event triggers a sync burst @@ -100,9 +100,12 @@ no-op when the profile doesn't exist. - `sync(2)` blocks for the length of the flush; on an idle Pi that is milliseconds, and it runs on a background goroutine so the bridge never waits for it. -- Hardware verification stays on the on-Pi checklist (README): pair → - reboot → back in the room playing without a touch; 20 power pulls at - random points after pairing; no restore prompt after any of them. +- Hardware verification stays on the on-Pi checklist (README) and the + power-pull gauntlet in docs/testing.md: pair → reboot → back in the + room playing without a touch; pulls at random points after pairing; no + restore prompt after any of them. With MAD-805 the profile sits on + /data behind a journaled ext4, so the bursts flush to the one + partition that persists. ## Considered and rejected diff --git a/docs/adr/0007-readonly-root-and-data.md b/docs/adr/0007-readonly-root-and-data.md new file mode 100644 index 0000000..e25e6b6 --- /dev/null +++ b/docs/adr/0007-readonly-root-and-data.md @@ -0,0 +1,145 @@ +# ADR 0007: read-only root and the /data partition + +- Status: Accepted +- Date: 2026-10-14 +- Deciders: Phase 11 plan (MAD-797), read-only-root issue (MAD-805) + +## Context + +The box sits in a living room: people unplug it, the power blips, nobody +runs `shutdown`. ADR 0006 made the *pairing* durable; this ADR is about +the whole filesystem. A write-mounted ext4 on an SD card will eventually +be caught mid-write by a power pull, and a corrupted root means a box +that does not come back — the one failure mode the box cannot afford. + +## Decision + +### Three partitions; root is read-only behind an overlay + +The image ships three partitions: + +1. **boot** (FAT, as before) — kernel, firmware, `syncphony.txt`; +2. **root** (ext4) — the OS, mounted read-only at runtime; +3. **data** (ext4, label `syncphony-data`, 512 MiB in the image) — + everything that must persist. + +pi-gen's export stage can only lay out two partitions, so +`scripts/add-data-partition.sh` appends the third after export: it grows +the image file, `parted mkpart`s the partition, `mke2fs -d`s the +persistent layout into it (including the kiosk-owned Chromium profile +dir), and resolves the `DATADEV` placeholder in fstab to +`PARTUUID=-03` — the same disk-id spelling pi-gen's own +04-set-partuuid uses. + +The overlay is exactly the mechanism `raspi-config`'s "Overlay FS" option +uses, enabled at build time instead of at runtime: an initramfs script at +`/etc/initramfs-tools/scripts/overlay` redefines `local_mount_root()` so +the real root is mounted read-only and overlaid with a tmpfs upper layer, +and the `boot=overlay` kernel command-line token activates it (the +initramfs `/init` sources `/scripts/$BOOT`). The command line is also +where the stock `resize` token comes *out* — see below. Our copy of the +script keeps the real root visible read-only at `/media/root-ro` for +maintenance, and mounts `/data` itself (after an `e2fsck -p`), so every +systemd unit — journald, cloud-init, the kiosk — sees the persistent +partition from the first moment. + +`/boot/firmware` is mounted read-only too (`defaults,ro` in fstab). The +only runtime writes to it are boxd's own: `syncphony.txt` (the setup +form) and `cmdline.txt` (a changed `resolution=`/`rotate=`). Both go +through one remount dance — remount rw, write, sync, remount ro, and fail +safe either way — in `config.WithMountWritable`. + +### /data holds everything that persists; one document lists it + +The Chromium profile, the applied `config.env`, boxd's state, persistent +journald logs (capped at 50 MB), the machine-id, cloud-init's state, the +account files (the Imager user), SSH host keys, and Wi-Fi connections. +Most are symlinks into `/data`; the account files, SSH keys and +connections can't be (rewritten atomically, read too early), so +`data-persist` restores them from `/data` before cloud-init/sshd/ +NetworkManager start and saves them back after the system settles plus +every 15 minutes. The complete, maintained list is +[docs/persistent-paths.md](../persistent-paths.md). + +Two interactions the overlay forces, and how they're handled: + +- **cloud-init.** With the overlay, `/etc/passwd` changes would vanish at + the first reboot; with `/var/lib/cloud` persisted, cloud-init runs its + first-boot modules exactly once and the identity restore/save pair keeps + the resulting user. Without the pair, the box would lose SSH/console + login after its first power cycle. +- **machine-id.** PID 1 binds it before any service runs, so a + userspace-only restore would be too late: the initramfs overlay script + seeds `/etc/machine-id` from `/data/system/machine-id`, and + `data-persist save` writes the bound id back — the first boot's id + becomes the box's permanent one. + +### First boot grows /data, not root + +The stock Trixie mechanism (`resize_early` in the initramfs + +`rpi-resize.service`) grows partition 2 — the root — which we never want +to grow. The `resize` cmdline token is removed at build time (leaving +both stock scripts inert; a package update reinstalling them changes +nothing), `rpi-resize.service` is disabled, and our own +`local-premount/grow-data` script — the same script, retargeted — grows +the **last** partition to fill the card. `syncphony-data-grow.service` +then runs the online `resize2fs` once (a `/data/system/.grown` marker +skips it afterwards; a reformatted `/data` loses the marker and grows +again). + +### A broken /data never hangs the boot + +`e2fsck -p` in the initramfs, `nofail` in fstab, and every service that +touches `/data` conditioned on it being mounted. If the partition can't +be read, the box boots with everything persistent on throwaway tmpfs: +unpaired, and with boxd's storage screen on the TV saying exactly that +instead of pretending. + +### Maintenance writes the real root + +The overlay makes on-box package updates an illusion that evaporates at +reboot, so the apt/unattended-upgrades timers are disabled. The supported +path is `sudo syncphony-box chroot [cmd]` (the overlayroot-chroot role): +it mounts the real root read-write at `/media/root-rw`, chroots with +`/dev` `/proc` `/sys` bound in, and changes take effect after a reboot. +The stage-4 updater builds on this. + +## Consequences + +- The box can be unplugged at any point — mid-boot, mid-playback, seconds + after pairing — and comes back paired; the worst case is a replayed + journal gap. The 50-pull test procedure is in + [docs/testing.md](../testing.md). +- Anything that writes and isn't on the list in + docs/persistent-paths.md is silently discarded at reboot. That is the + feature; adding a persistent path is a deliberate act. +- The Chromium profile lives on a journaled ext4 mounted rw — it is the + one thing still exposed to power pulls, mitigated by ADR 0006's sync + bursts and by ext4 journaling itself. +- Root filesystem size is fixed at build time (plus pi-gen's margin); + OS growth beyond that margin means rebuilding the image, which is the + update model anyway (stage 4). +- Dev machines and tests never perform a remount dance: the SB_* hooks + point at plain files, and `WithMountWritable` only dances when the + target actually sits on a read-only mount. + +## Considered and rejected + +- **The `overlayroot` package / Ubuntu's tooling.** Raspberry Pi OS's own + overlay mechanism (what raspi-config enables) is simpler, already in + the initramfs, and needs no extra package; `syncphony-box chroot` + covers the maintenance half that overlayroot-chroot would. +- **Keeping the stock `resize` token and shipping an fstab fixup.** The + stock `set_partuuid` initramfs script rewrites the disk id and sed's + fstab *through the overlay* on first boot — the edit lands in tmpfs and + the box breaks on its second boot. Removing the token and growing a + fixed root-free partition sidesteps all of it. +- **Bind-mounting /etc/passwd & friends from /data.** Atomic-replace + writers (passwd, shadow, useradd) rename over the bind mount and fail + with EBUSY; the restore/save pair keeps those tools working. +- **Read-write /boot/firmware.** The FAT has no journal at all; a power + pull mid-write there is the nastiest corruption of the three + partitions. Read-only with a remount dance for the two files boxd owns. +- **A squashfs root instead of an overlay.** More work to build and to + update for the same guarantee; the overlay is the mechanism the OS + already ships and documents. diff --git a/docs/persistent-paths.md b/docs/persistent-paths.md new file mode 100644 index 0000000..344e8fd --- /dev/null +++ b/docs/persistent-paths.md @@ -0,0 +1,38 @@ +# Persistent paths on the box + +The root filesystem is read-only behind an overlay (ADR 0007): everything +written under `/` at runtime lands in a tmpfs and vanishes at power-off. +Anything that must survive lives on `/data`, the third partition (label +`syncphony-data`). This page is the complete list — when a path is added +to or moved off `/data`, this list changes in the same commit. + +| path | lives at | put there by | what it is | +|---|---|---|---| +| Chromium profile (pairing cookie, speaker flag) | `/data/chromium` | kiosk user; path pinned by boxd's `config.DefaultProfileDir` | the box's whole identity — the room pairing | +| applied config | `/data/syncphony-box/config.env` | `boxd config apply` / the setup form | parsed `syncphony.txt`; `/etc/syncphony-box/config.env` is a symlink to it | +| boxd's own state | `/data/boxd` | boxd (as needed) | reserved for daemon state (e.g. learned CEC addresses) | +| journald logs | `/data/journal` | journald (`/var/log/journal` → symlink) | persistent logs, capped at 50 MB (`SystemMaxUse=50M`) | +| machine identity | `/data/system/machine-id` | `data-persist save` (the bound id) | seeded into `/etc/machine-id` by the initramfs overlay script | +| cloud-init state | `/data/system/cloud` | cloud-init (`/var/lib/cloud` → symlink) | without it cloud-init would re-run its first boot every boot | +| account files | `/data/system/identity/{passwd,shadow,group,gshadow,subuid,subgid}` | `data-persist save` → restored by `data-persist restore` | the Imager-created user and password; without this they would vanish with the overlay | +| SSH host keys | `/data/system/ssh/` | `ssh-keygen -A` at restore, saved back by `save` | `/etc/ssh/ssh_host_*` are symlinks here; keeps `known_hosts` stable | +| network connections | `/data/system/network/` | `data-persist save` → restored by `restore` | NetworkManager `system-connections` (Wi-Fi credentials) | + +Not on `/data`, still persistent: + +| path | where | what | +|---|---|---| +| `syncphony.txt` | the FAT boot partition (`/boot/firmware`) | the box's config, editable from any computer; written only through the remount dance (rw → write → sync → ro) | + +Everything else is deliberately throwaway: apt state, Chromium cache, +`$HOME`, `systemd`'s runtime state. The overlay discarding them is the +feature — the SD card can be yanked at any moment without corrupting +anything that matters. + +## If /data can't mount + +The initramfs fsck's `/data` (`e2fsck -p`) and mounts it only when it's +clean enough. If it isn't, the box boots anyway: the pairing is lost, the +kiosk shows the storage-lost screen, and everything above runs on tmpfs +for that boot. The fix is at the card (check, re-flash, or replace it) — +nothing on the TV is recoverable. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..616c6b1 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,81 @@ +# Testing the box on hardware + +CI can't boot a Pi. The unit tests (bats + Go, `make test`) and the image +smoke test (`scripts/smoke-test.sh`, runs in CI on every build) pin the +image's structure; the checks below are for real hardware. The README's +on-Pi checklist covers the everyday list — this page is the destructive +one. + +## The power-pull gauntlet (50 pulls) + +**Done-when for the read-only root (MAD-805):** 50 random power pulls — +some during boot, some during playback, some seconds after pairing — +leave a box that boots, is still paired, and plays. + +### Setup + +- A box flashed with the current image, paired to a Syncphony server, + playing. +- Any smart plug that can be power-cycled on a schedule (a cheap Wi-Fi + plug with a repeating timer is enough; this is the "smart plug on a + timer" the issue calls for). Suggested cycle: **on 2 minutes, off 30 + seconds, repeat**. That lands pulls all over the boot/playback + timeline without babysitting. For the pairing-adjacent pulls, cut power + by hand 5–15 s after the pairing code disappears. + +### Procedure + +1. Confirm the layout first: `findmnt /` shows `overlay`, `findmnt /data` + shows the ext4, `findmnt /boot/firmware` shows `ro`. +2. Set the plug cycling. Let it run through **40 cycles** unattended. +3. While it cycles, do **5 pulls by hand during boot** (cut 5–20 s after + applying power) and **5 pulls by hand within 10 s of pairing** (unpair + with `sudo syncphony-box reset` between them; the box re-shows a code). +4. After **every** power restore, check the three things: + - the box boots to the room (no setup screen, no error page); + - it's **still paired** (shows as the room's screen in the app, no new + pairing code); + - playback works (start something from the app; sound comes out). +5. Record the result of each pull (a tick sheet is fine — pass/boot, + pass/paired, pass/plays). + +### Pass criteria + +All 50: boots, paired, plays. If any pull loses the pairing, +`journalctl -u boxd -b` on the *recovered* boot should show the +`paired event; syncing disks` line from the pull that lost it — that's +the finding to report, along with which pull number and what the box was +doing. A box that fails to boot at all is a different, more serious +finding: capture `journalctl -b -1` after a manual recovery if possible. + +### Reading the logs afterwards + +Logs persist across power pulls (journald on `/data`, 50 MB cap), so +`journalctl -b -1` reaches the boot *before* the current one: + +```sh +journalctl -b -1 -u boxd -u syncphony-kiosk # the boot that got cut +journalctl --list-boots | wc -l # one per pull +``` + +## Related one-off checks + +- **Overlay really discards writes:** `sudo touch /usr/bin/nope`, reboot, + `ls /usr/bin/nope` → gone. `findmnt /` says `overlay`. +- **Boot partition stays ro:** `touch /boot/firmware/nope` → + `Read-only file system`. The setup form (QR flow) still saves — boxd + remounts around its write. +- **First-boot grow:** on a fresh flash, `lsblk` shows `/data` grown to + fill the card after the first boot, and + `systemctl status syncphony-data-grow` ran once. +- **A destroyed /data still boots:** (sacrificial card) `dd` garbage over + the third partition from another machine, boot → the TV shows the + storage-lost screen and the box is reachable; fix by re-flashing. +- **Maintenance path:** `sudo syncphony-box chroot`, `apt update` inside, + exit, reboot — the update is on the real root + (`grep -r . /media/root-ro/usr/bin/nope`-style spot check, or a package + version). +- **Imager user survives:** flash with a username/password in the Imager, + boot, reboot, SSH in with it — works (the identity restore/save round + trip). SSH host keys identical across two reboots: + `ssh-keygen -lf /data/system/ssh/ssh_host_ed25519_key.pub`. diff --git a/scripts/add-data-partition.sh b/scripts/add-data-partition.sh new file mode 100755 index 0000000..ac5c86d --- /dev/null +++ b/scripts/add-data-partition.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# add-data-partition — the /data post-export step (MAD-805, ADR 0007). +# +# pi-gen's export stage lays out two partitions (boot FAT + root ext4) and +# ships the compressed image; nothing inside it can add a third. So after +# pi-gen finishes, this script reopens the built image and appends the +# data partition: +# +# - a 512 MiB ext4 labelled syncphony-data as partition 3 (the grow-data +# initramfs script expands it to fill the card on first boot, and +# syncphony-data-grow.service grows the filesystem to match) +# - the /data directory layout pre-created, with the Chromium profile +# dir owned by the kiosk user (docs/persistent-paths.md) +# - the fstab DATADEV placeholder (written by stage 06) replaced with +# the new partition's PARTUUID — same disk-id spelling pi-gen's own +# 04-set-partuuid uses for partitions 1 and 2 +# +# Needs root for losetup/mount/mke2fs — via sudo, exactly like +# scripts/smoke-test.sh. Runs on the host after ./pi-gen/build-docker.sh; +# ./build.sh calls it. +# +# SB_DATA_PART_SIZE overrides the initial partition size (bytes) for +# tests. + +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "usage: $0 .img.xz" >&2 + exit 2 +fi + +ALIGN=$((8 * 1024 * 1024)) # pi-gen aligns all partition starts to 8 MiB +DATA_SIZE="${SB_DATA_PART_SIZE:-$((512 * 1024 * 1024))}" + +IMG_XZ="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" +WORK="$(mktemp -d /tmp/syncphony-box-data.XXXXXX)" +LOOP="" + +cleanup() { + sudo umount "${WORK}/root" 2>/dev/null || true + if [ -n "${LOOP}" ]; then + sudo losetup -d "${LOOP}" 2>/dev/null || true + fi + rm -rf "${WORK}" +} +trap cleanup EXIT + +echo "add-data-partition: decompressing ${IMG_XZ} ..." +xz -dk -T0 -c "${IMG_XZ}" > "${WORK}/image.img" +IMG="${WORK}/image.img" + +# The MBR disk id is what names partitions in fstab as PARTUUID=-NN. +IMGID="$(dd if="${IMG}" skip=440 bs=1 count=4 2>/dev/null | xxd -e | cut -f 2 -d ' ')" +if [ -z "${IMGID}" ]; then + echo "add-data-partition: cannot read the disk id" >&2 + exit 1 +fi + +# Append partition 3 directly after the last existing one, aligned. +LAST_END="$(parted -m -s "${IMG}" unit B print | awk -F: '$1 ~ /^[0-9]+$/ { e = $3 } END { sub(/B$/, "", e); print e }')" +if [ -z "${LAST_END}" ]; then + echo "add-data-partition: cannot read the partition table" >&2 + exit 1 +fi +DATA_START=$(( (LAST_END + 1 + ALIGN - 1) / ALIGN * ALIGN )) +NEW_SIZE=$(( DATA_START + (DATA_SIZE + ALIGN - 1) / ALIGN * ALIGN )) + +truncate -s "${NEW_SIZE}" "${IMG}" +parted --script "${IMG}" unit B mkpart primary ext4 "${DATA_START}" "$((NEW_SIZE - 1))" + +LOOP="$(sudo losetup --find --show --partscan "${IMG}")" + +# The kiosk user's numeric uid/gid from the image's own account database, +# so the pre-created profile dir lands owned correctly even though the +# user only exists inside the image. +mkdir -p "${WORK}/root" +sudo mount -o ro "${LOOP}p2" "${WORK}/root" +KIOSK_UID="$(sudo awk -F: '$1=="kiosk"{print $3}' "${WORK}/root/etc/passwd")" +KIOSK_GID="$(sudo awk -F: '$1=="kiosk"{print $3}' "${WORK}/root/etc/group")" +sudo umount "${WORK}/root" +if [ -z "${KIOSK_UID}" ] || [ -z "${KIOSK_GID}" ]; then + echo "add-data-partition: no kiosk user in the image" >&2 + exit 1 +fi + +# The persistent layout (docs/persistent-paths.md). data-persist recreates +# anything missing at boot; pre-creating it makes the very first boot +# (journald, ssh-keygen) persistent from the start. +STAGING="${WORK}/data" +mkdir -p \ + "${STAGING}/chromium" \ + "${STAGING}/journal" \ + "${STAGING}/syncphony-box" \ + "${STAGING}/boxd" \ + "${STAGING}/system/cloud" \ + "${STAGING}/system/identity" \ + "${STAGING}/system/ssh" \ + "${STAGING}/system/network" +sudo chown "${KIOSK_UID}:${KIOSK_GID}" "${STAGING}/chromium" + +echo "add-data-partition: making the ${DATA_SIZE}-byte ext4 (label syncphony-data) ..." +sudo mke2fs -q -t ext4 -L syncphony-data -d "${STAGING}" "${LOOP}p3" + +# fstab: the DATADEV placeholder (stage 06 wrote it) becomes the real +# PARTUUID, like pi-gen's 04-set-partuuid does for BOOTDEV/ROOTDEV. +sudo mount -o rw "${LOOP}p2" "${WORK}/root" +sudo sed -i "s/DATADEV/PARTUUID=${IMGID}-03/" "${WORK}/root/etc/fstab" +sudo umount "${WORK}/root" + +sudo losetup -d "${LOOP}" +LOOP="" + +echo "add-data-partition: recompressing ..." +xz --compress -T0 --memlimit-compress=50% -6 -c "${IMG}" > "${WORK}/out.img.xz" +mv "${WORK}/out.img.xz" "${IMG_XZ}" + +echo "add-data-partition: done — ${IMG_XZ} now has three partitions." diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 52419ee..6d8775f 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -3,7 +3,7 @@ # # Usage: scripts/smoke-test.sh deploy/syncphony-box--arm64.img.xz # -# Loop-mounts the image's two partitions and checks that the kiosk packages +# Loop-mounts the image's three partitions and checks that the kiosk packages # are installed, the cloud-init first-boot machinery (which applies Raspberry # Pi Imager settings) is enabled, the first user is locked, the NoCloud # seed files are present on the boot partition, the kiosk session is in @@ -11,10 +11,12 @@ # (syncphony.txt) ships on the boot partition fully commented out, the # audio stack is in place (PipeWire user services for the kiosk user with # linger, the sink-selection units, the WirePlumber tuning), boxd (the -# Go helper daemon) is installed with its units enabled, and the pairing -# persistence pieces agree (one profile path, crash-state prep, reset). -# -# Later issues extend this test as the image grows (boxd, /data, ...). +# Go helper daemon) is installed with its units enabled, the pairing +# persistence pieces agree (one profile path, crash-state prep, reset), and +# the read-only-root layout is complete: three partitions with the third +# labelled syncphony-data and pre-populated, the overlay mechanism wired +# into the initramfs and the kernel command line, every persistent path +# pointing at /data, and the stock root-resize replaced by the data grow. set -euo pipefail @@ -28,7 +30,7 @@ WORK="$(mktemp -d /tmp/syncphony-box-smoke.XXXXXX)" LOOP="" cleanup() { - sudo umount "${WORK}/boot" "${WORK}/root" 2>/dev/null || true + sudo umount "${WORK}/boot" "${WORK}/root" "${WORK}/data" 2>/dev/null || true if [ -n "${LOOP}" ]; then sudo losetup -d "${LOOP}" 2>/dev/null || true fi @@ -52,9 +54,42 @@ echo "Decompressing ${IMAGE_XZ} ..." xz -dk -T0 -c "${IMAGE_XZ}" > "${WORK}/image.img" LOOP="$(sudo losetup --find --show --partscan "${WORK}/image.img")" -mkdir -p "${WORK}/boot" "${WORK}/root" +mkdir -p "${WORK}/boot" "${WORK}/root" "${WORK}/data" sudo mount -o ro "${LOOP}p1" "${WORK}/boot" sudo mount -o ro "${LOOP}p2" "${WORK}/root" +sudo mount -o ro "${LOOP}p3" "${WORK}/data" + +# --- partitions: boot, root, data (MAD-805) ---------------------------------- +part_count="$(parted -m -s "${WORK}/image.img" print | awk -F: '$1 ~ /^[0-9]+$/ { n++ } END { print n }')" +if [ "${part_count}" = "3" ]; then + check "image has 3 partitions" ok +else + check "image has 3 partitions" "found ${part_count}" +fi + +data_label="$(sudo blkid -s LABEL -o value "${LOOP}p3" 2>/dev/null || true)" +if [ "${data_label}" = "syncphony-data" ]; then + check "third partition labelled syncphony-data" ok +else + check "third partition labelled syncphony-data" "label: ${data_label:-none}" +fi + +# The pre-created persistent layout (docs/persistent-paths.md). +for dir in chromium journal syncphony-box boxd system/cloud system/identity system/ssh system/network; do + if sudo test -d "${WORK}/data/${dir}"; then + check "/data/${dir} exists in the image" ok + else + check "/data/${dir} exists in the image" "missing" + fi +done + +kiosk_uid="$(sudo awk -F: '$1=="kiosk"{print $3}' "${WORK}/root/etc/passwd")" +profile_owner="$(sudo stat -c '%u' "${WORK}/data/chromium")" +if [ -n "${kiosk_uid}" ] && [ "${profile_owner}" = "${kiosk_uid}" ]; then + check "/data/chromium owned by the kiosk user" ok +else + check "/data/chromium owned by the kiosk user" "owner ${profile_owner}, kiosk uid ${kiosk_uid}" +fi # --- packages --------------------------------------------------------------- for pkg in cage chromium fonts-noto-color-emoji libgl1-mesa-dri cloud-init avahi-daemon \ @@ -75,7 +110,9 @@ done # --- units ------------------------------------------------------------------ for unit in cloud-config.service cloud-final.service syncphony-kiosk.service syncphony-box-config.service \ - avahi-daemon.service syncphony-audio.service syncphony-audio-monitor.service boxd.service; do + avahi-daemon.service syncphony-audio.service syncphony-audio-monitor.service boxd.service \ + syncphony-data-grow.service syncphony-data-prepare.service syncphony-data-save.service \ + syncphony-data-save.timer; do state="$(systemctl --root="${WORK}/root" is-enabled "${unit}" 2>/dev/null || true)" if [ "${state}" = "enabled" ]; then check "unit ${unit} enabled" ok @@ -160,7 +197,7 @@ if out="$(sudo "${WORK}/root/usr/lib/syncphony-box/boxd" profile path 2>/dev/nul profile_dir="${out}" check "boxd profile path answers" ok else - profile_dir="/var/lib/syncphony-box/chromium" + profile_dir="/data/chromium" check "boxd profile path answers" "skipped (cannot run the arm64 binary here)" fi @@ -306,6 +343,100 @@ else check "config.txt has disable_splash=1" "missing" fi +# --- read-only root (MAD-805) ------------------------------------------------ +# boot=overlay activates the overlay script in the initramfs; the stock +# `resize` token must be gone (it would grow the read-only root). +if [ "${CMDLINE}" != "${CMDLINE/ boot=overlay /}" ]; then + check "cmdline has boot=overlay" ok +else + check "cmdline has boot=overlay" "missing" +fi + +if [ "${CMDLINE}" != "${CMDLINE/ grow-data /}" ]; then + check "cmdline has grow-data" ok +else + check "cmdline has grow-data" "missing" +fi + +case "${CMDLINE}" in + *" resize "*) check "stock resize token removed from cmdline" "still present" ;; + *) check "stock resize token removed from cmdline" ok ;; +esac + +if sudo test -f "${WORK}/root/etc/initramfs-tools/scripts/overlay"; then + check "initramfs overlay script installed" ok +else + check "initramfs overlay script installed" "missing" +fi + +if sudo grep -q 'local_mount_root' "${WORK}/root/etc/initramfs-tools/scripts/overlay" && + sudo grep -q 'root-ro' "${WORK}/root/etc/initramfs-tools/scripts/overlay"; then + check "overlay script mounts the overlay and exposes the real root" ok +else + check "overlay script mounts the overlay and exposes the real root" "unexpected contents" +fi + +if sudo grep -qx 'overlay' "${WORK}/root/etc/initramfs-tools/modules"; then + check "overlay module in the initramfs module list" ok +else + check "overlay module in the initramfs module list" "missing" +fi + +if sudo test -x "${WORK}/root/etc/initramfs-tools/scripts/local-premount/grow-data"; then + check "grow-data initramfs script installed executable" ok +else + check "grow-data initramfs script installed executable" "missing" +fi + +resize_state="$(systemctl --root="${WORK}/root" is-enabled rpi-resize.service 2>/dev/null || true)" +if [ "${resize_state}" != "enabled" ]; then + check "stock rpi-resize (root grow) disabled" ok +else + check "stock rpi-resize (root grow) disabled" "still enabled" +fi + +# fstab: the boot partition is mounted read-only, and /data has its own +# nofail entry pointing at partition 3 (the DATADEV placeholder resolved). +if sudo grep -E '/boot/firmware[[:space:]]+vfat[[:space:]]+[^[:space:]]*ro' \ + "${WORK}/root/etc/fstab" >/dev/null; then + check "fstab mounts /boot/firmware read-only" ok +else + check "fstab mounts /boot/firmware read-only" "no ro flag" +fi + +data_fstab="$(sudo grep -E '/data[[:space:]]+ext4' "${WORK}/root/etc/fstab" || true)" +case "${data_fstab}" in + PARTUUID=*-03*nofail*) check "fstab has the /data entry (partition 3, nofail)" ok ;; + *) check "fstab has the /data entry (partition 3, nofail)" "got: ${data_fstab:-nothing}" ;; +esac + +# Persistent paths resolve onto /data (docs/persistent-paths.md). +for link in "var/log/journal:/data/journal" \ + "var/lib/cloud:/data/system/cloud" \ + "etc/syncphony-box/config.env:/data/syncphony-box/config.env" \ + "etc/ssh/ssh_host_ed25519_key:/data/system/ssh/ssh_host_ed25519_key"; do + path="${link%%:*}" want="${link#*:}" + got="$(sudo readlink "${WORK}/root/${path}" 2>/dev/null || true)" + if [ "${got}" = "${want}" ]; then + check "${path} points at /data" ok + else + check "${path} points at /data" "is ${got:-not a symlink}" + fi +done + +if sudo test -x "${WORK}/root/usr/lib/syncphony-box/data-persist"; then + check "data-persist installed executable" ok +else + check "data-persist installed executable" "missing" +fi + +if sudo grep -q 'SystemMaxUse=50M' \ + "${WORK}/root/etc/systemd/journald.conf.d/50-syncphony-journald.conf"; then + check "journald capped at 50M" ok +else + check "journald capped at 50M" "drop-in missing or no cap" +fi + # --- first user is locked (no password until Imager settings apply) --------- if sudo grep -q '^syncphony:!' "${WORK}/root/etc/shadow"; then check "first user syncphony has locked password" ok diff --git a/stage-syncphony/01-kiosk/files/kiosk b/stage-syncphony/01-kiosk/files/kiosk index 21e31da..2f95d62 100644 --- a/stage-syncphony/01-kiosk/files/kiosk +++ b/stage-syncphony/01-kiosk/files/kiosk @@ -25,15 +25,16 @@ if [ -r "${CONFIG_FILE}" ]; then . "${CONFIG_FILE}" fi -# The Chromium profile (pairing cookie + speaker flag) lives in one place, -# defined by boxd: config.env carries it, and boxd answers it directly when -# config.env is missing. The literal below is only the last resort when -# neither is available; the smoke test asserts it matches boxd's answer. +# The Chromium profile (pairing cookie + speaker flag) lives on the /data +# partition (MAD-805) — one path, defined by boxd: config.env carries it, +# and boxd answers it directly when config.env is missing. The literal +# below is only the last resort when neither is available; the smoke test +# asserts it matches boxd's answer. DATA_DIR="${PROFILE_DIR:-}" if [ -z "${DATA_DIR}" ]; then DATA_DIR="$("${BOXD_BIN}" profile path 2>/dev/null || true)" fi -[ -n "${DATA_DIR}" ] || DATA_DIR=/var/lib/syncphony-box/chromium +[ -n "${DATA_DIR}" ] || DATA_DIR=/data/chromium URL='' if curl -fsS -m 2 "${BOXD_URL}/healthz" >/dev/null 2>&1; then diff --git a/stage-syncphony/01-kiosk/files/syncphony-box b/stage-syncphony/01-kiosk/files/syncphony-box index 69d947b..3918654 100644 --- a/stage-syncphony/01-kiosk/files/syncphony-box +++ b/stage-syncphony/01-kiosk/files/syncphony-box @@ -2,19 +2,23 @@ # syncphony-box — the admin command for a running box (run it with sudo, # from the console or SSH). # -# sudo syncphony-box reset wipe the Chromium profile: the box forgets -# its pairing and shows a new code on the TV. -# Use it when the box moves to another room -# or another server. -# -# The Chromium profile path comes from config.env (boxd's output), so it is -# defined in exactly one place. +# sudo syncphony-box reset wipe the Chromium profile: the box forgets +# its pairing and shows a new code on the TV. +# Use it when the box moves to another room +# or another server. +# sudo syncphony-box chroot [CMD...] work on the REAL root filesystem. +# The box's / is an overlay (writes land in +# tmpfs and vanish at reboot); chroot mounts +# the real root read-write and chroots you +# into it (a shell with no CMD), so package +# updates and config edits stick. Reboot +# afterwards. See docs/adr/0007. set -eu CONFIG_FILE=/etc/syncphony-box/config.env BOXD_BIN=/usr/lib/syncphony-box/boxd -DEFAULT_PROFILE_DIR=/var/lib/syncphony-box/chromium +DEFAULT_PROFILE_DIR=/data/chromium PROFILE_DIR="${DEFAULT_PROFILE_DIR}" if [ -r "${CONFIG_FILE}" ]; then @@ -23,12 +27,24 @@ fi # config.env is written on every boot, but never trust an empty value. [ -n "${PROFILE_DIR}" ] || PROFILE_DIR="${DEFAULT_PROFILE_DIR}" -case "${1:-}" in -reset) +need_root() { if [ "$(id -u)" -ne 0 ]; then - echo "syncphony-box: reset needs root (try: sudo syncphony-box reset)" >&2 + echo "syncphony-box: this needs root (try: sudo syncphony-box $1)" >&2 exit 1 fi +} + +usage() { + cat << 'EOF' +usage: + sudo syncphony-box reset wipe the pairing (new pairing code) + sudo syncphony-box chroot [CMD...] shell/command on the real root fs +EOF +} + +case "${1:-}" in +reset) + need_root reset echo "Wiping the Chromium profile at ${PROFILE_DIR}." echo "This box will forget its room and show a new pairing code." systemctl stop syncphony-kiosk.service @@ -39,11 +55,47 @@ reset) systemctl start syncphony-kiosk.service echo "Done. The box is back on the pairing screen." ;; +chroot) + need_root chroot + # The real root: mounted read-only by the initramfs at /media/root-ro. + # Work on a second, writable mount of the same device; the ro one keeps + # serving the running overlay untouched. + if [ "$(findmnt -n -o FSTYPE /)" != "overlay" ]; then + echo "syncphony-box: the overlay is not active, so / is already the real root." >&2 + echo "Run your command directly." >&2 + exit 1 + fi + SRC="$(findmnt -n -o SOURCE /media/root-ro)" || { + echo "syncphony-box: /media/root-ro is not mounted; the overlay script should have left the real root there." >&2 + exit 1 + } + RW=/media/root-rw + mkdir -p "${RW}" + mount -o rw "${SRC}" "${RW}" + cleanup() { + cd / + umount -l "${RW}/dev" "${RW}/proc" "${RW}/sys" 2>/dev/null || true + umount "${RW}" + } + trap cleanup EXIT + mount --rbind /dev "${RW}/dev" + mount --rbind /proc "${RW}/proc" + mount --rbind /sys "${RW}/sys" + cp /etc/resolv.conf "${RW}/etc/resolv.conf" 2>/dev/null || true + echo "Real root mounted read-write at ${RW} — changes here survive reboots;" + echo "anything you used to change under / was going to tmpfs. Reboot when done." + if [ "$#" -gt 1 ]; then + shift + chroot "${RW}" "$@" + else + chroot "${RW}" /bin/bash + fi + ;; help|-h|--help) - echo "usage: sudo syncphony-box reset wipe the pairing (new pairing code)" + usage ;; *) - echo "usage: sudo syncphony-box reset wipe the pairing (new pairing code)" >&2 + usage >&2 exit 2 ;; esac diff --git a/stage-syncphony/06-readonly-root/00-run.sh b/stage-syncphony/06-readonly-root/00-run.sh new file mode 100644 index 0000000..ccaa3fa --- /dev/null +++ b/stage-syncphony/06-readonly-root/00-run.sh @@ -0,0 +1,117 @@ +#!/bin/bash -e + +# Read-only root and /data (MAD-805, ADR 0007): make the box safe to pull +# the plug on. +# +# - The overlay: raspi-config's "Overlay FS" mechanism, enabled at build +# time — /etc/initramfs-tools/scripts/overlay + the overlay module in +# the initramfs + boot=overlay on the kernel command line. The real +# root becomes a read-only lower layer; runtime writes land in tmpfs. +# - /data: a third partition (created by scripts/add-data-partition.sh +# after export, label syncphony-data) holds everything that must +# persist — the full list is docs/persistent-paths.md. Here we wire +# the paths that reach it. +# - The first-boot grow targets the last partition (our grow-data +# initramfs script, keyed off its own cmdline token); the stock +# `resize` token and rpi-resize (which would grow the read-only root) +# come out. + +FILES="$(dirname "$0")/files" + +# --- the overlay ------------------------------------------------------------- +IIR="${ROOTFS_DIR}/etc/initramfs-tools" +install -v -d -m 0755 "${IIR}/scripts" "${IIR}/scripts/local-premount" +install -v -m 0644 "${FILES}/overlay" "${IIR}/scripts/overlay" +install -v -m 0755 "${FILES}/grow-data" "${IIR}/scripts/local-premount/grow-data" +# The overlay module must be in the initramfs (the script also has an +# insmod fallback off the real root, but don't rely on it). +touch "${IIR}/modules" +grep -qx 'overlay' "${IIR}/modules" || echo overlay >> "${IIR}/modules" +# The initramfs itself is built at export (05-finalise runs +# update-initramfs -k all -c), after this stage, so both of these land +# inside it. No rename/initramfs config.txt line: auto_initramfs=1 loads +# the per-flavour initrd, and boot=overlay (below) activates the script. + +# --- the kernel command line -------------------------------------------------- +# boot=overlay turns the overlay on. `resize` (the stock first-boot token +# that grows the ROOT partition and rewrites the disk id) comes out: root +# stays at its image size and grow-data takes over for /data. +CMDLINE="${ROOTFS_DIR}/boot/firmware/cmdline.txt" +line="$(tr -d '\r\n' < "${CMDLINE}")" +kept="" +for tok in ${line}; do + case "${tok}" in + resize) ;; # stock first-boot root grow — replaced by grow-data + *) kept="${kept:+${kept} }${tok}" ;; + esac +done +line="${kept}" +case " ${line} " in + *" boot=overlay "*) ;; + *) line="boot=overlay ${line}" ;; +esac +case " ${line} " in + *" grow-data "*) ;; + *) line="${line} grow-data" ;; +esac +printf '%s\n' "${line}" > "${CMDLINE}" + +# --- fstab -------------------------------------------------------------------- +# /boot/firmware read-only: the FAT never sits writable at runtime (boxd +# remounts it rw around its syncphony.txt/cmdline.txt writes only). /data +# with nofail: a card with a destroyed data partition still boots (the +# screen says the storage is lost) instead of hanging in emergency mode. +FSTAB="${ROOTFS_DIR}/etc/fstab" +sed -i '\|/boot/firmware| s/defaults/defaults,ro/' "${FSTAB}" +printf 'DATADEV /data ext4 defaults,nofail,x-systemd.device-timeout=15s 0 2\n' >> "${FSTAB}" + +# --- paths that live on /data now --------------------------------------------- +# Symlinks are the whole trick for most of them: read early, followed +# wherever the filesystem points, graceful when /data is missing. The +# account files, SSH keys and NetworkManager connections can't be symlinks +# (rewritten atomically, read too early) — data-persist copies those. +install -v -d -m 0755 "${ROOTFS_DIR}/data" + +rm -rf "${ROOTFS_DIR}/var/log/journal" +ln -v -sfn /data/journal "${ROOTFS_DIR}/var/log/journal" + +rm -rf "${ROOTFS_DIR}/var/lib/cloud" +ln -v -sfn /data/system/cloud "${ROOTFS_DIR}/var/lib/cloud" + +# config.env: boxd writes the real file on /data; the /etc path stays a +# symlink so the kiosk wrapper and the reset tool keep reading it there. +ln -v -sfn /data/syncphony-box/config.env "${ROOTFS_DIR}/etc/syncphony-box/config.env" + +# SSH host keys: dangling until data-persist restore / ssh-keygen -A +# creates the targets on /data (through these symlinks). +install -v -d -m 0755 "${ROOTFS_DIR}/etc/ssh" +for type in rsa ecdsa ed25519; do + for suffix in "" ".pub"; do + key="ssh_host_${type}_key${suffix}" + rm -f "${ROOTFS_DIR}/etc/ssh/${key}" + ln -v -sfn "/data/system/ssh/${key}" "${ROOTFS_DIR}/etc/ssh/${key}" + done +done + +# --- units and tools ------------------------------------------------------------ +install -v -m 0755 "${FILES}/data-persist" "${ROOTFS_DIR}/usr/lib/syncphony-box/data-persist" +install -v -d -m 0755 "${ROOTFS_DIR}/etc/systemd/journald.conf.d" +install -v -m 0644 "${FILES}/50-syncphony-journald.conf" \ + "${ROOTFS_DIR}/etc/systemd/journald.conf.d/50-syncphony-journald.conf" +for unit in syncphony-data-grow.service syncphony-data-prepare.service \ + syncphony-data-save.service syncphony-data-save.timer; do + install -v -m 0644 "${FILES}/${unit}" "${ROOTFS_DIR}/etc/systemd/system/${unit}" +done + +on_chroot << EOF +# The stock first-boot grow targets the root partition; ours targets the +# last one. Disable the stock unit (the resize token is already out of the +# command line above). +systemctl disable rpi-resize.service +# Package downloads/upgrades onto a tmpfs overlay would vanish at reboot +# while looking applied — the one safe update path is writing the real +# root (sudo syncphony-box chroot; ADR 0007), so the timers stay off. +systemctl disable unattended-upgrades.timer apt-daily.timer apt-daily-upgrade.timer || true +systemctl enable syncphony-data-grow.service syncphony-data-prepare.service \ + syncphony-data-save.service syncphony-data-save.timer +EOF diff --git a/stage-syncphony/06-readonly-root/files/50-syncphony-journald.conf b/stage-syncphony/06-readonly-root/files/50-syncphony-journald.conf new file mode 100644 index 0000000..8527c4a --- /dev/null +++ b/stage-syncphony/06-readonly-root/files/50-syncphony-journald.conf @@ -0,0 +1,8 @@ +# Persistent logs for the read-only-root box (MAD-805, ADR 0007). +# /var/log/journal is a symlink to /data/journal, so with /data mounted +# (the normal case — the initramfs mounts it before systemd starts) +# journald stores here; without /data it falls back to volatile. Capped +# so the logs can never eat the card. +[Journal] +Storage=auto +SystemMaxUse=50M diff --git a/stage-syncphony/06-readonly-root/files/data-persist b/stage-syncphony/06-readonly-root/files/data-persist new file mode 100755 index 0000000..5fd8885 --- /dev/null +++ b/stage-syncphony/06-readonly-root/files/data-persist @@ -0,0 +1,143 @@ +#!/bin/sh +# data-persist — move the box's runtime state across the /data boundary +# (MAD-805, ADR 0007). The root filesystem sits behind an overlay with a +# tmpfs upper layer, so everything written under /etc or /var vanishes at +# power-off; /data is the only partition that persists. +# +# Most persistent things reach /data by a plain symlink (the Chromium +# profile, journald's /var/log/journal, config.env — the complete list is +# docs/persistent-paths.md). This script owns the few that cannot be a +# symlink because something reads them too early or another tool rewrites +# them atomically: the account files, the SSH host keys, the +# NetworkManager connections, and the machine-id (seeded from /data by +# the initramfs overlay script, saved back here). +# +# restore runs early on every boot (syncphony-data-prepare.service, +# before cloud-init/sshd/NetworkManager): put the saved state +# back, create any missing SSH keys, ensure the /data layout. +# save runs after the system has settled and then every 15 minutes +# (syncphony-data-save.service/.timer): snapshot the state back +# to /data, so changes made mid-session survive a power pull +# (worst case: the last 15 minutes of account edits are lost). +# +# Dev/test hooks (same spirit as boxd's SB_* hooks): SB_DATA_DIR moves +# /data, SB_ETC_DIR moves /etc, SB_SKIP_SSH=1 skips ssh-keygen. + +set -eu + +DATA="${SB_DATA_DIR:-/data}" +ETC="${SB_ETC_DIR:-/etc}" +IDENT="${DATA}/system/identity" +SSHDIR="${DATA}/system/ssh" +NETDIR="${DATA}/system/network" + +IDENTITY_FILES="passwd shadow group gshadow subuid subgid" +SSH_KEY_TYPES="rsa ecdsa ed25519" + +# A missing or unmountable /data is a supported state (the box boots, the +# TV says "storage lost"): every verb just exits. SB_DATA_DIR points at a +# plain directory in dev, where the mount check makes no sense. +data_usable() { + if [ -n "${SB_DATA_DIR:-}" ]; then + [ -d "${DATA}" ] + return + fi + grep -q " ${DATA} " /proc/mounts +} + +# ensure_layout creates every directory anything on /data expects. It is +# idempotent, so a fresh (or freshly reformatted) data partition is +# bootstrapped by the first boot. +ensure_layout() { + mkdir -p \ + "${DATA}/chromium" \ + "${DATA}/journal" \ + "${DATA}/syncphony-box" \ + "${DATA}/boxd" \ + "${DATA}/system/cloud" \ + "${IDENT}" "${SSHDIR}" "${NETDIR}" + # The Chromium profile belongs to the kiosk user. Harmless where the + # user doesn't exist (dev machines, test runs). + chown kiosk:kiosk "${DATA}/chromium" 2>/dev/null || true +} + +restore() { + data_usable || exit 0 + ensure_layout + + # The account files: whatever the last save had. On a fresh card this + # is a no-op and cloud-init creates the user from the Imager settings. + for f in ${IDENTITY_FILES}; do + if [ -f "${IDENT}/${f}" ]; then + cp -p "${IDENT}/${f}" "${ETC}/${f}" + fi + done + + # SSH host keys: restore first, then fill any that are missing. The + # /etc/ssh paths are symlinks into /data (see stage 06), so ssh-keygen + # -A writes straight to persistent storage. + for t in ${SSH_KEY_TYPES}; do + for suffix in "" ".pub"; do + key="ssh_host_${t}_key${suffix}" + if [ -f "${SSHDIR}/${key}" ]; then + cp -p "${SSHDIR}/${key}" "${ETC}/ssh/${key}" + fi + done + done + # ssh-keygen -A always operates on the real /etc/ssh, so it only runs + # against the box's own layout (never a dev/test SB_ETC_DIR). + if [ -z "${SB_SKIP_SSH:-}" ] && [ -z "${SB_ETC_DIR:-}" ]; then + ssh-keygen -A >/dev/null + fi + + # Wi-Fi (and any other) connections the Imager settings created. + if [ -d "${NETDIR}" ] && [ -d "${ETC}/NetworkManager" ]; then + mkdir -p "${ETC}/NetworkManager/system-connections" + cp -a "${NETDIR}/." "${ETC}/NetworkManager/system-connections/" 2>/dev/null || true + fi +} + +save() { + data_usable || exit 0 + ensure_layout + + for f in ${IDENTITY_FILES}; do + if [ -f "${ETC}/${f}" ]; then + cp -p "${ETC}/${f}" "${IDENT}/${f}" + fi + done + + mkdir -p "${SSHDIR}" + for t in ${SSH_KEY_TYPES}; do + for suffix in "" ".pub"; do + key="ssh_host_${t}_key${suffix}" + if [ -f "${ETC}/ssh/${key}" ]; then + cp -p "${ETC}/ssh/${key}" "${SSHDIR}/${key}" + fi + done + done + + NM="${ETC}/NetworkManager/system-connections" + if [ -d "${NM}" ] && [ -n "$(ls -A "${NM}" 2>/dev/null)" ]; then + cp -a "${NM}/." "${NETDIR}/" + fi + + # The identity the initramfs seeds from on the next boot. + if [ -f "${ETC}/machine-id" ]; then + cp -p "${ETC}/machine-id" "${DATA}/system/machine-id" + fi + + # Pairing-level durability: this is the same sync discipline the + # bridge's pairing burst uses — push it to the card now, not whenever + # the kernel feels like it. + sync +} + +case "${1:-}" in +restore) restore ;; +save) save ;; +*) + echo "usage: data-persist restore|save" >&2 + exit 2 + ;; +esac diff --git a/stage-syncphony/06-readonly-root/files/grow-data b/stage-syncphony/06-readonly-root/files/grow-data new file mode 100755 index 0000000..61e9c00 --- /dev/null +++ b/stage-syncphony/06-readonly-root/files/grow-data @@ -0,0 +1,99 @@ +#!/bin/sh +# Grow the /data partition to fill the card on first boot (MAD-805, +# ADR 0007). This is the stock Trixie resize mechanism +# (raspberrypi-sys-mods' resize_early) retargeted: the stock script grows +# the root partition; this box keeps root at its image size — it is +# read-only behind an overlay — and grows the LAST partition (the data +# one) instead. The filesystem grow follows at boot +# (syncphony-data-grow.service, an online resize2fs). +# +# Stock resize_early is inert here: the build removes its `resize` token +# from the kernel command line, so this script keys off its own token +# (`grow-data`), which stays in the line. The grow is a no-op once the +# partition reaches the end of the device, so the token never needs +# removing. +# +# Runs in the initramfs at local-premount: the table is rewritten while +# nothing is mounted from the card. parted and lsblk are in the initramfs +# (the stock mechanism needs them too; the firstboot hook copies them in). + +set -e + +case "$1" in + prereqs) + exit 0 + ;; +esac + +if ! grep -q ' grow-data' /proc/cmdline; then + exit 0 +fi + +get_variables () { + set +e # This function does not work as intended with -e + local_device_setup "$ROOT" "root file system" + set -e + ROOT_PART_NAME="$(lsblk -no kname "$DEV")" + ROOT_DEV_NAME="$(lsblk -no pkname "$DEV")" + ROOT_DEV="/dev/$ROOT_DEV_NAME" + ROOT_PART_NUM=$(cat "/sys/block/$ROOT_DEV_NAME/$ROOT_PART_NAME/partition") + + # The partition to grow is the LAST one, whatever its number: find it + # in sysfs (the highest partition number on an MBR card image is also + # the last by position; the dir name is the partition's kernel name). + LAST_PART_NUM=0 + for part_entry in "/sys/block/$ROOT_DEV_NAME"/*/partition; do + num=$(cat "$part_entry") + if [ "$num" -gt "$LAST_PART_NUM" ]; then + LAST_PART_NUM="$num" + LAST_PART_NAME="$(basename "$(dirname "$part_entry")")" + fi + done + LAST_START=$(cat "/sys/block/$ROOT_DEV_NAME/$LAST_PART_NAME/start") + LAST_SIZE=$(cat "/sys/block/$ROOT_DEV_NAME/$LAST_PART_NAME/size") + + ROOT_DEV_SIZE=$(cat "/sys/block/$ROOT_DEV_NAME/size") + TARGET_END="$((ROOT_DEV_SIZE - 1))" + if [ "$TARGET_END" -gt 4294967295 ]; then + TARGET_END=4294967295 + fi +} + +do_grow () { + if ! parted -m "$ROOT_DEV" u s resizepart "$LAST_PART_NUM" "$TARGET_END"; then + return 1 + fi +} + +. /scripts/functions +. /scripts/local + +log_begin_msg "Growing the data partition..." + +get_variables +CURRENT_END="$((LAST_START + LAST_SIZE - 1))" +if [ "$LAST_PART_NUM" -le "$ROOT_PART_NUM" ]; then + # Root IS the last partition (a card written before /data existed, or + # a hand-built image): growing it is exactly what we must not do. + log_warning_msg "Grow skipped: no partition beyond root" +elif [ "$CURRENT_END" -ge "$TARGET_END" ]; then + : # already grown — the steady state after the first boot +else + do_grow +fi + +unset ROOT_PART_NAME +unset ROOT_DEV_NAME +unset ROOT_DEV +unset ROOT_PART_NUM +unset LAST_PART_NUM +unset LAST_PART_NAME +unset LAST_START +unset LAST_SIZE +unset ROOT_DEV_SIZE +unset TARGET_END +unset CURRENT_END + +log_end_msg + +exit 0 diff --git a/stage-syncphony/06-readonly-root/files/overlay b/stage-syncphony/06-readonly-root/files/overlay new file mode 100755 index 0000000..362859b --- /dev/null +++ b/stage-syncphony/06-readonly-root/files/overlay @@ -0,0 +1,90 @@ +# Local filesystem mounting -*- shell-script -*- + +# The read-only overlay root for the Syncphony box (MAD-805, ADR 0007). +# +# This is exactly the mechanism raspi-config's "Overlay FS" option uses, +# enabled here at build time: the initramfs /init sources +# /scripts/ after /scripts/local, and BOOT comes from the +# boot=overlay token on the kernel command line — so redefining +# local_mount_root() here replaces how the root filesystem is mounted. +# The real root becomes the read-only lower layer; all runtime writes go +# to a tmpfs upper and vanish on power-off. A power pull can never catch +# the ext4 half-written. +# +# Syncphony additions over the raspi-config original: +# - the real root stays mounted read-only at /media/root-ro, so +# maintenance can still reach it (sudo syncphony-box chroot) +# - the /data partition is fsck'd and mounted here, before the real +# init starts, so every unit (journald, cloud-init, the kiosk) sees +# it. If it fsck's beyond repair the box boots without it: no +# pairing, no persistence, and boxd shows the storage screen. +# - /etc/machine-id is seeded from /data so the box keeps one identity +# across reboots (PID 1 binds the id before any service could +# restore one). + +. /scripts/local + +local_mount_root() +{ + local_top + local_device_setup "${ROOT}" "root file system" + ROOT="${DEV}" + + # Get the root filesystem type if not set + if [ -z "${ROOTFSTYPE}" ]; then + FSTYPE=$(get_fstype "${ROOT}") + else + FSTYPE=${ROOTFSTYPE} + fi + + local_premount + + # CHANGES TO THE ORIGINAL FUNCTION BEGIN HERE + + modprobe ${FSTYPE} + checkfs ${ROOT} root "${FSTYPE}" + + # Create directories for root and the overlay + mkdir /lower /upper + + # Mount read-only root to /lower + if [ "${FSTYPE}" != "unknown" ]; then + mount -r -t ${FSTYPE} ${ROOTFLAGS} ${ROOT} /lower + else + mount -r ${ROOTFLAGS} ${ROOT} /lower + fi + + modprobe overlay || insmod "/lower/lib/modules/$(uname -r)/kernel/fs/overlayfs/overlay.ko" + + # Mount a tmpfs for the overlay in /upper + mount -t tmpfs tmpfs /upper + mkdir /upper/data /upper/work + + # Mount the final overlay-root in $rootmnt + mount -t overlay \ + -olowerdir=/lower,upperdir=/upper/data,workdir=/upper/work \ + overlay ${rootmnt} + + # Keep the real root reachable, read-only, for maintenance. + mkdir -p ${rootmnt}/media/root-ro + mount --move /lower ${rootmnt}/media/root-ro + + # /data: the one partition that persists. fsck it; mount it only when + # it is clean enough (e2fsck -p: 0 = clean, 1 = corrected; anything + # higher means a human should look at it — boot without it instead). + mkdir -p ${rootmnt}/data + fsck_rc=0 + e2fsck -p LABEL=syncphony-data >/dev/null 2>&1 || fsck_rc=$? + if [ "${fsck_rc}" -le 1 ]; then + if mount -t ext4 LABEL=syncphony-data ${rootmnt}/data; then + # A stable identity across reboots: seed the overlay's + # /etc/machine-id from /data. data-persist save writes the + # bound id back on every boot, so the first boot's generated + # id becomes the box's permanent one. + if [ -f ${rootmnt}/data/system/machine-id ] && + ! grep -q '^uninitialized$' ${rootmnt}/data/system/machine-id; then + cp ${rootmnt}/data/system/machine-id ${rootmnt}/etc/machine-id + fi + fi + fi +} diff --git a/stage-syncphony/06-readonly-root/files/syncphony-data-grow.service b/stage-syncphony/06-readonly-root/files/syncphony-data-grow.service new file mode 100644 index 0000000..d964471 --- /dev/null +++ b/stage-syncphony/06-readonly-root/files/syncphony-data-grow.service @@ -0,0 +1,16 @@ +[Unit] +Description=Grow the /data filesystem to fill the card +# The partition itself was grown in the initramfs (grow-data); this is the +# online resize2fs that makes the filesystem notice. The marker keeps it a +# first-boot affair; a reformatted /data loses the marker and grows again. +ConditionPathIsMountPoint=/data +ConditionPathExists=!/data/system/.grown +After=data.mount + +[Service] +Type=oneshot +ExecStart=/usr/sbin/resize2fs /dev/disk/by-label/syncphony-data +ExecStartPost=/bin/touch /data/system/.grown + +[Install] +WantedBy=multi-user.target diff --git a/stage-syncphony/06-readonly-root/files/syncphony-data-prepare.service b/stage-syncphony/06-readonly-root/files/syncphony-data-prepare.service new file mode 100644 index 0000000..8ab883f --- /dev/null +++ b/stage-syncphony/06-readonly-root/files/syncphony-data-prepare.service @@ -0,0 +1,17 @@ +[Unit] +Description=Restore persistent state from /data (read-only-root box) +Documentation=file:///usr/lib/syncphony-box/syncphony.txt +# /data is mounted by the initramfs overlay script (with the fstab entry +# as belt and braces), so this runs ahead of everything that reads the +# state: cloud-init (which would create a fresh user), sshd (host keys), +# NetworkManager (Wi-Fi) and the kiosk (the Chromium profile). +ConditionPathIsMountPoint=/data +After=data.mount +Before=cloud-init-local.service cloud-init.service ssh.service NetworkManager.service boxd.service syncphony-kiosk.service + +[Service] +Type=oneshot +ExecStart=/usr/lib/syncphony-box/data-persist restore + +[Install] +WantedBy=multi-user.target diff --git a/stage-syncphony/06-readonly-root/files/syncphony-data-save.service b/stage-syncphony/06-readonly-root/files/syncphony-data-save.service new file mode 100644 index 0000000..6d0811b --- /dev/null +++ b/stage-syncphony/06-readonly-root/files/syncphony-data-save.service @@ -0,0 +1,14 @@ +[Unit] +Description=Save persistent state to /data (read-only-root box) +ConditionPathIsMountPoint=/data +# After cloud-init's final stage and sshd, so a first boot saves the user +# and host keys they created; the timer keeps saving so mid-session +# changes survive a power pull. +After=cloud-final.service ssh.service + +[Service] +Type=oneshot +ExecStart=/usr/lib/syncphony-box/data-persist save + +[Install] +WantedBy=multi-user.target diff --git a/stage-syncphony/06-readonly-root/files/syncphony-data-save.timer b/stage-syncphony/06-readonly-root/files/syncphony-data-save.timer new file mode 100644 index 0000000..f743921 --- /dev/null +++ b/stage-syncphony/06-readonly-root/files/syncphony-data-save.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Periodically save persistent state to /data + +[Timer] +OnBootSec=10min +OnUnitActiveSec=15min + +[Install] +WantedBy=timers.target diff --git a/tests/syncphony-box-data.bats b/tests/syncphony-box-data.bats new file mode 100644 index 0000000..0236365 --- /dev/null +++ b/tests/syncphony-box-data.bats @@ -0,0 +1,112 @@ +# Tests for data-persist — the script that moves the account files, SSH +# host keys and NetworkManager connections across the /data boundary on a +# read-only-root box (MAD-805, ADR 0007). It runs straight from the stage +# tree; SB_DATA_DIR and SB_ETC_DIR point it at temp dirs (and imply dev +# mode: no mount check, no ssh-keygen). + +BATS_TEST_DIRNAME_ABS="$(cd "${BATS_TEST_DIRNAME}" && pwd)" +SCRIPT="${BATS_TEST_DIRNAME_ABS}/../stage-syncphony/06-readonly-root/files/data-persist" + +setup() { + WORK="$(mktemp -d)" + DATA="${WORK}/data" + ETC="${WORK}/etc" + mkdir -p "${DATA}" "${ETC}/ssh" + export SB_DATA_DIR="${DATA}" SB_ETC_DIR="${ETC}" +} + +teardown() { + rm -rf "${WORK}" +} + +fake_identity() { + printf 'root:x:0:0:root:/root:/bin/bash\nsyncphony:x:1000:1000::/home/syncphony:/bin/bash\n' > "${ETC}/passwd" + printf 'root:*:20000:0:99999:7:::\nsyncphony:$6$rounds:20000:0:99999:7:::\n' > "${ETC}/shadow" + printf 'root:x:0:\n' > "${ETC}/group" + printf 'root:*::\n' > "${ETC}/gshadow" + printf 'syncphony:100000:65536\n' > "${ETC}/subuid" + printf 'syncphony:100000:65536\n' > "${ETC}/subgid" +} + +@test "restore on a fresh /data creates the persistent layout and is a no-op" { + run "${SCRIPT}" restore + [ "$status" -eq 0 ] + for d in chromium journal syncphony-box boxd system/cloud system/identity system/ssh system/network; do + [ -d "${DATA}/${d}" ] + done +} + +@test "save then restore round-trips the account files" { + fake_identity + run "${SCRIPT}" save + [ "$status" -eq 0 ] + [ -f "${DATA}/system/identity/passwd" ] + cmp "${ETC}/passwd" "${DATA}/system/identity/passwd" + cmp "${ETC}/shadow" "${DATA}/system/identity/shadow" + + # Lose the overlay (fresh boot): /etc back to the image's files. + printf 'root:x:0:0:root:/root:/usr/sbin/nologin\n' > "${ETC}/passwd" + printf 'root:*:20000:0:99999:7:::\n' > "${ETC}/shadow" + run "${SCRIPT}" restore + [ "$status" -eq 0 ] + grep -q '^syncphony:' "${ETC}/passwd" + grep -q '^syncphony:' "${ETC}/shadow" +} + +@test "save then restore round-trips the SSH host keys" { + mkdir -p "${DATA}/system/ssh" + printf 'PRIVATE-KEY-DATA' > "${ETC}/ssh/ssh_host_ed25519_key" + printf 'ssh-ed25519 AAAA box' > "${ETC}/ssh/ssh_host_ed25519_key.pub" + run "${SCRIPT}" save + [ "$status" -eq 0 ] + cmp "${ETC}/ssh/ssh_host_ed25519_key" "${DATA}/system/ssh/ssh_host_ed25519_key" + + rm "${ETC}/ssh/ssh_host_ed25519_key" + run "${SCRIPT}" restore + [ "$status" -eq 0 ] + grep -q 'PRIVATE-KEY-DATA' "${ETC}/ssh/ssh_host_ed25519_key" +} + +@test "save persists the machine-id for the initramfs seed" { + printf '0123456789abcdef0123456789abcdef\n' > "${ETC}/machine-id" + run "${SCRIPT}" save + [ "$status" -eq 0 ] + cmp "${ETC}/machine-id" "${DATA}/system/machine-id" +} + +@test "NetworkManager connections round-trip through /data" { + fake_identity + mkdir -p "${ETC}/NetworkManager/system-connections" + printf '[connection]\nid=home\n' > "${ETC}/NetworkManager/system-connections/home.nmconnection" + run "${SCRIPT}" save + [ "$status" -eq 0 ] + [ -f "${DATA}/system/network/home.nmconnection" ] + + rm -rf "${ETC}/NetworkManager/system-connections" + mkdir -p "${ETC}/NetworkManager/system-connections" + run "${SCRIPT}" restore + [ "$status" -eq 0 ] + grep -q 'id=home' "${ETC}/NetworkManager/system-connections/home.nmconnection" +} + +@test "no NetworkManager dir is fine" { + run "${SCRIPT}" save + [ "$status" -eq 0 ] + run "${SCRIPT}" restore + [ "$status" -eq 0 ] +} + +@test "an unmounted /data (real path) is a quiet no-op" { + unset SB_DATA_DIR + # /data does not exist on the test host; even if it did, it would not + # be a mount point here. Both verbs must exit 0 without touching it. + run "${SCRIPT}" restore + [ "$status" -eq 0 ] + run "${SCRIPT}" save + [ "$status" -eq 0 ] +} + +@test "usage errors out" { + run "${SCRIPT}" + [ "$status" -eq 2 ] +} diff --git a/tests/syncphony-box-profile.bats b/tests/syncphony-box-profile.bats index debc43e..df4f03e 100644 --- a/tests/syncphony-box-profile.bats +++ b/tests/syncphony-box-profile.bats @@ -25,7 +25,7 @@ teardown() { @test "path prints the default profile dir" { run "${SCRIPT}" profile path [ "$status" -eq 0 ] - [ "$output" = "/var/lib/syncphony-box/chromium" ] + [ "$output" = "/data/chromium" ] } @test "path honors SB_PROFILE" {