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 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.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/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/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/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/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/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/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/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 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/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-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}" +} 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 ] 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