diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7f0bdbc..f36f051 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -73,11 +73,43 @@ jobs: deploy/*.img.info deploy/*.deb + iso: + # The x86-64 live/installer ISO (MAD-846, ADR 0013): live-build in a + # Debian Trixie container, natively on an x86 runner. Same customise + # layer as the Pi image, so the same smoke test covers its rootfs. + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version-file: boxd/go.mod + + - name: Build ISO + run: make iso + + - name: Smoke test + run: scripts/smoke-test.sh deploy/syncphony-box-*-amd64.iso + + - name: Upload ISO + uses: actions/upload-artifact@v4 + with: + name: syncphony-box-iso-${{ github.sha }} + compression-level: 0 + if-no-files-found: error + path: | + deploy/*.iso + deploy/*.iso.sha256 + deploy/*_amd64.deb + release: - # Tags only: publish what the image job built and smoke-tested - # (MAD-809/MAD-810, ADR 0011 and 0012). + # Tags only: publish what the image and iso jobs built and smoke-tested + # (MAD-809/MAD-810/MAD-846, ADR 0011, 0012 and 0013). if: startsWith(github.ref, 'refs/tags/v') - needs: [tests, image] + needs: [tests, image, iso] runs-on: ubuntu-latest timeout-minutes: 30 permissions: @@ -93,7 +125,8 @@ jobs: - uses: actions/download-artifact@v4 with: - name: syncphony-box-image-${{ github.sha }} + pattern: syncphony-box-*-${{ github.sha }} + merge-multiple: true path: deploy - name: Check the release inputs @@ -111,9 +144,13 @@ jobs: scripts/changelog-section.sh "${VERSION}" > notes.md IMAGE="deploy/syncphony-box-${GITHUB_REF_NAME}-arm64.img.xz" DEB="deploy/syncphony-box_${VERSION}_arm64.deb" - ls -l "${IMAGE}" "${DEB}" + ISO="deploy/syncphony-box-${GITHUB_REF_NAME}-amd64.iso" + DEB_AMD64="deploy/syncphony-box_${VERSION}_amd64.deb" + ls -l "${IMAGE}" "${DEB}" "${ISO}" "${DEB_AMD64}" echo "IMAGE=${IMAGE}" >> "$GITHUB_ENV" echo "DEB=${DEB}" >> "$GITHUB_ENV" + echo "ISO=${ISO}" >> "$GITHUB_ENV" + echo "DEB_AMD64=${DEB_AMD64}" >> "$GITHUB_ENV" - uses: sigstore/cosign-installer@v3 with: @@ -123,7 +160,8 @@ jobs: run: | set -euo pipefail cd deploy - sha256sum "$(basename "${IMAGE}")" "$(basename "${DEB}")" > SHA256SUMS + sha256sum "$(basename "${IMAGE}")" "$(basename "${DEB}")" \ + "$(basename "${ISO}")" "$(basename "${DEB_AMD64}")" > SHA256SUMS # Keyless: the signature binds SHA256SUMS to this workflow run at # this tag (Sigstore's transparency log records it). cosign sign-blob --yes --bundle SHA256SUMS.sigstore.json SHA256SUMS @@ -148,6 +186,7 @@ jobs: --notes-file notes.md \ --verify-tag \ "${IMAGE}" "${IMAGE}.sha256" "${DEB}" \ + "${ISO}" "${ISO}.sha256" "${DEB_AMD64}" \ deploy/SHA256SUMS deploy/SHA256SUMS.sigstore.json \ imager/os_list.json @@ -170,7 +209,7 @@ jobs: git worktree add --orphan -b gh-pages site fi - scripts/apt-repo.sh site/apt "${DEB}" + scripts/apt-repo.sh site/apt "${DEB}" "${DEB_AMD64}" cp packaging/apt/syncphony-box-archive-keyring.asc site/apt/ mkdir -p site/imager cp imager/os_list.json site/imager/os_list.json diff --git a/.gitignore b/.gitignore index f3496fb..a93d64e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ deploy/ .build/ # Built by ./build.sh into the pi-gen stage, never committed. -stage-syncphony/00-packages/files/ +stage-syncphony/*/files/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 83eb5b6..c746018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ Add to `## [Unreleased]` as you go; rename it when you tag. ## [Unreleased] ### Added +- **The box on any x86-64 PC.** Each release also has + `syncphony-box--amd64.iso`, a hybrid ISO for a USB stick or + DVD, UEFI or legacy BIOS (Secure Boot off). Boot **Syncphony Box** to + run the box from the stick (a `syncphony-data` partition on the stick + keeps its pairing), or **Install Syncphony Box to disk** to install it, + with the same read-only root, `/data`, setup and nightly updates as a + Pi. Thermals come from the PC's thermal zones, `audio=` picks a PC's + HDMI/DisplayPort or analog outputs, and there's no HDMI-CEC. - **Releases you can flash from Raspberry Pi Imager.** A `v*` tag builds the image, smoke-tests it and publishes a GitHub Release with the `.img.xz`, its checksum, a cosign-signed `SHA256SUMS` and the notes @@ -37,6 +45,8 @@ Add to `## [Unreleased]` as you go; rename it when you tag. ### Changed - The image is smaller: documentation, man pages and translations are stripped (copyright files stay). +- The Pi image and the x86 ISO are built from one shared customisation + layer (`rootfs/`), so they can't drift apart. ## [0.1.0] diff --git a/Makefile b/Makefile index afbb93d..052e0fa 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,12 @@ -.PHONY: image clean test lint reset +.PHONY: image iso clean test lint reset image: ./build.sh +# The x86-64 live/installer ISO (MAD-846): live-build in a Debian container. +iso: + ./build-iso.sh + test: .build/boxd @command -v bats >/dev/null 2>&1 || { echo "bats is required (e.g. apt install bats)"; exit 1; } @command -v go >/dev/null 2>&1 || { echo "go is required (https://go.dev/dl/)"; exit 1; } @@ -28,5 +32,5 @@ reset: .build/boxd clean: rm -rf deploy .build - rm -rf stage-syncphony/00-packages/files + rm -rf stage-syncphony/*/files -docker rm -v pigen_work >/dev/null 2>&1 || true diff --git a/README.md b/README.md index a86115e..b94f396 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ A flashable Raspberry Pi image that turns a Pi plugged into a TV into the room's big screen and its speaker — a kiosk for an existing [Syncphony](https://github.com/madeofpendletonwool/Syncphony) server. Flash the image with [Raspberry Pi Imager](https://www.raspberrypi.com/software/), -plug the Pi into the TV, and it boots straight into the room. +plug the Pi into the TV, and it boots straight into the room. An old PC or +a mini-PC works too: boot the [x86-64 ISO](#on-a-pc-x86-64) from a USB +stick and install it. The box is **client only**: it runs no Syncphony server. Point it at yours. @@ -68,11 +70,52 @@ sha256sum --check --ignore-missing SHA256SUMS tagged source. (Imager checks the image against the listing's SHA-256 on its own while writing.) +### On a PC (x86-64) + +Any 64-bit PC or mini-PC with 2 GB of RAM and a disk of 8 GB or more +([ADR 0013](docs/adr/0013-x86-iso.md)). Turn **Secure Boot off** in the +firmware settings; UEFI and legacy BIOS both work. + +1. **Download** `syncphony-box--amd64.iso` from the + [release](https://github.com/madeofpendletonwool/syncphony-box/releases) + and write it to a USB stick (Imager's **Use custom**, balenaEtcher, + or `sudo dd if=syncphony-box--amd64.iso of=/dev/sdX bs=4M + conv=fsync`). It's a hybrid ISO, so a DVD works too. +2. **Boot the PC from the stick**, plugged into the TV. The menu has two + entries: + - **Syncphony Box** runs the box from the stick, with nothing + installed. Good for trying it. Its changes live in RAM, so it + forgets its setup and pairing at power-off, unless the stick has a + `syncphony-data` partition (below). + - **Install Syncphony Box to disk** erases a disk you pick and installs + the box on it. It offers an optional admin user (sudo, for SSH and + maintenance; a PC has no Imager to make one). When it's done, remove + the stick; the PC restarts into the box. +3. **Point it at your server and pair**, as in steps 5 and 6 above. The + setup QR, the hotspot, updates and the read-only root all work as on + a Pi. There is no HDMI-CEC: most PC graphics can't send it. + +**Keeping a live stick's pairing.** Add a partition labelled +`syncphony-data` to the stick, in the free space after the ISO, and the +live box keeps its setup and pairing there: + +```sh +# /dev/sdX is the stick with the ISO already written to it. +echo ',,L' | sudo sfdisk --append /dev/sdX +sudo mkfs.ext4 -L syncphony-data /dev/sdX4 # the ISO has 3; this is the 4th +``` + +Remove the stick before booting an installed box: both would offer a +`syncphony-data` partition. + ## Supported hardware - Raspberry Pi 4 or Pi 5, 2 GB RAM or more - 16 GB+ SD card -- arm64 only. Pi 3, Pi Zero 2 W and armhf are not supported. +- On a Pi, arm64 only. Pi 3, Pi Zero 2 W and armhf are not supported. +- Or an x86-64 PC: 2 GB RAM, an 8 GB+ disk, Secure Boot off + ([On a PC](#on-a-pc-x86-64)). The ISO carries firmware for Intel and + AMD graphics; NVIDIA graphics are untested. - A case with a heatsink or a fan. A long party with the visualizer on warms a Pi in a closed case past its soft temperature limit, and the firmware silently halves the CPU first (the box logs temperature and @@ -100,6 +143,13 @@ The result lands in `deploy/`: package. The image installs it, and boxes update to newer ones from the apt repo. +`make iso` builds the x86-64 ISO the same way, with Debian's live-build +in a privileged `debian:trixie` container (native on an x86 host, +emulated and slow on an arm64 one): `syncphony-box--amd64.iso`, +its `.sha256`, and the `_amd64.deb`. Both images run the same +customisation (`rootfs/customise.sh`) inside their build chroots, so a +change to the box lands in both ([ADR 0013](docs/adr/0013-x86-iso.md)). + `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 @@ -117,8 +167,11 @@ 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, 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. Pushing a -`v*` tag also releases it; see [docs/releasing.md](docs/releasing.md). +path on /data), and uploads the image as a workflow artifact. An x86 +runner builds the ISO alongside it and runs the same smoke test against +the ISO's root filesystem, plus its boot menu (UEFI and BIOS), its +initramfs and the installer. Pushing a `v*` tag releases both; see +[docs/releasing.md](docs/releasing.md). ## boxd @@ -386,11 +439,19 @@ the box from booting; bad values are logged and fall back to defaults. | `audio` | `hdmi`, `hdmi2`, `analog`, `usb` | `hdmi` | where the sound goes (`analog` is the Pi 4's jack — a Pi 5 has none; use `usb` for a DAC) | | `resolution` | e.g. `1920x1080@60` | auto | forces the HDMI mode (takes a reboot; the box reboots once on its own) | | `rotate` | `0`, `90`, `180`, `270` | `0` | rotates the screen (takes a reboot) | -| `cec` | `on`, `off` | `on` | switch the TV's input when music starts (HDMI-CEC; the remote's keys drive the box too — [docs/hardware.md](docs/hardware.md) for the TV matrix) | +| `cec` | `on`, `off` | `on` | switch the TV's input when music starts (HDMI-CEC; the remote's keys drive the box too — [docs/hardware.md](docs/hardware.md) for the TV matrix). Pi only | | `cec_standby` | `on`, `off` | `off` | put the TV in standby once the room has been idle (only ever a TV the box switched on itself) | | `cec_standby_minutes` | `1`–`240` | `30` | how many idle minutes before that standby | | `auto_update` | `on`, `off` | `on` | install updates every night around 04:00 ([Updates](#updates)) | +On a PC box, `syncphony.txt` is on the `SB-BOOT` partition (the EFI +system partition, also at `/boot/firmware`), and `resolution`/`rotate` +go into `/boot/firmware/cmdline.cfg`, which GRUB reads. `audio=hdmi` and +`hdmi2` are the first and second HDMI/DisplayPort outputs; `analog` is +the headphone/line-out jack. In the live session (from the USB stick) +the file is in RAM, or on the stick's `syncphony-data` partition, and +`resolution`/`rotate` don't apply. + On every boot, `syncphony-box-config.service` runs `boxd config apply`: 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` @@ -490,19 +551,27 @@ has its own page: [docs/testing.md](docs/testing.md). ``` pi-gen/ pi-gen submodule (pinned, never edited) +rootfs/ the shared customise layer both images run inside + their chroot: package lists, the .deb, the steps + (ADR 0013) packaging/root/ every file of the syncphony-box .deb, laid out as it installs (units, kiosk wrapper, scripts, ...) packaging/debian/ the .deb's control template and postinst packaging/apt/ the updater's apt source and the repo's public key packaging/imager/ the Imager listing's icon -stage-syncphony/ custom pi-gen stage: installs the .deb, then the - image-only changes (cmdline, fstab, /data links, ...) +stage-syncphony/ custom pi-gen stage: runs rootfs/customise.sh, then + the Pi's boot partition (cmdline, config.txt, ...) +x86/live/ live-build config and hooks for the x86 ISO +x86/root/ the ISO's own files: the installer, its units and + GRUB templates, the live session's /boot/firmware boxd/ the box's Go helper daemon (bridge, config, screens, watchdogs, CEC, Wi-Fi setup hotspot) config pi-gen build configuration build.sh wraps pi-gen's build-docker.sh (builds boxd and the .deb first, then appends the /data partition) -Makefile make image / make test / make lint / make clean +build-iso.sh builds the x86 ISO with live-build in a container +Makefile make image / make iso / 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) scripts/build-deb.sh builds the syncphony-box .deb diff --git a/boxd/internal/config/apply.go b/boxd/internal/config/apply.go index 22b384a..3e96b95 100644 --- a/boxd/internal/config/apply.go +++ b/boxd/internal/config/apply.go @@ -26,7 +26,7 @@ func PathsFromEnv() Paths { return Paths{ Txt: envOr("SB_TXT", DefaultTxtPath), Env: envOr("SB_ENV", DefaultEnvPath), - Cmdline: envOr("SB_CMDLINE", DefaultCmdlinePath), + Cmdline: envOr("SB_CMDLINE", defaultCmdlinePath()), ProfileDir: os.Getenv("SB_PROFILE"), HostnameCmd: os.Getenv("SB_HOSTNAME_CMD"), RebootCmd: os.Getenv("SB_REBOOT_CMD"), @@ -40,6 +40,67 @@ func envOr(key, def string) string { return def } +// defaultCmdlinePath picks the box's kernel command line file: GRUB's +// cmdline.cfg on an installed x86 box, the Pi's cmdline.txt otherwise. +func defaultCmdlinePath() string { + if _, err := os.Stat(DefaultGrubCmdlinePath); err == nil { + return DefaultGrubCmdlinePath + } + return DefaultCmdlinePath +} + +// grubCmdlineVar is the variable cmdline.cfg sets; the x86 grub.cfg +// passes ${syncphony_cmdline} to the kernel. +const grubCmdlineVar = "syncphony_cmdline" + +// isGrubCmdline reports whether a command line file is the x86 GRUB +// script (cmdline.cfg) rather than the Pi's one-line cmdline.txt. +func isGrubCmdline(path string) bool { + return strings.HasSuffix(path, ".cfg") +} + +// cmdlineTokens extracts the kernel command line from a file's content: +// the whole file for cmdline.txt, the quoted value of the +// `set syncphony_cmdline="..."` line for cmdline.cfg. +func cmdlineTokens(path, content string) string { + if !isGrubCmdline(path) { + return strings.Map(func(r rune) rune { + if r == '\r' || r == '\n' { + return -1 + } + return r + }, content) + } + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if v, ok := strings.CutPrefix(line, "set "+grubCmdlineVar+"="); ok { + return strings.Trim(v, `"'`) + } + } + return "" +} + +// cmdlineContent is cmdlineTokens' inverse: the new file content for a +// command line. cmdline.cfg keeps its other lines (the comments) and +// gets its set line replaced, or appended when it had none. +func cmdlineContent(path, old, line string) string { + if !isGrubCmdline(path) { + return line + "\n" + } + set := "set " + grubCmdlineVar + `="` + line + `"` + lines := strings.Split(strings.TrimSuffix(old, "\n"), "\n") + for i, l := range lines { + if strings.HasPrefix(strings.TrimSpace(l), "set "+grubCmdlineVar+"=") { + lines[i] = set + return strings.Join(lines, "\n") + "\n" + } + } + if old == "" { + return set + "\n" + } + return strings.Join(append(lines, set), "\n") + "\n" +} + // RunCommand runs an external command (systemctl and friends). A stub in // tests records what would have run on the box. type RunCommand func(name string, args ...string) error @@ -157,21 +218,25 @@ func runSh(command, arg0, arg1 string, run RunCommand) error { // line: drop any previous one, append the desired one. Changing it takes a // reboot, which happens exactly once — after the write the next boot // computes the same token and finds nothing to do. +// +// No command line file at all means no bootloader reads one: the x86 live +// session boots from the read-only ISO (MAD-846). Writing one there would +// only cost a pointless reboot, so the display settings are skipped. func syncCmdline(resolution, rotate string, p Paths, run RunCommand, log *log.Logger) { desired := VideoToken(resolution, rotate) current := "" + old := "" if data, err := os.ReadFile(p.Cmdline); err == nil { - current = strings.Map(func(r rune) rune { - if r == '\r' || r == '\n' { - return -1 - } - return r - }, string(data)) - } else if !os.IsNotExist(err) { - // Unreadable (a directory, say): behave as empty and let the write - // below fail loudly instead. - current = "" + old = string(data) + current = cmdlineTokens(p.Cmdline, old) + } else if os.IsNotExist(err) { + if desired != "" { + log.Printf("boxd config: no kernel command line here (%s); resolution and rotate are not applied", p.Cmdline) + } + return } + // Otherwise unreadable (a directory, say): behave as empty and let the + // write below fail loudly instead. var kept []string for _, tok := range strings.Fields(current) { @@ -196,7 +261,7 @@ func syncCmdline(resolution, rotate string, p Paths, run RunCommand, log *log.Lo // /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) + return os.WriteFile(p.Cmdline, []byte(cmdlineContent(p.Cmdline, old, next)), 0o644) }) if err != nil { log.Printf("boxd config: cannot write %s; display settings stay as they are: %v", p.Cmdline, err) diff --git a/boxd/internal/config/config.go b/boxd/internal/config/config.go index 018ac3f..0df850b 100644 --- a/boxd/internal/config/config.go +++ b/boxd/internal/config/config.go @@ -19,6 +19,13 @@ const ( DefaultTxtPath = "/boot/firmware/syncphony.txt" DefaultCmdlinePath = "/boot/firmware/cmdline.txt" + // DefaultGrubCmdlinePath is the kernel command line on an x86 box + // (MAD-846, ADR 0013). /boot/firmware is the EFI system partition + // there, and GRUB can't read a plain file into the command line, so + // the installer writes this GRUB script instead and grub.cfg sources + // it. When it exists it wins over cmdline.txt. + DefaultGrubCmdlinePath = "/boot/firmware/cmdline.cfg" + // 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. diff --git a/boxd/internal/config/config_test.go b/boxd/internal/config/config_test.go index 517483d..a1ba1ec 100644 --- a/boxd/internal/config/config_test.go +++ b/boxd/internal/config/config_test.go @@ -196,6 +196,87 @@ func TestApplyWritesEnvAndSyncsCmdline(t *testing.T) { } } +func TestApplySyncsGrubCmdline(t *testing.T) { + // The x86 box (MAD-846): the command line is a GRUB script on the + // EFI partition; only the set line changes, the comments stay. + dir := t.TempDir() + txt := filepath.Join(dir, "syncphony.txt") + cfg := filepath.Join(dir, "cmdline.cfg") + rebooted := filepath.Join(dir, "rebooted") + os.WriteFile(cfg, []byte("# written by syncphony-install\nset syncphony_cmdline=\"boot=overlay quiet\"\n"), 0o644) + os.WriteFile(txt, []byte("resolution=1280x720\nrotate=90\n"), 0o644) + + p := Paths{Txt: txt, Env: filepath.Join(dir, "config.env"), Cmdline: cfg, + HostnameCmd: "true", RebootCmd: "touch " + rebooted} + Apply(p, func(string, ...string) error { return nil }, testLogger()) + + got, _ := os.ReadFile(cfg) + want := "# written by syncphony-install\nset syncphony_cmdline=\"boot=overlay quiet video=HDMI-A-1:1280x720D,rotate=90\"\n" + if string(got) != want { + t.Fatalf("cmdline.cfg = %q, want %q", got, want) + } + if _, err := os.Stat(rebooted); err != nil { + t.Fatal("did not reboot on a cmdline change") + } + + // Unchanged config: no write, no reboot loop. + os.Remove(rebooted) + Apply(p, func(string, ...string) error { return nil }, testLogger()) + if _, err := os.Stat(rebooted); !os.IsNotExist(err) { + t.Fatal("rebooted again for an unchanged cmdline.cfg") + } + + // Back to auto: the token goes, the rest stays. + os.WriteFile(txt, []byte("\n"), 0o644) + Apply(p, func(string, ...string) error { return nil }, testLogger()) + got, _ = os.ReadFile(cfg) + if want := "# written by syncphony-install\nset syncphony_cmdline=\"boot=overlay quiet\"\n"; string(got) != want { + t.Fatalf("cmdline.cfg after reset = %q, want %q", got, want) + } +} + +func TestCmdlineTokensAndContent(t *testing.T) { + for _, tc := range []struct { + path, content, tokens string + }{ + {"cmdline.txt", "a b c\r\n", "a b c"}, + {"cmdline.cfg", "set syncphony_cmdline=\"a b\"\n", "a b"}, + {"cmdline.cfg", "# no set line\n", ""}, + {"cmdline.cfg", " set syncphony_cmdline='a' \n", "a"}, + } { + if got := cmdlineTokens(tc.path, tc.content); got != tc.tokens { + t.Errorf("cmdlineTokens(%q, %q) = %q, want %q", tc.path, tc.content, got, tc.tokens) + } + } + // A cfg without a set line gets one appended; an empty one is just it. + if got := cmdlineContent("x.cfg", "# hi\n", "a"); got != "# hi\nset syncphony_cmdline=\"a\"\n" { + t.Errorf("append: %q", got) + } + if got := cmdlineContent("x.cfg", "", "a"); got != "set syncphony_cmdline=\"a\"\n" { + t.Errorf("empty: %q", got) + } +} + +func TestApplySkipsDisplayWithoutCmdline(t *testing.T) { + // The x86 live session has no command line file: resolution= must not + // create one or reboot (nothing would read it). + dir := t.TempDir() + txt := filepath.Join(dir, "syncphony.txt") + cmdline := filepath.Join(dir, "cmdline.txt") + rebooted := filepath.Join(dir, "rebooted") + os.WriteFile(txt, []byte("resolution=1920x1080\n"), 0o644) + + p := Paths{Txt: txt, Env: filepath.Join(dir, "config.env"), Cmdline: cmdline, + HostnameCmd: "true", RebootCmd: "touch " + rebooted} + Apply(p, func(string, ...string) error { return nil }, testLogger()) + if _, err := os.Stat(cmdline); !os.IsNotExist(err) { + t.Fatal("created a command line file nothing reads") + } + if _, err := os.Stat(rebooted); !os.IsNotExist(err) { + t.Fatal("rebooted without a command line file") + } +} + func TestApplySetsHostnameFromName(t *testing.T) { dir := t.TempDir() txt := filepath.Join(dir, "syncphony.txt") diff --git a/boxd/internal/daemon/daemon.go b/boxd/internal/daemon/daemon.go index e49d8f2..504c679 100644 --- a/boxd/internal/daemon/daemon.go +++ b/boxd/internal/daemon/daemon.go @@ -87,6 +87,10 @@ type Daemon struct { // vcgencmd samples the hardware thermals; a func field so tests can // feed readings without a Pi. vcgencmd func(args ...string) (string, error) + // thermalDir is where the thermal zones are read when there is no + // vcgencmd (x86, MAD-846); thermalZone is the one picked, once. + thermalDir string + thermalZone string startedAt time.Time // boxd start; watchdog graces count from here setupUntil time.Time // LAN setup window open until then (zero = closed) @@ -124,6 +128,12 @@ func New(version string, log *log.Logger, run config.RunCommand) *Daemon { paths: config.PathsFromEnv(), startedAt: time.Now(), vcgencmd: realVcgencmd, + thermalDir: func() string { + if dir := os.Getenv("SB_THERMAL_DIR"); dir != "" { + return dir + } + return defaultThermalDir + }(), syncDisk: func() error { syscall.Sync() return nil diff --git a/boxd/internal/daemon/daemon_test.go b/boxd/internal/daemon/daemon_test.go index 49ab67c..9f83192 100644 --- a/boxd/internal/daemon/daemon_test.go +++ b/boxd/internal/daemon/daemon_test.go @@ -36,6 +36,9 @@ func newTestDaemon(t *testing.T, txtContent string) (*Daemon, *recorder) { // The nightly update's last result (MAD-810): none unless a test // writes one. t.Setenv("SB_UPDATE_STATE", filepath.Join(dir, "update.json")) + // No thermal zones unless a test makes some (the host's own must not + // leak into the no-vcgencmd tests). + t.Setenv("SB_THERMAL_DIR", filepath.Join(dir, "thermal")) d := New("test-version", log.New(log.Writer(), "", 0), rec.run) return d, rec } diff --git a/boxd/internal/daemon/thermals.go b/boxd/internal/daemon/thermals.go index 4d2b300..c17d2e2 100644 --- a/boxd/internal/daemon/thermals.go +++ b/boxd/internal/daemon/thermals.go @@ -4,7 +4,9 @@ import ( "context" "errors" "math" + "os" "os/exec" + "path/filepath" "strconv" "strings" "time" @@ -14,11 +16,17 @@ import ( // flags, sampled once a minute into the journal (one line per sample, so // `journalctl -u boxd` doubles as a temperature log) and reported through // /v1/info for the room's Screens list (stage 4). vcgencmd is the -// Raspberry Pi firmware tool (raspi-utils-core); where it is missing — -// a dev machine, a test — thermals simply go unreported and everything -// downstream tolerates their absence. +// Raspberry Pi firmware tool (raspi-utils-core). Without it — an x86 box +// (MAD-846) — the temperature comes from the kernel's thermal zones in +// /sys/class/thermal, with no throttling flags (a PC has no equivalent of +// the Pi firmware's bitmap). With neither, thermals simply go unreported +// and everything downstream tolerates their absence. const thermalsEvery = time.Minute +// defaultThermalDir is where the kernel lists its thermal zones; the +// SB_THERMAL_DIR hook points it at a fixture in tests. +const defaultThermalDir = "/sys/class/thermal" + // thermals is one sample. The flags are vcgencmd get_throttled's bitmap: // four conditions, current in bits 0-3 and the same four again as sticky // "has occurred" flags in bits 16-19 (never cleared until reboot — the @@ -32,6 +40,7 @@ type thermals struct { at time.Time tempC float64 rawFlags uint64 + hasFlags bool // the source reports throttling flags (vcgencmd only) } func (t thermals) valid() bool { return !t.at.IsZero() } @@ -45,14 +54,17 @@ func (t thermals) limitedNow() bool { func (t thermals) underVoltage() bool { return t.rawFlags&(1<<0) != 0 } func (t thermals) throttleOccurred() bool { return t.rawFlags&(0xF<<16) != 0 } -// infoFields is what /v1/info (and /status) add once a sample exists. +// infoFields is what /v1/info (and /status) add once a sample exists. The +// flag fields only come from a source that has them: a PC's thermal zone +// saying nothing about throttling is not "not throttled". func (t thermals) infoFields() map[string]any { - return map[string]any{ - "temperatureC": math.Round(t.tempC*10) / 10, - "throttled": t.limitedNow(), - "underVoltage": t.underVoltage(), - "throttleOccurred": t.throttleOccurred(), + m := map[string]any{"temperatureC": math.Round(t.tempC*10) / 10} + if t.hasFlags { + m["throttled"] = t.limitedNow() + m["underVoltage"] = t.underVoltage() + m["throttleOccurred"] = t.throttleOccurred() } + return m } // realVcgencmd runs the firmware tool (on the box; the daemon field is @@ -66,8 +78,8 @@ func realVcgencmd(args ...string) (string, error) { } // sampleThermalsLoop samples immediately and then once a minute. It -// returns for good where vcgencmd doesn't exist (not a Pi): thermals are -// optional and absent-by-default everywhere they're reported. +// returns for good where neither vcgencmd nor a thermal zone exists: +// thermals are optional and absent-by-default everywhere they're reported. func (d *Daemon) sampleThermalsLoop() { if !d.sampleThermals() { return @@ -80,14 +92,23 @@ func (d *Daemon) sampleThermalsLoop() { } // sampleThermals takes one sample, stores it and logs it. It reports -// false only for "there is no vcgencmd here" (stop sampling); a +// false only for "there is nothing to sample here" (stop sampling); a // transient failure keeps the loop alive and the journal informed. func (d *Daemon) sampleThermals() bool { + if d.thermalZone != "" { + return d.sampleThermalZone() + } tempOut, err := d.vcgencmd("measure_temp") if err != nil { if errors.Is(err, exec.ErrNotFound) { - d.log.Printf("boxd: thermals: no vcgencmd; temperature and throttling go unreported") - return false + zone, ok := pickThermalZone(d.thermalDir) + if !ok { + d.log.Printf("boxd: thermals: no vcgencmd and no thermal zone; temperature goes unreported") + return false + } + d.thermalZone = zone + d.log.Printf("boxd: thermals: no vcgencmd; reading %s (no throttling flags)", zone) + return d.sampleThermalZone() } d.log.Printf("boxd: thermals: %v", err) return true @@ -99,7 +120,7 @@ func (d *Daemon) sampleThermals() bool { } now := time.Now() d.mu.Lock() - d.therm = thermals{at: now, tempC: temp, rawFlags: flags} + d.therm = thermals{at: now, tempC: temp, rawFlags: flags, hasFlags: true} d.mu.Unlock() if tempOK { d.log.Printf("boxd: thermals temp=%.1f'C throttled=0x%x", temp, flags) @@ -131,3 +152,64 @@ func parseThrottleFlags(out string) uint64 { } return f } + +// sampleThermalZone is sampleThermals for a box without vcgencmd: one +// read of the chosen zone's temp file (millidegrees Celsius). +func (d *Daemon) sampleThermalZone() bool { + temp, err := readZoneTempC(d.thermalZone) + if err != nil { + d.log.Printf("boxd: thermals: %v", err) + return true + } + d.mu.Lock() + d.therm = thermals{at: time.Now(), tempC: temp} + d.mu.Unlock() + d.log.Printf("boxd: thermals temp=%.1f'C", temp) + return true +} + +// thermalZonePreference ranks zone types: the CPU package sensor first, +// then the other CPU/SoC sensors, then ACPI's board zone. A zone of any +// other type (a Wi-Fi card's, say) is only a last resort. +var thermalZonePreference = []string{ + "x86_pkg_temp", "cpu-thermal", "cpu_thermal", "soc_dts0", "soc_dts1", "acpitz", +} + +// pickThermalZone returns the zone directory to sample under dir +// (/sys/class/thermal): the best-ranked type that reads, else the first +// zone that reads at all. +func pickThermalZone(dir string) (string, bool) { + zones, _ := filepath.Glob(filepath.Join(dir, "thermal_zone*")) + best, bestRank := "", len(thermalZonePreference)+1 + for _, z := range zones { + if _, err := readZoneTempC(z); err != nil { + continue + } + rank := len(thermalZonePreference) + if typ, err := os.ReadFile(filepath.Join(z, "type")); err == nil { + for i, want := range thermalZonePreference { + if strings.TrimSpace(string(typ)) == want { + rank = i + break + } + } + } + if rank < bestRank { + best, bestRank = z, rank + } + } + return best, best != "" +} + +// readZoneTempC reads a thermal zone's temp (millidegrees) as Celsius. +func readZoneTempC(zone string) (float64, error) { + data, err := os.ReadFile(filepath.Join(zone, "temp")) + if err != nil { + return 0, err + } + milli, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64) + if err != nil { + return 0, err + } + return float64(milli) / 1000, nil +} diff --git a/boxd/internal/daemon/thermals_test.go b/boxd/internal/daemon/thermals_test.go index 0c51942..ac22348 100644 --- a/boxd/internal/daemon/thermals_test.go +++ b/boxd/internal/daemon/thermals_test.go @@ -4,7 +4,9 @@ import ( "encoding/json" "errors" "net/http" + "os" "os/exec" + "path/filepath" "testing" "time" ) @@ -143,3 +145,77 @@ func TestThermalsAbsentWithoutVcgencmd(t *testing.T) { t.Fatal("no-vcgencmd daemon must not report thermals") } } + +// fakeZone makes a /sys/class/thermal-style zone under dir. +func fakeZone(t *testing.T, dir, name, typ, temp string) { + t.Helper() + z := filepath.Join(dir, name) + if err := os.MkdirAll(z, 0o755); err != nil { + t.Fatal(err) + } + os.WriteFile(filepath.Join(z, "type"), []byte(typ+"\n"), 0o644) + if temp != "" { + os.WriteFile(filepath.Join(z, "temp"), []byte(temp+"\n"), 0o644) + } +} + +func TestPickThermalZonePrefersTheCPUPackage(t *testing.T) { + dir := t.TempDir() + fakeZone(t, dir, "thermal_zone0", "acpitz", "40000") + fakeZone(t, dir, "thermal_zone1", "iwlwifi_1", "35000") + fakeZone(t, dir, "thermal_zone2", "x86_pkg_temp", "51500") + fakeZone(t, dir, "thermal_zone3", "x86_pkg_temp", "") // no temp: skipped + zone, ok := pickThermalZone(dir) + if !ok || filepath.Base(zone) != "thermal_zone2" { + t.Fatalf("picked %q, %v", zone, ok) + } + + // Only an unranked zone: still better than nothing. + dir2 := t.TempDir() + fakeZone(t, dir2, "thermal_zone0", "iwlwifi_1", "35000") + if zone, ok := pickThermalZone(dir2); !ok || filepath.Base(zone) != "thermal_zone0" { + t.Fatalf("fallback picked %q, %v", zone, ok) + } + + if _, ok := pickThermalZone(t.TempDir()); ok { + t.Fatal("picked a zone in an empty dir") + } +} + +func TestThermalsFromSysfsWithoutVcgencmd(t *testing.T) { + // An x86 box (MAD-846): no vcgencmd, a thermal zone instead. The + // temperature is reported; throttling flags are not (no source). + d, _ := newTestDaemon(t, "server_url=https://s.example\n") + d.thermalDir = t.TempDir() + fakeZone(t, d.thermalDir, "thermal_zone0", "x86_pkg_temp", "47250") + d.vcgencmd = func(args ...string) (string, error) { return "", exec.ErrNotFound } + + if !d.sampleThermals() { + t.Fatal("sampling gave up despite a thermal zone") + } + rec := get(t, d.bridgeMux(), "/v1/info") + var m map[string]any + json.Unmarshal(rec.Body.Bytes(), &m) + if m["temperatureC"] != 47.3 { + t.Fatalf("temperatureC = %v", m["temperatureC"]) + } + for _, key := range []string{"throttled", "underVoltage", "throttleOccurred"} { + if _, ok := m[key]; ok { + t.Fatalf("sysfs sample reports %q", key) + } + } + + // Later samples read the zone directly; vcgencmd isn't asked again. + d.vcgencmd = func(args ...string) (string, error) { + t.Fatal("vcgencmd asked again after falling back") + return "", nil + } + os.WriteFile(filepath.Join(d.thermalDir, "thermal_zone0", "temp"), []byte("60000\n"), 0o644) + d.sampleThermals() + rec = get(t, d.bridgeMux(), "/v1/info") + m = nil + json.Unmarshal(rec.Body.Bytes(), &m) + if m["temperatureC"] != 60.0 { + t.Fatalf("second sample temperatureC = %v", m["temperatureC"]) + } +} diff --git a/build-iso.sh b/build-iso.sh new file mode 100755 index 0000000..b47d570 --- /dev/null +++ b/build-iso.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# Build the x86-64 live/installer ISO (MAD-846, ADR 0013) with Debian's +# live-build, the way ./build.sh builds the Pi image with pi-gen. +# +# - resolves the version from `git describe` and names the output +# syncphony-box--amd64.iso (+ .sha256) +# - builds boxd for amd64 and the syncphony-box__amd64.deb +# (scripts/build-deb.sh), which lands in deploy/ too, for the apt repo +# - assembles the live-build tree in .build/live: x86/live (the lb config +# and hooks), x86/root (the image-only files: installer, units) and the +# shared customise layer (rootfs/) with the .deb, which the chroot hook +# runs +# - runs lb in a privileged Debian Trixie container (live-build mounts +# /proc, /sys and /dev in its chroot), as linux/amd64: native on an x86 +# host, emulated (slow) on an arm64 one. The chroot is built in the +# container's own filesystem, not the bind-mounted checkout (a macOS +# or root-squashed mount can't hold a Debian root); only the ISO and +# build.log come back out. +# - drops the result in ./deploy +# +# Environment: DOCKER (default docker), LB_IMAGE (default debian:trixie). +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${REPO_ROOT}" + +DOCKER="${DOCKER:-docker}" +LB_IMAGE="${LB_IMAGE:-debian:trixie}" +VERSION="$(scripts/version.sh)" +ISO_BASE="syncphony-box-${VERSION}-amd64" + +if ! command -v go >/dev/null 2>&1; then + echo "Go is required to build boxd (https://go.dev/dl/)." >&2 + exit 1 +fi +if ! command -v "${DOCKER}" >/dev/null 2>&1; then + echo "${DOCKER} is required: live-build runs in a Debian Trixie container." >&2 + exit 1 +fi + +echo "Building boxd ${VERSION} for amd64..." +mkdir -p .build +( cd boxd && + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -mod=vendor -trimpath \ + -ldflags "-s -w -X main.version=${VERSION}" \ + -o "../.build/boxd-amd64" ./cmd/boxd ) + +rm -rf .build/deb-amd64 +DEB="$(scripts/build-deb.sh amd64 .build/boxd-amd64 .build/deb-amd64)" +echo "Packaged ${DEB}" + +# The live-build config tree (the container copies it in and builds there). +LB=.build/live +rm -rf "${LB}" +cp -a x86/live "${LB}" +INCLUDES="${LB}/config/includes.chroot_after_packages" +mkdir -p "${INCLUDES}/root" +cp -a x86/root/. "${INCLUDES}/" +cp -a rootfs "${INCLUDES}/root/syncphony-rootfs" +cp "${DEB}" "${INCLUDES}/root/syncphony-rootfs/syncphony-box.deb" + +echo "Running live-build in ${LB_IMAGE} (linux/amd64)..." +OUT=.build/live-out +rm -rf "${OUT}" +mkdir -p "${OUT}" +"${DOCKER}" run --rm --privileged --platform linux/amd64 \ + -v "${REPO_ROOT}/${LB}:/src:ro" -v "${REPO_ROOT}/${OUT}:/out" \ + -e HOST_UID="$(id -u)" -e HOST_GID="$(id -g)" \ + "${LB_IMAGE}" bash -euc ' + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq --no-install-recommends \ + live-build debootstrap ca-certificates xorriso \ + grub-pc-bin grub-efi-amd64-bin mtools dosfstools squashfs-tools cpio >/dev/null + cp -a /src /build + cd /build + lb config + set +e + lb build 2>&1 | tee build.log + status="${PIPESTATUS[0]}" + set -e + cp build.log *.iso /out/ 2>/dev/null || true + chown -R "${HOST_UID}:${HOST_GID}" /out + exit "${status}" + ' + +mkdir -p deploy +built="$(ls "${OUT}"/*.iso 2>/dev/null | head -n 1 || true)" +if [ -z "${built}" ]; then + echo "live-build finished but produced no ISO; see ${OUT}/build.log" >&2 + exit 1 +fi +ISO="deploy/${ISO_BASE}.iso" +mv "${built}" "${ISO}" +sha256sum "${ISO}" > "${ISO}.sha256" +# The package the ISO carries, for the apt repo (installed x86 boxes +# update from it like the Pis do). +cp "${DEB}" deploy/ + +echo +echo "Built ${ISO}" +ls -lah deploy diff --git a/build.sh b/build.sh index 95b9c7c..1c49d26 100755 --- a/build.sh +++ b/build.sh @@ -7,6 +7,8 @@ # - builds boxd and the syncphony-box .deb (scripts/build-deb.sh), which # the custom stage installs — and which lands in deploy/ too, for the # apt repo (ADR 0012) +# - hands the custom stage the shared customise layer (rootfs/, the same +# one the x86 ISO build runs; ADR 0013) next to the .deb # - 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/ @@ -46,10 +48,15 @@ mkdir -p .build rm -rf .build/deb DEB="$(scripts/build-deb.sh arm64 .build/boxd-arm64 .build/deb)" -mkdir -p stage-syncphony/00-packages/files -cp "${DEB}" stage-syncphony/00-packages/files/syncphony-box.deb echo "Packaged ${DEB}" +# The stage's inputs: the package and the shared customise layer. +STAGE_FILES=stage-syncphony/00-customise/files +rm -rf "${STAGE_FILES}" +mkdir -p "${STAGE_FILES}" +cp "${DEB}" "${STAGE_FILES}/syncphony-box.deb" +cp -a rootfs "${STAGE_FILES}/rootfs" + # Only stage-syncphony/EXPORT_IMAGE (empty IMG_SUFFIX) exports an image. touch pi-gen/stage2/SKIP_IMAGES diff --git a/docs/adr/0013-x86-iso.md b/docs/adr/0013-x86-iso.md new file mode 100644 index 0000000..06c22e9 --- /dev/null +++ b/docs/adr/0013-x86-iso.md @@ -0,0 +1,205 @@ +# ADR 0013: The x86-64 box, a live/installer ISO + +- Status: Accepted +- Date: 2026-10-15 +- Deciders: x86 image issue (MAD-846), widening MAD-797's "x86 mini-PC + images: out of scope" + +## Context + +Until now the box was Pi-only: arm64, built by pi-gen (ADR 0001). Plenty +of rooms already have an old PC or a mini-PC next to the TV, and there +was nothing for them. The goal: any x86-64 PC can be a box with the same +behavior as a Pi (the kiosk, the audio, `/data`, the read-only root, the +updates), from one download. + +Two things had to stay true. The Pi image must not fork into a second +copy of the box's setup, and nothing about how a Pi works may change. + +## Decision + +### One deliverable: a hybrid ISO with two boot entries + +`syncphony-box--amd64.iso` is a hybrid ISO: it boots from a DVD +or written straight to a USB stick (`dd`, Etcher, Imager's "Use custom"), +so there is no separate x86 `.img`. Its one GRUB menu serves both +firmware types, UEFI and legacy BIOS: + +1. **Syncphony Box (live).** The box, running from the medium. live-boot + gives it the read-only squashfs root with writes going to RAM, the + overlay the Pi gets from its own initramfs script. A partition + labelled `syncphony-data` on the stick becomes `/data`, so pairing + and setup survive reboots. Without one, the box works and forgets + everything at power-off. +2. **Install Syncphony Box to disk.** Runs the installer. + +Secure Boot is not a goal. The ISO and installed boxes boot unsigned +GRUB, so Secure Boot has to be off. + +### Builder: Debian live-build on Trixie + +live-build is Debian's own tool for exactly this artifact (it builds +Debian's official live images): debootstrap, a chroot stage with hooks, +a squashfs, live-boot's initramfs, and the ISO with GRUB for both +firmware types. The alternatives were worse fits. mkosi makes disk +images, not a live ISO with an installer. A hand-rolled debootstrap + +grub-mkrescue would reimplement live-build. And the Pi's base is +Raspberry Pi OS Trixie, so Debian Trixie on x86 keeps both boxes on one +Debian release, one Chromium and one PipeWire. + +`./build-iso.sh` (`make iso`) runs `lb build` in a privileged +`debian:trixie` container, so any Linux host with Docker can build it, +the way `./build.sh` runs pi-gen in its container. CI builds it on an +x86 runner next to the Pi image on an arm64 one. + +### The shared stage layer: `rootfs/` + +The old `stage-syncphony/*/00-run.sh` scripts mixed two things: what +makes a Debian root a box, and pi-gen's way of doing it (`on_chroot`, +`${ROOTFS_DIR}`, `/boot/firmware`). They are split: + +- **`rootfs/customise.sh PLATFORM DEB`** runs *inside* the target root, + as root, with the target at `/`. It installs `packages/common.list` + plus `packages/.list`, then the `syncphony-box` .deb, then + `steps/*.sh` in order: the kiosk user's audio, the read-only-root + links and fstab's `/data` line, timesyncd, NetworkManager, the + slimming. It knows nothing about either builder. +- **pi-gen wrapper** (`stage-syncphony/`): `00-customise` copies + `rootfs/` and the .deb into the chroot and runs + `customise.sh pi` through `on_chroot`. `01-pi-boot` does what only a + Pi has: `cmdline.txt` (quiet, `boot=overlay`, `grow-data`, no + `resize`), `config.txt`, the read-only FAT in fstab, `syncphony.txt` + onto the boot partition, `rpi-resize` off. +- **live-build wrapper** (`x86/live`): a chroot hook runs + `customise.sh x86`, resolves the `/data` line to the label, enables + the x86-only units and rebuilds the initramfs. A binary hook writes + the ISO's boot menu. `x86/root` holds the image-only files: the + installer, its units and templates, and the live session's + `/boot/firmware`. + +Package lists use the format both builders already share (one name per +line, `#` comments), but `customise.sh` installs them itself +(`--no-install-recommends`, like pi-gen), so the lists live in one place. + +The split itself doesn't change what lands in the Pi image: the same +packages, files, links and units, in the same order. Two package changes +made for x86 reach the Pi too, both no-ops there: the initramfs hook +(the Pi's initramfs already had `e2fsck` and the overlay module) and the +update unit's live-session condition. The smoke test's Pi checks are the +same ones as before. + +### What changed for x86, and how + +- **Boot partition.** An installed box's EFI system partition (FAT32, + label `SB-BOOT`) is mounted at `/boot/firmware`, read-only, exactly + where the Pi's FAT is. So `syncphony.txt` lives on it, and boxd's + rw-remount writes (the setup form, `resolution=`) follow it there with + no code change. +- **`cmdline.txt` becomes `cmdline.cfg`.** GRUB can't read a plain file + into the kernel command line, so the installer writes + `/boot/firmware/cmdline.cfg`, a GRUB script with one line + (`set syncphony_cmdline="boot=overlay quiet ..."`), and `grub.cfg` + sources it. boxd uses `cmdline.cfg` when it exists and `cmdline.txt` + otherwise, and edits only that `set` line. The Pi path is untouched. + The live session has neither file, and boxd skips display settings + there instead of writing a file nothing reads and rebooting. +- **`config.txt`** has no x86 counterpart: the firmware splash is the + PC's own, and GRUB's menu is hidden (`timeout_style=hidden`). +- **Read-only root.** The same initramfs overlay script as the Pi + (`boot=overlay`), the same `/data`, the same `syncphony-box chroot` and + nightly update. A new initramfs hook in the package copies `e2fsck` in. + initramfs-tools only adds it when fstab has an ext4 root line, and the + live-built root has none. One initramfs serves both the live session + (`boot=live`) and the installed box (`boot=overlay`). +- **Thermals.** boxd reads `/sys/class/thermal` when there is no + `vcgencmd`: the CPU package zone (`x86_pkg_temp`) first, then other + CPU/SoC zones, then ACPI's. A PC has nothing like the Pi firmware's + throttling bitmap, so `/v1/info` reports `temperatureC` only, rather + than claiming "not throttled". +- **Audio.** `audio-setup`'s HDMI markers are vc4 names. Where no vc4 + sink exists at all, `hdmi`/`hdmi2` are the first and second built-in + HDMI/DisplayPort sinks, and `analog` is the built-in analog output. A + Pi with HDMI0 absent still never falls back to HDMI1. +- **Watchdog.** `RuntimeWatchdogSec` arms whatever `/dev/watchdog` the + PC has (iTCO and similar). A PC without one logs it and runs on. +- **CEC.** Most PC GPUs can't send CEC. boxd already runs without + `/dev/cec*`, so the box simply has no CEC there. A Pulse-Eight USB + adapter is a later issue. +- **Firmware.** `packages/x86.list` names it: GPU (Intel, AMD), + Wi-Fi (Intel, Realtek, Atheros), the SOF audio DSPs of recent Intel + machines with the UCM profiles PipeWire needs, and microcode. + live-build's own all-firmware switch stays off. + +### The installer: a script on tty1 + +`syncphony-install` is a bash script with `whiptail` dialogs, run on +tty1 by `syncphony-installer.service` when the install entry put +`syncphony.install` on the kernel command line. Drop-ins keep the kiosk, +boxd and `/data` off in that boot. It: + +1. offers every writable disk big enough, except the one the live + system booted from, and asks twice before erasing it; +2. optionally creates an admin user with sudo. A PC has no Imager to make + one, and without one there is no login for SSH or + `syncphony-box chroot`; +3. partitions GPT: a 1 MiB BIOS boot partition (GRUB's core image on a + BIOS machine), the ESP (`SB-BOOT`, 256 MiB), root (ext4, + `syncphony-root`, twice the image's size and at least 4 GiB, so + nightly updates have room), and `syncphony-data` taking the rest. The + Pi's three partitions plus BIOS boot, so `/data` is full size from the + start and `grow-data` isn't used; +4. copies the live system's *pristine* root (live-boot's read-only + squashfs mount, not the running overlay), plus the kernel and + initramfs from the medium if the squashfs lacks them, and writes + fstab by UUID; +5. runs `grub-install` twice, for UEFI (`--removable --no-nvram`: the + fallback path every UEFI firmware boots, no NVRAM entry to go stale) + and for BIOS, both into the ESP, then writes `grub.cfg` and + `cmdline.cfg` from templates; +6. lays out `/data` (the same directories `add-data-partition.sh` makes + for the Pi image) and puts `syncphony.txt` on the ESP. + +Only the `-bin` GRUB packages are installed (`grub-pc-bin`, +`grub-efi-amd64-bin`). The full `grub-pc`/`grub-efi-amd64` packages +would try to reinstall the bootloader on the real root during a nightly +update. On an installed box, the bootloader is the installer's and stays +put. + +The issue allowed the disk picker to be a boxd screen in the kiosk. A +tty1 script is less code to get wrong for a step that runs once per +machine, needs no network and no phone, and works on a PC whose GPU +Chromium can't drive yet. A boxd screen can replace it later without +changing the disk layout. + +### The live session's /boot/firmware + +The live session boots from read-only media with no boot partition. +`syncphony-live-boot.service` makes one for boxd. With `/data` mounted +from the stick, `/boot/firmware` is a bind mount of +`/data/boot-firmware`, so the setup form's writes persist. Without it, +`/boot/firmware` is on the RAM overlay with the template. The nightly +update is off in a live session (`ConditionKernelCommandLine=!boot=live` +on its unit): there is no real root to update. + +## Consequences + +- Releases carry the ISO next to the Pi image, and the apt repo carries + an amd64 `syncphony-box` next to the arm64 one, so installed PCs update + the way Pis do. The Imager listing stays Pi-only (Imager's device + list is Pi boards). +- The release now waits for two image builds. The ISO job runs on a + standard x86 runner in about the time the Pi job takes. +- `/data` is found by label everywhere (the Pi's initramfs and the x86 + live session). With two `syncphony-data` partitions attached (the + live stick still plugged into an installed box), which one mounts is + undefined. The installer says to remove the stick. +- Disks over 2 TiB work (GPT), and the 4 GiB root minimum means a disk + smaller than about 6 GB isn't offered. +- `resolution=`/`rotate=` keep the Pi's `video=HDMI-A-1:` token. On a PC + the connector may be `DP-1` or `HDMI-A-2`, and the setting then does + nothing. Picking the connector is a follow-up if PCs need it. +- No VM image (qcow2/raw for Proxmox) yet. It can be made from the same + squashfs later. +- CI can't boot the ISO. The VM procedures that check the done-when + (live boot on UEFI and BIOS, install, reboot still paired) are in + `docs/testing.md`. diff --git a/docs/hardware.md b/docs/hardware.md index 198a13f..e2d5769 100644 --- a/docs/hardware.md +++ b/docs/hardware.md @@ -12,7 +12,9 @@ record the CEC issue (MAD-807, ADR 0009) asked for. | Pi 5 | pending bench | HDMI0 is the port next to the USB-C power input; that port is `cec0` | | Pi 4 (2 GB+) | pending bench | HDMI0 is next to the power input too; the 3.5 mm jack makes `audio=analog` possible | -Both ports: plugging into HDMI1 instead of HDMI0 means `audio=hdmi2` in +| x86-64 PC (ISO, ADR 0013) | pending bench | VM-tested procedure in docs/testing.md; no CEC (PC GPUs can't send it); `audio=hdmi` is the first HDMI/DisplayPort output | + +Both Pi ports: plugging into HDMI1 instead of HDMI0 means `audio=hdmi2` in `syncphony.txt` (the box then claims `/dev/cec1`). Power: use the official power supply — under-voltage shows up as audio dropouts long before anything else (the watchdog's thermals log flags it; see ADR diff --git a/docs/persistent-paths.md b/docs/persistent-paths.md index 7208c82..6a265a7 100644 --- a/docs/persistent-paths.md +++ b/docs/persistent-paths.md @@ -2,8 +2,8 @@ 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 +Anything that must survive lives on `/data`, the partition labelled +`syncphony-data` (the third on a Pi's card, the fourth on a PC's disk). 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 | @@ -23,7 +23,14 @@ 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) | +| `syncphony.txt` | the FAT boot partition (`/boot/firmware`; on a PC box, the EFI system partition `SB-BOOT`) | the box's config, editable from any computer; written only through the remount dance (rw → write → sync → ro) | +| kernel command line | `/boot/firmware/cmdline.txt` on a Pi, `/boot/firmware/cmdline.cfg` (GRUB) on a PC | boxd's `video=` token from `resolution=`/`rotate=` (ADR 0013) | + +The x86 live session (the ISO booted from a USB stick, ADR 0013) has no +boot partition. With a `syncphony-data` partition on the stick, the +whole list above applies, and `syncphony.txt` lives at +`/data/boot-firmware/syncphony.txt` (bind-mounted at `/boot/firmware`). +Without one, everything is in RAM for that session. Everything else is deliberately throwaway: apt state, Chromium cache, `$HOME`, `systemd`'s runtime state. The overlay discarding them is the diff --git a/docs/releasing.md b/docs/releasing.md index 2472a22..452248d 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -71,14 +71,16 @@ workflow's own GitHub identity. git push origin vX.Y.Z ``` -4. The `build` workflow builds and smoke-tests the image, then the - `release` job publishes: +4. The `build` workflow builds and smoke-tests the Pi image and the x86 + ISO, then the `release` job publishes: - the GitHub Release: `syncphony-box-vX.Y.Z-arm64.img.xz` (+ `.sha256`), - `syncphony-box_X.Y.Z_arm64.deb`, `SHA256SUMS`, - `SHA256SUMS.sigstore.json`, `os_list.json`, and the changelog - section as the notes; - - on `gh-pages`: the .deb added to the apt repo (re-signed), and - `imager/os_list.json` pointing at the new image. + `syncphony-box-vX.Y.Z-amd64.iso` (+ `.sha256`), + `syncphony-box_X.Y.Z_arm64.deb` and `_amd64.deb`, `SHA256SUMS` + over all four, `SHA256SUMS.sigstore.json`, `os_list.json`, and the + changelog section as the notes; + - on `gh-pages`: both .debs added to the apt repo (re-signed), and + `imager/os_list.json` pointing at the new Pi image (the listing is + Pi-only; ADR 0013). 5. Boxes pick it up at their next 04:00. To see one take it right away: `sudo syncphony-box update` on the box. @@ -105,6 +107,7 @@ the next night retries. Details: `journalctl -u syncphony-update` and `/data/syncphony-box/update.json`. If a box doesn't come back after an update: reflash the card with the -current release in Imager and pair it again from the room. There are no +current release in Imager (on a PC, reinstall from the current ISO) and +pair it again from the room. There are no A/B partitions to fall back to (ADR 0012). Please open an issue with what the box was running (the Screens list shows the version). diff --git a/docs/testing.md b/docs/testing.md index ba52fc9..a1ac05b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,8 +1,9 @@ # 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 +CI can't boot a Pi (or the x86 ISO). The unit tests (bats + Go, `make +test`) and the image smoke test (`scripts/smoke-test.sh`, runs in CI on +every build) pin the images' structure; the checks below are for real +hardware, and for the ISO, a VM (the last section). The README's on-Pi checklist covers the everyday list — this page is the destructive one. @@ -295,3 +296,87 @@ Then the negative cases: kernel update, check that it lands: `uname -r` after the reboot matches the newest `/lib/modules`, and the box still boots with the overlay (the initramfs was regenerated with the overlay script). + +## The x86 ISO in a VM (MAD-846) + +The done-when for the x86 box ([ADR 0013](adr/0013-x86-iso.md)), in QEMU +on a Linux host. CI builds and smoke-tests the ISO but can't boot it. +Have OVMF (`apt install ovmf`) for UEFI; BIOS is QEMU's default. The +box's network is QEMU's user-mode NAT, so the server must be reachable +from the host. Forward 8080 to open the box's setup page from the host +(`http://localhost:8080`). + +```sh +ISO=deploy/syncphony-box--amd64.iso +UEFI="-drive if=pflash,format=raw,readonly=on,file=/usr/share/OVMF/OVMF_CODE_4M.fd" +COMMON="-machine q35,accel=kvm -m 3G -smp 2 -vga virtio -display gtk \ + -audiodev pa,id=snd -device intel-hda -device hda-output,audiodev=snd \ + -nic user,model=virtio,hostfwd=tcp::8080-:80 \ + -device i6300esb -watchdog-action reset" +``` + +`i6300esb` gives the VM a hardware watchdog, so `RuntimeWatchdogSec` has +something to arm. + +### Live, UEFI and BIOS + +```sh +qemu-system-x86_64 $COMMON $UEFI -cdrom "$ISO" # UEFI +qemu-system-x86_64 $COMMON -cdrom "$ISO" # BIOS +``` + +For each: the menu shows **Syncphony Box** and **Install Syncphony Box +to disk**; the first boots to the setup QR. Set the server from the host, +pair from a phone (or the room), queue a song: it plays through the +VM's sound. `findmnt /` is live-boot's overlay; `systemctl status +syncphony-update.timer` shows its service skipped (condition), not +failed. + +### Live with a data partition + +```sh +cp "$ISO" stick.img && truncate -s 4G stick.img +echo ',,L' | sfdisk --append stick.img +LOOP=$(sudo losetup --find --show --partscan stick.img) +sudo mkfs.ext4 -L syncphony-data "${LOOP}p4" && sudo losetup -d "$LOOP" +qemu-system-x86_64 $COMMON $UEFI -drive file=stick.img,format=raw,if=virtio +``` + +(The ISO itself is three GPT partitions; the appended one is the 4th.) +Set the server and pair, power the VM off hard (close the window), +start it again: still paired, same server, and +`/data/boot-firmware/syncphony.txt` has the server URL. + +### Install to disk + +```sh +qemu-img create -f qcow2 disk.qcow2 16G +qemu-system-x86_64 $COMMON $UEFI -cdrom "$ISO" \ + -drive file=disk.qcow2,if=virtio -boot d +``` + +Pick **Install Syncphony Box to disk**. The installer offers `/dev/vda` +only (never the CD), asks before erasing, offers the admin user; give it +one. After the restart, boot from the disk (drop `-cdrom` and +`-boot d`): + +- the box comes up on the setup QR; set the server, pair, play; +- `ssh -p @localhost` works with the password + (add `hostfwd=tcp::2222-:22` to the `-nic` line); +- `findmnt /` is the overlay, `findmnt /boot/firmware` is the `SB-BOOT` + vfat mounted `ro`, `findmnt /data` is `syncphony-data`; `lsblk` + shows four partitions with `/data` taking the rest of the disk; +- `touch /usr/bin/nope` is gone after a reboot; +- **pull the plug** (close the window) mid-song, start again: still + paired, playing on its own; +- `resolution=1280x720` through the setup page or `syncphony.txt`: the + box reboots once and `cat /proc/cmdline` has + `video=HDMI-A-1:1280x720D` (whether the VM's display honors it + depends on its connector name; the cmdline is what's under test); +- `curl -s 127.0.0.1:8099/v1/info` has `temperatureC` if the VM exposes + a thermal zone (often it doesn't; then it's absent, not zero); +- `sudo syncphony-box update` runs and leaves the box booting. + +Repeat the install with BIOS (no `$UEFI`) on a fresh disk, and boot the +UEFI-installed disk with BIOS and vice versa: one install boots both +ways. diff --git a/packaging/root/etc/dpkg/dpkg.cfg.d/50-syncphony-slim b/packaging/root/etc/dpkg/dpkg.cfg.d/50-syncphony-slim index 497e8c5..baef6a7 100644 --- a/packaging/root/etc/dpkg/dpkg.cfg.d/50-syncphony-slim +++ b/packaging/root/etc/dpkg/dpkg.cfg.d/50-syncphony-slim @@ -1,7 +1,7 @@ # Keep the box image small (MAD-809): no documentation, man pages or # translations. The copyright files stay. Applies to updates too; the -# image build strips what stage0-2 already installed (stage-syncphony/ -# 10-slim). +# image build strips what was installed before it (rootfs/steps/ +# 90-slim.sh). path-exclude /usr/share/doc/* path-include /usr/share/doc/*/copyright path-exclude /usr/share/man/* diff --git a/packaging/root/etc/initramfs-tools/hooks/syncphony-box b/packaging/root/etc/initramfs-tools/hooks/syncphony-box new file mode 100755 index 0000000..f3aed4e --- /dev/null +++ b/packaging/root/etc/initramfs-tools/hooks/syncphony-box @@ -0,0 +1,24 @@ +#!/bin/sh +# The read-only root's initramfs needs (MAD-805, MAD-846, ADR 0007/0013). +# +# The overlay script fsck's /data (e2fsck -p LABEL=syncphony-data) before +# mounting it. initramfs-tools' own fsck hook only copies the checker for +# the root filesystem type it finds in /etc/fstab, and the x86 root is +# built by live-build with no root line there, so without this the check +# would fail with "not found" and /data would never mount early. The +# overlay module rides along for the same reason (the modules list has it +# too). + +PREREQ="" +prereqs() { echo "${PREREQ}"; } +case "$1" in +prereqs) + prereqs + exit 0 + ;; +esac + +. /usr/share/initramfs-tools/hook-functions + +copy_exec /usr/sbin/e2fsck /sbin +manual_add_modules overlay diff --git a/packaging/root/usr/lib/syncphony-box/audio-setup b/packaging/root/usr/lib/syncphony-box/audio-setup index 3be82b8..437b388 100755 --- a/packaging/root/usr/lib/syncphony-box/audio-setup +++ b/packaging/root/usr/lib/syncphony-box/audio-setup @@ -12,7 +12,8 @@ # Never fails a boot: apply always exits 0. A missing server or a sink that # doesn't exist (yet) is a warning — the default sink is left alone until # the output appears and the monitor switches to it. PipeWire runs as the -# kiosk user's user services (linger; enabled at image build by 04-audio). +# kiosk user's user services (linger; enabled at image build by +# rootfs/steps/10-audio.sh). # # Test hooks (unit defaults shown): SB_PACTL (pactl), SB_CONFIG_ENV # (/etc/syncphony-box/config.env), SB_SLEEP (sleep). @@ -95,7 +96,10 @@ sink_block() { # against each sink's identifying text in order; the primary HDMI markers # are the vc4 driver's PCM names ("bcm2835 HDMI 1" = HDMI0, the port next # to USB-C power; "bcm2835 HDMI 2" = HDMI1) — the same on Pi 4 and Pi 5. -# No silent wrong-port fallback: nothing matching means a warning upstream. +# No silent wrong-port fallback on a Pi: nothing matching means a warning +# upstream. A PC (the x86 box, MAD-846) has no fixed port names, so +# there, and only where no vc4 sink exists at all, hdmi and hdmi2 are the +# first and second built-in HDMI/DisplayPort sinks in PipeWire's order. pick_sink() { audio=$1 data=$(sinks_data) @@ -109,11 +113,13 @@ pick_sink() { set -- 'bcm2835 hdmi 2' 'vc4-hdmi-1' 'hdmi1' ;; analog) - # The Pi 4 jack (the Pi 5 has none); never a USB headset - # whose text also says "headphones". + # The Pi 4 jack (the Pi 5 has none), or a PC's built-in + # analog output; never a USB headset whose text also says + # "headphones". printf '%s\n' "$data" | awk -F '\t' ' $2 != "usb" && (index($3, "headphone") || - (index($3, "bcm2835") && !index($3, "hdmi"))) { + (index($3, "bcm2835") && !index($3, "hdmi")) || + (index($3, "analog") && !index($3, "hdmi"))) { print $1; exit } ' @@ -144,6 +150,17 @@ pick_sink() { return 0 fi done + + # Not a Pi: the nth built-in HDMI/DisplayPort sink (n = 1 for hdmi, + # 2 for hdmi2). + case $data in + *bcm2835*|*vc4*) return 0 ;; + esac + n=1 + [ "$audio" = hdmi2 ] && n=2 + printf '%s\n' "$data" | awk -F '\t' -v n="$n" ' + $2 != "usb" && index($3, "hdmi") && ++seen == n { print $1; exit } + ' return 0 } diff --git a/packaging/root/usr/lib/syncphony-box/syncphony.txt b/packaging/root/usr/lib/syncphony-box/syncphony.txt index 49647d5..0f92bb5 100644 --- a/packaging/root/usr/lib/syncphony-box/syncphony.txt +++ b/packaging/root/usr/lib/syncphony-box/syncphony.txt @@ -1,10 +1,11 @@ # Syncphony Box settings. # -# This file sits on the SD card's boot partition, so you can edit it with -# any computer: put the card in a card reader and open the small "bootfs" -# drive. Lines are name=value; remove the leading "# " to turn an option -# on, put the card back, and power the box on. Plain text only; CRLF line -# endings are fine. Unknown lines are ignored. +# This file sits on the box's boot partition, so you can edit it with +# any computer: on a Pi, put the SD card in a card reader and open the +# small "bootfs" drive (on a PC box it is the "SB-BOOT" partition). Lines +# are name=value; remove the leading "# " to turn an option on, put the +# card back, and power the box on. Plain text only; CRLF line endings are +# fine. Unknown lines are ignored. # # The Syncphony server this box shows. Required. #server_url=https://syncphony.example.com diff --git a/packaging/root/usr/lib/systemd/system/syncphony-update.service b/packaging/root/usr/lib/systemd/system/syncphony-update.service index 70e25d0..f6a2a45 100644 --- a/packaging/root/usr/lib/systemd/system/syncphony-update.service +++ b/packaging/root/usr/lib/systemd/system/syncphony-update.service @@ -4,6 +4,9 @@ Documentation=https://github.com/madeofpendletonwool/syncphony-box/blob/main/doc # boxd answers "is music playing?" and owns the auto_update= setting. After=network-online.target boxd.service Wants=network-online.target +# An x86 live session (MAD-846) has no real root to update: it runs from +# the read-only ISO, and its overlay is RAM. Installed boxes and Pis only. +ConditionKernelCommandLine=!boot=live [Service] Type=oneshot diff --git a/rootfs/customise.sh b/rootfs/customise.sh new file mode 100755 index 0000000..63bd4c4 --- /dev/null +++ b/rootfs/customise.sh @@ -0,0 +1,71 @@ +#!/bin/bash +# Customise a Debian root filesystem into a Syncphony box (MAD-846, ADR +# 0013). +# +# This is the layer both image builders share. pi-gen (the Pi .img) and +# live-build (the x86 ISO) each run it inside their own chroot, as root, +# with the target root at /. It knows nothing about either builder: no +# on_chroot, no ROOTFS_DIR, no /boot/firmware. What's left per platform +# lives in the thin wrappers: the Pi's config.txt and cmdline.txt in +# stage-syncphony/, and the x86 bootloader and installer in x86/. +# +# Usage: customise.sh PLATFORM DEB +# PLATFORM pi or x86. Installs packages/common.list plus +# packages/.list; the steps see it as SB_PLATFORM. +# DEB the syncphony-box .deb to install (scripts/build-deb.sh) +# +# Then runs steps/*.sh in order. Every step is idempotent. +set -euo pipefail + +if [ "$#" -ne 2 ]; then + echo "usage: $0 pi|x86 DEB" >&2 + exit 2 +fi +SB_PLATFORM="$1" +DEB="$2" +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export SB_PLATFORM DEBIAN_FRONTEND=noninteractive + +case "${SB_PLATFORM}" in +pi | x86) ;; +*) + echo "customise.sh: unknown platform '${SB_PLATFORM}' (want pi or x86)" >&2 + exit 2 + ;; +esac +if [ ! -f "${DEB}" ]; then + echo "customise.sh: ${DEB} is missing. Build it with scripts/build-deb.sh." >&2 + exit 1 +fi + +# Package lists: one name per line, # comments (pi-gen's format, and +# live-build's). +packages() { + sed -e 's/#.*//' -e 's/[[:space:]]//g' -e '/^$/d' "$@" +} + +# Builders leave the apt lists in place for their own installs; refresh +# only when there are none. +if ! compgen -G '/var/lib/apt/lists/*_Packages*' > /dev/null; then + apt-get update +fi + +mapfile -t pkgs < <(packages "${HERE}/packages/common.list" "${HERE}/packages/${SB_PLATFORM}.list") +echo "customise.sh: installing ${#pkgs[@]} packages for ${SB_PLATFORM}" +apt-get install -y --no-install-recommends "${pkgs[@]}" + +# The box's own software, as the syncphony-box package (MAD-810, ADR +# 0012): boxd, the kiosk wrapper, the units, the drop-ins, the initramfs +# overlay scripts, plus the postinst that creates the kiosk user and the +# cec group and enables the units. The nightly update installs newer +# builds of this same package from the apt repo, so the image and the +# updates can't drift apart. Installed before the steps, which build on +# its files. +cp "${DEB}" /tmp/syncphony-box.deb +apt-get install -y --no-install-recommends /tmp/syncphony-box.deb +rm -f /tmp/syncphony-box.deb + +for step in "${HERE}"/steps/*.sh; do + echo "customise.sh: ${step##*/}" + bash -euo pipefail "${step}" +done diff --git a/rootfs/packages/common.list b/rootfs/packages/common.list new file mode 100644 index 0000000..b9e1a65 --- /dev/null +++ b/rootfs/packages/common.list @@ -0,0 +1,40 @@ +# Every box, Pi or x86 (MAD-846, ADR 0013). The platform lists add to it. + +# Kiosk stack: cage (Wayland kiosk compositor), chromium, and color emoji +# for the room UI. Explicit DRI drivers so the GPU is usable by cage and +# Chromium (pulls in Mesa even if a chromium dependency change would not). +cage +chromium +fonts-noto-color-emoji +libgl1-mesa-dri + +# mDNS: the box advertises .local (set from name= in +# syncphony.txt), which the setup screen's QR code points phones at. +avahi-daemon + +# The kiosk wrapper probes boxd with curl; boxd checks the server's +# /healthz (including over HTTPS) before the kiosk navigates. +curl +ca-certificates + +# Audio stack: PipeWire with its PulseAudio compatibility layer. Chromium +# plays through pipewire-pulse, and audio-setup selects the sink with +# pactl (ADR 0004). alsa-utils: aplay for the hardware checklist. +pipewire +pipewire-bin +pipewire-alsa +pipewire-pulse +wireplumber +pulseaudio-utils +alsa-utils + +# Watchdogs (MAD-806, ADR 0008): a sane clock before the kiosk starts. +systemd-timesyncd + +# HDMI-CEC (MAD-807, ADR 0009): cec-ctl talks to the kernel CEC framework. +v4l-utils + +# The Wi-Fi setup hotspot (MAD-808, ADR 0010): NetworkManager runs the +# access point, and its shared mode needs dnsmasq-base for DHCP and DNS. +network-manager +dnsmasq-base diff --git a/rootfs/packages/pi.list b/rootfs/packages/pi.list new file mode 100644 index 0000000..849d781 --- /dev/null +++ b/rootfs/packages/pi.list @@ -0,0 +1,5 @@ +# Raspberry Pi only, on top of common.list. + +# vcgencmd (raspi-utils-core) is what boxd samples every minute for the +# temperature and the firmware's throttling flags (MAD-806). +raspi-utils-core diff --git a/rootfs/packages/x86.list b/rootfs/packages/x86.list new file mode 100644 index 0000000..c77b6cd --- /dev/null +++ b/rootfs/packages/x86.list @@ -0,0 +1,43 @@ +# x86-64 only, on top of common.list (MAD-846, ADR 0013). The kernel and +# live-boot come from the live-build config (x86/live), not from here. + +# What Raspberry Pi OS Lite brings and a bare Debian root doesn't: the +# logind session the kiosk's PAMName=login needs (seat access for cage), +# the D-Bus it talks over, a text font for Chromium, and a busybox for the +# initramfs's mount (the overlay script's lowerdir= options). +libpam-systemd +dbus +fonts-dejavu-core +busybox + +# Firmware for common PC hardware (non-free-firmware): GPUs, Wi-Fi, the +# audio DSPs of recent Intel machines (SOF, with the UCM profiles +# PipeWire needs to see their outputs), and CPU microcode. +firmware-linux-free +firmware-misc-nonfree +firmware-intel-graphics +firmware-amd-graphics +firmware-iwlwifi +firmware-realtek +firmware-atheros +firmware-sof-signed +alsa-ucm-conf +intel-microcode +amd64-microcode + +# The installer (syncphony-install, the "Install to disk" boot entry): +# partitioning, the filesystems, and GRUB for both firmware types. The +# -bin packages only: grub-pc and grub-efi-amd64 would try to reinstall +# the bootloader on every update. +fdisk +dosfstools +e2fsprogs +grub2-common +grub-pc-bin +grub-efi-amd64-bin +whiptail + +# Maintenance: an installed box can get an admin user from the installer +# (there is no Imager here), and SSH to reach it. +sudo +openssh-server diff --git a/stage-syncphony/04-audio/00-run.sh b/rootfs/steps/10-audio.sh old mode 100755 new mode 100644 similarity index 71% rename from stage-syncphony/04-audio/00-run.sh rename to rootfs/steps/10-audio.sh index d54dee7..fd8d659 --- a/stage-syncphony/04-audio/00-run.sh +++ b/rootfs/steps/10-audio.sh @@ -1,18 +1,16 @@ -#!/bin/bash -e - # Audio: PipeWire runs for the kiosk user (started at boot via linger, no # login), and audio-setup picks the sink from audio= in syncphony.txt, # sets it to 100% and follows hotplug. See docs/adr/0004-audio-output.md. -# audio-setup and its two units come with the syncphony-box package -# (00-packages); what's left here is the kiosk user's own setup. +# audio-setup and its two units come with the syncphony-box package; +# what's left here is the kiosk user's own setup. # PipeWire's user services for the kiosk user. Linger makes systemd start # the kiosk user's manager (user@.service) at boot without a login; # the wants symlinks below pull the audio stack into it. -install -v -d -m 0755 "${ROOTFS_DIR}/var/lib/systemd/linger" -touch "${ROOTFS_DIR}/var/lib/systemd/linger/kiosk" +install -v -d -m 0755 /var/lib/systemd/linger +touch /var/lib/systemd/linger/kiosk -USER_UNITS="${ROOTFS_DIR}/var/lib/syncphony-box/.config/systemd/user" +USER_UNITS=/var/lib/syncphony-box/.config/systemd/user install -v -d -m 0755 "${USER_UNITS}/default.target.wants" "${USER_UNITS}/sockets.target.wants" for unit in pipewire.service wireplumber.service pipewire-pulse.service; do ln -v -sfn "/usr/lib/systemd/user/${unit}" "${USER_UNITS}/default.target.wants/${unit}" @@ -23,11 +21,9 @@ done # Keep the outputs from auto-suspending: waking an ALSA device for the # first sound can clip its opening notes (audible between tracks). -install -v -d -m 0755 "${ROOTFS_DIR}/var/lib/syncphony-box/.config/wireplumber/wireplumber.conf.d" +install -v -d -m 0755 /var/lib/syncphony-box/.config/wireplumber/wireplumber.conf.d # A link to the packaged file, so an update that retunes it reaches the box. ln -v -sfn /usr/share/syncphony-box/wireplumber/50-syncphony-box.conf \ - "${ROOTFS_DIR}/var/lib/syncphony-box/.config/wireplumber/wireplumber.conf.d/50-syncphony-box.conf" + /var/lib/syncphony-box/.config/wireplumber/wireplumber.conf.d/50-syncphony-box.conf -on_chroot << EOF chown -R -h kiosk:kiosk /var/lib/syncphony-box/.config -EOF diff --git a/rootfs/steps/20-readonly-root.sh b/rootfs/steps/20-readonly-root.sh new file mode 100644 index 0000000..c591393 --- /dev/null +++ b/rootfs/steps/20-readonly-root.sh @@ -0,0 +1,72 @@ +# Read-only root and /data (MAD-805, ADR 0007): make the box safe to pull +# the plug on. The platform-neutral half; the boot side (the kernel +# command line, the boot partition in fstab) is the wrappers' job: +# stage-syncphony/01-pi-boot on the Pi, the x86 installer on a PC. +# +# - The overlay: raspi-config's "Overlay FS" mechanism, enabled at build +# time. /etc/initramfs-tools/scripts/overlay (from the package), the +# overlay module in the initramfs, and boot=overlay on the kernel +# command line (the wrappers). The real root becomes a read-only lower +# layer; runtime writes land in tmpfs. +# - /data: a partition labelled syncphony-data holds everything that must +# persist. The full list is docs/persistent-paths.md. Here we wire the +# paths that reach it. +# +# The files (the initramfs overlay and grow-data scripts, data-persist, +# the /data units, the journald cap) come with the syncphony-box package, +# which also enables the units. This step makes the root-level changes a +# package can't own: the module list, fstab, and the paths that become +# links onto /data. + +# --- the overlay ------------------------------------------------------------- +# 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). The initramfs +# itself is built after this (pi-gen's export runs update-initramfs; the +# x86 hook does it at the end), so the line lands inside it. +touch /etc/initramfs-tools/modules +grep -qx 'overlay' /etc/initramfs-tools/modules || echo overlay >> /etc/initramfs-tools/modules + +# --- fstab -------------------------------------------------------------------- +# /data with nofail: a box with a destroyed data partition still boots (the +# screen says the storage is lost) instead of hanging in emergency mode. +# DATADEV is a placeholder each platform resolves: the Pi to the third +# partition's PARTUUID (scripts/add-data-partition.sh), x86 to the label. +grep -q '[[:space:]]/data[[:space:]]' /etc/fstab || + printf 'DATADEV /data ext4 defaults,nofail,x-systemd.device-timeout=15s 0 2\n' >> /etc/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 /data + +rm -rf /var/log/journal +ln -v -sfn /data/journal /var/log/journal + +rm -rf /var/lib/cloud +ln -v -sfn /data/system/cloud /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. +install -v -d -m 0755 /etc/syncphony-box +ln -v -sfn /data/syncphony-box/config.env /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 /etc/ssh +for type in rsa ecdsa ed25519; do + for suffix in "" ".pub"; do + key="ssh_host_${type}_key${suffix}" + rm -f "/etc/ssh/${key}" + ln -v -sfn "/data/system/ssh/${key}" "/etc/ssh/${key}" + done +done + +# --- units ---------------------------------------------------------------------- +# Package downloads/upgrades onto a tmpfs overlay would vanish at reboot +# while looking applied. The one safe update path is writing the real +# root, which the box's own nightly update does (syncphony-update.timer +# runs boxd update through syncphony-box chroot; ADR 0012). Debian's +# timers stay off. +systemctl disable unattended-upgrades.timer apt-daily.timer apt-daily-upgrade.timer 2>/dev/null || true diff --git a/stage-syncphony/07-watchdog/00-run.sh b/rootfs/steps/30-watchdog.sh old mode 100755 new mode 100644 similarity index 53% rename from stage-syncphony/07-watchdog/00-run.sh rename to rootfs/steps/30-watchdog.sh index ead142d..9cc943e --- a/stage-syncphony/07-watchdog/00-run.sh +++ b/rootfs/steps/30-watchdog.sh @@ -1,21 +1,18 @@ -#!/bin/bash -e - # Watchdogs and self-healing (MAD-806, ADR 0008). Two of the three # watchdogs are image-level; the kiosk watchdog lives in boxd # (boxd/internal/daemon/watchdog.go). # -# - Hardware watchdog: systemd's RuntimeWatchdogSec=15s arms the BCM -# watchdog. The drop-in comes with the syncphony-box package +# - Hardware watchdog: systemd's RuntimeWatchdogSec=15s arms whatever +# /dev/watchdog the board has (the Pi's BCM watchdog, a PC's iTCO or +# similar). The drop-in comes with the syncphony-box package # (/usr/lib/systemd/system.conf.d/50-syncphony-watchdog.conf). A -# kernel or systemd hang reboots the Pi — the one failure no +# kernel or systemd hang reboots the box, the one failure no # userspace loop can catch. # - Time: systemd-timesyncd, enabled here and ordered before the kiosk # (time-sync.target in syncphony-kiosk.service). The server's # playback clock sync trusts the browser's clock, and a Pi has no RTC -# battery by default — without sync the box boots with a stale clock. -# - Thermals: boxd runs vcgencmd (installed with this stage's packages) -# every minute; nothing to wire here. +# battery by default; without sync the box boots with a stale clock. +# - Thermals: boxd reads vcgencmd on a Pi and /sys/class/thermal +# elsewhere, every minute; nothing to wire here. -on_chroot << EOF systemctl enable systemd-timesyncd.service -EOF diff --git a/stage-syncphony/09-wifi-setup/00-run.sh b/rootfs/steps/40-wifi.sh old mode 100755 new mode 100644 similarity index 64% rename from stage-syncphony/09-wifi-setup/00-run.sh rename to rootfs/steps/40-wifi.sh index 8a79e99..0750c7e --- a/stage-syncphony/09-wifi-setup/00-run.sh +++ b/rootfs/steps/40-wifi.sh @@ -1,22 +1,18 @@ -#!/bin/bash -e - # The Wi-Fi setup hotspot (MAD-808, ADR 0010): a box with no Ethernet and # no Wi-Fi becomes its own open network, Syncphony-Box-XXXX, so a phone # can give it the house Wi-Fi. The image-level pieces: # -# - NetworkManager + dnsmasq-base (00-packages): the access point runs -# in NetworkManager's shared mode, which serves DHCP and DNS through -# dnsmasq. +# - NetworkManager + dnsmasq-base (packages/common.list): the access +# point runs in NetworkManager's shared mode, which serves DHCP and DNS +# through dnsmasq. # - The dnsmasq-shared.d drop-in (on that network every name resolves -# to the box and DHCP names the portal — the captive portal) and the +# to the box and DHCP names the portal: the captive portal) and the # boxd drop-in ordering it after NetworkManager both come with the # syncphony-box package. # # boxd itself (when to start the hotspot, the portal form, the join, the # TV screen) lives in boxd/internal/daemon/hotspot.go and # boxd/internal/wifi. The access point's connection is created in memory -# only, so it never lands in system-connections — or on /data. +# only, so it never lands in system-connections or on /data. -on_chroot << EOF systemctl enable NetworkManager.service -EOF diff --git a/rootfs/steps/90-slim.sh b/rootfs/steps/90-slim.sh new file mode 100644 index 0000000..6cc588d --- /dev/null +++ b/rootfs/steps/90-slim.sh @@ -0,0 +1,29 @@ +# Keep the image small (MAD-809: the Pi image under about 1 GB +# compressed, which the smoke test enforces). The syncphony-box package +# ships a dpkg path-exclude drop-in (/etc/dpkg/dpkg.cfg.d/50-syncphony-slim) +# so nothing installed after it (later steps, nightly updates) brings +# documentation, man pages or translations. This step removes what was +# installed before it, following the same rules: the copyright files and +# the English locale stay. + +# Documentation, except the copyright files. +find /usr/share/doc -mindepth 1 ! -type d ! -name copyright -delete 2>/dev/null || true +find /usr/share/doc -mindepth 1 -type d -empty -delete 2>/dev/null || true + +rm -rf /usr/share/man/* /usr/share/info/* /usr/share/lintian/* + +# Translations, except English and the alias table. +find /usr/share/locale -mindepth 1 -maxdepth 1 \ + ! -name 'en' ! -name 'en_US' ! -name 'locale.alias' \ + -exec rm -rf {} + 2>/dev/null || true + +# Chromium's UI strings: the box never shows Chromium's own UI, and its +# en-US pack is the fallback for every other locale. +if [ -d /usr/lib/chromium/locales ]; then + find /usr/lib/chromium/locales -type f ! -name 'en-US.pak*' -delete +fi + +# Nothing reads the apt list cache offline; the nightly update refreshes +# it before every upgrade. +rm -rf /var/lib/apt/lists/* +apt-get clean diff --git a/scripts/build-deb.sh b/scripts/build-deb.sh index cde18bc..6f5f989 100755 --- a/scripts/build-deb.sh +++ b/scripts/build-deb.sh @@ -66,7 +66,8 @@ chmod 0755 "${PKG}/usr/bin/syncphony-box" \ "${PKG}/usr/lib/syncphony-box/kiosk" \ "${PKG}/usr/lib/syncphony-box/audio-setup" \ "${PKG}/usr/lib/syncphony-box/data-persist" \ - "${PKG}/etc/initramfs-tools/scripts/local-premount/grow-data" + "${PKG}/etc/initramfs-tools/scripts/local-premount/grow-data" \ + "${PKG}/etc/initramfs-tools/hooks/syncphony-box" install -d -m 0755 "${PKG}/DEBIAN" installed_kb="$(du -sk --apparent-size "${PKG}" | cut -f1)" diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 6de309e..7be66f8 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -2,8 +2,16 @@ # Smoke-test a built syncphony-box image without hardware. # # Usage: scripts/smoke-test.sh deploy/syncphony-box--arm64.img.xz +# scripts/smoke-test.sh deploy/syncphony-box--amd64.iso # -# Loop-mounts the image's three partitions and checks that the kiosk packages +# The x86 ISO (MAD-846, ADR 0013) gets the same rootfs checks, run against +# its squashfs: both images come out of the same customise layer +# (rootfs/), so they must agree. The Pi-only checks (the three partitions, +# cloud-init and the Imager seed, config.txt, vcgencmd) are skipped there, +# and the ISO gets its own: both boot menus, the EFI and BIOS loaders, the +# overlay inside the initramfs, the installer and the live-session units. +# +# For the Pi image: 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 @@ -32,16 +40,25 @@ set -euo pipefail if [ "$#" -ne 1 ]; then - echo "usage: $0 .img.xz" >&2 + echo "usage: $0 .img.xz | .iso" >&2 exit 2 fi IMAGE_XZ="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" +case "${IMAGE_XZ}" in + *.img.xz) PLATFORM=pi ;; + *.iso) PLATFORM=x86 ;; + *) + echo "$0: want a .img.xz (Pi) or an .iso (x86), got $1" >&2 + exit 2 + ;; +esac WORK="$(mktemp -d /tmp/syncphony-box-smoke.XXXXXX)" LOOP="" cleanup() { sudo umount "${WORK}/boot" "${WORK}/root" "${WORK}/data" 2>/dev/null || true + sudo umount "${WORK}/iso" 2>/dev/null || true if [ -n "${LOOP}" ]; then sudo losetup -d "${LOOP}" 2>/dev/null || true fi @@ -61,15 +78,34 @@ check() { fi } -echo "Decompressing ${IMAGE_XZ} ..." -xz -dk -T0 -c "${IMAGE_XZ}" > "${WORK}/image.img" +# has_tokens LINE TOKEN...: every token is a whole word of LINE. +has_tokens() { + local line=" $1 " tok + shift + for tok in "$@"; do + case "${line}" in *" ${tok} "*) ;; *) return 1 ;; esac + done +} + +mkdir -p "${WORK}/boot" "${WORK}/root" "${WORK}/data" "${WORK}/iso" +if [ "${PLATFORM}" = pi ]; then + 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" "${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" + LOOP="$(sudo losetup --find --show --partscan "${WORK}/image.img")" + sudo mount -o ro "${LOOP}p1" "${WORK}/boot" + sudo mount -o ro "${LOOP}p2" "${WORK}/root" + sudo mount -o ro "${LOOP}p3" "${WORK}/data" + # The shipped syncphony.txt: on the boot partition. + TXT="${WORK}/boot/syncphony.txt" +else + sudo mount -o loop,ro "${IMAGE_XZ}" "${WORK}/iso" + sudo mount -t squashfs -o loop,ro "${WORK}/iso/live/filesystem.squashfs" "${WORK}/root" + # The installer and the live session copy the packaged template. + TXT="${WORK}/root/usr/lib/syncphony-box/syncphony.txt" +fi +if [ "${PLATFORM}" = pi ]; then # --- 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 @@ -101,11 +137,21 @@ if [ -n "${kiosk_uid}" ] && [ "${profile_owner}" = "${kiosk_uid}" ]; then else check "/data/chromium owned by the kiosk user" "owner ${profile_owner}, kiosk uid ${kiosk_uid}" fi +fi # pi # --- packages --------------------------------------------------------------- -for pkg in cage chromium fonts-noto-color-emoji libgl1-mesa-dri cloud-init avahi-daemon \ +if [ "${PLATFORM}" = pi ]; then + platform_pkgs="cloud-init raspi-utils-core" + platform_units="cloud-config.service cloud-final.service" +else + platform_pkgs="linux-image-amd64 live-boot libpam-systemd grub-pc-bin grub-efi-amd64-bin \ + fdisk dosfstools whiptail firmware-misc-nonfree firmware-sof-signed alsa-ucm-conf" + platform_units="syncphony-live-boot.service syncphony-installer.service" +fi +# shellcheck disable=SC2086 # the platform lists split on purpose +for pkg in cage chromium fonts-noto-color-emoji libgl1-mesa-dri avahi-daemon \ pipewire pipewire-bin pipewire-alsa pipewire-pulse wireplumber pulseaudio-utils alsa-utils \ - raspi-utils-core systemd-timesyncd v4l-utils syncphony-box; do + systemd-timesyncd v4l-utils network-manager dnsmasq-base syncphony-box ${platform_pkgs}; do if sudo awk -v p="${pkg}" ' $0 == "Package: " p { inpkg = 1 } inpkg && /^Status: / { @@ -121,10 +167,11 @@ for pkg in cage chromium fonts-noto-color-emoji libgl1-mesa-dri cloud-init avahi done # --- units ------------------------------------------------------------------ -for unit in cloud-config.service cloud-final.service syncphony-kiosk.service syncphony-box-config.service \ +# shellcheck disable=SC2086 +for unit in syncphony-kiosk.service syncphony-box-config.service \ 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 systemd-timesyncd.service syncphony-update.timer; do + syncphony-data-save.timer systemd-timesyncd.service syncphony-update.timer ${platform_units}; do state="$(systemctl --root="${WORK}/root" is-enabled "${unit}" 2>/dev/null || true)" if [ "${state}" = "enabled" ]; then check "unit ${unit} enabled" ok @@ -201,16 +248,18 @@ else check "kiosk wrapper uses the configurable profile dir" "no PROFILE_DIR reference" fi -# The image's boxd is a static arm64 binary and the CI runner is arm64, so -# it can run directly; where it can't (a dev's x86 machine), skip the -# run-time checks and keep the static ones. +# The image's boxd is a static binary for the image's architecture, and CI +# smoke-tests each image on a runner of that architecture (arm64 for the +# Pi, x86 for the ISO), so it can run directly; where it can't (an image +# for the other architecture), skip the run-time checks and keep the +# static ones. if out="$(sudo "${WORK}/root/usr/lib/syncphony-box/boxd" profile path 2>/dev/null)" && [ -n "${out}" ]; then profile_dir="${out}" check "boxd profile path answers" ok else profile_dir="/data/chromium" - check "boxd profile path answers" "skipped (cannot run the arm64 binary here)" + check "boxd profile path answers" "skipped (cannot run the image's binary here)" fi if sudo grep -qF "DATA_DIR=${profile_dir}" "${WORK}/root/usr/lib/syncphony-box/kiosk"; then @@ -225,7 +274,7 @@ if sudo grep -q "PROFILE_DIR='${profile_dir}'" \ else # config.env only exists after first boot; the parse output is what # generates it. - if out="$(sudo "${WORK}/root/usr/lib/syncphony-box/boxd" parse "${WORK}/boot/syncphony.txt" 2>/dev/null)" && + if out="$(sudo "${WORK}/root/usr/lib/syncphony-box/boxd" parse "${TXT}" 2>/dev/null)" && echo "${out}" | grep -qF "PROFILE_DIR='${profile_dir}'"; then check "config.env names the profile dir" ok else @@ -240,12 +289,16 @@ else check "boxd installed executable" "missing or not executable" fi -# Static (CGO_ENABLED=0) arm64 binary — only verifiable where `file` runs -# on it, i.e. on the arm64 CI runner. +# Static (CGO_ENABLED=0) binary for the image's architecture. +if [ "${PLATFORM}" = pi ]; then + boxd_arch='ARM aarch64' +else + boxd_arch='x86-64' +fi if ! command -v file >/dev/null 2>&1; then check "boxd is a static binary" ok elif file "${WORK}/root/usr/lib/syncphony-box/boxd" 2>/dev/null | - grep -q 'ELF 64-bit LSB.*ARM aarch64.*statically linked'; then + grep -q "ELF 64-bit LSB.*${boxd_arch}.*statically linked"; then check "boxd is a static binary" ok else check "boxd is a static binary" "$(file "${WORK}/root/usr/lib/syncphony-box/boxd" 2>/dev/null)" @@ -279,10 +332,12 @@ else check "kiosk unit waits for time sync" "no time-sync.target ordering" fi -if sudo test -x "${WORK}/root/usr/bin/vcgencmd"; then - check "vcgencmd installed (thermals)" ok -else - check "vcgencmd installed (thermals)" "missing or not executable" +if [ "${PLATFORM}" = pi ]; then + if sudo test -x "${WORK}/root/usr/bin/vcgencmd"; then + check "vcgencmd installed (thermals)" ok + else + check "vcgencmd installed (thermals)" "missing or not executable" + fi fi # --- HDMI-CEC (MAD-807) -------------------------------------------------------- @@ -368,14 +423,14 @@ else check "kiosk unit waits for boxd" "no boxd reference" fi -if [ -f "${WORK}/boot/syncphony.txt" ]; then - check "boot partition has syncphony.txt" ok +if [ -f "${TXT}" ]; then + check "syncphony.txt ships (${TXT#"${WORK}"/})" ok else - check "boot partition has syncphony.txt" "missing" + check "syncphony.txt ships (${TXT#"${WORK}"/})" "missing" fi # The file must ship fully commented out: every non-comment line is empty. -active="$(sudo grep -Ev '^[[:space:]]*(#|$)' "${WORK}/boot/syncphony.txt" 2>/dev/null || true)" +active="$(sudo grep -Ev '^[[:space:]]*(#|$)' "${TXT}" 2>/dev/null || true)" if [ -z "${active}" ]; then check "syncphony.txt ships fully commented out" ok else @@ -383,7 +438,7 @@ else fi for key in server_url name audio resolution rotate cec cec_standby cec_standby_minutes auto_update; do - if sudo grep -q "^#${key}=" "${WORK}/boot/syncphony.txt"; then + if sudo grep -q "^#${key}=" "${TXT}"; then check "syncphony.txt documents ${key}" ok else check "syncphony.txt documents ${key}" "no commented ${key}= line" @@ -434,7 +489,15 @@ else fi # --- boot presentation ------------------------------------------------------ -CMDLINE=" $(sudo cat "${WORK}/boot/cmdline.txt") " +# The installed box's kernel command line: the Pi's cmdline.txt, or on x86 +# the cmdline.cfg the installer puts on the EFI partition (boxd rewrites +# both; ADR 0013). +if [ "${PLATFORM}" = pi ]; then + CMDLINE=" $(sudo cat "${WORK}/boot/cmdline.txt") " +else + CMDLINE=" $(sudo sed -n 's/^set syncphony_cmdline="\(.*\)"$/\1/p' \ + "${WORK}/root/usr/share/syncphony-box/x86/cmdline.cfg" 2>/dev/null) " +fi for param in quiet splash loglevel=3 vt.global_cursor_default=0 consoleblank=0; do if [ "${CMDLINE}" != "${CMDLINE/ ${param} /}" ]; then check "cmdline has ${param}" ok @@ -443,10 +506,12 @@ for param in quiet splash loglevel=3 vt.global_cursor_default=0 consoleblank=0; fi done -if sudo grep -q '^disable_splash=1' "${WORK}/boot/config.txt"; then - check "config.txt has disable_splash=1" ok -else - check "config.txt has disable_splash=1" "missing" +if [ "${PLATFORM}" = pi ]; then + if sudo grep -q '^disable_splash=1' "${WORK}/boot/config.txt"; then + check "config.txt has disable_splash=1" ok + else + check "config.txt has disable_splash=1" "missing" + fi fi # --- read-only root (MAD-805) ------------------------------------------------ @@ -458,10 +523,13 @@ 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" +# The Pi grows /data on first boot; the x86 installer makes it full size. +if [ "${PLATFORM}" = pi ]; then + if [ "${CMDLINE}" != "${CMDLINE/ grow-data /}" ]; then + check "cmdline has grow-data" ok + else + check "cmdline has grow-data" "missing" + fi fi case "${CMDLINE}" in @@ -494,6 +562,20 @@ else check "grow-data initramfs script installed executable" "missing" fi +if sudo grep -q 'copy_exec /usr/sbin/e2fsck' "${WORK}/root/etc/initramfs-tools/hooks/syncphony-box" 2>/dev/null && + sudo test -x "${WORK}/root/etc/initramfs-tools/hooks/syncphony-box"; then + check "initramfs hook carries e2fsck (the /data check)" ok +else + check "initramfs hook carries e2fsck (the /data check)" "hook missing or wrong" +fi + +if sudo grep -q '^ConditionKernelCommandLine=!boot=live$' \ + "${WORK}/root/usr/lib/systemd/system/syncphony-update.service"; then + check "nightly update stays off in a live session" ok +else + check "nightly update stays off in a live session" "no condition" +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 @@ -502,18 +584,26 @@ else fi # fstab: the boot partition is mounted read-only, and /data has its own -# nofail entry pointing at partition 3 (the DATADEV placeholder resolved). +# nofail entry (the DATADEV placeholder resolved): partition 3 on the Pi, +# the label on the x86 live root. The installer writes an installed x86 +# box's fstab, so its lines are checked in the installer. +if [ "${PLATFORM}" = pi ]; then + boot_fstab="${WORK}/root/etc/fstab" +else + boot_fstab="${WORK}/root/usr/sbin/syncphony-install" +fi if sudo grep -E '/boot/firmware[[:space:]]+vfat[[:space:]]+[^[:space:]]*ro' \ - "${WORK}/root/etc/fstab" >/dev/null; then + "${boot_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}" ;; +case "${PLATFORM}:${data_fstab}" in + pi:PARTUUID=*-03*nofail*) check "fstab has the /data entry (partition 3, nofail)" ok ;; + x86:LABEL=syncphony-data*nofail*) check "fstab has the /data entry (by label, nofail)" ok ;; + *) check "fstab has the /data entry (nofail)" "got: ${data_fstab:-nothing}" ;; esac # Persistent paths resolve onto /data (docs/persistent-paths.md). @@ -603,14 +693,23 @@ else check "man pages and docs stripped (copyright kept)" "${man_files} man, ${doc_files} doc files left" fi +# The Pi image under 1 GiB compressed (MAD-809). The ISO carries PC +# firmware the Pi doesn't need; it must stay under 2 GiB, GitHub's +# release asset limit. image_bytes="$(stat -c %s "${IMAGE_XZ}")" -limit=$((1024 * 1024 * 1024)) +if [ "${PLATFORM}" = pi ]; then + limit_gib=1 +else + limit_gib=2 +fi +limit=$((limit_gib * 1024 * 1024 * 1024)) if [ "${image_bytes}" -le "${limit}" ]; then - check "compressed image under 1 GiB ($((image_bytes / 1024 / 1024)) MiB)" ok + check "image under ${limit_gib} GiB ($((image_bytes / 1024 / 1024)) MiB)" ok else - check "compressed image under 1 GiB" "$((image_bytes / 1024 / 1024)) MiB" + check "image under ${limit_gib} GiB" "$((image_bytes / 1024 / 1024)) MiB" fi +if [ "${PLATFORM}" = pi ]; then # --- 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 @@ -626,6 +725,109 @@ for file in user-data network-config meta-data; do check "boot partition has ${file}" "missing" fi done +fi # pi + +# --- the x86 ISO (MAD-846, ADR 0013) ------------------------------------------ +if [ "${PLATFORM}" = x86 ]; then + ISO="${WORK}/iso" + GRUB_CFG="${ISO}/boot/grub/grub.cfg" + + # One menu for BIOS and UEFI: the live box, and the installer. + live_entry="$(sudo grep -E '^[[:space:]]*linux[[:space:]]' "${GRUB_CFG}" 2>/dev/null | grep -v syncphony.install | head -n 1 || true)" + if has_tokens "${live_entry}" boot=live quiet loglevel=3 vt.global_cursor_default=0 consoleblank=0; then + check "ISO menu boots the live box quietly" ok + else + check "ISO menu boots the live box quietly" "got: ${live_entry:-no entry}" + fi + if sudo grep -qE '^[[:space:]]*linux[[:space:]].*boot=live.*syncphony\.install' "${GRUB_CFG}" 2>/dev/null; then + check "ISO menu has the Install to disk entry" ok + else + check "ISO menu has the Install to disk entry" "missing" + fi + + if [ -n "$(sudo find "${ISO}" -ipath '*/efi/boot/bootx64.efi' 2>/dev/null | head -n 1)" ]; then + check "ISO boots on UEFI (EFI/BOOT/BOOTX64.EFI)" ok + else + check "ISO boots on UEFI (EFI/BOOT/BOOTX64.EFI)" "missing" + fi + if sudo test -d "${ISO}/boot/grub/i386-pc"; then + check "ISO boots on BIOS (grub i386-pc)" ok + else + check "ISO boots on BIOS (grub i386-pc)" "no boot/grub/i386-pc" + fi + + initrd="$(sudo find "${ISO}/live" -maxdepth 1 -name 'initrd.img*' | head -n 1)" + if [ -n "${initrd}" ] && sudo test -n "$(sudo find "${ISO}/live" -maxdepth 1 -name 'vmlinuz*')"; then + check "ISO carries the kernel and initramfs" ok + else + check "ISO carries the kernel and initramfs" "missing in live/" + fi + + # One initramfs serves both: live-boot's scripts for boot=live, the + # box's overlay (and the e2fsck it runs on /data) for boot=overlay. + if command -v lsinitramfs >/dev/null 2>&1 && [ -n "${initrd}" ]; then + contents="$(sudo lsinitramfs "${initrd}" 2>/dev/null || true)" + for want in scripts/live scripts/overlay e2fsck; do + # A here-string, not a pipe: grep -q quitting early would + # SIGPIPE the writer and fail the pipeline under pipefail. + if grep -q "${want}\$" <<< "${contents}"; then + check "initramfs has ${want}" ok + else + check "initramfs has ${want}" "missing" + fi + done + else + echo "NOTE: no lsinitramfs here; the initramfs contents go unchecked" + fi + + if sudo test -x "${WORK}/root/usr/sbin/syncphony-install"; then + check "installer installed executable" ok + else + check "installer installed executable" "missing or not executable" + fi + for f in grub.cfg.in cmdline.cfg; do + if sudo test -f "${WORK}/root/usr/share/syncphony-box/x86/${f}"; then + check "installer template ${f} present" ok + else + check "installer template ${f} present" "missing" + fi + done + if sudo grep -q 'cmdline.cfg' "${WORK}/root/usr/share/syncphony-box/x86/grub.cfg.in" && + sudo grep -q '@ROOT_UUID@' "${WORK}/root/usr/share/syncphony-box/x86/grub.cfg.in"; then + check "installed grub.cfg sources boxd's cmdline.cfg" ok + else + check "installed grub.cfg sources boxd's cmdline.cfg" "template wrong" + fi + for dir in i386-pc x86_64-efi; do + if sudo test -d "${WORK}/root/usr/lib/grub/${dir}"; then + check "GRUB ${dir} modules for the installer" ok + else + check "GRUB ${dir} modules for the installer" "missing" + fi + done + + # The install boot runs the installer instead of the box, and mounts + # no /data (the disk being wiped may hold one). + for dropin in syncphony-kiosk.service.d boxd.service.d data.mount.d; do + if sudo grep -q '^ConditionKernelCommandLine=!syncphony.install$' \ + "${WORK}/root/usr/lib/systemd/system/${dropin}/50-installer.conf" 2>/dev/null; then + check "${dropin%.d} stays off in the install boot" ok + else + check "${dropin%.d} stays off in the install boot" "drop-in missing or wrong" + fi + done + + if [ "$(sudo cat "${WORK}/root/etc/hostname" 2>/dev/null)" = syncphony-box ]; then + check "hostname is syncphony-box" ok + else + check "hostname is syncphony-box" "got: $(sudo cat "${WORK}/root/etc/hostname" 2>/dev/null)" + fi + if [ ! -s "${WORK}/root/etc/machine-id" ]; then + check "machine-id empty (made per box)" ok + else + check "machine-id empty (made per box)" "baked in" + fi +fi if [ "${failures}" -ne 0 ]; then echo "${failures} check(s) failed" >&2 diff --git a/stage-syncphony/00-customise/00-run.sh b/stage-syncphony/00-customise/00-run.sh new file mode 100755 index 0000000..9cf79f3 --- /dev/null +++ b/stage-syncphony/00-customise/00-run.sh @@ -0,0 +1,28 @@ +#!/bin/bash -e + +# The pi-gen side of the shared customise layer (MAD-846, ADR 0013). The +# box itself is rootfs/customise.sh: packages, the syncphony-box .deb, +# the kiosk user's audio, the read-only-root links, the units and the +# slimming. The x86 ISO build runs the same script. This wrapper only +# gets it into the chroot and runs it there. +# +# ./build.sh copies rootfs/ and the freshly built .deb into files/ (not +# committed). + +if [ ! -f files/syncphony-box.deb ] || [ ! -x files/rootfs/customise.sh ]; then + echo "stage-syncphony/00-customise: files/ is missing the .deb or rootfs/." >&2 + echo "Build the image with ./build.sh, which builds boxd and the package first." >&2 + exit 1 +fi + +# Not under /tmp: on_chroot mounts a fresh tmpfs there the first time it +# runs in a stage, which would hide anything copied in before. +STAGED="${ROOTFS_DIR}/root/syncphony-rootfs" +rm -rf "${STAGED}" +cp -a files/rootfs "${STAGED}" +install -v -m 0644 files/syncphony-box.deb "${STAGED}/syncphony-box.deb" + +on_chroot << EOF +/root/syncphony-rootfs/customise.sh pi /root/syncphony-rootfs/syncphony-box.deb +rm -rf /root/syncphony-rootfs +EOF diff --git a/stage-syncphony/00-packages/00-packages b/stage-syncphony/00-packages/00-packages deleted file mode 100644 index 6035b65..0000000 --- a/stage-syncphony/00-packages/00-packages +++ /dev/null @@ -1,13 +0,0 @@ -# Kiosk stack for the Syncphony box: cage (Wayland kiosk compositor), -# chromium, and color emoji for the room UI. The session itself is assembled -# in 01-kiosk. -cage -chromium -fonts-noto-color-emoji -# mDNS: the box advertises .local (set from name= in syncphony.txt), -# which the setup screen's QR code points phones at. -avahi-daemon -# The kiosk wrapper probes boxd with curl; boxd checks the server's /healthz -# (including over HTTPS) before the kiosk navigates. -curl -ca-certificates diff --git a/stage-syncphony/00-packages/01-run.sh b/stage-syncphony/00-packages/01-run.sh deleted file mode 100755 index 4c97cc8..0000000 --- a/stage-syncphony/00-packages/01-run.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/bash -e - -# The box's own software, as the syncphony-box package (MAD-810, ADR -# 0012): boxd, the kiosk wrapper, the units, the drop-ins, the initramfs -# overlay scripts — everything in packaging/root — plus the postinst that -# creates the kiosk user and the cec group and enables the units. The -# nightly update installs newer builds of this same package from the apt -# repo, so the image and the updates can't drift apart. -# -# Installed here, right after the kiosk stack, so the later substages can -# build on its files (the boot-partition syncphony.txt, the kiosk user's -# home, the initramfs module list). -# -# ./build.sh builds the .deb into files/ (it is not committed). - -if [ ! -f files/syncphony-box.deb ]; then - echo "stage-syncphony/00-packages: files/syncphony-box.deb is missing." >&2 - echo "Build the image with ./build.sh, which builds boxd and the package first." >&2 - exit 1 -fi - -install -v -m 0644 files/syncphony-box.deb "${ROOTFS_DIR}/tmp/syncphony-box.deb" -on_chroot << EOF -apt-get install -y /tmp/syncphony-box.deb -rm -f /tmp/syncphony-box.deb -EOF diff --git a/stage-syncphony/01-kiosk/00-packages b/stage-syncphony/01-kiosk/00-packages deleted file mode 100644 index 8d5b1d7..0000000 --- a/stage-syncphony/01-kiosk/00-packages +++ /dev/null @@ -1,4 +0,0 @@ -# Explicit DRI drivers so the vc4/v3d GPU is usable by cage and Chromium -# (pulls in the Mesa drivers even if a future chromium dependency change -# would not). -libgl1-mesa-dri diff --git a/stage-syncphony/01-pi-boot/00-run.sh b/stage-syncphony/01-pi-boot/00-run.sh new file mode 100755 index 0000000..ebf0ab9 --- /dev/null +++ b/stage-syncphony/01-pi-boot/00-run.sh @@ -0,0 +1,67 @@ +#!/bin/bash -e + +# The Pi's boot partition. MAD-846 split this out of the shared layer; the +# x86 installer does the same jobs for GRUB and the EFI partition. +# +# - Boot presentation: quiet kernel and console, no blinking cursor, no +# console blanking, and no firmware rainbow splash. The TV shows +# nothing but the kiosk. Cage itself never blanks or DPMS-es its +# output. +# - The read-only root (MAD-805, ADR 0007): boot=overlay turns the +# initramfs overlay on; the stock first-boot `resize` (which grows the +# ROOT partition) comes out and grow-data (which grows /data) goes in. +# /boot/firmware is mounted read-only (boxd remounts it around its +# syncphony.txt and cmdline.txt writes only). +# - The boot config file (ADR 0003): syncphony.txt ships on the FAT boot +# partition, every option commented out, so it can be edited from any +# computer. pi-gen's export copies /boot/firmware onto the FAT. + +CMDLINE="${ROOTFS_DIR}/boot/firmware/cmdline.txt" +CONFIG="${ROOTFS_DIR}/boot/firmware/config.txt" + +# --- the kernel command line -------------------------------------------------- +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 +for param in quiet splash loglevel=3 vt.global_cursor_default=0 consoleblank=0 grow-data; do + case " ${line} " in + *" ${param} "*) ;; + *) line="${line} ${param}" ;; + esac +done +printf '%s\n' "${line}" > "${CMDLINE}" + +# --- config.txt --------------------------------------------------------------- +# config.txt ends inside an [all] section, so appended lines apply to every +# board. disable_splash turns off the firmware rainbow screen. No +# rename/initramfs line: auto_initramfs=1 loads the per-flavour initrd, +# which pi-gen's export builds (05-finalise runs update-initramfs) with +# the overlay script and module inside. +if [ -n "$(tail -c 1 "${CONFIG}")" ]; then + printf '\n' >> "${CONFIG}" +fi +grep -q '^disable_splash=1' "${CONFIG}" || printf 'disable_splash=1\n' >> "${CONFIG}" + +# --- fstab: the boot partition read-only --------------------------------------- +grep -q '/boot/firmware.*defaults,ro' "${ROOTFS_DIR}/etc/fstab" || + sed -i '\|/boot/firmware| s/defaults/defaults,ro/' "${ROOTFS_DIR}/etc/fstab" + +# --- syncphony.txt -------------------------------------------------------------- +install -v -m 0644 "${ROOTFS_DIR}/usr/lib/syncphony-box/syncphony.txt" \ + "${ROOTFS_DIR}/boot/firmware/syncphony.txt" + +# --- the stock root grow ---------------------------------------------------------- +# It targets the root partition; ours (grow-data) targets the last one. +on_chroot << EOF +systemctl disable rpi-resize.service +EOF diff --git a/stage-syncphony/02-boot-config/00-run.sh b/stage-syncphony/02-boot-config/00-run.sh deleted file mode 100755 index 2ea6037..0000000 --- a/stage-syncphony/02-boot-config/00-run.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/bash -e - -# Boot presentation: quiet kernel and console, no blinking cursor, no -# console blanking, and no firmware rainbow splash. The TV shows nothing -# but the kiosk. Cage itself never blanks or DPMS-es its output. - -CMDLINE="${ROOTFS_DIR}/boot/firmware/cmdline.txt" -CONFIG="${ROOTFS_DIR}/boot/firmware/config.txt" - -line="$(tr -d '\r\n' < "${CMDLINE}")" -for param in quiet splash loglevel=3 vt.global_cursor_default=0 consoleblank=0; do - case " ${line} " in - *" ${param} "*) ;; - *) line="${line} ${param}" ;; - esac -done -printf '%s\n' "${line}" > "${CMDLINE}" - -# config.txt ends inside an [all] section, so appended lines apply to every -# board. disable_splash turns off the firmware rainbow screen. -if [ -n "$(tail -c 1 "${CONFIG}")" ]; then - printf '\n' >> "${CONFIG}" -fi -grep -q '^disable_splash=1' "${CONFIG}" || printf 'disable_splash=1\n' >> "${CONFIG}" diff --git a/stage-syncphony/03-config/00-run.sh b/stage-syncphony/03-config/00-run.sh deleted file mode 100755 index 37a7088..0000000 --- a/stage-syncphony/03-config/00-run.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash -e - -# The boot config file: syncphony.txt ships on the FAT boot partition so -# it can be edited from any computer. The parsing and applying live in boxd -# (boxd config apply, run by syncphony-box-config.service); both come with -# the syncphony-box package (00-packages), which also carries the template. -# See docs/adr/0003-box-config-file.md and 0005-boxd.md. - -# Every option ships commented out; boxd's config apply fills in defaults. -# pi-gen's export step copies /boot/firmware onto the FAT partition. -install -v -m 0644 "${ROOTFS_DIR}/usr/lib/syncphony-box/syncphony.txt" \ - "${ROOTFS_DIR}/boot/firmware/syncphony.txt" diff --git a/stage-syncphony/04-audio/00-packages b/stage-syncphony/04-audio/00-packages deleted file mode 100644 index 2c1c97c..0000000 --- a/stage-syncphony/04-audio/00-packages +++ /dev/null @@ -1,11 +0,0 @@ -# Audio stack: PipeWire (the Pi OS default) with its PulseAudio -# compatibility layer — Chromium plays through pipewire-pulse, and -# audio-setup selects the sink with pactl. See docs/adr/0004-audio-output.md. -# pulseaudio-utils: pactl. alsa-utils: aplay for the on-Pi smoke checklist. -pipewire -pipewire-bin -pipewire-alsa -pipewire-pulse -wireplumber -pulseaudio-utils -alsa-utils diff --git a/stage-syncphony/06-readonly-root/00-run.sh b/stage-syncphony/06-readonly-root/00-run.sh deleted file mode 100755 index 0a6aabc..0000000 --- a/stage-syncphony/06-readonly-root/00-run.sh +++ /dev/null @@ -1,110 +0,0 @@ -#!/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. -# -# The files — the initramfs overlay and grow-data scripts, data-persist, -# the /data units, the journald cap — come with the syncphony-box package -# (00-packages), which also enables the units. This substage makes the -# image-level changes a package can't own: the kernel command line, -# fstab, the module list, and the paths that become links onto /data. - -# --- the overlay ------------------------------------------------------------- -IIR="${ROOTFS_DIR}/etc/initramfs-tools" -# 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. -install -v -d -m 0755 "${ROOTFS_DIR}/etc/syncphony-box" -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 ---------------------------------------------------------------------- -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, which the box's own nightly update does (syncphony-update.timer -# runs boxd update through syncphony-box chroot; ADR 0012). Debian's -# timers stay off. -systemctl disable unattended-upgrades.timer apt-daily.timer apt-daily-upgrade.timer || true -EOF diff --git a/stage-syncphony/07-watchdog/00-packages b/stage-syncphony/07-watchdog/00-packages deleted file mode 100644 index b129171..0000000 --- a/stage-syncphony/07-watchdog/00-packages +++ /dev/null @@ -1,6 +0,0 @@ -# Watchdogs and self-healing (MAD-806): vcgencmd (raspi-utils-core) is -# what boxd samples every minute for the temperature and throttling log, -# and systemd-timesyncd gives the box a sane clock before the kiosk starts -# (a Pi has no RTC battery by default). -raspi-utils-core -systemd-timesyncd diff --git a/stage-syncphony/08-cec/00-packages b/stage-syncphony/08-cec/00-packages deleted file mode 100644 index 8228602..0000000 --- a/stage-syncphony/08-cec/00-packages +++ /dev/null @@ -1,4 +0,0 @@ -# HDMI-CEC (MAD-807, ADR 0009): cec-ctl (v4l-utils) is how boxd talks to -# the kernel CEC framework — registering the playback logical address, -# watching the bus, and waking/switching/standing-by the TV. -v4l-utils diff --git a/stage-syncphony/09-wifi-setup/00-packages b/stage-syncphony/09-wifi-setup/00-packages deleted file mode 100644 index 925d568..0000000 --- a/stage-syncphony/09-wifi-setup/00-packages +++ /dev/null @@ -1,5 +0,0 @@ -# The Wi-Fi setup hotspot (MAD-808, ADR 0010): NetworkManager (the Pi OS -# default; listed so the dependency is explicit) runs the access point, -# and its shared mode needs dnsmasq-base for DHCP and DNS on it. -network-manager -dnsmasq-base diff --git a/stage-syncphony/10-slim/00-run.sh b/stage-syncphony/10-slim/00-run.sh deleted file mode 100755 index 0fc78ca..0000000 --- a/stage-syncphony/10-slim/00-run.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/bin/bash -e - -# Keep the image small (MAD-809: under about 1 GB compressed — the -# smoke test enforces it). The syncphony-box package ships a dpkg -# path-exclude drop-in (/etc/dpkg/dpkg.cfg.d/50-syncphony-slim) so -# nothing installed after it — later substages, nightly updates — brings -# documentation, man pages or translations. This substage removes what -# stage0-2 installed before it, following the same rules: the copyright -# files and the English locale stay. - -R="${ROOTFS_DIR}" - -# Documentation, except the copyright files. -find "${R}/usr/share/doc" -mindepth 1 ! -type d ! -name copyright -delete 2>/dev/null || true -find "${R}/usr/share/doc" -mindepth 1 -type d -empty -delete 2>/dev/null || true - -rm -rf "${R}/usr/share/man/"* "${R}/usr/share/info/"* "${R}/usr/share/lintian/"* - -# Translations, except English and the alias table. -find "${R}/usr/share/locale" -mindepth 1 -maxdepth 1 \ - ! -name 'en' ! -name 'en_US' ! -name 'locale.alias' \ - -exec rm -rf {} + 2>/dev/null || true - -# Chromium's UI strings: the box never shows Chromium's own UI, and its -# en-US pack is the fallback for every other locale. -if [ -d "${R}/usr/lib/chromium/locales" ]; then - find "${R}/usr/lib/chromium/locales" -type f ! -name 'en-US.pak*' -delete -fi - -# Nothing reads the apt list cache offline; the nightly update refreshes -# it before every upgrade. -rm -rf "${R}/var/lib/apt/lists/"* -on_chroot << EOF -apt-get clean -EOF diff --git a/tests/syncphony-box-audio.bats b/tests/syncphony-box-audio.bats index 56f46d7..df110b8 100644 --- a/tests/syncphony-box-audio.bats +++ b/tests/syncphony-box-audio.bats @@ -242,6 +242,51 @@ volume_100_sink() { ' "${FIXTURE}" > "${FIXTURE}.new" && mv "${FIXTURE}.new" "${FIXTURE}" } +# A PC (the x86 box, MAD-846): Intel HDA with the analog jack and two +# HDMI/DisplayPort outputs, PipeWire's usual names for them. +sinks_pc() { + cat > "${FIXTURE}" <<'EOF2' +Sink #50 + State: SUSPENDED + Name: alsa_output.pci-0000_00_1f.3.analog-stereo + Description: Built-in Audio Analog Stereo + Driver: PipeWire + Mute: no + Volume: front-left: 65536 / 100% / 0.00 dB, front-right: 65536 / 100% / 0.00 dB + Properties: + alsa.card_name = "HDA Intel PCH" + alsa.name = "ALC233 Analog" + device.bus = "pci" + device.description = "Built-in Audio Analog Stereo" + +Sink #51 + State: SUSPENDED + Name: alsa_output.pci-0000_00_1f.3.hdmi-stereo + Description: Built-in Audio Digital Stereo (HDMI) + Driver: PipeWire + Mute: no + Volume: front-left: 65536 / 100% / 0.00 dB, front-right: 65536 / 100% / 0.00 dB + Properties: + alsa.card_name = "HDA Intel PCH" + alsa.name = "HDMI 0" + device.bus = "pci" + device.description = "Built-in Audio Digital Stereo (HDMI)" + +Sink #52 + State: SUSPENDED + Name: alsa_output.pci-0000_00_1f.3.hdmi-stereo-extra1 + Description: Built-in Audio Digital Stereo (HDMI 2) + Driver: PipeWire + Mute: no + Volume: front-left: 65536 / 100% / 0.00 dB, front-right: 65536 / 100% / 0.00 dB + Properties: + alsa.card_name = "HDA Intel PCH" + alsa.name = "HDMI 1" + device.bus = "pci" + device.description = "Built-in Audio Digital Stereo (HDMI 2)" +EOF2 +} + HDMI0=alsa_output.platform-fef00700.hdmi.stereo-fallback HDMI1=alsa_output.platform-fef00710.hdmi.stereo-fallback JACK=alsa_output.platform-b1.stereo-fallback @@ -407,3 +452,45 @@ called() { grep -Fqx "$*" "${CALLS}"; } # The initial apply plus the event-driven re-apply. [ "$(grep -c '^set-default-sink' "${CALLS}")" -ge 2 ] } + +# --- x86 (MAD-846) ------------------------------------------------------------- + +PC_ANALOG=alsa_output.pci-0000_00_1f.3.analog-stereo +PC_HDMI=alsa_output.pci-0000_00_1f.3.hdmi-stereo +PC_HDMI2=alsa_output.pci-0000_00_1f.3.hdmi-stereo-extra1 + +@test "hdmi on a PC picks the first HDMI/DisplayPort sink" { + sinks_pc + set_audio hdmi + apply + [ "$status" -eq 0 ] + called "set-default-sink ${PC_HDMI}" +} + +@test "hdmi2 on a PC picks the second HDMI/DisplayPort sink" { + sinks_pc + set_audio hdmi2 + apply + [ "$status" -eq 0 ] + called "set-default-sink ${PC_HDMI2}" +} + +@test "analog on a PC picks the built-in analog output" { + sinks_pc + set_audio analog + apply + [ "$status" -eq 0 ] + called "set-default-sink ${PC_ANALOG}" +} + +@test "the PC fallback never applies on a Pi with HDMI0 absent" { + # TV on HDMI0 off: only HDMI1 is there. A Pi must not silently play + # out of the other port. + sinks_pi4 + sed -i '/^Sink #41/,/^Sink #42/{/^Sink #42/!d}' "${FIXTURE}" + ! grep -q "${HDMI0}" "${FIXTURE}" + set_audio hdmi + apply + [ "$status" -eq 0 ] + ! called "set-default-sink ${HDMI1}" +} diff --git a/x86/live/auto/config b/x86/live/auto/config new file mode 100755 index 0000000..9f35fba --- /dev/null +++ b/x86/live/auto/config @@ -0,0 +1,31 @@ +#!/bin/sh +# live-build configuration for the x86-64 live/installer ISO (MAD-846, ADR +# 0013). `lb config` runs this; ./build-iso.sh runs lb in a Debian Trixie +# container. +# +# The box itself comes from the shared customise layer (rootfs/, the same +# one the Pi image runs), through the chroot hook in +# config/hooks/normal/. Firmware is picked there too +# (rootfs/packages/x86.list), not by live-build's catch-all. +set -e + +lb config noauto \ + --mode debian \ + --distribution trixie \ + --architecture amd64 \ + --archive-areas "main contrib non-free-firmware" \ + --linux-flavours amd64 \ + --initramfs live-boot \ + --binary-images iso-hybrid \ + --bootloaders grub-pc,grub-efi \ + --debian-installer none \ + --firmware-binary false \ + --firmware-chroot false \ + --apt-recommends false \ + --apt-indices false \ + --memtest none \ + --image-name syncphony-box \ + --iso-application "Syncphony Box" \ + --iso-volume "SYNCPHONY_BOX" \ + --bootappend-live "boot=live quiet splash loglevel=3 vt.global_cursor_default=0 consoleblank=0" \ + "${@}" diff --git a/x86/live/config/hooks/normal/9000-syncphony-box.hook.chroot b/x86/live/config/hooks/normal/9000-syncphony-box.hook.chroot new file mode 100755 index 0000000..6221184 --- /dev/null +++ b/x86/live/config/hooks/normal/9000-syncphony-box.hook.chroot @@ -0,0 +1,32 @@ +#!/bin/bash +# The x86 side of the shared customise layer (MAD-846, ADR 0013). +# live-build runs this inside the chroot after its own packages and the +# includes (x86/root, plus rootfs/ and the .deb that ./build-iso.sh puts +# in /root/syncphony-rootfs). +set -euo pipefail + +# The box: the same script the Pi image's pi-gen stage runs. +/root/syncphony-rootfs/customise.sh x86 /root/syncphony-rootfs/syncphony-box.deb +rm -rf /root/syncphony-rootfs + +# /data by label: the live session finds a syncphony-data partition on the +# stick this way. A short timeout, because a stick without one is normal +# here and the box waits for the mount before boxd starts. The installer +# writes its own fstab with UUIDs. +sed -i 's|^DATADEV[[:space:]].*|LABEL=syncphony-data /data ext4 defaults,nofail,x-systemd.device-timeout=5s 0 2|' /etc/fstab + +# The image-only units (x86/root): the live session's /boot/firmware and +# the installer's boot entry. +systemctl enable syncphony-live-boot.service syncphony-installer.service + +echo syncphony-box > /etc/hostname + +# Every live boot (and an installed box's first boot) makes its own id; +# data-persist keeps an installed box's from then on (ADR 0007). +: > /etc/machine-id +rm -f /var/lib/dbus/machine-id + +# The initramfs, now that the overlay script, its hook and the module +# list are all in place. Both the live session and the installed box boot +# this one (live-boot's scripts for boot=live, the box's for boot=overlay). +update-initramfs -u -k all diff --git a/x86/live/config/hooks/normal/9100-syncphony-menu.hook.binary b/x86/live/config/hooks/normal/9100-syncphony-menu.hook.binary new file mode 100755 index 0000000..6e8b98a --- /dev/null +++ b/x86/live/config/hooks/normal/9100-syncphony-menu.hook.binary @@ -0,0 +1,37 @@ +#!/bin/sh +# The ISO's boot menu (MAD-846, ADR 0013), written over live-build's own. +# BIOS (grub-pc, El Torito) and UEFI (grub-efi) both end up loading +# /boot/grub/grub.cfg, so this one file is the menu for both. live-build +# runs binary hooks from the build directory, after the bootloaders and +# before the ISO is made. +set -e + +[ -d binary ] && cd binary + +kernel="$(cd live && ls vmlinuz* | head -n 1)" +initrd="$(cd live && ls initrd.img* | head -n 1)" +[ -n "${kernel}" ] && [ -n "${initrd}" ] || { + echo "9100-syncphony-menu: no kernel or initrd in live/" >&2 + exit 1 +} + +cat > boot/grub/grub.cfg << GRUB +# The Syncphony Box ISO (MAD-846). "Syncphony Box" is the live box: the +# system runs from this medium with its writes in RAM, and a partition +# labelled syncphony-data on the same stick keeps the pairing and setup +# across reboots. "Install to disk" puts the box on this computer's disk. +insmod all_video +set default=0 +set timeout=5 + +menuentry "Syncphony Box" { + linux /live/${kernel} boot=live quiet splash loglevel=3 vt.global_cursor_default=0 consoleblank=0 + initrd /live/${initrd} +} + +menuentry "Install Syncphony Box to disk" { + linux /live/${kernel} boot=live syncphony.install loglevel=3 consoleblank=0 + initrd /live/${initrd} +} +GRUB +echo "9100-syncphony-menu: boot/grub/grub.cfg written (${kernel}, ${initrd})" diff --git a/x86/root/usr/lib/syncphony-box/live-boot-firmware b/x86/root/usr/lib/syncphony-box/live-boot-firmware new file mode 100755 index 0000000..50fa2ac --- /dev/null +++ b/x86/root/usr/lib/syncphony-box/live-boot-firmware @@ -0,0 +1,26 @@ +#!/bin/sh +# The x86 live session's /boot/firmware (MAD-846, ADR 0013). On a Pi and +# an installed PC box, /boot/firmware is the boot partition and holds +# syncphony.txt. The live session has none, so: +# +# - with /data (a syncphony-data partition on the stick), /boot/firmware +# is a bind mount of /data/boot-firmware: the setup screen's writes +# land on the stick and survive a reboot +# - without it, /boot/firmware stays on the RAM overlay with the +# template: the box works, and forgets its setup at power-off +# +# Run by syncphony-live-boot.service, before boxd reads the file. +set -eu + +TEMPLATE=/usr/lib/syncphony-box/syncphony.txt +mkdir -p /boot/firmware + +if mountpoint -q /data; then + mkdir -p /data/boot-firmware + [ -f /data/boot-firmware/syncphony.txt ] || cp "${TEMPLATE}" /data/boot-firmware/syncphony.txt + mountpoint -q /boot/firmware || mount --bind /data/boot-firmware /boot/firmware + echo "live-boot-firmware: syncphony.txt kept on /data" +else + [ -f /boot/firmware/syncphony.txt ] || cp "${TEMPLATE}" /boot/firmware/syncphony.txt + echo "live-boot-firmware: no /data; syncphony.txt is in RAM for this session" +fi diff --git a/x86/root/usr/lib/systemd/system/boxd.service.d/50-installer.conf b/x86/root/usr/lib/systemd/system/boxd.service.d/50-installer.conf new file mode 100644 index 0000000..a5d47fd --- /dev/null +++ b/x86/root/usr/lib/systemd/system/boxd.service.d/50-installer.conf @@ -0,0 +1,4 @@ +# The x86 ISO's "Install to disk" boot runs the installer on tty1 instead +# of the box (MAD-846, ADR 0013). On every other boot, the token is absent. +[Unit] +ConditionKernelCommandLine=!syncphony.install diff --git a/x86/root/usr/lib/systemd/system/data.mount.d/50-installer.conf b/x86/root/usr/lib/systemd/system/data.mount.d/50-installer.conf new file mode 100644 index 0000000..bb4a3d2 --- /dev/null +++ b/x86/root/usr/lib/systemd/system/data.mount.d/50-installer.conf @@ -0,0 +1,5 @@ +# The "Install to disk" boot mounts no /data (MAD-846, ADR 0013): the disk +# being installed on may hold an old box's data partition, and a mounted +# partition can't be wiped. +[Unit] +ConditionKernelCommandLine=!syncphony.install diff --git a/x86/root/usr/lib/systemd/system/syncphony-installer.service b/x86/root/usr/lib/systemd/system/syncphony-installer.service new file mode 100644 index 0000000..5af6a6b --- /dev/null +++ b/x86/root/usr/lib/systemd/system/syncphony-installer.service @@ -0,0 +1,25 @@ +[Unit] +Description=Syncphony Box installer (the "Install to disk" boot entry) +Documentation=https://github.com/madeofpendletonwool/syncphony-box/blob/main/docs/adr/0013-x86-iso.md +# Only when the x86 ISO's install entry put this token on the command +# line (MAD-846). The kiosk and boxd stay off then (their drop-ins), so +# the installer has tty1 to itself. +ConditionKernelCommandLine=syncphony.install +Conflicts=getty@tty1.service +After=systemd-user-sessions.service getty@tty1.service systemd-udev-settle.service + +[Service] +Type=idle +ExecStart=/usr/sbin/syncphony-install +StandardInput=tty +StandardOutput=tty +# whiptail draws on stderr (the installer swaps it with stdout to read +# the answers), so both go to the terminal; its own log goes to the journal. +StandardError=tty +TTYPath=/dev/tty1 +TTYReset=yes +TTYVHangup=yes +Environment=TERM=linux + +[Install] +WantedBy=multi-user.target diff --git a/x86/root/usr/lib/systemd/system/syncphony-kiosk.service.d/50-installer.conf b/x86/root/usr/lib/systemd/system/syncphony-kiosk.service.d/50-installer.conf new file mode 100644 index 0000000..a5d47fd --- /dev/null +++ b/x86/root/usr/lib/systemd/system/syncphony-kiosk.service.d/50-installer.conf @@ -0,0 +1,4 @@ +# The x86 ISO's "Install to disk" boot runs the installer on tty1 instead +# of the box (MAD-846, ADR 0013). On every other boot, the token is absent. +[Unit] +ConditionKernelCommandLine=!syncphony.install diff --git a/x86/root/usr/lib/systemd/system/syncphony-live-boot.service b/x86/root/usr/lib/systemd/system/syncphony-live-boot.service new file mode 100644 index 0000000..06d72f8 --- /dev/null +++ b/x86/root/usr/lib/systemd/system/syncphony-live-boot.service @@ -0,0 +1,19 @@ +[Unit] +Description=Live session: a /boot/firmware for syncphony.txt +Documentation=https://github.com/madeofpendletonwool/syncphony-box/blob/main/docs/adr/0013-x86-iso.md +# The x86 ISO's live session (MAD-846) boots from read-only media with no +# boot partition. This gives boxd a /boot/firmware/syncphony.txt: kept on +# /data when the stick has a syncphony-data partition (so the setup +# survives a reboot), in RAM otherwise. +ConditionKernelCommandLine=boot=live +DefaultDependencies=no +After=local-fs.target data.mount +Before=syncphony-box-config.service boxd.service syncphony-data-prepare.service + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/lib/syncphony-box/live-boot-firmware + +[Install] +WantedBy=multi-user.target diff --git a/x86/root/usr/sbin/syncphony-install b/x86/root/usr/sbin/syncphony-install new file mode 100755 index 0000000..c9253fc --- /dev/null +++ b/x86/root/usr/sbin/syncphony-install @@ -0,0 +1,310 @@ +#!/bin/bash +# syncphony-install: put this Syncphony Box on a disk (MAD-846, ADR 0013). +# +# The x86 ISO's "Install Syncphony Box to disk" boot entry runs this on +# tty1 (syncphony-installer.service). It: +# +# 1. asks for a disk (never the one the live system booted from) and for +# a final yes, because everything on it is erased +# 2. optionally asks for an admin user. A PC has no Imager to make one, +# and without one there is no login for SSH or maintenance. +# 3. partitions it GPT: BIOS boot (1 MiB) + EFI system partition (FAT32, +# SB-BOOT, mounted at /boot/firmware like the Pi's boot partition) + +# root (ext4, syncphony-root) + data (ext4, syncphony-data, the rest) +# 4. copies the live system's pristine root (the squashfs, not the +# running overlay) onto root, plus the kernel and initramfs when the +# squashfs doesn't carry them +# 5. installs GRUB for UEFI (the removable-media path, so no NVRAM entry +# is needed) and legacy BIOS, with a grub.cfg that boots the root +# read-only behind the Pi's overlay (boot=overlay) and a cmdline.cfg +# that boxd rewrites for resolution= and rotate= +# 6. lays out /data and puts syncphony.txt on the EFI partition +# 7. reboots into the installed box +# +# Unattended (a scripted install in a VM, say): SB_INSTALL_DISK=/dev/vda +# SB_INSTALL_YES=1, plus SB_INSTALL_USER and SB_INSTALL_PASSWORD for the +# admin user. SB_INSTALL_SOURCE copies the root from a directory instead +# of the live squashfs; SB_INSTALL_NO_REBOOT=1 stops before the reboot. +set -euo pipefail + +SHARE=/usr/share/syncphony-box/x86 +TEMPLATE=/usr/lib/syncphony-box/syncphony.txt +MEDIUM=/run/live/medium +MNT=/run/syncphony-install +ESP_MIB=256 +ROOT_MIN_MIB=4096 +DATA_MIN_MIB=1024 + +UNATTENDED="${SB_INSTALL_YES:-}" + +# On tty1 the screen belongs to the dialogs: log to the journal there +# (journalctl -t syncphony-install), to stderr when unattended. +log() { + if [ -n "${UNATTENDED}" ]; then + echo "syncphony-install: $*" >&2 + else + logger -t syncphony-install -- "$*" 2>/dev/null || true + fi +} + +ui() { + whiptail --backtitle "Syncphony Box installer" "$@" 3>&1 1>&2 2>&3 +} + +die() { + log "$*" + if [ -z "${UNATTENDED}" ]; then + ui --title "Install failed" --msgbox "$*\n\nNothing was installed. Press Enter to restart." 14 72 || true + systemctl reboot || true + fi + exit 1 +} + +status() { + log "$*" + if [ -z "${UNATTENDED}" ]; then + ui --title "Installing" --infobox "$*" 8 72 || true + fi +} + +cleanup() { + set +e + sync + for m in "${MNT}/root/boot/firmware" "${MNT}/root" "${MNT}/data" "${MNT}/src"; do + mountpoint -q "${m}" && umount "${m}" + done +} +trap cleanup EXIT + +# part DISK N: the device of partition N (sda -> sda2, nvme0n1 -> nvme0n1p2). +part() { + case "$1" in + *[0-9]) echo "${1}p${2}" ;; + *) echo "${1}${2}" ;; + esac +} + +# The disk the live system booted from, so it is never offered. +live_disk() { + local src pk + src="$(findmnt -n -o SOURCE "${MEDIUM}" 2>/dev/null || true)" + [ -n "${src}" ] || return 0 + pk="$(lsblk -no PKNAME "${src}" 2>/dev/null | head -n 1)" + if [ -n "${pk}" ]; then + echo "/dev/${pk}" + else + echo "${src}" + fi +} + +[ "$(id -u)" -eq 0 ] || { echo "syncphony-install: run it as root" >&2; exit 1; } + +# --- the source: the live system's pristine root ---------------------------- +mkdir -p "${MNT}" +if [ -n "${SB_INSTALL_SOURCE:-}" ]; then + SRC="${SB_INSTALL_SOURCE}" +elif [ -d /run/live/rootfs/filesystem.squashfs/usr ]; then + SRC=/run/live/rootfs/filesystem.squashfs +elif [ -f "${MEDIUM}/live/filesystem.squashfs" ]; then + mkdir -p "${MNT}/src" + mount -o loop,ro "${MEDIUM}/live/filesystem.squashfs" "${MNT}/src" + SRC="${MNT}/src" +else + die "Can't find the live system to copy. Start the installer from the Syncphony Box USB stick or DVD." +fi + +USED_MIB="$(du -sxm "${SRC}" | cut -f1)" +# Room for the nightly updates (a new kernel while the old one is still +# there, new Chromium builds) on top of what the image uses today. +ROOT_MIB=$((USED_MIB * 2)) +[ "${ROOT_MIB}" -ge "${ROOT_MIN_MIB}" ] || ROOT_MIB="${ROOT_MIN_MIB}" +NEED_MIB=$((1 + ESP_MIB + ROOT_MIB + DATA_MIN_MIB + 1)) + +# --- the disk ------------------------------------------------------------------ +LIVE="$(live_disk)" +candidates=() +while read -r name size type ro model; do + case "${type}:${name}" in + disk:/dev/zram* | disk:/dev/sr*) continue ;; + disk:*) ;; + # A disk image, only when named outright (testing the installer). + loop:*) [ "${name}" = "${SB_INSTALL_DISK:-}" ] || continue ;; + *) continue ;; + esac + [ "${ro}" = 0 ] || continue + [ "${name}" != "${LIVE}" ] || continue + [ $((size / 1024 / 1024)) -ge "${NEED_MIB}" ] || continue + candidates+=("${name}" "$(numfmt --to=iec --suffix=B "${size}") ${model:-disk}") +done < <(lsblk -dnpb -o NAME,SIZE,TYPE,RO,MODEL) + +if [ -n "${SB_INSTALL_DISK:-}" ]; then + DISK="${SB_INSTALL_DISK}" + found="" + for ((i = 0; i < ${#candidates[@]}; i += 2)); do + if [ "${candidates[i]}" = "${DISK}" ]; then found=1; fi + done + [ -n "${found}" ] || die "${DISK} can't take the box: it is the boot medium, read-only, or smaller than ${NEED_MIB} MiB." +else + [ "${#candidates[@]}" -gt 0 ] || die "No disk to install to. The box needs an internal disk of at least $((NEED_MIB / 1024 + 1)) GB besides the USB stick it booted from." + DISK="$(ui --title "Install Syncphony Box" --menu \ + "Choose the disk to install the box on.\n\nEVERYTHING ON IT WILL BE ERASED." \ + 18 72 8 "${candidates[@]}")" || die "No disk chosen." +fi + +if [ -z "${UNATTENDED}" ]; then + ui --title "Erase ${DISK}?" --defaultno --yesno \ + "Everything on ${DISK} will be erased and replaced by Syncphony Box.\n\nThis can't be undone. Continue?" \ + 12 72 || die "Cancelled. ${DISK} was not touched." +fi + +# Nothing on the target may be in use. (The installer's boot entry mounts +# no /data at all for exactly this reason: reinstalling over an old box.) +if lsblk -nro MOUNTPOINT "${DISK}" | grep -q .; then + die "${DISK} has mounted partitions ($(lsblk -nro MOUNTPOINT "${DISK}" | grep . | tr '\n' ' ')). Use the \"Install to disk\" boot entry." +fi + +# --- the admin user (optional) ------------------------------------------------------ +ADMIN="${SB_INSTALL_USER:-}" +PASSWORD="${SB_INSTALL_PASSWORD:-}" +if [ -z "${UNATTENDED}" ]; then + while :; do + ADMIN="$(ui --title "Admin user (optional)" --inputbox \ + "A user for SSH and maintenance (it gets sudo). The box itself needs no login.\n\nLeave it empty to skip." \ + 12 72 "")" || ADMIN="" + [ -n "${ADMIN}" ] || break + if ! [[ "${ADMIN}" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || [ "${ADMIN}" = root ] || [ "${ADMIN}" = kiosk ]; then + ui --msgbox "\"${ADMIN}\" can't be a user name: lowercase letters, digits, - and _, starting with a letter." 10 72 || true + continue + fi + PASSWORD="$(ui --title "Password for ${ADMIN}" --passwordbox "Password:" 10 72)" || continue + again="$(ui --title "Password for ${ADMIN}" --passwordbox "Once more:" 10 72)" || continue + if [ -z "${PASSWORD}" ] || [ "${PASSWORD}" != "${again}" ]; then + ui --msgbox "The passwords were empty or didn't match. Try again." 8 72 || true + continue + fi + break + done +fi +if [ -n "${ADMIN}" ] && [ -z "${PASSWORD}" ]; then + die "An admin user needs a password (SB_INSTALL_PASSWORD)." +fi + +# --- partitions ---------------------------------------------------------------------- +status "Partitioning ${DISK}..." +wipefs -q -a "${DISK}" +sfdisk -q --wipe always --wipe-partitions always "${DISK}" << EOF +label: gpt +size=1MiB, type=21686148-6449-6E6F-744E-656564454649, name="bios" +size=${ESP_MIB}MiB, type=C12A7328-F81F-11D2-BA4B-00A0C93EC93B, name="SB-BOOT" +size=${ROOT_MIB}MiB, type=0FC63DAF-8483-4772-8E79-3D69D8477DE4, name="syncphony-root" +type=0FC63DAF-8483-4772-8E79-3D69D8477DE4, name="syncphony-data" +EOF +udevadm settle +P_ESP="$(part "${DISK}" 2)" +P_ROOT="$(part "${DISK}" 3)" +P_DATA="$(part "${DISK}" 4)" +for _ in $(seq 20); do + [ -b "${P_DATA}" ] && break + sleep 0.5 +done +[ -b "${P_DATA}" ] || die "The new partitions on ${DISK} didn't appear." + +mkfs.vfat -F 32 -n SB-BOOT "${P_ESP}" > /dev/null +mkfs.ext4 -q -F -L syncphony-root "${P_ROOT}" +mkfs.ext4 -q -F -L syncphony-data "${P_DATA}" +ROOT_UUID="$(blkid -s UUID -o value "${P_ROOT}")" +ESP_UUID="$(blkid -s UUID -o value "${P_ESP}")" +DATA_UUID="$(blkid -s UUID -o value "${P_DATA}")" + +# --- the root ---------------------------------------------------------------------------- +status "Copying the system (${USED_MIB} MiB)..." +mkdir -p "${MNT}/root" "${MNT}/data" +mount "${P_ROOT}" "${MNT}/root" +cp -a "${SRC}/." "${MNT}/root/" + +# The kernel and initramfs: the squashfs may not carry them (live-build +# serves them from the medium). The initramfs on the medium is the one +# the build made in the same chroot: live-boot's scripts plus the box's +# overlay script, so it boots the installed root too. +R="${MNT}/root" +mkdir -p "${R}/boot" +kernel="$(cd "${R}/boot" && ls vmlinuz-* 2>/dev/null | sort -V | tail -n 1 || true)" +if [ -z "${kernel}" ]; then + kernel="vmlinuz-$(uname -r)" + cp "$(ls "${MEDIUM}"/live/vmlinuz* | head -n 1)" "${R}/boot/${kernel}" +fi +kver="${kernel#vmlinuz-}" +if [ ! -f "${R}/boot/initrd.img-${kver}" ]; then + cp "$(ls "${MEDIUM}"/live/initrd.img* | head -n 1)" "${R}/boot/initrd.img-${kver}" +fi +# Debian's kernel packages keep these two links current on every update. +ln -sfn "boot/${kernel}" "${R}/vmlinuz" +ln -sfn "boot/initrd.img-${kver}" "${R}/initrd.img" + +cat > "${R}/etc/fstab" << EOF +# Written by syncphony-install (MAD-846, ADR 0013). The initramfs mounts +# the root read-only behind the overlay (boot=overlay) and mounts /data +# itself; these lines are the rest of the system's view (ADR 0007). +UUID=${ROOT_UUID} / ext4 defaults,noatime 0 1 +UUID=${ESP_UUID} /boot/firmware vfat defaults,ro 0 2 +UUID=${DATA_UUID} /data ext4 defaults,nofail,x-systemd.device-timeout=15s 0 2 +EOF + +if [ -n "${ADMIN}" ]; then + status "Adding the admin user ${ADMIN}..." + useradd --root "${R}" --create-home --shell /bin/bash --groups sudo "${ADMIN}" + printf '%s:%s\n' "${ADMIN}" "${PASSWORD}" | chpasswd --root "${R}" +fi + +# --- the boot partition: GRUB, the command line, syncphony.txt -------------------------- +status "Installing the bootloader..." +B="${R}/boot/firmware" +mkdir -p "${B}" +mount "${P_ESP}" "${B}" +GRUB_LOG=/run/syncphony-install-grub.log +grub-install --target=x86_64-efi --efi-directory="${B}" --boot-directory="${B}" \ + --removable --no-nvram >> "${GRUB_LOG}" 2>&1 || + die "grub-install (UEFI) failed on ${DISK}: $(tail -n 3 "${GRUB_LOG}")" +grub-install --target=i386-pc --boot-directory="${B}" "${DISK}" >> "${GRUB_LOG}" 2>&1 || + die "grub-install (BIOS) failed on ${DISK}: $(tail -n 3 "${GRUB_LOG}")" +sed "s/@ROOT_UUID@/${ROOT_UUID}/g" "${SHARE}/grub.cfg.in" > "${B}/grub/grub.cfg" +cp "${SHARE}/cmdline.cfg" "${B}/cmdline.cfg" +# The live session's syncphony.txt, if the setup screen already wrote one; +# the commented-out template otherwise. +if [ -f /boot/firmware/syncphony.txt ]; then + cp /boot/firmware/syncphony.txt "${B}/syncphony.txt" +else + cp "${TEMPLATE}" "${B}/syncphony.txt" +fi + +# --- /data ------------------------------------------------------------------------------ +# The persistent layout (docs/persistent-paths.md), with the Chromium +# profile dir owned by the kiosk user. data-persist recreates anything +# missing at boot; making it now makes the first boot persistent from the +# start (same as scripts/add-data-partition.sh does for the Pi image). +status "Preparing /data..." +mount "${P_DATA}" "${MNT}/data" +D="${MNT}/data" +mkdir -p "${D}/chromium" "${D}/journal" "${D}/syncphony-box" "${D}/boxd" \ + "${D}/system/cloud" "${D}/system/identity" "${D}/system/ssh" "${D}/system/network" +kiosk_ids="$(awk -F: '$1 == "kiosk" { print $3 ":" $4 }' "${R}/etc/passwd")" +[ -n "${kiosk_ids}" ] || die "The copied system has no kiosk user." +chown "${kiosk_ids}" "${D}/chromium" +# The new filesystem already fills the partition. +touch "${D}/system/.grown" + +status "Finishing..." +sync +cleanup +trap - EXIT + +log "installed on ${DISK}" +if [ -n "${SB_INSTALL_NO_REBOOT:-}" ]; then + exit 0 +fi +if [ -z "${UNATTENDED}" ]; then + ui --title "Syncphony Box is installed" --msgbox \ + "Remove the USB stick (or DVD), then press Enter.\n\nThe box restarts from ${DISK} and shows its setup screen on the TV." \ + 12 72 || true +fi +systemctl reboot diff --git a/x86/root/usr/share/syncphony-box/x86/cmdline.cfg b/x86/root/usr/share/syncphony-box/x86/cmdline.cfg new file mode 100644 index 0000000..9c17c0d --- /dev/null +++ b/x86/root/usr/share/syncphony-box/x86/cmdline.cfg @@ -0,0 +1,4 @@ +# The kernel command line of this Syncphony Box (MAD-846, ADR 0013). +# grub.cfg sources this file. boxd rewrites the video= token in it from +# resolution= and rotate= in syncphony.txt; keep it one `set` line. +set syncphony_cmdline="boot=overlay quiet splash loglevel=3 vt.global_cursor_default=0 consoleblank=0" diff --git a/x86/root/usr/share/syncphony-box/x86/grub.cfg.in b/x86/root/usr/share/syncphony-box/x86/grub.cfg.in new file mode 100644 index 0000000..0d43e88 --- /dev/null +++ b/x86/root/usr/share/syncphony-box/x86/grub.cfg.in @@ -0,0 +1,28 @@ +# The Syncphony Box boot menu (MAD-846, ADR 0013), written by +# syncphony-install onto the EFI system partition. One entry: the root +# read-only behind the overlay (boot=overlay, the Pi's mechanism, ADR +# 0007), with the kernel command line from cmdline.cfg next to this +# directory. boxd rewrites that file for resolution= and rotate=. +set default=0 +set timeout_style=hidden +set timeout=1 + +insmod part_gpt +insmod fat +insmod ext2 +insmod all_video + +# $root starts as the partition grub.cfg was loaded from: the EFI one. +set esp="${root}" +set syncphony_cmdline="boot=overlay quiet" +if [ -f "(${esp})/cmdline.cfg" ]; then + source "(${esp})/cmdline.cfg" +fi +export syncphony_cmdline + +search --no-floppy --fs-uuid --set=root @ROOT_UUID@ + +menuentry "Syncphony Box" { + linux /vmlinuz root=UUID=@ROOT_UUID@ ${syncphony_cmdline} + initrd /initrd.img +}