Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 54 additions & 2 deletions boxd/internal/config/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <hostname>
RebootCmd string // run as: sh -c "$RebootCmd"
Expand All @@ -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"),
Expand Down Expand Up @@ -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 {
Expand All @@ -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")
}
Expand Down Expand Up @@ -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)
}
1 change: 1 addition & 0 deletions boxd/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions boxd/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions boxd/internal/daemon/daemon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
5 changes: 1 addition & 4 deletions boxd/internal/daemon/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions boxd/internal/update/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions boxd/internal/update/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 6 additions & 1 deletion docs/adr/0002-kiosk-session.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,13 @@ 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
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

Expand Down
5 changes: 5 additions & 0 deletions docs/adr/0012-box-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
19 changes: 13 additions & 6 deletions packaging/root/usr/lib/syncphony-box/data-persist
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,7 +19,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

Expand Down
4 changes: 2 additions & 2 deletions tests/syncphony-box-config.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ]
Expand Down
18 changes: 18 additions & 0 deletions tests/syncphony-box-data.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading