From 6920c1c0682ed60c8583d976731a699a41bfc118 Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Sun, 18 Oct 2026 10:52:48 -0500 Subject: [PATCH 1/5] fix: let the server's page reach boxd on loopback (Chromium Local Network Access) Since Chrome 142 a public site needs the Local Network Access permission to call 127.0.0.1, and a kiosk has nobody to answer the prompt. /tv's heartbeats and events never reached boxd, so the watchdog took the page for stuck and restarted the kiosk every few minutes (then rebooted the box after three). boxd config apply (every boot) and the setup save write a Chromium managed policy allowing the configured server's origin, under all three names the policy has had (LocalNetworkAccess, and the 146+ LocalNetwork / LoopbackNetwork split). config.ServerOrigin is shared with the bridge's Origin check. --- boxd/internal/config/apply.go | 66 ++++++++++++++++++++++++++++- boxd/internal/config/config.go | 5 +++ boxd/internal/config/config_test.go | 29 +++++++++++++ boxd/internal/daemon/bridge.go | 21 +-------- boxd/internal/daemon/daemon_test.go | 1 + boxd/internal/daemon/setup.go | 5 +++ tests/syncphony-box-config.bats | 4 +- 7 files changed, 108 insertions(+), 23 deletions(-) diff --git a/boxd/internal/config/apply.go b/boxd/internal/config/apply.go index 6d64de3..1f6a899 100644 --- a/boxd/internal/config/apply.go +++ b/boxd/internal/config/apply.go @@ -1,6 +1,7 @@ package config import ( + "encoding/json" "fmt" "log" "os" @@ -9,8 +10,8 @@ import ( "strings" ) -// Paths resolves the SB_* test hooks: SB_TXT, SB_ENV, SB_CMDLINE, SB_HOSTS -// override the real file locations, SB_PROFILE the Chromium profile dir, and +// Paths resolves the SB_* test hooks: SB_TXT, SB_ENV, SB_CMDLINE, SB_HOSTS, +// SB_CHROMIUM_POLICY override the real file locations, SB_PROFILE the Chromium profile dir, and // SB_HOSTNAME_CMD / SB_REBOOT_CMD replace the privileged actions, so // `boxd config apply` can run on a dev machine. type Paths struct { @@ -18,6 +19,7 @@ type Paths struct { Env string Cmdline string Hosts string // empty = leave /etc/hosts alone (tests) + Policy string // Chromium managed policy file; empty = none (tests) ProfileDir string // empty = DefaultProfileDir HostnameCmd string // run as: sh -c "$HostnameCmd" boxd RebootCmd string // run as: sh -c "$RebootCmd" @@ -29,6 +31,7 @@ func PathsFromEnv() Paths { Env: envOr("SB_ENV", DefaultEnvPath), Cmdline: envOr("SB_CMDLINE", defaultCmdlinePath()), Hosts: envOr("SB_HOSTS", DefaultHostsPath), + Policy: envOr("SB_CHROMIUM_POLICY", DefaultPolicyPath), ProfileDir: os.Getenv("SB_PROFILE"), HostnameCmd: os.Getenv("SB_HOSTNAME_CMD"), RebootCmd: os.Getenv("SB_REBOOT_CMD"), @@ -132,6 +135,11 @@ func Apply(p Paths, run RunCommand, log *log.Logger) { if envCfg.Hostname != "" { setHostname(envCfg.Hostname, p, run, log) } + if p.Policy != "" { + if err := WritePolicy(p.Policy, cfg.ServerURL); err != nil { + log.Printf("boxd config: cannot write %s: %v", p.Policy, err) + } + } syncCmdline(envCfg.Resolution, envCfg.Rotate, p, run, log) } @@ -456,3 +464,57 @@ func SyncHosts(path, hostname string) error { } return os.Rename(tmp, path) } + +// ServerOrigin is the scheme://host[:port] of a server URL, lowercased +// (https when no scheme is given); "" for no server. +func ServerOrigin(server string) string { + if server == "" { + return "" + } + u := server + if i := strings.Index(u, "://"); i >= 0 { + u = u[i+3:] + } + if i := strings.IndexAny(u, "/?#"); i >= 0 { + u = u[:i] + } + if u == "" { + return "" + } + scheme := "https" + if strings.HasPrefix(server, "http://") { + scheme = "http" + } + return scheme + "://" + strings.ToLower(u) +} + +// WritePolicy writes the kiosk's Chromium managed policy: the server's +// page may reach boxd on loopback. Since Chrome 142 a public site needs +// the Local Network Access permission for that, and in a kiosk nobody can +// answer the prompt, so the page's heartbeats never arrived and the +// watchdog restarted the kiosk every few minutes. Chromium 146 split the +// permission into Local Network and Loopback Network, each with its own +// allow-list; the policy names all of them (Chromium ignores the ones it +// doesn't know). No server, no policy. +func WritePolicy(path, serverURL string) error { + origin := ServerOrigin(serverURL) + if origin == "" { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil + } + list, _ := json.Marshal([]string{origin}) + content := fmt.Sprintf("{\n \"LocalNetworkAccessAllowedForUrls\": %s,\n \"LocalNetworkAllowedForUrls\": %s,\n \"LoopbackNetworkAllowedForUrls\": %s\n}\n", list, list, list) + if old, err := os.ReadFile(path); err == nil && string(old) == content { + return nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp := path + ".tmp" + if err := writeFileSync(tmp, content, 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} diff --git a/boxd/internal/config/config.go b/boxd/internal/config/config.go index b59d735..4e92c28 100644 --- a/boxd/internal/config/config.go +++ b/boxd/internal/config/config.go @@ -20,6 +20,11 @@ const ( DefaultCmdlinePath = "/boot/firmware/cmdline.txt" DefaultHostsPath = "/etc/hosts" + // DefaultPolicyPath is the kiosk's Chromium managed policy (Debian's + // chromium reads /etc/chromium/policies/managed/*.json). /etc is the + // overlay's tmpfs: Apply rewrites it on every boot. + DefaultPolicyPath = "/etc/chromium/policies/managed/syncphony-box.json" + // 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 diff --git a/boxd/internal/config/config_test.go b/boxd/internal/config/config_test.go index b62d549..dfcb1cb 100644 --- a/boxd/internal/config/config_test.go +++ b/boxd/internal/config/config_test.go @@ -1,6 +1,7 @@ package config import ( + "encoding/json" "os" "path/filepath" "strings" @@ -310,6 +311,34 @@ func TestApplyPointsHostsAtTheNewName(t *testing.T) { } } +func TestApplyLetsTheServerReachLoopback(t *testing.T) { + dir := t.TempDir() + txt := filepath.Join(dir, "syncphony.txt") + policy := filepath.Join(dir, "policies", "managed", "syncphony-box.json") + os.WriteFile(txt, []byte("server_url=https://Jams.Example.com/tv\n"), 0o644) + p := Paths{Txt: txt, Env: filepath.Join(dir, "config.env"), Cmdline: filepath.Join(dir, "cmdline.txt"), + Policy: policy, HostnameCmd: "true"} + Apply(p, func(string, ...string) error { return nil }, testLogger()) + + var got map[string][]string + data, err := os.ReadFile(policy) + if err != nil || json.Unmarshal(data, &got) != nil { + t.Fatalf("policy %q: %v", data, err) + } + for _, key := range []string{"LocalNetworkAccessAllowedForUrls", "LocalNetworkAllowedForUrls", "LoopbackNetworkAllowedForUrls"} { + if len(got[key]) != 1 || got[key][0] != "https://jams.example.com" { + t.Fatalf("%s = %v", key, got[key]) + } + } + + // The server removed: so is the policy. + os.WriteFile(txt, []byte("name=Den\n"), 0o644) + Apply(p, func(string, ...string) error { return nil }, testLogger()) + if _, err := os.Stat(policy); !os.IsNotExist(err) { + t.Fatal("policy kept without a server") + } +} + func TestSyncHostsAppendsWhenMissingAndIsIdempotent(t *testing.T) { hosts := filepath.Join(t.TempDir(), "hosts") os.WriteFile(hosts, []byte("127.0.0.1\tlocalhost"), 0o644) diff --git a/boxd/internal/daemon/bridge.go b/boxd/internal/daemon/bridge.go index c51815a..4f4b084 100644 --- a/boxd/internal/daemon/bridge.go +++ b/boxd/internal/daemon/bridge.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/madeofpendletonwool/syncphony-box/boxd/internal/config" "github.com/madeofpendletonwool/syncphony-box/boxd/internal/update" ) @@ -65,25 +66,7 @@ func allowedHost(host string) bool { // serverOrigin is the configured server's origin (scheme://host[:port]) — // the only Origin the bridge accepts (ADR 0016). Empty when unconfigured. func (d *Daemon) serverOrigin() string { - server := d.serverURL() - if server == "" { - return "" - } - u := server - if i := strings.Index(u, "://"); i >= 0 { - u = u[i+3:] - } - if i := strings.IndexAny(u, "/?#"); i >= 0 { - u = u[:i] - } - if u == "" { - return "" - } - scheme := "https" - if strings.HasPrefix(server, "http://") { - scheme = "http" - } - return scheme + "://" + strings.ToLower(u) + return config.ServerOrigin(d.serverURL()) } func (d *Daemon) originAllowed(origin string) bool { diff --git a/boxd/internal/daemon/daemon_test.go b/boxd/internal/daemon/daemon_test.go index d4b2f66..2e0066a 100644 --- a/boxd/internal/daemon/daemon_test.go +++ b/boxd/internal/daemon/daemon_test.go @@ -29,6 +29,7 @@ func newTestDaemon(t *testing.T, txtContent string) (*Daemon, *recorder) { t.Setenv("SB_ENV", env) // A setup save with name= syncs the hosts file: never the host's own. t.Setenv("SB_HOSTS", filepath.Join(dir, "hosts")) + t.Setenv("SB_CHROMIUM_POLICY", filepath.Join(dir, "policy.json")) // No dev machine has /data mounted: the storage hook pins "healthy" so // the screens under test don't trip over the host's mount table. t.Setenv("SB_STORAGE", "ok") diff --git a/boxd/internal/daemon/setup.go b/boxd/internal/daemon/setup.go index 3f7785e..237c29a 100644 --- a/boxd/internal/daemon/setup.go +++ b/boxd/internal/daemon/setup.go @@ -105,6 +105,11 @@ func (d *Daemon) saveServer(serverURL, name, from string) error { if err := config.WriteEnvFile(d.paths.Env, cfg); err != nil { d.log.Printf("boxd: setup: cannot write %s: %v", d.paths.Env, err) } + if d.paths.Policy != "" { + if err := config.WritePolicy(d.paths.Policy, cfg.ServerURL); err != nil { + d.log.Printf("boxd: setup: cannot write %s: %v", d.paths.Policy, err) + } + } if name != "" && cfg.Hostname != "" && cfg.Hostname != hostname() { config.SetHostname(cfg.Hostname, d.paths, d.run, d.log) } diff --git a/tests/syncphony-box-config.bats b/tests/syncphony-box-config.bats index bffa049..b15f3af 100644 --- a/tests/syncphony-box-config.bats +++ b/tests/syncphony-box-config.bats @@ -30,7 +30,7 @@ txt() { } apply() { - SB_TXT="${TXT}" SB_ENV="${ENV_OUT}" SB_CMDLINE="${CMDLINE}" SB_HOSTS="${WORK}/hosts" \ + SB_TXT="${TXT}" SB_ENV="${ENV_OUT}" SB_CMDLINE="${CMDLINE}" SB_HOSTS="${WORK}/hosts" SB_CHROMIUM_POLICY="${WORK}/policy.json" \ SB_HOSTNAME_CMD="printf %s \"\$1\" > '${HOST_OUT}'" \ SB_REBOOT_CMD="touch '${REBOOT_FLAG}'" \ run "${SCRIPT}" apply @@ -256,7 +256,7 @@ cmdline_is() { @test "apply survives an unwritable cmdline: no reboot, still exit 0" { mkdir -p "${WORK}/dircmdline" - SB_TXT="${TXT}" SB_ENV="${ENV_OUT}" SB_CMDLINE="${WORK}/dircmdline" SB_HOSTS="${WORK}/hosts" \ + SB_TXT="${TXT}" SB_ENV="${ENV_OUT}" SB_CMDLINE="${WORK}/dircmdline" SB_HOSTS="${WORK}/hosts" SB_CHROMIUM_POLICY="${WORK}/policy.json" \ SB_HOSTNAME_CMD=true SB_REBOOT_CMD="touch '${REBOOT_FLAG}'" \ run "${SCRIPT}" apply [ "$status" -eq 0 ] From e79d04a043de11b4a776575e87cdbdac01fafa70 Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Sun, 18 Oct 2026 10:57:24 -0500 Subject: [PATCH 2/5] fix: wait for the network to show in a scan before joining it; Wi-Fi power saving off Right after the setup access point went down the scan list was empty, and 'nmcli device wifi rescan' returns before the scan ends, so a join could fail with "couldn't find " for a network in range. Connect now rescans with --rescan yes (which waits) until the SSID shows, up to four times, before connecting. Commas in a name were never the problem: the SSID is one argument to nmcli (now pinned by the test). NetworkManager enables Wi-Fi power saving by default; with it, cards like the mt7921 stop answering incoming traffic while idle (the box answered ARP but not ping or SSH). A conf.d drop-in turns it off. --- boxd/internal/wifi/wifi.go | 36 ++++++++++++++- boxd/internal/wifi/wifi_test.go | 44 +++++++++++++++++++ .../conf.d/50-syncphony-wifi-powersave.conf | 7 +++ 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 packaging/root/etc/NetworkManager/conf.d/50-syncphony-wifi-powersave.conf diff --git a/boxd/internal/wifi/wifi.go b/boxd/internal/wifi/wifi.go index 58f9e32..2fb0e68 100644 --- a/boxd/internal/wifi/wifi.go +++ b/boxd/internal/wifi/wifi.go @@ -44,6 +44,15 @@ const ( // connectWait bounds a join attempt: association, the 4-way // handshake and DHCP on a slow router fit comfortably. connectWait = 45 + + // visibleTries rescans, visiblePause apart, wait for the network + // before a join. + visibleTries = 4 +) + +var ( + visiblePause = 3 * time.Second + sleep = time.Sleep // stubbed in tests ) // Network is one scanned Wi-Fi network, deduplicated by SSID. @@ -233,7 +242,9 @@ func (n *NM) StopAP() error { func (n *NM) Connect(dev, ssid, password string, hidden bool) error { n.nmcli("radio", "wifi", "on") n.nmcli("connection", "delete", "id", ssid) - n.nmcli("device", "wifi", "rescan", "ifname", dev) + if !hidden { + n.waitVisible(dev, ssid) + } args := []string{"--wait", strconv.Itoa(connectWait), "device", "wifi", "connect", ssid} if password != "" { args = append(args, "password", password) @@ -249,6 +260,29 @@ func (n *NM) Connect(dev, ssid, password string, hidden bool) error { return nil } +// waitVisible rescans until ssid is in the scan list, a few times at +// most. Right after the access point goes down the list is empty, and a +// plain "wifi rescan" returns before the scan ends, so the connect that +// followed failed with "No network with SSID" for a network in range +// ("--rescan yes" waits for the scan). A network that never shows still +// gets the connect, whose error says why. +func (n *NM) waitVisible(dev, ssid string) { + for i := 0; i < visibleTries; i++ { + if i > 0 { + sleep(visiblePause) + } + out, err := n.nmcli("-t", "-f", "SSID", "device", "wifi", "list", "ifname", dev, "--rescan", "yes") + if err != nil { + continue + } + for _, line := range strings.Split(out, "\n") { + if f := splitTerse(line); len(f) > 0 && f[0] == ssid { + return + } + } + } +} + // friendlyError turns nmcli's activation errors into something a person // on a couch can act on. func friendlyError(ssid string, err error) error { diff --git a/boxd/internal/wifi/wifi_test.go b/boxd/internal/wifi/wifi_test.go index f7c99c7..f9f8eb3 100644 --- a/boxd/internal/wifi/wifi_test.go +++ b/boxd/internal/wifi/wifi_test.go @@ -5,6 +5,7 @@ import ( "reflect" "strings" "testing" + "time" ) // script is a scripted nmcli: replies by the joined args' prefix, and a @@ -31,6 +32,9 @@ func (s *script) nmcli(args ...string) (string, error) { return "", nil } +// No real waits between rescans in tests. +func init() { sleep = func(time.Duration) {} } + func newScripted(s *script) *NM { return NewWith(s.nmcli, func(string) ([]byte, error) { return []byte("dc:a6:32:12:ab:cd\n"), nil @@ -193,6 +197,46 @@ func TestConnect(t *testing.T) { } } +func TestConnectWaitsForTheNetworkToShow(t *testing.T) { + slept := 0 + prev := sleep + sleep = func(time.Duration) { slept++ } + defer func() { sleep = prev }() + + // In the list (a comma in the name is just a character to nmcli). + ssid := "my cool, big network" + s := &script{replies: map[string]string{"-t -f SSID device wifi list": "Other\n" + ssid + "\n"}} + if err := newScripted(s).Connect("wlan0", ssid, "hunter22", false); err != nil { + t.Fatal(err) + } + scans := 0 + for _, c := range s.calls { + if strings.HasPrefix(c, "-t -f SSID device wifi list ifname wlan0 --rescan yes") { + scans++ + } + } + if scans != 1 || slept != 0 { + t.Errorf("scans = %d, slept = %d; want one scan, no wait", scans, slept) + } + if last := s.calls[len(s.calls)-1]; last != "--wait 45 device wifi connect "+ssid+" password hunter22 ifname wlan0 name "+ssid { + t.Errorf("connect = %q", last) + } + + // Never shows: a few scans, then the connect anyway (its error says why). + s = &script{replies: map[string]string{"-t -f SSID device wifi list": "Other\n"}} + slept = 0 + newScripted(s).Connect("wlan0", "Home", "hunter22", false) + scans = 0 + for _, c := range s.calls { + if strings.HasPrefix(c, "-t -f SSID device wifi list") { + scans++ + } + } + if scans != visibleTries || slept != visibleTries-1 || !strings.HasPrefix(s.calls[len(s.calls)-1], "--wait 45 device wifi connect Home") { + t.Errorf("scans = %d, slept = %d, calls %q", scans, slept, s.calls) + } +} + func TestConnectFailure(t *testing.T) { s := &script{fail: map[string]error{ "--wait": errors.New("Error: Connection activation failed: Secrets were required, but not provided."), diff --git a/packaging/root/etc/NetworkManager/conf.d/50-syncphony-wifi-powersave.conf b/packaging/root/etc/NetworkManager/conf.d/50-syncphony-wifi-powersave.conf new file mode 100644 index 0000000..bd208c9 --- /dev/null +++ b/packaging/root/etc/NetworkManager/conf.d/50-syncphony-wifi-powersave.conf @@ -0,0 +1,7 @@ +# Wi-Fi power saving off on every connection. A box sits idle on the LAN +# for hours, and with power saving on, some cards (MediaTek mt7921 among +# them) stop answering incoming traffic until the box itself sends +# something: no ping, no SSH, no phone setup form. A mains-powered box +# saves nothing worth that. 2 = disable (NM_SETTING_WIRELESS_POWERSAVE_DISABLE). +[connection] +wifi.powersave = 2 From 40080a5b27a43d5f0615b78b21a23891bd87750b Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Sun, 18 Oct 2026 10:59:17 -0500 Subject: [PATCH 3/5] fix: give the server check after a Wi-Fi join time for the clock; keep the last known time on /data A fresh box boots months behind (no RTC, and timesyncd's clock file lived on the overlay's tmpfs), so every https certificate looked invalid until NTP answered. The check after a join gave up after 30 s and didn't save the server, so it had to be set a second time from a phone. - the join restarts timesyncd (it had been backing off while offline) and checks the server for two minutes, logging why the last check failed - data-persist saves timesyncd's clock file (its mtime) to /data and restores it before timesyncd starts, so a box starts at its last known time instead of the image's build date --- boxd/internal/daemon/daemon.go | 13 ++++++++-- boxd/internal/daemon/hotspot.go | 14 ++++++++-- docs/persistent-paths.md | 1 + .../root/usr/lib/syncphony-box/data-persist | 26 ++++++++++++++++--- .../system/syncphony-data-prepare.service | 5 ++-- tests/syncphony-box-data.bats | 17 ++++++++++-- 6 files changed, 65 insertions(+), 11 deletions(-) diff --git a/boxd/internal/daemon/daemon.go b/boxd/internal/daemon/daemon.go index 504c679..3827783 100644 --- a/boxd/internal/daemon/daemon.go +++ b/boxd/internal/daemon/daemon.go @@ -8,6 +8,7 @@ package daemon import ( "crypto/tls" + "fmt" "log" "net" "net/http" @@ -353,12 +354,20 @@ func (d *Daemon) serverReachable() bool { } func checkHealthz(server string) bool { + return healthz(server) == nil +} + +// healthz is checkHealthz with the reason, for the log. +func healthz(server string) error { resp, err := healthzClient.Get(strings.TrimSuffix(server, "/") + "/healthz") if err != nil { - return false + return err } defer resp.Body.Close() - return resp.StatusCode >= 200 && resp.StatusCode < 300 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("HTTP %d", resp.StatusCode) + } + return nil } // flushDisk makes the pairing state durable: a sync() burst after the diff --git a/boxd/internal/daemon/hotspot.go b/boxd/internal/daemon/hotspot.go index a2c001e..8de14fa 100644 --- a/boxd/internal/daemon/hotspot.go +++ b/boxd/internal/daemon/hotspot.go @@ -55,7 +55,7 @@ var ( joinDelay = 1 * time.Second // let the phone get its answer before the AP goes away recheckWait = 45 * time.Second // how long NetworkManager gets to find a saved network recheckPoll = 3 * time.Second - healthzTries = 6 // after a join: DHCP, DNS and the clock need a moment + healthzTries = 24 // two minutes after a join for DHCP, DNS and the clock (see joinWiFi) healthzRetry = 5 * time.Second netWatchEvery = 5 * time.Second ) @@ -401,17 +401,27 @@ func (d *Daemon) joinWiFi(ssid, password string, hidden bool, serverURL, name st if serverURL == "" || d.configured() { return } + // The clock is usually what's slowest: a box without an RTC boots + // months behind, and every https certificate looks invalid until NTP + // corrects it, so the server "can't be reached" and isn't saved. + // timesyncd has been failing while the box was offline and retries + // on its own schedule; a restart asks NTP now, on the new network. + d.run("systemctl", "try-restart", "systemd-timesyncd.service") + var lastErr error for i := 0; i < healthzTries; i++ { if i > 0 { time.Sleep(healthzRetry) } - if checkHealthz(serverURL) { + if lastErr = healthz(serverURL); lastErr == nil { if err := d.saveServer(serverURL, name, "the Wi-Fi setup"); err == nil { return } break } } + if lastErr != nil { + d.log.Printf("boxd: wifi: joined %s, but %s didn't answer: %v", ssid, serverURL, lastErr) + } d.mu.Lock() d.net.notice = fmt.Sprintf("Joined %s, but couldn't reach %s. Set the server from a phone on %s.", ssid, serverURL, ssid) d.mu.Unlock() diff --git a/docs/persistent-paths.md b/docs/persistent-paths.md index 6a265a7..c3da34f 100644 --- a/docs/persistent-paths.md +++ b/docs/persistent-paths.md @@ -18,6 +18,7 @@ to or moved off `/data`, this list changes in the same commit. | account files | `/data/system/identity/{passwd,shadow,group,gshadow,subuid,subgid}` | `data-persist save` → restored by `data-persist restore` | the Imager-created user and password; without this they would vanish with the overlay | | SSH host keys | `/data/system/ssh/` | `ssh-keygen -A` at restore, saved back by `save` | `/etc/ssh/ssh_host_*` are symlinks here; keeps `known_hosts` stable | | network connections | `/data/system/network/` | `data-persist save` → restored by `restore`; boxd runs a save right after the setup hotspot joins a network | NetworkManager `system-connections` (Wi-Fi credentials). The hotspot's own access point is in-memory only and never lands here | +| last known time | `/data/system/timesync-clock` | `data-persist save` → restored by `restore`, before timesyncd starts | timesyncd's clock file; only its mtime matters. timesyncd moves the clock forward to it at boot, so a box without an RTC doesn't start at the image's build date | Not on `/data`, still persistent: diff --git a/packaging/root/usr/lib/syncphony-box/data-persist b/packaging/root/usr/lib/syncphony-box/data-persist index 28e64fb..71de6a7 100755 --- a/packaging/root/usr/lib/syncphony-box/data-persist +++ b/packaging/root/usr/lib/syncphony-box/data-persist @@ -9,8 +9,8 @@ # docs/persistent-paths.md). This script owns the few that cannot be a # symlink because something reads them too early or another tool rewrites # them atomically: the account files, the SSH host keys, the -# NetworkManager connections, and the machine-id (seeded from /data by -# the initramfs overlay script, saved back here). +# NetworkManager connections, timesyncd's clock file, and the machine-id +# (seeded from /data by the initramfs overlay script, saved back here). # # restore runs early on every boot (syncphony-data-prepare.service, # before cloud-init/sshd/NetworkManager): put the saved state @@ -21,16 +21,26 @@ # (worst case: the last 15 minutes of account edits are lost). # # Dev/test hooks (same spirit as boxd's SB_* hooks): SB_DATA_DIR moves -# /data, SB_ETC_DIR moves /etc, SB_SKIP_SSH=1 skips ssh-keygen. +# /data, SB_ETC_DIR moves /etc, SB_VAR_DIR moves /var, SB_SKIP_SSH=1 skips +# ssh-keygen. set -eu DATA="${SB_DATA_DIR:-/data}" ETC="${SB_ETC_DIR:-/etc}" +VAR="${SB_VAR_DIR:-/var}" IDENT="${DATA}/system/identity" SSHDIR="${DATA}/system/ssh" NETDIR="${DATA}/system/network" +# timesyncd's clock file: its mtime is the last time NTP was right, and +# timesyncd moves the clock forward to it at start. On the overlay's +# tmpfs it vanished at every power-off, so every boot started at the +# image's build date: https looked broken (certificates "not yet valid") +# until NTP answered, and the clock then jumped months. +CLOCK="${VAR}/lib/systemd/timesync/clock" +SAVED_CLOCK="${DATA}/system/timesync-clock" + IDENTITY_FILES="passwd shadow group gshadow subuid subgid" SSH_KEY_TYPES="rsa ecdsa ed25519" @@ -124,6 +134,12 @@ restore() { ssh-keygen -A >/dev/null fi + # cp -p keeps the mtime, which is the whole content. + if [ -f "${SAVED_CLOCK}" ]; then + mkdir -p "$(dirname "${CLOCK}")" + cp -p "${SAVED_CLOCK}" "${CLOCK}" 2>/dev/null || true + fi + # Wi-Fi (and any other) connections the Imager settings created. if [ -d "${NETDIR}" ] && [ -d "${ETC}/NetworkManager" ]; then mkdir -p "${ETC}/NetworkManager/system-connections" @@ -154,6 +170,10 @@ save() { cp -a "${NM}/." "${NETDIR}/" fi + if [ -f "${CLOCK}" ]; then + cp -p "${CLOCK}" "${SAVED_CLOCK}" 2>/dev/null || true + fi + # The identity the initramfs seeds from on the next boot. if [ -f "${ETC}/machine-id" ]; then cp -p "${ETC}/machine-id" "${DATA}/system/machine-id" diff --git a/packaging/root/usr/lib/systemd/system/syncphony-data-prepare.service b/packaging/root/usr/lib/systemd/system/syncphony-data-prepare.service index 8ab883f..078aa07 100644 --- a/packaging/root/usr/lib/systemd/system/syncphony-data-prepare.service +++ b/packaging/root/usr/lib/systemd/system/syncphony-data-prepare.service @@ -4,10 +4,11 @@ Documentation=file:///usr/lib/syncphony-box/syncphony.txt # /data is mounted by the initramfs overlay script (with the fstab entry # as belt and braces), so this runs ahead of everything that reads the # state: cloud-init (which would create a fresh user), sshd (host keys), -# NetworkManager (Wi-Fi) and the kiosk (the Chromium profile). +# NetworkManager (Wi-Fi), timesyncd (the last known time) and the kiosk +# (the Chromium profile). ConditionPathIsMountPoint=/data After=data.mount -Before=cloud-init-local.service cloud-init.service ssh.service NetworkManager.service boxd.service syncphony-kiosk.service +Before=cloud-init-local.service cloud-init.service ssh.service NetworkManager.service systemd-timesyncd.service boxd.service syncphony-kiosk.service [Service] Type=oneshot diff --git a/tests/syncphony-box-data.bats b/tests/syncphony-box-data.bats index 39f1418..45e69de 100644 --- a/tests/syncphony-box-data.bats +++ b/tests/syncphony-box-data.bats @@ -11,8 +11,9 @@ setup() { WORK="$(mktemp -d)" DATA="${WORK}/data" ETC="${WORK}/etc" - mkdir -p "${DATA}" "${ETC}/ssh" - export SB_DATA_DIR="${DATA}" SB_ETC_DIR="${ETC}" + VAR="${WORK}/var" + mkdir -p "${DATA}" "${ETC}/ssh" "${VAR}" + export SB_DATA_DIR="${DATA}" SB_ETC_DIR="${ETC}" SB_VAR_DIR="${VAR}" } teardown() { @@ -109,6 +110,18 @@ fake_identity() { [ -L "${ETC}/ssh/ssh_host_ed25519_key" ] } +@test "timesyncd's clock file keeps its mtime through save and restore" { + mkdir -p "${VAR}/lib/systemd/timesync" + touch -d '2026-10-17 17:30:00' "${VAR}/lib/systemd/timesync/clock" + run "${SCRIPT}" save + [ "$status" -eq 0 ] + + rm -rf "${VAR}/lib" + run "${SCRIPT}" restore + [ "$status" -eq 0 ] + [ "$(stat -c %Y "${VAR}/lib/systemd/timesync/clock")" = "$(date -d '2026-10-17 17:30:00' +%s)" ] +} + @test "save persists the machine-id for the initramfs seed" { printf '0123456789abcdef0123456789abcdef\n' > "${ETC}/machine-id" run "${SCRIPT}" save From d7aee4e8be871f55717f2d645308c02c3bfe8b61 Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Sun, 18 Oct 2026 11:01:28 -0500 Subject: [PATCH 4/5] fix: on a PC, play to the HDMI/DisplayPort output the TV is plugged into audio-setup took the first HDMI/DisplayPort sink. A PC has one per port (a Framework laptop: one per USB-C port), so the TV on another port got silence while the page played. And when the card sat on its analog profile (WirePlumber's preference on a laptop), the TV's output wasn't a sink at all. audio=hdmi on a PC now takes the sink whose port reports a display, and if none does, switches the card to the available HDMI profile (output only first; never re-switched once active, so the hotplug monitor can't loop). The TV-off fallback (first/second sink) and Pi behavior are unchanged; a Pi's cards are never switched. --- docs/adr/0013-x86-iso.md | 13 ++- .../root/usr/lib/syncphony-box/audio-setup | 71 ++++++++++++++- tests/syncphony-box-audio.bats | 87 ++++++++++++++++++- 3 files changed, 165 insertions(+), 6 deletions(-) diff --git a/docs/adr/0013-x86-iso.md b/docs/adr/0013-x86-iso.md index f74b731..fcec46c 100644 --- a/docs/adr/0013-x86-iso.md +++ b/docs/adr/0013-x86-iso.md @@ -118,9 +118,16 @@ same ones as before. 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. + sink exists at all, `hdmi` is the HDMI/DisplayPort output whose port + reports a display plugged in. A PC has one per port (a laptop: one + per USB-C port), so "the first" was often the wrong one and the TV + stayed silent. When the card's active profile has no such output + (WirePlumber prefers a laptop's analog profile), the card is switched + to the available HDMI profile, output-only first, and never again + once it's on it. With no display reporting (a TV that's off), `hdmi` + and `hdmi2` fall back to the first and second HDMI/DisplayPort sinks. + `analog` is the built-in analog output. A Pi's cards are never + switched, and 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 diff --git a/packaging/root/usr/lib/syncphony-box/audio-setup b/packaging/root/usr/lib/syncphony-box/audio-setup index 437b388..ab6e649 100755 --- a/packaging/root/usr/lib/syncphony-box/audio-setup +++ b/packaging/root/usr/lib/syncphony-box/audio-setup @@ -92,6 +92,20 @@ sink_block() { ' } +# Whether this is a Pi (its vc4/bcm2835 audio devices): the PC-only +# HDMI handling below never touches a Pi's cards. +on_pi() { + { "$PACTL" list sinks; "$PACTL" list cards; } 2>/dev/null | + grep -qiE 'bcm2835|vc4' +} + +# Whether a sink's port reports a display plugged in (PC HDMI/DP). An +# empty name, or a Pi sink, is "not known to be plugged". +sink_plugged() { + [ -n "$1" ] || return 1 + sink_block "$1" | grep -q ', available)' +} + # Print the pulse sink name for audio=$1, or nothing. Markers are matched # 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 @@ -151,11 +165,24 @@ pick_sink() { fi done - # Not a Pi: the nth built-in HDMI/DisplayPort sink (n = 1 for hdmi, - # 2 for hdmi2). + # Not a Pi. case $data in *bcm2835*|*vc4*) return 0 ;; esac + # A PC has one HDMI/DisplayPort output per port (a laptop: one per + # USB-C port), and nothing says which is "first": for hdmi, take the + # one with a display plugged in (its port reports available). + if [ "$audio" = hdmi ]; then + plugged=$(printf '%s\n' "$data" | awk -F '\t' ' + $2 != "usb" && index($3, "hdmi") && index($3, ", available)") { print $1; exit } + ') + if [ -n "$plugged" ]; then + printf '%s\n' "$plugged" + return 0 + fi + fi + # Otherwise the nth built-in HDMI/DisplayPort sink (n = 1 for hdmi, + # 2 for hdmi2): the TV may be off, so its port says nothing. n=1 [ "$audio" = hdmi2 ] && n=2 printf '%s\n' "$data" | awk -F '\t' -v n="$n" ' @@ -164,6 +191,36 @@ pick_sink() { return 0 } +# A PC's card shows only the outputs of its active profile, and +# WirePlumber prefers the analog one (a laptop's speakers), so the HDMI +# output with the TV on it may not be a sink at all. Switch the card to +# an available HDMI profile ("available: yes" = a display is on that +# port), output-only first. Pi cards don't have these profiles. Prints +# the profile it switched to, or nothing. +hdmi_profile() { + "$PACTL" list cards 2>/dev/null | awk ' + function pick() { + # The card already on it: nothing to do (and nothing to loop on). + p = (solo != "") ? solo : any + if (out == "" && p != "" && p != active) out = card " " p + solo = ""; any = ""; active = "" + } + /^Card #/ { if (card != "") pick(); card = ""; inprof = 0 } + /^\tName: / { card = $2 } + /^\tActive Profile: / { active = $3 } + /^\tProfiles:/ { inprof = 1; next } + /^\t[^\t]/ { inprof = 0 } + inprof && /^\t\t/ && /available: yes\)/ { + prof = $1 + sub(/:$/, "", prof) + if (prof !~ /hdmi/) next + if (prof !~ /\+/ && solo == "") solo = prof + if (any == "") any = prof + } + END { if (card != "") pick(); if (out != "") print out } + ' +} + apply_once() { audio=hdmi if [ -r "$CONFIG_ENV" ]; then @@ -183,6 +240,16 @@ apply_once() { fi sink=$(pick_sink "$audio") + if [ "$audio" = hdmi ] && ! on_pi && ! sink_plugged "$sink"; then + # shellcheck disable=SC2046 # two words: card, profile + set -- $(hdmi_profile) + if [ $# -eq 2 ]; then + log "switching ${1} to ${2} (a display is on that port)" + "$PACTL" set-card-profile "$1" "$2" 2>/dev/null || true + "$SLEEP" 1 + sink=$(pick_sink "$audio") + fi + fi i=0 while [ -z "$sink" ] && [ "$i" -lt "$SINK_WAIT_SECS" ]; do "$SLEEP" 1 diff --git a/tests/syncphony-box-audio.bats b/tests/syncphony-box-audio.bats index df110b8..106b985 100644 --- a/tests/syncphony-box-audio.bats +++ b/tests/syncphony-box-audio.bats @@ -32,7 +32,11 @@ case "\$1" in exit 0 ;; list) - cat "${FIXTURE}" + if [ "\$2" = cards ]; then + [ -f "${WORK}/cards.txt" ] && cat "${WORK}/cards.txt" + else + cat "${FIXTURE}" + fi ;; get-default-sink) if [ -f "${WORK}/default_sink" ]; then cat "${WORK}/default_sink"; else echo "alsa_output.auto_null"; fi @@ -494,3 +498,84 @@ PC_HDMI2=alsa_output.pci-0000_00_1f.3.hdmi-stereo-extra1 [ "$status" -eq 0 ] ! called "set-default-sink ${HDMI1}" } + +# A laptop (a Framework: one HDMI/DisplayPort output per USB-C port), the +# TV on the third port. The ports say which output has a display. +sinks_pc_tv_on_port3() { + sinks_pc + cat >> "${FIXTURE}" <<'EOF2' + +Sink #53 + State: SUSPENDED + Name: alsa_output.pci-0000_00_1f.3.hdmi-stereo-extra2 + Description: Built-in Audio Digital Stereo (HDMI 3) + Driver: PipeWire + Mute: no + Volume: front-left: 65536 / 100% / 0.00 dB, front-right: 65536 / 100% / 0.00 dB + Ports: + hdmi-output-2: HDMI / DisplayPort 3 (type: HDMI, priority: 5700, availability group: Legacy 5, available) + Active Port: hdmi-output-2 + Properties: + alsa.card_name = "HDA Intel PCH" + alsa.name = "HDMI 2" + device.bus = "pci" + device.description = "Built-in Audio Digital Stereo (HDMI 3)" +EOF2 + # The first two ports: nothing plugged. + sed -i 's|^\tDescription: Built-in Audio Digital Stereo (HDMI)$|&\n\tPorts:\n\t\thdmi-output-0: HDMI / DisplayPort (type: HDMI, priority: 5900, availability group: Legacy 3, not available)|' "${FIXTURE}" +} + +# The same laptop with the card on its analog profile: no HDMI sinks at +# all, but the card knows the TV is on the third port. +cards_pc_analog_active() { + cat > "${WORK}/cards.txt" <<'EOF2' +Card #45 + Name: alsa_card.pci-0000_00_1f.3 + Driver: alsa + Profiles: + off: Off (sinks: 0, sources: 0, priority: 0, available: yes) + output:hdmi-stereo: Digital Stereo (HDMI) Output (sinks: 1, sources: 0, priority: 5900, available: no) + output:hdmi-stereo-extra2+input:analog-stereo: Digital Stereo (HDMI 3) Output + Analog Stereo Input (sinks: 1, sources: 1, priority: 5765, available: yes) + output:hdmi-stereo-extra2: Digital Stereo (HDMI 3) Output (sinks: 1, sources: 0, priority: 5700, available: yes) + output:analog-stereo+input:analog-stereo: Analog Stereo Duplex (sinks: 1, sources: 1, priority: 6565, available: yes) + Active Profile: output:analog-stereo+input:analog-stereo +EOF2 +} + +@test "hdmi on a PC picks the output with the TV plugged in, not the first" { + sinks_pc_tv_on_port3 + set_audio hdmi + apply + [ "$status" -eq 0 ] + called "set-default-sink alsa_output.pci-0000_00_1f.3.hdmi-stereo-extra2" +} + +@test "hdmi on a PC switches the card to the HDMI profile the TV is on" { + sinks_pc + sed -i '/^Sink #51/,$d' "${FIXTURE}" # analog only: the card is on its analog profile + cards_pc_analog_active + set_audio hdmi + apply + [ "$status" -eq 0 ] + called "set-card-profile alsa_card.pci-0000_00_1f.3 output:hdmi-stereo-extra2" +} + +@test "an HDMI profile already active is not switched again (no monitor loop)" { + sinks_pc + cards_pc_analog_active + sed -i 's|^\tActive Profile: .*|\tActive Profile: output:hdmi-stereo-extra2|' "${WORK}/cards.txt" + set_audio hdmi + apply + [ "$status" -eq 0 ] + ! grep -q '^set-card-profile' "${CALLS}" +} + +@test "a Pi's cards are never switched" { + sinks_pi4 + cards_pc_analog_active + set_audio hdmi + apply + [ "$status" -eq 0 ] + ! grep -q '^set-card-profile' "${CALLS}" + called "set-default-sink ${HDMI0}" +} From a89c24cd93140f44156073456131d573ae730220 Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Sun, 18 Oct 2026 11:02:19 -0500 Subject: [PATCH 5/5] docs: changelog for the first-run field fixes --- CHANGELOG.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5f74e2..aa6187b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,25 @@ Add to `## [Unreleased]` as you go; rename it when you tag. - A box with no clock battery could start the nightly update the moment it first got online (the clock correcting itself made the timer fire). Timer runs outside the 04:00 hour are skipped. +- The kiosk restarted every few minutes on a server reached over the + internet. Chromium 142+ asks permission before a public site may call + the box's helper on `127.0.0.1`, nobody can answer that in a kiosk, so + the page's heartbeats never arrived and the watchdog took it for stuck. + The box now sets a Chromium policy allowing its configured server. +- On a PC, audio went to the first HDMI/DisplayPort output rather than + the one the TV is on (a laptop has one per USB-C port), so the TV was + silent. It now picks the output with a display plugged in, switching + the sound card's profile to it when needed. +- After joining Wi-Fi from the setup hotspot, the server was often not + saved ("couldn't reach"), because the box's clock was still months + behind and https failed. The box now asks NTP right after joining and + checks the server for two minutes, and it keeps its last known time + on `/data`, so it no longer boots at the image's build date. +- Joining a network right after the setup hotspot closed could fail with + "couldn't find " because the scan hadn't finished; the join + now waits for the network to show up. +- Wi-Fi power saving is off: some cards (MediaTek mt7921) stopped + answering ping and SSH while the box was idle. ### Changed - The image is smaller: documentation, man pages and translations are