From d15e5cb2656876c555bd3009b830cf31e40e879c Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Thu, 15 Oct 2026 14:36:11 -0500 Subject: [PATCH] fix: x86 live boot without /data, the missing wpasupplicant; TV Wi-Fi controls - Live session: no /data line in the live fstab (a missing label stalled the boot and failed fsck). live-session mounts the stick's syncphony-data partition, or a tmpfs, before data-persist, sshd and the kiosk, so SSH host keys and the Chromium profile have a home. The install boot always gets the tmpfs. - wpasupplicant and wireless-regdb are explicit packages: network-manager only recommends them, so the x86 root couldn't scan or run the setup hotspot. MediaTek and Broadcom Wi-Fi firmware for laptops. - Setup screen: a live countdown to the hotspot, why it couldn't start, and "Start setup Wi-Fi now". A new /wifi screen picks and joins a network on the TV itself (loopback, boxd's own origin only). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 +- README.md | 10 +- boxd/internal/daemon/bridge.go | 7 + boxd/internal/daemon/hotspot.go | 319 +++++++++++++--- boxd/internal/daemon/hotspot_test.go | 155 ++++++++ boxd/internal/daemon/pages.go | 342 ++++++++++++++++-- boxd/internal/daemon/screens.go | 9 +- docs/adr/0010-wifi-setup-hotspot.md | 30 ++ docs/adr/0013-x86-iso.md | 42 ++- docs/testing.md | 20 +- rootfs/packages/common.list | 7 + rootfs/packages/x86.list | 5 +- scripts/smoke-test.sh | 26 +- .../normal/9000-syncphony-box.hook.chroot | 15 +- .../usr/lib/syncphony-box/live-boot-firmware | 26 -- x86/root/usr/lib/syncphony-box/live-session | 61 ++++ .../system/data.mount.d/50-installer.conf | 5 - .../system/syncphony-live-boot.service | 16 +- 18 files changed, 971 insertions(+), 139 deletions(-) delete mode 100755 x86/root/usr/lib/syncphony-box/live-boot-firmware create mode 100755 x86/root/usr/lib/syncphony-box/live-session delete mode 100644 x86/root/usr/lib/systemd/system/data.mount.d/50-installer.conf diff --git a/CHANGELOG.md b/CHANGELOG.md index c746018..4cf183d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,10 @@ Add to `## [Unreleased]` as you go; rename it when you tag. `sudo syncphony-box update` runs it by hand. - `/v1/info` reports uptime and the last update (when, and whether it worked) for the room's Screens list. -- The Wi-Fi setup hotspot for a box with no network. +- The Wi-Fi setup hotspot for a box with no network. The setup screen + counts down to it, says why when it can't start, and has a **Start + setup Wi-Fi now** button. **Choose Wi-Fi on this screen** sets the + Wi-Fi from the TV with a keyboard, no phone needed. - HDMI-CEC: the TV wakes and switches when music starts; the remote's keys drive the box; optional standby when the room is idle. - Watchdogs and self-healing: hardware watchdog, kiosk watchdog, @@ -42,6 +45,16 @@ Add to `## [Unreleased]` as you go; rename it when you tag. - Audio output selection (`audio=`) on PipeWire. - `syncphony.txt` boot config file and the cage + Chromium kiosk session. +### Fixed +- x86 live session: booting from a stick without a `syncphony-data` + partition no longer stalls on a missing `/data` device and then fails + SSH and the kiosk (`mkdir: cannot create directory '/data/chromium'`). + `/data` is the stick's partition when there is one, and RAM otherwise. +- x86: the setup hotspot never appeared, because the image had no + `wpasupplicant` (NetworkManager only recommends it), so NetworkManager + couldn't scan or run an access point. It also gains `wireless-regdb` + and MediaTek and Broadcom Wi-Fi firmware. + ### Changed - The image is smaller: documentation, man pages and translations are stripped (copyright files stay). diff --git a/README.md b/README.md index b94f396..c584c74 100644 --- a/README.md +++ b/README.md @@ -207,9 +207,15 @@ and serves the box's own screens: the TV says why. A new box starts the hotspot 60 s after boot. A box that has been online before shows the offline screen first and only falls back to the hotspot after 5 minutes without a network, so a - router reboot never puts it in setup mode. Imager's Wi-Fi settings - still work and are the fast path + router reboot never puts it in setup mode. Until the hotspot starts, + the setup screen counts down to it and says why if it can't start. + Imager's Wi-Fi settings still work and are the fast path ([ADR 0010](docs/adr/0010-wifi-setup-hotspot.md)). +- **Wi-Fi from the TV itself.** With a mouse, a keyboard or the remote, + the setup screen's **Start setup Wi-Fi now** skips the wait. **Choose + Wi-Fi on this screen** (also on the hotspot screen) lists the networks + in range on the TV: pick one, type its password, and the box joins it + with no phone needed. Logs go to journald: `journalctl -u boxd -u syncphony-kiosk`. diff --git a/boxd/internal/daemon/bridge.go b/boxd/internal/daemon/bridge.go index 49c4545..c51815a 100644 --- a/boxd/internal/daemon/bridge.go +++ b/boxd/internal/daemon/bridge.go @@ -35,6 +35,13 @@ func (d *Daemon) bridgeMux() *http.ServeMux { mux.HandleFunc("GET /start", d.handleStartScreen) mux.HandleFunc("GET /storage", d.handleStorageScreen) mux.HandleFunc("GET /hotspot", d.handleHotspotScreen) + // The TV's own Wi-Fi controls (the setup and hotspot screens' buttons, + // and the on-screen network picker). + mux.HandleFunc("GET /wifi", screenOnly(d.handleWiFiScreen)) + mux.HandleFunc("POST /wifi/hotspot", screenOnly(d.handleWiFiHotspotNow)) + mux.HandleFunc("POST /wifi/scan", screenOnly(d.handleWiFiScan)) + mux.HandleFunc("POST /wifi/join", screenOnly(d.handleWiFiJoin)) + mux.HandleFunc("POST /wifi/hold", screenOnly(d.handleWiFiHold)) return mux } diff --git a/boxd/internal/daemon/hotspot.go b/boxd/internal/daemon/hotspot.go index 52fceec..a2c001e 100644 --- a/boxd/internal/daemon/hotspot.go +++ b/boxd/internal/daemon/hotspot.go @@ -1,6 +1,8 @@ package daemon import ( + "encoding/json" + "errors" "fmt" "net" "net/http" @@ -40,6 +42,11 @@ const ( recheckEvery = 5 * time.Minute portalQuiet = 2 * time.Minute + // tvQuiet: someone is choosing a network on the TV's own Wi-Fi screen + // (it pings /wifi/hold while in use). The hotspot doesn't start under + // them; it needs the radio the screen is scanning with. + tvQuiet = 30 * time.Second + dataPersist = "/usr/lib/syncphony-box/data-persist" ) @@ -87,6 +94,9 @@ type netState struct { offlineSince time.Time // no connection continuously since (zero = online) seenOnline bool // this box has been online before (persisted) lastPortal time.Time // last request to the captive portal + lastTV time.Time // last use of the TV's own Wi-Fi screen + deviceErr string // why there is no Wi-Fi device (yet) + startErr string // why the hotspot couldn't start, last time it tried } // netView is the locked snapshot decideNet runs on. @@ -99,6 +109,7 @@ type netView struct { offlineSince time.Time apSince time.Time lastPortal time.Time + lastTV time.Time savedWiFi bool } @@ -128,19 +139,25 @@ func decideNet(v netView) (netAction, string) { } return netNothing, "" } - if v.online || v.offlineSince.IsZero() { + if v.online || v.offlineSince.IsZero() || v.now.Sub(v.lastTV) < tvQuiet { return netNothing, "" } - grace := bootGrace - if v.seenOnline { - grace = lostGrace - } + grace := hotspotGrace(v.seenOnline) if v.now.Sub(v.offlineSince) >= grace { return netStartAP, fmt.Sprintf("no Ethernet or Wi-Fi connection for %s", grace) } return netNothing, "" } +// hotspotGrace is how long a box without a connection waits before it +// starts the hotspot. +func hotspotGrace(seenOnline bool) time.Duration { + if seenOnline { + return lostGrace + } + return bootGrace +} + // boxdDir is boxd's own state directory on /data (docs/persistent-paths.md); // SB_BOXD_DIR moves it in dev and tests. func boxdDir() string { @@ -187,10 +204,14 @@ func (d *Daemon) netWatch() { logged := false for { dev, err := d.wifi.Device() + d.mu.Lock() + if err == nil { + d.net.device, d.net.deviceErr = dev, "" + } else { + d.net.deviceErr = err.Error() + } + d.mu.Unlock() if err == nil { - d.mu.Lock() - d.net.device = dev - d.mu.Unlock() break } if !logged { @@ -236,6 +257,7 @@ func (d *Daemon) netTick(now time.Time) { offlineSince: d.net.offlineSince, apSince: d.net.apSince, lastPortal: d.net.lastPortal, + lastTV: d.net.lastTV, } d.mu.Unlock() if first { @@ -269,12 +291,17 @@ func (d *Daemon) startHotspot(reason string) { d.log.Printf("boxd: wifi: cannot start the setup hotspot: %v", err) d.mu.Lock() d.net.phase = phaseNone + d.net.startErr = err.Error() // the TV says why d.net.offlineSince = time.Now() // re-arm the grace instead of retrying every tick + if nets != nil { + d.net.networks = nets // the TV's own Wi-Fi screen can still use them + } d.mu.Unlock() return } d.mu.Lock() d.net.phase = phaseHotspot + d.net.startErr = "" d.net.apSSID = ssid d.net.apSince = time.Now() if nets != nil { @@ -319,14 +346,18 @@ func (d *Daemon) recheck() { d.startHotspot("no saved network in range") } -// joinWiFi is the save from the portal, run after the phone got its -// answer: give the radio back, join, and on success persist the network -// and (if the form had it) the server URL. On failure the hotspot comes -// back and the TV shows why. -func (d *Daemon) joinWiFi(ssid, password string, hidden bool, serverURL, name string) { +// joinWiFi is the save from the portal (or the TV's own Wi-Fi screen), +// run after the asker got its answer: give the radio back, join, and on +// success persist the network and (if the form had it) the server URL. +// On failure from the portal the hotspot comes back and the TV shows +// why; from the TV, its Wi-Fi screen shows why and the hotspot keeps to +// its usual timing. +func (d *Daemon) joinWiFi(ssid, password string, hidden bool, serverURL, name string, fromTV bool) { d.netOp.Lock() defer d.netOp.Unlock() - time.Sleep(joinDelay) + if !fromTV { + time.Sleep(joinDelay) + } d.mu.Lock() dev := d.net.device @@ -340,8 +371,13 @@ func (d *Daemon) joinWiFi(ssid, password string, hidden bool, serverURL, name st d.mu.Lock() d.net.joining = "" d.net.lastError = capitalize(err.Error()) + if fromTV { + d.net.lastTV = time.Now() // the screen is still in use: retry, not hotspot + } d.mu.Unlock() - d.startHotspot("the join failed") + if !fromTV { + d.startHotspot("the join failed") + } return } @@ -440,35 +476,18 @@ func (d *Daemon) handleWiFiSave(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) writePage(w, wifiFormHTML(d.wifiForm(msg))) } - ssid := strings.TrimSpace(r.PostFormValue("other_ssid")) - if ssid == "" { - ssid = r.PostFormValue("ssid") - } - password := r.PostFormValue("password") // spaces are legal in a passphrase + ssid, password := joinRequest(r) serverURL := strings.TrimSpace(r.PostFormValue("server_url")) name := strings.TrimSpace(r.PostFormValue("name")) - if ssid == "" { - fail("Pick your Wi-Fi network, or type its name.") + known, problem := d.checkJoin(ssid, password) + if problem != "" { + fail(problem) return } d.mu.Lock() - known, secure := false, false - for _, nw := range d.net.networks { - if nw.SSID == ssid { - known, secure = true, nw.Secure - } - } askServer := d.cfg.ServerURL == "" d.mu.Unlock() - if secure && password == "" { - fail(ssid + " needs its Wi-Fi password.") - return - } - if password != "" && (len(password) < 8 || len(password) > 64) { - fail("Wi-Fi passwords are 8 to 63 characters — check it and try again.") - return - } if !askServer { serverURL, name = "", "" } @@ -493,7 +512,42 @@ func (d *Daemon) handleWiFiSave(w http.ResponseWriter, r *http.Request) { d.log.Printf("boxd: wifi: join %s requested from %s", ssid, r.RemoteAddr) writePage(w, joiningPageHTML(ssid, apSSID)) - go d.joinWiFi(ssid, password, !known, serverURL, name) + go d.joinWiFi(ssid, password, !known, serverURL, name, false) +} + +// joinRequest reads the network and password from a join form: a typed +// name (a network that isn't listed) wins over the picked one. +func joinRequest(r *http.Request) (ssid, password string) { + ssid = strings.TrimSpace(r.PostFormValue("other_ssid")) + if ssid == "" { + ssid = r.PostFormValue("ssid") + } + return ssid, r.PostFormValue("password") // spaces are legal in a passphrase +} + +// checkJoin vets a join against the last scan before anything changes, +// so the asker sees the mistake while still connected. known says the +// network was in the scan (one that wasn't is joined as hidden); problem +// is what to tell them, empty when the join can go ahead. +func (d *Daemon) checkJoin(ssid, password string) (known bool, problem string) { + if ssid == "" { + return false, "Pick your Wi-Fi network, or type its name." + } + d.mu.Lock() + secure := false + for _, nw := range d.net.networks { + if nw.SSID == ssid { + known, secure = true, nw.Secure + } + } + d.mu.Unlock() + if secure && password == "" { + return known, ssid + " needs its Wi-Fi password." + } + if password != "" && (len(password) < 8 || len(password) > 64) { + return known, "Wi-Fi passwords are 8 to 63 characters — check it and try again." + } + return known, "" } // handleHotspotScreen is the TV side: join the box's network (QR), then @@ -524,15 +578,192 @@ func (d *Daemon) handleHotspotScreen(w http.ResponseWriter, r *http.Request) { writePage(w, hotspotScreenHTML(data)) } -// wifiStatus is the hotspot's part of /status. +// wifiStatus is the hotspot's part of /status. Beyond the phase: whether +// the box has a Wi-Fi device at all (and why not), why the hotspot last +// failed to start, and — while the box is offline and waiting — how many +// seconds until it starts (hotspotIn), for the setup screen's countdown. func (d *Daemon) wifiStatus() map[string]any { + return d.wifiStatusAt(time.Now()) +} + +func (d *Daemon) wifiStatusAt(now time.Time) map[string]any { d.mu.Lock() defer d.mu.Unlock() - return map[string]any{ - "phase": string(d.net.phase), - "hotspot": d.net.apSSID, - "joining": d.net.joining, - "error": d.net.lastError, - "notice": d.net.notice, + st := map[string]any{ + "phase": string(d.net.phase), + "hotspot": d.net.apSSID, + "joining": d.net.joining, + "error": d.net.lastError, + "notice": d.net.notice, + "device": d.net.device != "", + "startError": d.net.startErr, + } + if d.net.device == "" { + st["unavailable"] = d.net.deviceErr + } + if d.net.device != "" && d.net.phase == phaseNone && d.net.joining == "" && !d.net.offlineSince.IsZero() { + left := hotspotGrace(d.net.seenOnline) - now.Sub(d.net.offlineSince) + st["hotspotIn"] = max(0, int((left+time.Second-1)/time.Second)) + } + return st +} + +// --- the TV's own Wi-Fi controls ------------------------------------------------ +// +// For a box with a keyboard or a mouse (a PC, or a Pi with one plugged +// in), or a remote for the buttons: start the hotspot now instead of +// waiting out the grace, or skip the phone and pick the network on the +// TV. Served on loopback only, to the kiosk's own pages (screenOnly). + +var errNetBusy = errors.New("the Wi-Fi is busy (a join or a check is running); try again in a moment") + +// startHotspotNow is the setup screen's "Start setup Wi-Fi now" button: +// the hotspot without the grace. It returns once the request is vetted; +// the scan and the access point come up in the background, and the +// screen follows through /status. +func (d *Daemon) startHotspotNow() error { + d.mu.Lock() + dev, phase, joining := d.net.device, d.net.phase, d.net.joining + d.mu.Unlock() + switch { + case dev == "": + return errors.New("this box has no Wi-Fi it can use") + case phase != phaseNone || joining != "": + return nil // already up, or about to be + } + if online, _ := d.wifi.Online(); online { + return errors.New("this box is already connected") } + d.mu.Lock() + d.net.startErr = "" // this attempt's outcome, not the last one's + d.mu.Unlock() + go func() { + d.netOp.Lock() + defer d.netOp.Unlock() + d.mu.Lock() + busy := d.net.phase != phaseNone || d.net.joining != "" + d.mu.Unlock() + if !busy { + d.startHotspot("started from the TV") + } + }() + return nil +} + +// scanForTV is the TV Wi-Fi screen's list: a fresh scan, which needs the +// radio, so a running hotspot gives it up (whoever is at the TV chose to +// set up here). It holds the hotspot off for tvQuiet from now. +func (d *Daemon) scanForTV() ([]wifi.Network, error) { + if !d.netOp.TryLock() { + return nil, errNetBusy + } + defer d.netOp.Unlock() + d.mu.Lock() + dev, phase := d.net.device, d.net.phase + d.net.lastTV = time.Now() + d.mu.Unlock() + if dev == "" { + return nil, errors.New("this box has no Wi-Fi it can use") + } + if phase == phaseHotspot { + d.stopHotspot("Wi-Fi is being set up on the TV") + } + nets, err := d.wifi.Scan(dev) + if err != nil { + d.log.Printf("boxd: wifi: scan from the TV: %v", err) + return nil, fmt.Errorf("couldn't look for networks: %w", err) + } + d.mu.Lock() + d.net.networks = nets + d.mu.Unlock() + return nets, nil +} + +func (d *Daemon) handleWiFiScreen(w http.ResponseWriter, r *http.Request) { + d.holdForTV() + writePage(w, wifiScreenHTML()) +} + +func (d *Daemon) holdForTV() { + d.mu.Lock() + d.net.lastTV = time.Now() + d.mu.Unlock() +} + +func (d *Daemon) handleWiFiHold(w http.ResponseWriter, r *http.Request) { + d.holdForTV() + w.WriteHeader(http.StatusNoContent) +} + +func (d *Daemon) handleWiFiHotspotNow(w http.ResponseWriter, r *http.Request) { + if err := d.startHotspotNow(); err != nil { + writeJSON(w, http.StatusConflict, map[string]any{"error": capitalize(err.Error()) + "."}) + return + } + d.log.Printf("boxd: wifi: setup hotspot requested from the TV") + writeJSON(w, http.StatusAccepted, map[string]any{"ok": true}) +} + +func (d *Daemon) handleWiFiScan(w http.ResponseWriter, r *http.Request) { + nets, err := d.scanForTV() + if err != nil { + writeJSON(w, http.StatusConflict, map[string]any{"error": capitalize(err.Error())}) + return + } + list := make([]map[string]any, 0, len(nets)) + for _, nw := range nets { + list = append(list, map[string]any{"ssid": nw.SSID, "signal": nw.Signal, "secure": nw.Secure}) + } + writeJSON(w, http.StatusOK, map[string]any{"networks": list}) +} + +// handleWiFiJoin is the TV Wi-Fi screen's Connect: the same checks as +// the portal's form, then the join in the background (the screen follows +// it through /status). +func (d *Daemon) handleWiFiJoin(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": "Bad request."}) + return + } + ssid, password := joinRequest(r) + known, problem := d.checkJoin(ssid, password) + if problem != "" { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": problem}) + return + } + d.mu.Lock() + if busy := d.net.joining; busy != "" { + d.mu.Unlock() + writeJSON(w, http.StatusConflict, map[string]any{"error": "Already joining " + busy + "."}) + return + } + d.net.joining = ssid + d.net.lastError, d.net.notice = "", "" + d.net.lastTV = time.Now() + d.mu.Unlock() + d.log.Printf("boxd: wifi: join %s requested from the TV", ssid) + writeJSON(w, http.StatusAccepted, map[string]any{"ok": true}) + go d.joinWiFi(ssid, password, !known, "", "", true) +} + +// screenOnly admits only the kiosk's own pages: addressed as loopback +// (no DNS rebinding) and, when the browser says where the request comes +// from, from boxd's own origin — not the server's page, not anything else. +func screenOnly(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + own := strings.TrimPrefix(origin, "http://") + if !allowedHost(r.Host) || (origin != "" && (own == origin || !allowedHost(own))) { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + next(w, r) + } +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(code) + json.NewEncoder(w).Encode(v) } diff --git a/boxd/internal/daemon/hotspot_test.go b/boxd/internal/daemon/hotspot_test.go index d6c777b..b34f034 100644 --- a/boxd/internal/daemon/hotspot_test.go +++ b/boxd/internal/daemon/hotspot_test.go @@ -22,6 +22,7 @@ type fakeNet struct { saved bool nets []wifi.Network connectErr error + startErr error apUp bool calls []string // onConnect runs inside Connect (a join can bring the box online). @@ -54,6 +55,9 @@ func (f *fakeNet) StartAP(dev, ssid string) error { f.mu.Lock() defer f.mu.Unlock() f.log("start " + ssid) + if f.startErr != nil { + return f.startErr + } f.apUp = true return nil } @@ -427,3 +431,154 @@ func TestScreensFollowTheHotspot(t *testing.T) { t.Errorf("/status = %s", st) } } + +// --- the TV's own Wi-Fi controls --------------------------------------------------- + +func TestDecideNetWaitsForTheTVScreen(t *testing.T) { + t0 := time.Date(2026, 10, 15, 20, 0, 0, 0, time.UTC) + v := netView{now: t0, offlineSince: t0.Add(-time.Hour), lastTV: t0.Add(-10 * time.Second)} + if got, _ := decideNet(v); got != netNothing { + t.Errorf("hotspot started under the TV's Wi-Fi screen: %q", got) + } + v.lastTV = t0.Add(-tvQuiet) + if got, _ := decideNet(v); got != netStartAP { + t.Errorf("hotspot held after the TV screen went quiet: %q", got) + } +} + +func TestStatusCountsDownToTheHotspot(t *testing.T) { + d, _, _ := newHotspotDaemon(t, "") + t0 := time.Now() + d.netTick(t0) + st := d.wifiStatusAt(t0.Add(15 * time.Second)) + if st["hotspotIn"] != 45 || st["device"] != true { + t.Fatalf("status = %v, want hotspotIn 45 with a device", st) + } + if st := d.wifiStatusAt(t0.Add(2 * bootGrace)); st["hotspotIn"] != 0 { + t.Errorf("hotspotIn past the grace = %v, want 0", st["hotspotIn"]) + } + upHotspot(t, d) + if _, ok := d.wifiStatus()["hotspotIn"]; ok { + t.Error("a countdown with the hotspot already up") + } + + // No Wi-Fi device: no countdown, and the reason. + d2, _, _ := newHotspotDaemon(t, "") + d2.net.device, d2.net.deviceErr = "", "no Wi-Fi device" + if st := d2.wifiStatus(); st["device"] != false || st["unavailable"] != "no Wi-Fi device" || st["hotspotIn"] != nil { + t.Errorf("no-device status = %v", st) + } +} + +func TestStartHotspotNowFromTheTV(t *testing.T) { + d, _, f := newHotspotDaemon(t, "") + d.netTick(time.Now()) // offline, well inside the grace + rec := post(t, d.bridgeMux(), "/wifi/hotspot", "", "Origin", "http://127.0.0.1:8099") + if rec.Code != http.StatusAccepted { + t.Fatalf("start now: %d %s", rec.Code, rec.Body) + } + waitFor(t, d.hotspotUp) + if _, up := f.snapshot(); !up { + t.Fatal("AP not up") + } + + // Already online: nothing to set up. + d2, _, f2 := newHotspotDaemon(t, "") + f2.online = true + if rec := post(t, d2.bridgeMux(), "/wifi/hotspot", ""); rec.Code != http.StatusConflict || + !strings.Contains(rec.Body.String(), "already connected") { + t.Errorf("online box: %d %s", rec.Code, rec.Body) + } +} + +func TestStartFailureReachesTheTV(t *testing.T) { + d, _, f := newHotspotDaemon(t, "") + f.mu.Lock() + f.startErr = errors.New("No suitable device found for this connection") + f.mu.Unlock() + upHotspot(t, d) + if d.hotspotUp() { + t.Fatal("hotspot up despite the failure") + } + if st := d.wifiStatus(); !strings.Contains(st["startError"].(string), "No suitable device") { + t.Errorf("status = %v, want the start error", st) + } +} + +func TestWiFiControlsOnlyForTheKiosk(t *testing.T) { + d, _, _ := newHotspotDaemon(t, "server_url=https://s.example\n") + for _, origin := range []string{"https://s.example", "null", "http://evil.example"} { + if rec := post(t, d.bridgeMux(), "/wifi/scan", "", "Origin", origin); rec.Code != http.StatusForbidden { + t.Errorf("origin %q: %d, want 403", origin, rec.Code) + } + } + req := httptest.NewRequest(http.MethodPost, "/wifi/scan", nil) + req.Host = "rebound.example:8099" + rec := httptest.NewRecorder() + d.bridgeMux().ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Errorf("rebound host: %d, want 403", rec.Code) + } +} + +func TestTVScanTakesTheRadioFromTheHotspot(t *testing.T) { + d, _, f := newHotspotDaemon(t, "") + upHotspot(t, d) + rec := post(t, d.bridgeMux(), "/wifi/scan", "", "Origin", "http://127.0.0.1:8099") + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"ssid":"Home"`) { + t.Fatalf("scan: %d %s", rec.Code, rec.Body) + } + if _, up := f.snapshot(); up || d.hotspotUp() { + t.Fatal("hotspot still holds the radio after a TV scan") + } + // And the watch loop leaves it down while the screen is in use. + d.netTick(time.Now().Add(time.Hour)) + if d.hotspotUp() { + t.Fatal("hotspot came back under the TV's Wi-Fi screen") + } + if body := get(t, d.bridgeMux(), "/wifi").Body.String(); !strings.Contains(body, "/wifi/scan") { + t.Error("TV Wi-Fi screen doesn't scan") + } +} + +func TestTVJoin(t *testing.T) { + d, rec, f := newHotspotDaemon(t, "") + f.onConnect = func() { + f.mu.Lock() + f.online = true + f.mu.Unlock() + } + post(t, d.bridgeMux(), "/wifi/scan", "") + form := "ssid=Home&password=short" + if r := post(t, d.bridgeMux(), "/wifi/join", form, "Content-Type", "application/x-www-form-urlencoded"); r.Code != http.StatusBadRequest || + !strings.Contains(r.Body.String(), "8 to 63") { + t.Fatalf("short password: %d %s", r.Code, r.Body) + } + form = "ssid=Home&password=hunter2222" + if r := post(t, d.bridgeMux(), "/wifi/join", form, "Content-Type", "application/x-www-form-urlencoded"); r.Code != http.StatusAccepted { + t.Fatalf("join: %d %s", r.Code, r.Body) + } + waitFor(t, func() bool { return !d.wifiSetup() }) + if calls, _ := f.snapshot(); !strings.Contains(strings.Join(calls, ";"), "connect Home hunter2222") { + t.Errorf("calls = %q", calls) + } + waitFor(t, func() bool { return strings.Contains(strings.Join(rec.calls(), ";"), dataPersist+" save") }) +} + +func TestTVJoinFailureStaysOnTheTV(t *testing.T) { + d, _, f := newHotspotDaemon(t, "") + f.connectErr = errors.New("couldn't join Home — is the password right?") + post(t, d.bridgeMux(), "/wifi/scan", "") + post(t, d.bridgeMux(), "/wifi/join", "ssid=Home&password=wrongpass", "Content-Type", "application/x-www-form-urlencoded") + waitFor(t, func() bool { + d.mu.Lock() + defer d.mu.Unlock() + return d.net.joining == "" + }) + if _, up := f.snapshot(); up || d.hotspotUp() { + t.Fatal("a failed TV join brought the hotspot up under the screen") + } + if st := d.wifiStatus(); !strings.Contains(st["error"].(string), "is the password right?") { + t.Errorf("status = %v, want the join error", st) + } +} diff --git a/boxd/internal/daemon/pages.go b/boxd/internal/daemon/pages.go index 034b086..f37c287 100644 --- a/boxd/internal/daemon/pages.go +++ b/boxd/internal/daemon/pages.go @@ -40,12 +40,50 @@ var pageStyle = ` .steps { text-align: left; display: inline-block; } ` +// controlStyle is for the screens with buttons (the Wi-Fi controls): a +// pointer when there is a mouse, and a focus ring big enough to follow +// from the couch when the remote's arrows or a keyboard move it. +var controlStyle = ` + body { cursor: auto; } + .actions { display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap; margin-top: 1.5rem; } + button, .button { + font: inherit; font-size: 1.3rem; font-weight: 600; + padding: 0.9rem 1.6rem; border-radius: 0.8rem; border: 0; + color: oklch(0.98 0.01 295); background: oklch(0.45 0.112 295); + text-decoration: none; cursor: pointer; + } + button.secondary, .button.secondary { background: #2c2c34; color: #f4f4f5; } + button:disabled { opacity: 0.5; cursor: default; } + button:focus-visible, .button:focus-visible, input:focus-visible { + outline: 4px solid oklch(0.75 0.16 295); outline-offset: 3px; + } + .count { font-variant-numeric: tabular-nums; } +` + +// arrowFocusJS lets a TV remote (whose arrows arrive as arrow keys, see +// the cec package) walk the buttons: Up/Left and Down/Right move the +// focus, Enter clicks. Typing fields keep their own Left/Right. +const arrowFocusJS = ` + document.addEventListener('keydown', (e) => { + const keys = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 }; + if (!(e.key in keys)) return; + const typing = document.activeElement && document.activeElement.tagName === 'INPUT'; + if (typing && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) return; + const all = [...document.querySelectorAll('button, a.button, input')] + .filter((el) => !el.disabled && el.offsetParent !== null); + if (!all.length) return; + const i = all.indexOf(document.activeElement); + const next = all[(i < 0 ? 0 : i + keys[e.key] + all.length) % all.length]; + next.focus(); + e.preventDefault(); + }); +` + type setupScreenData struct { - QR string - URL string - IPs []string - Network string - Notice string // after a hotspot join that couldn't save the server URL + QR string + URL string + IPs []string + Notice string // after a hotspot join that couldn't save the server URL } func setupScreenHTML(d setupScreenData) string { @@ -60,7 +98,7 @@ func setupScreenHTML(d setupScreenData) string { Syncphony Box — set up - +
@@ -71,29 +109,89 @@ func setupScreenHTML(d setupScreenData) string { {{if .IPs}}

or try {{range .IPs}}{{.}} {{end}}

{{end}}

There you can set which Syncphony server this box shows.

{{if .Notice}}

{{.Notice}}

{{end}} - {{if .NoNetwork}}

No network yet — without Ethernet or Wi-Fi, this box - starts its own setup Wi-Fi in a minute.

{{end}} +
`)) t.Execute(b, struct { - QR template.URL // boxd generates this data URI itself; html/template would otherwise rewrite it - URL string - IPs []string - Notice string - NoNetwork bool - }{template.URL(d.QR), d.URL, ips, d.Notice, d.Network == "offline"}) + QR template.URL // boxd generates this data URI itself; html/template would otherwise rewrite it + URL string + IPs []string + Notice string + }{template.URL(d.QR), d.URL, ips, d.Notice}) return b.String() } @@ -298,7 +396,7 @@ func hotspotScreenHTML(d hotspotScreenData) string { Syncphony Box — Wi-Fi setup - + + +
+

Choose this box's Wi-Fi

+

+ + +
+
+
+ + + Back +
+
+ + +
+ + +` +} + func writePage(w http.ResponseWriter, html string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") diff --git a/boxd/internal/daemon/screens.go b/boxd/internal/daemon/screens.go index bbc8104..34ae63f 100644 --- a/boxd/internal/daemon/screens.go +++ b/boxd/internal/daemon/screens.go @@ -74,11 +74,10 @@ func (d *Daemon) handleSetupScreen(w http.ResponseWriter, r *http.Request) { notice := d.net.notice d.mu.Unlock() writePage(w, setupScreenHTML(setupScreenData{ - QR: data, - URL: setupURL, - IPs: boxIPs(), - Network: networkState(), - Notice: notice, + QR: data, + URL: setupURL, + IPs: boxIPs(), + Notice: notice, })) } diff --git a/docs/adr/0010-wifi-setup-hotspot.md b/docs/adr/0010-wifi-setup-hotspot.md index fc216d3..71638c5 100644 --- a/docs/adr/0010-wifi-setup-hotspot.md +++ b/docs/adr/0010-wifi-setup-hotspot.md @@ -90,6 +90,36 @@ handovers. `/status` gains `wifiSetup` and a `wifi` object with the phase, the access point's name, the network being joined, and the last error. +### The TV's own controls + +Added after the first PC run (ADR 0013), where the setup screen said +"in a minute" and then, with the hotspot failing to start, nothing at +all. A box with a mouse or a keyboard (a PC, or a Pi with one plugged +in), or a remote for the buttons, can do more than wait: + +- **The countdown.** `/status`'s `wifi` object gains `hotspotIn` (seconds + until the hotspot starts, while the box is offline and waiting), + `device` and `unavailable` (no Wi-Fi device, and nmcli's reason), and + `startError` (why the last start failed; the grace then re-arms). The + setup screen shows all of it instead of a fixed "in a minute". +- **Start setup Wi-Fi now.** A button on the setup screen + (`POST /wifi/hotspot`): the hotspot without the grace, for an offline + box with a Wi-Fi device. +- **Choose Wi-Fi on this screen.** `/wifi`, linked from the setup and + hotspot screens, lists the networks from a fresh scan + (`POST /wifi/scan`) and joins one (`POST /wifi/join`) with the + portal's checks. A scan needs the radio, so a running hotspot gives it + up. While the screen is in use it pings `POST /wifi/hold`, and the + policy doesn't start the hotspot within 30 s of the last ping; after + three idle minutes the screen goes back to `/setup` and the usual + timing resumes. A failed join from the TV shows its reason there and + doesn't bring the hotspot up under it. + +These routes are on the loopback listener only, and refuse any Origin +but boxd's own, so neither the server's page nor anything else can +drive the radio. The remote's arrows move the focus between the +buttons; typing a password needs a keyboard. + ### Saving: answer first, then join The access point and the house network share one radio. So boxd answers diff --git a/docs/adr/0013-x86-iso.md b/docs/adr/0013-x86-iso.md index 06c22e9..f74b731 100644 --- a/docs/adr/0013-x86-iso.md +++ b/docs/adr/0013-x86-iso.md @@ -71,7 +71,8 @@ makes a Debian root a box, and pi-gen's way of doing it (`on_chroot`, `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 + `customise.sh x86`, drops the `/data` line from fstab (see the live + session below), 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 @@ -125,8 +126,13 @@ same ones as before. - **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. +- **Wi-Fi.** Debian's `network-manager` only Recommends `wpasupplicant`, + and the box installs without recommends. Pi OS Lite brings it; a bare + Debian root doesn't, and without it NetworkManager can't scan, join or + run the setup hotspot. `packages/common.list` names it and + `wireless-regdb` explicitly (no-ops on the Pi). - **Firmware.** `packages/x86.list` names it: GPU (Intel, AMD), - Wi-Fi (Intel, Realtek, Atheros), the SOF audio DSPs of recent Intel + Wi-Fi (Intel, Realtek, Atheros, MediaTek, Broadcom), 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. @@ -171,13 +177,27 @@ 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 +### The live session's /data and /boot/firmware + +live-boot doesn't run the box's initramfs overlay script, so nothing +mounts `/data` early. The live image has no fstab line for it either: a +stick without a `syncphony-data` partition is the normal case, and an +fstab entry for a missing label stalled the boot on a device timeout and +a failed fsck. An unmounted `/data` was worse: a root-owned empty +directory, so the SSH host-key links dangled (sshd failed in a loop) and +the kiosk couldn't create its profile. + +`syncphony-live-boot.service` (`/usr/lib/syncphony-box/live-session`) +mounts it instead, before data-persist, sshd and the kiosk: the stick's +`syncphony-data` partition when there is one (fsck'd first, with the +initramfs script's rule), and a tmpfs otherwise, which data-persist then +lays out like a fresh data partition. The "Install to disk" boot always +gets the tmpfs, so a disk being installed on is never mounted. A tmpfs +`/data` counts as healthy storage to boxd: in a live session, forgetting +at power-off is the expected behavior, not the storage-lost screen. + +`/boot/firmware` is then a bind mount of `/data/boot-firmware`, so the +setup form's writes persist exactly as well as `/data` does. The nightly update is off in a live session (`ConditionKernelCommandLine=!boot=live` on its unit): there is no real root to update. @@ -189,8 +209,8 @@ on its unit): there is no real root to update. 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 +- `/data` is found by label everywhere (the Pi's initramfs, an installed + PC's initramfs, and the x86 live session's `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 diff --git a/docs/testing.md b/docs/testing.md index a1ac05b..a4a17da 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -255,6 +255,22 @@ Ethernet cable. A serial console or a keyboard helps you watch goes down within seconds and the TV goes back to the room (or the setup screen, if no server is set). +With a keyboard or mouse on the box (a PC, or a Pi with one plugged in), +on a fresh box with no network: + +9. **The countdown and the button.** The setup screen counts down + ("starts its own setup Wi-Fi in 0:52"). Press **Start setup Wi-Fi + now**: the hotspot screen follows within seconds. If the hotspot + can't start, the setup screen says why (`journalctl -u boxd` has the + same nmcli message) and counts down again. +10. **Wi-Fi on the TV.** Reboot, press **Choose Wi-Fi on this screen**. + The networks in range are listed. Pick the house network, type a + wrong password: "Couldn't join … — is the password right?" on the + TV, and no hotspot appears under it. The right password: the TV goes + to the setup QR (now on the house network) or the room. Leave the + picker idle for three minutes: it goes back to the setup screen and + the hotspot starts. + ## The overnight update (MAD-810) The done-when for updates: a box flashed with release N, left on @@ -326,7 +342,9 @@ 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, +to disk**; the first boots to the setup QR with no device timeout or +failed units on the way (`systemctl --failed` is empty; `findmnt /data` +is a tmpfs, `systemctl is-active ssh` is `active`). 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 diff --git a/rootfs/packages/common.list b/rootfs/packages/common.list index b9e1a65..8dc9700 100644 --- a/rootfs/packages/common.list +++ b/rootfs/packages/common.list @@ -36,5 +36,12 @@ 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. +# NetworkManager only Recommends wpasupplicant, and packages go in with +# --no-install-recommends: without it NetworkManager can't scan, join or +# run an access point at all. wireless-regdb gives the radio its +# regulatory channels. Raspberry Pi OS Lite brings both; a bare Debian +# root (the x86 box) doesn't. network-manager dnsmasq-base +wpasupplicant +wireless-regdb diff --git a/rootfs/packages/x86.list b/rootfs/packages/x86.list index c77b6cd..3291ee8 100644 --- a/rootfs/packages/x86.list +++ b/rootfs/packages/x86.list @@ -10,7 +10,8 @@ dbus fonts-dejavu-core busybox -# Firmware for common PC hardware (non-free-firmware): GPUs, Wi-Fi, the +# Firmware for common PC hardware (non-free-firmware): GPUs, Wi-Fi (Intel, +# Realtek, Atheros, MediaTek, Broadcom: the cards laptops ship with), the # audio DSPs of recent Intel machines (SOF, with the UCM profiles # PipeWire needs to see their outputs), and CPU microcode. firmware-linux-free @@ -20,6 +21,8 @@ firmware-amd-graphics firmware-iwlwifi firmware-realtek firmware-atheros +firmware-mediatek +firmware-brcm80211 firmware-sof-signed alsa-ucm-conf intel-microcode diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 7be66f8..1a8b1e1 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -151,7 +151,8 @@ 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 \ - systemd-timesyncd v4l-utils network-manager dnsmasq-base syncphony-box ${platform_pkgs}; do + systemd-timesyncd v4l-utils network-manager dnsmasq-base wpasupplicant wireless-regdb \ + syncphony-box ${platform_pkgs}; do if sudo awk -v p="${pkg}" ' $0 == "Package: " p { inpkg = 1 } inpkg && /^Status: / { @@ -584,9 +585,10 @@ else fi # fstab: the boot partition is mounted read-only, and /data has its own -# 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. +# nofail entry (the DATADEV placeholder resolved) on the Pi. The x86 live +# root has none: the live session mounts /data itself (a missing label in +# fstab stalls the boot), and the installer writes an installed box's +# fstab, so its lines are checked in the installer. if [ "${PLATFORM}" = pi ]; then boot_fstab="${WORK}/root/etc/fstab" else @@ -602,7 +604,7 @@ fi data_fstab="$(sudo grep -E '/data[[:space:]]+ext4' "${WORK}/root/etc/fstab" || true)" 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 ;; + x86:) check "live fstab has no /data entry (the live session mounts it)" ok ;; *) check "fstab has the /data entry (nofail)" "got: ${data_fstab:-nothing}" ;; esac @@ -780,6 +782,15 @@ if [ "${PLATFORM}" = x86 ]; then echo "NOTE: no lsinitramfs here; the initramfs contents go unchecked" fi + # The live session mounts /data itself (the stick's partition, or a + # tmpfs), and never the real one in the install boot. + if sudo test -x "${WORK}/root/usr/lib/syncphony-box/live-session" && + sudo grep -q 'syncphony.install' "${WORK}/root/usr/lib/syncphony-box/live-session"; then + check "live session mounts /data (tmpfs in the install boot)" ok + else + check "live session mounts /data (tmpfs in the install boot)" "live-session missing or wrong" + fi + if sudo test -x "${WORK}/root/usr/sbin/syncphony-install"; then check "installer installed executable" ok else @@ -806,9 +817,8 @@ if [ "${PLATFORM}" = x86 ]; then 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 + # The install boot runs the installer instead of the box. + for dropin in syncphony-kiosk.service.d boxd.service.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 diff --git a/x86/live/config/hooks/normal/9000-syncphony-box.hook.chroot b/x86/live/config/hooks/normal/9000-syncphony-box.hook.chroot index 6221184..c192d3e 100755 --- a/x86/live/config/hooks/normal/9000-syncphony-box.hook.chroot +++ b/x86/live/config/hooks/normal/9000-syncphony-box.hook.chroot @@ -9,14 +9,15 @@ set -euo pipefail /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 +# No /data line in the live image's fstab: a stick without a +# syncphony-data partition is normal here, and a fstab entry for a +# missing label stalls the boot on a device timeout and a failed fsck. +# The live session mounts /data itself (syncphony-live-boot.service). The +# installer writes an installed box's fstab, /data by UUID, from scratch. +sed -i '/^DATADEV[[:space:]]/d' /etc/fstab -# The image-only units (x86/root): the live session's /boot/firmware and -# the installer's boot entry. +# The image-only units (x86/root): the live session's /data and +# /boot/firmware, and the installer's boot entry. systemctl enable syncphony-live-boot.service syncphony-installer.service echo syncphony-box > /etc/hostname diff --git a/x86/root/usr/lib/syncphony-box/live-boot-firmware b/x86/root/usr/lib/syncphony-box/live-boot-firmware deleted file mode 100755 index 50fa2ac..0000000 --- a/x86/root/usr/lib/syncphony-box/live-boot-firmware +++ /dev/null @@ -1,26 +0,0 @@ -#!/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/syncphony-box/live-session b/x86/root/usr/lib/syncphony-box/live-session new file mode 100755 index 0000000..71b34a8 --- /dev/null +++ b/x86/root/usr/lib/syncphony-box/live-session @@ -0,0 +1,61 @@ +#!/bin/sh +# The x86 live session's /data and /boot/firmware (MAD-846, ADR 0013). +# Run by syncphony-live-boot.service, before data-persist, sshd, the +# kiosk and boxd look at either. +# +# /data. On a Pi and an installed PC box, the initramfs mounts the +# syncphony-data partition. live-boot doesn't, and the live image has no +# fstab line for it either: a stick without one is the normal case here, +# and a fstab entry for a missing label stalls the boot on a device +# timeout and a failed fsck. So this script mounts it: +# +# - a partition labelled syncphony-data (on the stick), fsck'd first, +# when there is one: pairing and setup survive a reboot +# - otherwise a tmpfs: the box works and forgets everything at +# power-off. /data must still be a mount, so data-persist lays it out +# (the kiosk's Chromium profile, the SSH host keys the /etc/ssh links +# point at) exactly as on a fresh data partition +# +# The "Install to disk" boot always gets the tmpfs: the disk being +# installed on may hold an old box's data partition, and a mounted +# partition can't be wiped. +# +# /boot/firmware. On a Pi and an installed PC box it is the boot +# partition and holds syncphony.txt. The live session has none, so it is +# a bind mount of /data/boot-firmware, which persists exactly as well as +# /data does. +set -eu + +TEMPLATE=/usr/lib/syncphony-box/syncphony.txt + +mount_data() { + mountpoint -q /data && return 0 + mkdir -p /data + if ! grep -qw syncphony.install /proc/cmdline; then + dev="$(blkid -L syncphony-data 2>/dev/null || true)" + if [ -n "${dev}" ]; then + # e2fsck -p: 0 = clean, 1 = corrected; anything higher needs a + # human, so the session runs in RAM instead (the initramfs + # overlay script's rule). + rc=0 + e2fsck -p "${dev}" >/dev/null 2>&1 || rc=$? + if [ "${rc}" -le 1 ] && mount -t ext4 -o defaults "${dev}" /data; then + echo "live-session: /data is ${dev}" + # journald started before /data existed; move this boot's + # log onto it (/var/log/journal links to /data/journal). + mkdir -p /data/journal + journalctl --flush >/dev/null 2>&1 || true + return 0 + fi + echo "live-session: ${dev} failed its check (e2fsck ${rc}) or didn't mount; /data is in RAM" >&2 + fi + fi + mount -t tmpfs -o mode=0755 tmpfs /data + echo "live-session: no syncphony-data partition; /data is in RAM for this session" +} + +mount_data + +mkdir -p /data/boot-firmware /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 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 deleted file mode 100644 index bb4a3d2..0000000 --- a/x86/root/usr/lib/systemd/system/data.mount.d/50-installer.conf +++ /dev/null @@ -1,5 +0,0 @@ -# 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-live-boot.service b/x86/root/usr/lib/systemd/system/syncphony-live-boot.service index 06d72f8..d487fd2 100644 --- a/x86/root/usr/lib/systemd/system/syncphony-live-boot.service +++ b/x86/root/usr/lib/systemd/system/syncphony-live-boot.service @@ -1,19 +1,21 @@ [Unit] -Description=Live session: a /boot/firmware for syncphony.txt +Description=Live session: /data and 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. +# boot partition and no fstab line for /data. This mounts /data (the +# stick's syncphony-data partition, or a tmpfs) and gives boxd a +# /boot/firmware/syncphony.txt on it. data-persist restore runs after it, +# so the SSH host keys and the kiosk's profile directory exist before +# sshd and the kiosk start. ConditionKernelCommandLine=boot=live DefaultDependencies=no -After=local-fs.target data.mount -Before=syncphony-box-config.service boxd.service syncphony-data-prepare.service +After=local-fs.target +Before=syncphony-data-prepare.service syncphony-box-config.service boxd.service ssh.service syncphony-kiosk.service [Service] Type=oneshot RemainAfterExit=yes -ExecStart=/usr/lib/syncphony-box/live-boot-firmware +ExecStart=/usr/lib/syncphony-box/live-session [Install] WantedBy=multi-user.target