From cf776c8bdac13d72b074f139cf980ff1c723c6b8 Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Sat, 17 Oct 2026 12:36:55 -0500 Subject: [PATCH 1/4] fix: data-persist stopped at the symlinked SSH keys, so Wi-Fi never persisted /etc/ssh/ssh_host_* are symlinks into /data (stage 20). Both restore and save cp'd each key onto its own target, cp refused ("are the same file"), and set -e ended the script before the NetworkManager step. A Wi-Fi joined from the setup hotspot lived only in RAM, and every boot after the first logged syncphony-data-prepare/-save as failed. copy_key skips a copy when both paths are already the same file. The new bats test uses the box's real layout (symlinked keys); it fails without the fix. --- .../root/usr/lib/syncphony-box/data-persist | 19 +++++++++++++------ tests/syncphony-box-data.bats | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/packaging/root/usr/lib/syncphony-box/data-persist b/packaging/root/usr/lib/syncphony-box/data-persist index 6bea28b..28e64fb 100755 --- a/packaging/root/usr/lib/syncphony-box/data-persist +++ b/packaging/root/usr/lib/syncphony-box/data-persist @@ -61,6 +61,17 @@ ensure_layout() { chown kiosk:kiosk "${DATA}/chromium" 2>/dev/null || true } +# copy_key copies one SSH host key unless both paths are already the +# same file. On the box they are: /etc/ssh/ssh_host_* are symlinks into +# /data (stage 20), and cp refuses to copy a file onto itself — which, +# under set -e, ended restore and save right here, before the +# NetworkManager connections, so a joined Wi-Fi never survived a reboot. +copy_key() { + [ -f "$1" ] || return 0 + [ "$1" -ef "$2" ] && return 0 + cp -p "$1" "$2" +} + # restore_identity merges one saved account file over the image's: every # saved entry wins (the Imager user, its password, its groups), and an # account the image has but the save doesn't is kept. That second half is @@ -104,9 +115,7 @@ restore() { for t in ${SSH_KEY_TYPES}; do for suffix in "" ".pub"; do key="ssh_host_${t}_key${suffix}" - if [ -f "${SSHDIR}/${key}" ]; then - cp -p "${SSHDIR}/${key}" "${ETC}/ssh/${key}" - fi + copy_key "${SSHDIR}/${key}" "${ETC}/ssh/${key}" done done # ssh-keygen -A always operates on the real /etc/ssh, so it only runs @@ -136,9 +145,7 @@ save() { for t in ${SSH_KEY_TYPES}; do for suffix in "" ".pub"; do key="ssh_host_${t}_key${suffix}" - if [ -f "${ETC}/ssh/${key}" ]; then - cp -p "${ETC}/ssh/${key}" "${SSHDIR}/${key}" - fi + copy_key "${ETC}/ssh/${key}" "${SSHDIR}/${key}" done done diff --git a/tests/syncphony-box-data.bats b/tests/syncphony-box-data.bats index 99c3194..39f1418 100644 --- a/tests/syncphony-box-data.bats +++ b/tests/syncphony-box-data.bats @@ -91,6 +91,24 @@ fake_identity() { grep -q 'PRIVATE-KEY-DATA' "${ETC}/ssh/ssh_host_ed25519_key" } +@test "symlinked SSH keys (the box's layout) don't stop the Wi-Fi round trip" { + # On the box /etc/ssh/ssh_host_* point into /data; cp onto the same + # file failed and ended both verbs before the NetworkManager step. + mkdir -p "${DATA}/system/ssh" "${ETC}/NetworkManager/system-connections" + printf 'PRIVATE-KEY-DATA' > "${DATA}/system/ssh/ssh_host_ed25519_key" + ln -s "${DATA}/system/ssh/ssh_host_ed25519_key" "${ETC}/ssh/ssh_host_ed25519_key" + printf '[wifi]\nssid=Home\n' > "${ETC}/NetworkManager/system-connections/Home.nmconnection" + run "${SCRIPT}" save + [ "$status" -eq 0 ] + cmp "${ETC}/NetworkManager/system-connections/Home.nmconnection" "${DATA}/system/network/Home.nmconnection" + + rm "${ETC}/NetworkManager/system-connections/Home.nmconnection" + run "${SCRIPT}" restore + [ "$status" -eq 0 ] + grep -q 'ssid=Home' "${ETC}/NetworkManager/system-connections/Home.nmconnection" + [ -L "${ETC}/ssh/ssh_host_ed25519_key" ] +} + @test "save persists the machine-id for the initramfs seed" { printf '0123456789abcdef0123456789abcdef\n' > "${ETC}/machine-id" run "${SCRIPT}" save From bec01e5d5f18b604c68994e61280c5ef2fdc4976 Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Sat, 17 Oct 2026 12:38:03 -0500 Subject: [PATCH 2/4] fix: keep /etc/hosts' 127.0.1.1 line on the box's name name= renamed the box (hostnamectl) but left the image's old name in /etc/hosts, so the box couldn't resolve itself: sudo printed "unable to resolve host" on every call. config.SetHostname now syncs that line, on the boot path and on boxd's setup save (which called hostnamectl itself before). SB_HOSTS points it elsewhere in tests. --- boxd/internal/config/apply.go | 56 +++++++++++++++++++++++++++-- boxd/internal/config/config.go | 1 + boxd/internal/config/config_test.go | 31 ++++++++++++++++ boxd/internal/daemon/daemon_test.go | 2 ++ boxd/internal/daemon/setup.go | 5 +-- tests/syncphony-box-config.bats | 4 +-- 6 files changed, 91 insertions(+), 8 deletions(-) diff --git a/boxd/internal/config/apply.go b/boxd/internal/config/apply.go index 3e96b95..6d64de3 100644 --- a/boxd/internal/config/apply.go +++ b/boxd/internal/config/apply.go @@ -9,14 +9,15 @@ import ( "strings" ) -// Paths resolves the SB_* test hooks: SB_TXT, SB_ENV, SB_CMDLINE 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 +// 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 { Txt string Env string Cmdline string + Hosts string // empty = leave /etc/hosts alone (tests) ProfileDir string // empty = DefaultProfileDir HostnameCmd string // run as: sh -c "$HostnameCmd" boxd RebootCmd string // run as: sh -c "$RebootCmd" @@ -27,6 +28,7 @@ func PathsFromEnv() Paths { Txt: envOr("SB_TXT", DefaultTxtPath), Env: envOr("SB_ENV", DefaultEnvPath), Cmdline: envOr("SB_CMDLINE", defaultCmdlinePath()), + Hosts: envOr("SB_HOSTS", DefaultHostsPath), ProfileDir: os.Getenv("SB_PROFILE"), HostnameCmd: os.Getenv("SB_HOSTNAME_CMD"), RebootCmd: os.Getenv("SB_REBOOT_CMD"), @@ -193,6 +195,14 @@ func readEnvFile(path string) Config { return cfg } +// SetHostname applies a new hostname: the running name, /etc/hostname +// and the 127.0.1.1 line in /etc/hosts. Without that line the box can't +// resolve its own name (sudo complains on every call). Both the boot path +// and boxd's setup save use it. +func SetHostname(hostname string, p Paths, run RunCommand, log *log.Logger) { + setHostname(hostname, p, run, log) +} + func setHostname(hostname string, p Paths, run RunCommand, log *log.Logger) { if p.HostnameCmd != "" { if err := runSh(p.HostnameCmd, "boxd", hostname, run); err != nil { @@ -206,6 +216,11 @@ func setHostname(hostname string, p Paths, run RunCommand, log *log.Logger) { } else if err := run("hostname", hostname); err != nil { log.Printf("boxd config: cannot set the running hostname: %v", err) } + if p.Hosts != "" { + if err := SyncHosts(p.Hosts, hostname); err != nil { + log.Printf("boxd config: cannot update %s: %v", p.Hosts, err) + } + } // If avahi managed to start first, re-announce under the new name. run("systemctl", "try-restart", "avahi-daemon.service") } @@ -404,3 +419,40 @@ func urlEscape(s string) string { } return b.String() } + +// SyncHosts points the Debian 127.0.1.1 line of a hosts file at +// hostname: it replaces the existing line (the image's old name) or +// appends one. Everything else in the file is kept as it was. +func SyncHosts(path, hostname string) error { + data, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return err + } + want := "127.0.1.1\t" + hostname + var out []string + found := false + for _, line := range strings.Split(strings.TrimRight(string(data), "\n"), "\n") { + if f := strings.Fields(line); len(f) > 0 && f[0] == "127.0.1.1" { + if !found { + out = append(out, want) + found = true + } + continue + } + if line != "" || len(out) > 0 { + out = append(out, line) + } + } + if !found { + out = append(out, want) + } + content := strings.Join(out, "\n") + "\n" + if content == string(data) { + return nil + } + 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 0df850b..b59d735 100644 --- a/boxd/internal/config/config.go +++ b/boxd/internal/config/config.go @@ -18,6 +18,7 @@ import ( const ( DefaultTxtPath = "/boot/firmware/syncphony.txt" DefaultCmdlinePath = "/boot/firmware/cmdline.txt" + DefaultHostsPath = "/etc/hosts" // DefaultGrubCmdlinePath is the kernel command line on an x86 box // (MAD-846, ADR 0013). /boot/firmware is the EFI system partition diff --git a/boxd/internal/config/config_test.go b/boxd/internal/config/config_test.go index a1ba1ec..b62d549 100644 --- a/boxd/internal/config/config_test.go +++ b/boxd/internal/config/config_test.go @@ -293,6 +293,37 @@ func TestApplySetsHostnameFromName(t *testing.T) { } } +func TestApplyPointsHostsAtTheNewName(t *testing.T) { + dir := t.TempDir() + txt := filepath.Join(dir, "syncphony.txt") + hosts := filepath.Join(dir, "hosts") + os.WriteFile(txt, []byte("name=House 1\n"), 0o644) + os.WriteFile(hosts, []byte("127.0.0.1\tlocalhost\n::1\t\tlocalhost ip6-localhost\n\n127.0.1.1\t\tsyncphony-box\n"), 0o644) + + p := Paths{Txt: txt, Env: filepath.Join(dir, "config.env"), Cmdline: filepath.Join(dir, "cmdline.txt"), + Hosts: hosts, HostnameCmd: "true"} + Apply(p, func(string, ...string) error { return nil }, testLogger()) + got, _ := os.ReadFile(hosts) + want := "127.0.0.1\tlocalhost\n::1\t\tlocalhost ip6-localhost\n\n127.0.1.1\thouse-1\n" + if string(got) != want { + t.Fatalf("hosts = %q, want %q", got, want) + } +} + +func TestSyncHostsAppendsWhenMissingAndIsIdempotent(t *testing.T) { + hosts := filepath.Join(t.TempDir(), "hosts") + os.WriteFile(hosts, []byte("127.0.0.1\tlocalhost"), 0o644) + for i := 0; i < 2; i++ { + if err := SyncHosts(hosts, "den-tv"); err != nil { + t.Fatal(err) + } + } + got, _ := os.ReadFile(hosts) + if string(got) != "127.0.0.1\tlocalhost\n127.0.1.1\tden-tv\n" { + t.Fatalf("hosts = %q", got) + } +} + func TestSetKeysPreservesCommentsAndEndings(t *testing.T) { dir := t.TempDir() txt := filepath.Join(dir, "syncphony.txt") diff --git a/boxd/internal/daemon/daemon_test.go b/boxd/internal/daemon/daemon_test.go index 9f83192..d4b2f66 100644 --- a/boxd/internal/daemon/daemon_test.go +++ b/boxd/internal/daemon/daemon_test.go @@ -27,6 +27,8 @@ func newTestDaemon(t *testing.T, txtContent string) (*Daemon, *recorder) { rec := &recorder{} t.Setenv("SB_TXT", txt) 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")) // 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 5a39db1..3f7785e 100644 --- a/boxd/internal/daemon/setup.go +++ b/boxd/internal/daemon/setup.go @@ -106,10 +106,7 @@ func (d *Daemon) saveServer(serverURL, name, from string) error { d.log.Printf("boxd: setup: cannot write %s: %v", d.paths.Env, err) } if name != "" && cfg.Hostname != "" && cfg.Hostname != hostname() { - if err := d.run("hostnamectl", "set-hostname", cfg.Hostname); err != nil { - d.log.Printf("boxd: setup: hostname: %v", err) - } - d.run("systemctl", "try-restart", "avahi-daemon.service") + config.SetHostname(cfg.Hostname, d.paths, d.run, d.log) } d.closeSetupWindow() d.restartKiosk("setup saved; entering the server through the start page", false) diff --git a/tests/syncphony-box-config.bats b/tests/syncphony-box-config.bats index 3f9e664..bffa049 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_TXT="${TXT}" SB_ENV="${ENV_OUT}" SB_CMDLINE="${CMDLINE}" SB_HOSTS="${WORK}/hosts" \ 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_TXT="${TXT}" SB_ENV="${ENV_OUT}" SB_CMDLINE="${WORK}/dircmdline" SB_HOSTS="${WORK}/hosts" \ SB_HOSTNAME_CMD=true SB_REBOOT_CMD="touch '${REBOOT_FLAG}'" \ run "${SCRIPT}" apply [ "$status" -eq 0 ] From 1f806f69d8e1b3fca035712cddfd7cc6f719a315 Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Sat, 17 Oct 2026 12:38:46 -0500 Subject: [PATCH 3/4] fix: let Ctrl+Alt+Fn reach a console; skip timer updates fired by a clock jump cage blocks VT switching unless started with -s, so a box whose kiosk died showed a frozen screen and no way to log in from its own keyboard. The consoles still ask for a password. syncphony-update.timer plans its next 04:00 from the boot clock. A box with no RTC boots in the past; once NTP corrects the clock that 04:00 is gone and the timer fires immediately, mid-setup. boxd update now skips a non-manual run outside the 04:00 hour. --- boxd/internal/update/update.go | 12 +++++++++++ boxd/internal/update/update_test.go | 21 +++++++++++++++++++ docs/adr/0002-kiosk-session.md | 3 +++ docs/adr/0012-box-updates.md | 5 +++++ .../systemd/system/syncphony-kiosk.service | 2 +- 5 files changed, 42 insertions(+), 1 deletion(-) diff --git a/boxd/internal/update/update.go b/boxd/internal/update/update.go index c59aacc..78f1685 100644 --- a/boxd/internal/update/update.go +++ b/boxd/internal/update/update.go @@ -67,6 +67,14 @@ const ( SourceList = "/usr/share/syncphony-box/apt/syncphony-box.list" lockPath = "/run/syncphony-update.lock" + + // scheduledHour is syncphony-update.timer's hour (04:00 plus up to + // 10 minutes of spread). A timer run outside it is a clock jump, not + // the schedule: a box with no RTC (every Pi, and a PC with a flat + // battery) boots with an old clock, systemd plans the next 04:00 from + // it, and when NTP corrects the clock that 04:00 is in the past and + // the timer fires at once, mid-setup, in the middle of the day. + scheduledHour = 4 ) // Results recorded in the state file. @@ -211,6 +219,10 @@ func Run(env Env, opt Options) (State, error) { lg.Printf("update: auto_update=off in syncphony.txt; skipping (sudo syncphony-box update still works)") return State{}, Skipped } + if now := env.Now(); !opt.Manual && now.Hour() != scheduledHour { + lg.Printf("update: started at %s, outside the %02d:00 window (the clock jumped); skipping until tonight", now.Format("15:04"), scheduledHour) + return State{}, Skipped + } if playing, err := env.Playing(); err != nil { // boxd down means no page and no music; nothing to protect. lg.Printf("update: cannot ask boxd whether the room is playing (%v); treating it as idle", err) diff --git a/boxd/internal/update/update_test.go b/boxd/internal/update/update_test.go index 3bd262d..8ae0c95 100644 --- a/boxd/internal/update/update_test.go +++ b/boxd/internal/update/update_test.go @@ -146,6 +146,27 @@ func TestPlayingSkipsTheNightlyRun(t *testing.T) { } } +func TestTimerRunOutsideTheWindowSkipsUnlessManual(t *testing.T) { + // The clock-jump case: NTP moved the clock past the planned 04:00 and + // the timer fired at once, in the afternoon. + box := newBox(t) + env := box.env(filepath.Join(t.TempDir(), "u.json")) + env.Now = func() time.Time { return time.Date(2026, 10, 17, 17, 6, 0, 0, time.UTC) } + if _, err := Run(env, Options{AutoUpdate: "on"}); err != Skipped { + t.Fatalf("err %v", err) + } + if len(box.calls) != 0 { + t.Fatalf("ran %q outside the window", box.calls) + } + + box = newBox(t, snapN, snapN) + env = box.env(filepath.Join(t.TempDir(), "u.json")) + env.Now = func() time.Time { return time.Date(2026, 10, 17, 17, 6, 0, 0, time.UTC) } + if st, err := Run(env, Options{AutoUpdate: "on", Manual: true}); err != nil || st.Result != ResultCurrent { + t.Fatalf("manual run: st %+v err %v", st, err) + } +} + func TestAutoUpdateOffSkipsUnlessManual(t *testing.T) { box := newBox(t) if _, err := Run(box.env(filepath.Join(t.TempDir(), "u.json")), Options{AutoUpdate: "off"}); err != Skipped { diff --git a/docs/adr/0002-kiosk-session.md b/docs/adr/0002-kiosk-session.md index ae321d9..a4feb73 100644 --- a/docs/adr/0002-kiosk-session.md +++ b/docs/adr/0002-kiosk-session.md @@ -31,6 +31,9 @@ time): `TTYVDisown=yes`, `UtmpIdentifier=tty1` - `After=`/`Wants=network-online.target` so Chromium doesn't start into an unconfigured interface +- `cage -s`: cage blocks Ctrl+Alt+F*n* by default, which left a box + with a dead kiosk and a keyboard plugged in no way to reach a console. + Every console still asks for a password. - `Restart=always`, `RestartSec=2`: a killed Chromium (cage exits when its only client goes) brings the session back within seconds diff --git a/docs/adr/0012-box-updates.md b/docs/adr/0012-box-updates.md index 57a78c9..0596924 100644 --- a/docs/adr/0012-box-updates.md +++ b/docs/adr/0012-box-updates.md @@ -87,6 +87,11 @@ switched on at 09:00 doesn't start updating mid-party). It runs `boxd update`: 1. `auto_update=off` in `syncphony.txt` → skip. + Started outside the 04:00 hour → skip (manual runs exempt). That is a + clock jump, not the schedule: a box without an RTC boots with an old + clock, systemd plans the next 04:00 from it, and when NTP corrects the + clock that 04:00 is already past, so the timer fires at once, in the + middle of setup. 2. The room is playing → skip tonight. "Playing" is boxd's view: the page's last bridge event is `playing` **and** its heartbeat is fresh (a dead page's last word doesn't count). boxd exposes it on `/status`. diff --git a/packaging/root/usr/lib/systemd/system/syncphony-kiosk.service b/packaging/root/usr/lib/systemd/system/syncphony-kiosk.service index 8b15fd5..6f288b2 100644 --- a/packaging/root/usr/lib/systemd/system/syncphony-kiosk.service +++ b/packaging/root/usr/lib/systemd/system/syncphony-kiosk.service @@ -15,7 +15,7 @@ TTYReset=yes TTYVHangup=yes UtmpIdentifier=tty1 StandardInput=tty -ExecStart=/usr/bin/cage -- /usr/lib/syncphony-box/kiosk +ExecStart=/usr/bin/cage -s -- /usr/lib/syncphony-box/kiosk Restart=always RestartSec=2 From e25e0498fa3ad4497801b9063081ebd4c6a4842f Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Sat, 17 Oct 2026 12:40:54 -0500 Subject: [PATCH 4/4] fix: never stop restarting the kiosk; changelog for the persistence fixes systemd's default start limit (5 starts in 10 s) stopped a fast-failing kiosk for good, leaving the TV on a dead console. StartLimitIntervalSec=0 keeps Restart=always true. --- CHANGELOG.md | 14 ++++++++++++++ docs/adr/0002-kiosk-session.md | 4 +++- .../usr/lib/systemd/system/syncphony-kiosk.service | 4 ++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef961b7..f5f74e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,20 @@ Add to `## [Unreleased]` as you go; rename it when you tag. Chromium wouldn't take over the lock its killed predecessor left behind. The kiosk now clears that stale lock before every start (the same after a power cut). +- A Wi-Fi network joined from the setup hotspot was forgotten at every + reboot, so the box came back "offline" and asked for Wi-Fi again. + `data-persist` copied the SSH host keys onto themselves (they are + symlinks into `/data`), failed, and stopped before saving or restoring + the NetworkManager connections. +- The box's hostname is now in `/etc/hosts` after a rename (`sudo` said + "unable to resolve host" on every call). +- Ctrl+Alt+F1–F12 now reach a console login when a keyboard is plugged + in; cage blocked VT switching. +- A kiosk that fails several times quickly keeps being restarted instead + of leaving the TV on a dead console. +- 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. ### Changed - The image is smaller: documentation, man pages and translations are diff --git a/docs/adr/0002-kiosk-session.md b/docs/adr/0002-kiosk-session.md index a4feb73..886b64a 100644 --- a/docs/adr/0002-kiosk-session.md +++ b/docs/adr/0002-kiosk-session.md @@ -35,7 +35,9 @@ time): with a dead kiosk and a keyboard plugged in no way to reach a console. Every console still asks for a password. - `Restart=always`, `RestartSec=2`: a killed Chromium (cage exits when its - only client goes) brings the session back within seconds + only client goes) brings the session back within seconds. `StartLimitIntervalSec=0` + keeps that true for a kiosk that fails fast: systemd's default start + limit would stop it for good after five quick failures ### All Chromium flags in one wrapper diff --git a/packaging/root/usr/lib/systemd/system/syncphony-kiosk.service b/packaging/root/usr/lib/systemd/system/syncphony-kiosk.service index 6f288b2..3cdc3ea 100644 --- a/packaging/root/usr/lib/systemd/system/syncphony-kiosk.service +++ b/packaging/root/usr/lib/systemd/system/syncphony-kiosk.service @@ -6,6 +6,10 @@ Conflicts=getty@tty1.service # give the box a sane clock before the page starts (MAD-806). After=systemd-user-sessions.service getty@tty1.service network-online.target syncphony-audio.service boxd.service time-sync.target Wants=network-online.target syncphony-audio.service boxd.service time-sync.target +# Never give up restarting: systemd's default start limit (5 starts in +# 10 s) stops a kiosk that fails fast for good, leaving the TV on a dead +# console until someone power-cycles the box. +StartLimitIntervalSec=0 [Service] User=kiosk