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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <network>" 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
Expand Down
66 changes: 64 additions & 2 deletions boxd/internal/config/apply.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package config

import (
"encoding/json"
"fmt"
"log"
"os"
Expand All @@ -9,15 +10,16 @@ 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 {
Txt string
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 <hostname>
RebootCmd string // run as: sh -c "$RebootCmd"
Expand All @@ -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"),
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
5 changes: 5 additions & 0 deletions boxd/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions boxd/internal/config/config_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package config

import (
"encoding/json"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -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)
Expand Down
21 changes: 2 additions & 19 deletions boxd/internal/daemon/bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"
"time"

"github.com/madeofpendletonwool/syncphony-box/boxd/internal/config"
"github.com/madeofpendletonwool/syncphony-box/boxd/internal/update"
)

Expand Down Expand Up @@ -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 {
Expand Down
13 changes: 11 additions & 2 deletions boxd/internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ package daemon

import (
"crypto/tls"
"fmt"
"log"
"net"
"net/http"
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions boxd/internal/daemon/daemon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
14 changes: 12 additions & 2 deletions boxd/internal/daemon/hotspot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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()
Expand Down
5 changes: 5 additions & 0 deletions boxd/internal/daemon/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
36 changes: 35 additions & 1 deletion boxd/internal/wifi/wifi.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
Loading
Loading