From 6b6c2ab199d2bd9256ab506b40fd704954f1d928 Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Thu, 15 Oct 2026 09:51:08 -0500 Subject: [PATCH] feat: Wi-Fi setup hotspot for a box with no network - boxd starts an open access point, Syncphony-Box-XXXX (last 4 of the MAC), when there is no Ethernet or Wi-Fi: after 60 s for a box that has never been online, after 5 min for one that has (the offline screen shows first, so a router reboot never means setup) - NetworkManager runs it in shared mode at 10.42.0.1 (an in-memory connection that never reaches /data); a dnsmasq-shared.d drop-in resolves every name to the box and sends DHCP option 114, making boxd's :80 listener the captive portal - the portal form lists the scanned networks, plus a hidden-network field, a password, and the server URL when none is set; saving answers the phone, joins with nmcli, and persists the connection to /data right away - a failed join brings the hotspot back and shows the reason on the TV and the form; Ethernet coming up stops it; a saved network is rechecked every 5 min while the portal is idle - new /hotspot TV screen with the Wi-Fi join QR; /setup and /start hand off to it, and /status reports the wifi state - stage-syncphony/09-wifi-setup (network-manager, dnsmasq-base, captive drop-in, boxd after NetworkManager); smoke test covers it - docs: ADR 0010, README, persistent paths, the Wi-Fi setup run in docs/testing.md Co-Authored-By: Claude Opus 5.5 --- README.md | 26 +- boxd/internal/daemon/bridge.go | 1 + boxd/internal/daemon/daemon.go | 18 +- boxd/internal/daemon/daemon_test.go | 3 + boxd/internal/daemon/hotspot.go | 538 ++++++++++++++++++ boxd/internal/daemon/hotspot_test.go | 429 ++++++++++++++ boxd/internal/daemon/pages.go | 109 +++- boxd/internal/daemon/screens.go | 14 + boxd/internal/daemon/setup.go | 129 ++++- boxd/internal/wifi/wifi.go | 314 ++++++++++ boxd/internal/wifi/wifi_test.go | 216 +++++++ docs/adr/0005-boxd.md | 2 +- docs/adr/0010-wifi-setup-hotspot.md | 131 +++++ docs/persistent-paths.md | 4 +- docs/testing.md | 42 ++ scripts/smoke-test.sh | 32 +- stage-syncphony/09-wifi-setup/00-packages | 5 + stage-syncphony/09-wifi-setup/00-run.sh | 31 + .../09-wifi-setup/files/60-network.conf | 7 + .../files/syncphony-captive.conf | 16 + 20 files changed, 2046 insertions(+), 21 deletions(-) create mode 100644 boxd/internal/daemon/hotspot.go create mode 100644 boxd/internal/daemon/hotspot_test.go create mode 100644 boxd/internal/wifi/wifi.go create mode 100644 boxd/internal/wifi/wifi_test.go create mode 100644 docs/adr/0010-wifi-setup-hotspot.md create mode 100644 stage-syncphony/09-wifi-setup/00-packages create mode 100755 stage-syncphony/09-wifi-setup/00-run.sh create mode 100644 stage-syncphony/09-wifi-setup/files/60-network.conf create mode 100644 stage-syncphony/09-wifi-setup/files/syncphony-captive.conf diff --git a/README.md b/README.md index a6ebbec..580aa13 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,19 @@ and serves the box's own screens: room is open, boxd notices (heartbeats keep coming while the page is alive) and moves the TV to the offline screen after a couple of minutes. +- **No network at all: the setup hotspot.** A box with no Ethernet and + no Wi-Fi becomes its own open Wi-Fi network, `Syncphony-Box-XXXX` + (the last four of its MAC). The TV shows a QR code that joins it, and + the phone's captive-portal page opens with the Wi-Fi networks in range, + a password field, and the server URL if the box doesn't have one yet. + Saving joins that Wi-Fi. If the join fails, the hotspot comes back and + the TV says why. A new box starts the hotspot 60 s after boot. A box + that has been online before shows the offline screen first and only + falls back to the hotspot after 5 minutes without a network, so a + router reboot never puts it in setup mode. Imager's Wi-Fi settings + still work and are the fast path + ([ADR 0010](docs/adr/0010-wifi-setup-hotspot.md)). + Logs go to journald: `journalctl -u boxd -u syncphony-kiosk`. ## The TV remote (HDMI-CEC) @@ -338,6 +351,12 @@ has its own page: [docs/testing.md](docs/testing.md). - `audio=usb` with a USB DAC: the DAC is the default sink and the test tone plays through it after a reboot. - Unplug HDMI while music plays, plug it back: audio returns by itself. +- Fresh flash, no Imager Wi-Fi, no cable: about a minute after boot the + TV shows the `Syncphony-Box-XXXX` QR; a phone joins, the setup page + opens by itself, and choosing the house Wi-Fi (plus the server URL) + ends in the pairing screen. A wrong password brings the hotspot back + with the reason on the TV ([docs/testing.md](docs/testing.md), "The + Wi-Fi setup run"). - Fresh flash, no `server_url`: the setup screen's QR appears; setting the URL from a phone (the box validates it via `/healthz`) brings up `/tv`'s pairing code with no SD-card editing. @@ -368,7 +387,8 @@ has its own page: [docs/testing.md](docs/testing.md). 1. Write the `.img.xz` to an SD card with Raspberry Pi Imager. 2. In the OS customization settings, set at minimum a username and password - (Wi-Fi, SSH and hostname as needed). The first user in the image ships with + (Wi-Fi, SSH and hostname as needed — without Wi-Fi settings or a cable, + the box offers its setup hotspot instead). The first user in the image ships with a **locked password** — there is no default login; the Imager settings are applied on first boot by cloud-init. 3. Boot the Pi. Without Imager customization the image behaves like stock @@ -379,9 +399,9 @@ has its own page: [docs/testing.md](docs/testing.md). ``` pi-gen/ pi-gen submodule (pinned, never edited) stage-syncphony/ custom pi-gen stage (packages, kiosk session, - read-only root, CEC, ...) + read-only root, CEC, Wi-Fi setup, ...) boxd/ the box's Go helper daemon (bridge, config, screens, - watchdogs, CEC) + watchdogs, CEC, Wi-Fi setup hotspot) config pi-gen build configuration build.sh wraps pi-gen's build-docker.sh (builds boxd first, then appends the /data partition after export) diff --git a/boxd/internal/daemon/bridge.go b/boxd/internal/daemon/bridge.go index 187621b..9bada07 100644 --- a/boxd/internal/daemon/bridge.go +++ b/boxd/internal/daemon/bridge.go @@ -30,6 +30,7 @@ func (d *Daemon) bridgeMux() *http.ServeMux { mux.HandleFunc("GET /setup", d.handleSetupScreen) mux.HandleFunc("GET /start", d.handleStartScreen) mux.HandleFunc("GET /storage", d.handleStorageScreen) + mux.HandleFunc("GET /hotspot", d.handleHotspotScreen) return mux } diff --git a/boxd/internal/daemon/daemon.go b/boxd/internal/daemon/daemon.go index ba3aa69..e49d8f2 100644 --- a/boxd/internal/daemon/daemon.go +++ b/boxd/internal/daemon/daemon.go @@ -1,7 +1,8 @@ // Package daemon is boxd's running half: the box bridge on loopback // (ADR 0016), the local screens the kiosk shows when there's no server -// to display — setup with a QR code, and the offline "can't reach" -// screen — and the watchdogs and thermals that make the box heal itself +// to display — setup with a QR code, the offline "can't reach" screen, +// and the Wi-Fi setup hotspot for a box with no network at all +// (MAD-808, ADR 0010) — and the watchdogs and thermals that make the box heal itself // (MAD-806, ADR 0008). package daemon @@ -19,6 +20,7 @@ import ( "github.com/madeofpendletonwool/syncphony-box/boxd/internal/cec" "github.com/madeofpendletonwool/syncphony-box/boxd/internal/config" + "github.com/madeofpendletonwool/syncphony-box/boxd/internal/wifi" ) const ( @@ -103,6 +105,13 @@ type Daemon struct { lastNightlyDay string // local day the nightly reload last ran ("2006-01-02") therm thermals // last thermal sample (zero = none yet) lastFlush time.Time // last pairing-durability sync burst + + // The Wi-Fi setup hotspot (MAD-808): NetworkManager behind an + // interface for tests, its state, and netOp serializing the + // NetworkManager work (the watch loop, a join, a recheck). + wifi netDriver + net netState + netOp sync.Mutex } // New builds a daemon around the current syncphony.txt (the SB_* env hooks @@ -121,6 +130,10 @@ func New(version string, log *log.Logger, run config.RunCommand) *Daemon { }, } d.reloadConfig() + d.wifi = wifi.New() + if _, err := os.Stat(seenMarker()); err == nil { + d.net.seenOnline = true + } d.cec = cec.New(d.cecConfig(d.cfg), log, d.onRemoteLongPress) d.wakeTV = d.cec.WakeAndSwitch return d @@ -218,6 +231,7 @@ func (d *Daemon) Run() error { d.log.Printf("boxd: %s bridge on http://%s (server: %s)", d.version, bridgeAddr, d.serverOrNone()) go d.watch() + go d.netWatch() go d.sampleThermalsLoop() go func() { // CEC (MAD-807): register as a playback device on the adapters diff --git a/boxd/internal/daemon/daemon_test.go b/boxd/internal/daemon/daemon_test.go index 5d9671d..ec2d47c 100644 --- a/boxd/internal/daemon/daemon_test.go +++ b/boxd/internal/daemon/daemon_test.go @@ -30,6 +30,9 @@ func newTestDaemon(t *testing.T, txtContent string) (*Daemon, *recorder) { // 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") + // boxd's /data state (the hotspot's "been online" marker) in the + // test's own directory. + t.Setenv("SB_BOXD_DIR", dir) d := New("test-version", log.New(log.Writer(), "", 0), rec.run) return d, rec } diff --git a/boxd/internal/daemon/hotspot.go b/boxd/internal/daemon/hotspot.go new file mode 100644 index 0000000..52fceec --- /dev/null +++ b/boxd/internal/daemon/hotspot.go @@ -0,0 +1,538 @@ +package daemon + +import ( + "fmt" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/madeofpendletonwool/syncphony-box/boxd/internal/config" + "github.com/madeofpendletonwool/syncphony-box/boxd/internal/wifi" +) + +// The Wi-Fi setup hotspot (MAD-808, ADR 0010): setup with no network at +// all. A box with neither Ethernet nor Wi-Fi becomes its own open Wi-Fi +// network, Syncphony-Box-XXXX, and boxd's LAN listener becomes that +// network's captive portal — a phone joins (the TV shows the join QR), +// the portal opens, and the form takes the house Wi-Fi (and the server +// URL, if the box has none yet). Saving gives the radio back and joins; +// on failure the hotspot comes back and the TV says why. +const ( + // bootGrace: a box that has never been online starts the hotspot + // this long after boxd starts (≈ boot) without a connection — long + // enough for Ethernet DHCP and an Imager-configured Wi-Fi to come up. + bootGrace = 60 * time.Second + + // lostGrace: a box that has been online before (a marker on /data) + // gets this long — at boot or mid-session — before it gives up its + // Wi-Fi for the hotspot. Until then the TV shows the offline screen: + // a router reboot must never put the box into setup mode. + lostGrace = 5 * time.Minute + + // recheckEvery: while the hotspot is up and a Wi-Fi network is saved, + // give the radio back this often to look for it (the router may have + // come back) — but only when nobody has used the portal for + // portalQuiet, so a phone mid-form is never dropped. + recheckEvery = 5 * time.Minute + portalQuiet = 2 * time.Minute + + dataPersist = "/usr/lib/syncphony-box/data-persist" +) + +// Waits in the join and recheck flows; vars so tests run them instantly. +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 + healthzRetry = 5 * time.Second + netWatchEvery = 5 * time.Second +) + +// netDriver is what boxd needs from NetworkManager (wifi.NM); an +// interface so the daemon's tests can play the network. +type netDriver interface { + Device() (string, error) + Online() (bool, error) + SavedWiFi() bool + APSSID(dev string) string + Scan(dev string) ([]wifi.Network, error) + StartAP(dev, ssid string) error + StopAP() error + Connect(dev, ssid, password string, hidden bool) error +} + +type netPhase string + +const ( + phaseNone netPhase = "" + phaseHotspot netPhase = "hotspot" // the setup AP is up + phaseChecking netPhase = "checking" // AP down for a moment, looking for a saved network +) + +// netState is the hotspot's slice of the daemon state (under d.mu). +type netState struct { + device string // the Wi-Fi interface; empty = no hotspot possible + phase netPhase + apSSID string + apSince time.Time + networks []wifi.Network // the last scan, taken before the AP came up + joining string // a phone asked to join this network; in progress + lastError string // why the last join failed (TV and form) + notice string // after a join: what still needs doing (setup screen) + offlineSince time.Time // no connection continuously since (zero = online) + seenOnline bool // this box has been online before (persisted) + lastPortal time.Time // last request to the captive portal +} + +// netView is the locked snapshot decideNet runs on. +type netView struct { + now time.Time + online bool + phase netPhase + joining bool + seenOnline bool + offlineSince time.Time + apSince time.Time + lastPortal time.Time + savedWiFi bool +} + +type netAction string + +const ( + netNothing netAction = "" + netStartAP netAction = "start" + netStopAP netAction = "stop" + netRecheck netAction = "recheck" +) + +// decideNet is the whole hotspot policy, pure on its input (the const +// block above has the reasoning). +func decideNet(v netView) (netAction, string) { + if v.joining || v.phase == phaseChecking { + return netNothing, "" // a join or a recheck owns the radio + } + if v.phase == phaseHotspot { + if v.online { + // The AP holds the Wi-Fi radio, so this is Ethernet: setup + // is moot, give the radio back. + return netStopAP, "a connection came up" + } + if v.savedWiFi && v.now.Sub(v.apSince) >= recheckEvery && v.now.Sub(v.lastPortal) >= portalQuiet { + return netRecheck, "looking for a saved network" + } + return netNothing, "" + } + if v.online || v.offlineSince.IsZero() { + return netNothing, "" + } + grace := bootGrace + if v.seenOnline { + grace = lostGrace + } + if v.now.Sub(v.offlineSince) >= grace { + return netStartAP, fmt.Sprintf("no Ethernet or Wi-Fi connection for %s", grace) + } + return netNothing, "" +} + +// boxdDir is boxd's own state directory on /data (docs/persistent-paths.md); +// SB_BOXD_DIR moves it in dev and tests. +func boxdDir() string { + if v := os.Getenv("SB_BOXD_DIR"); v != "" { + return v + } + return filepath.Join(config.DefaultDataDir, "boxd") +} + +// seenMarker records that this box has been online once: from then on a +// lost network gets the offline screen and lostGrace, not setup after a +// minute. +func seenMarker() string { return filepath.Join(boxdDir(), "network-seen") } + +func (d *Daemon) markSeenOnline() { + if err := os.WriteFile(seenMarker(), []byte(time.Now().UTC().Format(time.RFC3339)+"\n"), 0o644); err != nil { + d.log.Printf("boxd: wifi: cannot record the first connection: %v", err) + } +} + +// hotspotUp reports whether the setup AP is up (the LAN listener is the +// captive portal then). +func (d *Daemon) hotspotUp() bool { + d.mu.Lock() + defer d.mu.Unlock() + return d.net.phase == phaseHotspot +} + +// wifiSetup reports whether the TV belongs to the hotspot screen: the AP +// is up, a join is in flight, or a recheck has the radio for a moment. +func (d *Daemon) wifiSetup() bool { + d.mu.Lock() + defer d.mu.Unlock() + return d.net.phase != phaseNone || d.net.joining != "" +} + +// netWatch is the hotspot's background loop. It waits for a Wi-Fi device +// (NetworkManager and the driver can come up after boxd); a box with +// none, or no NetworkManager at all (a dev machine), logs once and the +// hotspot stays inert. +func (d *Daemon) netWatch() { + tick := time.NewTicker(netWatchEvery) + defer tick.Stop() + logged := false + for { + dev, err := d.wifi.Device() + if err == nil { + d.mu.Lock() + d.net.device = dev + d.mu.Unlock() + break + } + if !logged { + d.log.Printf("boxd: wifi: setup hotspot unavailable for now: %v", err) + logged = true + } + <-tick.C + } + for { + d.netTick(time.Now()) + <-tick.C + } +} + +// netTick samples the network, decides, acts. The NetworkManager work +// is serialized by netOp; a tick that finds a join or a recheck running +// skips (the next one comes 5 s later). +func (d *Daemon) netTick(now time.Time) { + if !d.netOp.TryLock() { + return + } + defer d.netOp.Unlock() + online, err := d.wifi.Online() + if err != nil { + return + } + d.mu.Lock() + first := false + if online { + d.net.offlineSince = time.Time{} + if !d.net.seenOnline { + d.net.seenOnline, first = true, true + } + } else if d.net.offlineSince.IsZero() { + d.net.offlineSince = now + } + v := netView{ + now: now, + online: online, + phase: d.net.phase, + joining: d.net.joining != "", + seenOnline: d.net.seenOnline, + offlineSince: d.net.offlineSince, + apSince: d.net.apSince, + lastPortal: d.net.lastPortal, + } + d.mu.Unlock() + if first { + d.markSeenOnline() + } + if v.phase == phaseHotspot { + v.savedWiFi = d.wifi.SavedWiFi() + } + switch act, reason := decideNet(v); act { + case netStartAP: + d.startHotspot(reason) + case netStopAP: + d.stopHotspot(reason) + case netRecheck: + d.recheck() + } +} + +// startHotspot scans (the radio is free now — not once the AP has it) +// and brings the setup AP up. Called with netOp held. +func (d *Daemon) startHotspot(reason string) { + d.mu.Lock() + dev := d.net.device + d.mu.Unlock() + nets, err := d.wifi.Scan(dev) + if err != nil { + d.log.Printf("boxd: wifi: scan: %v", err) + } + ssid := d.wifi.APSSID(dev) + if err := d.wifi.StartAP(dev, ssid); err != nil { + d.log.Printf("boxd: wifi: cannot start the setup hotspot: %v", err) + d.mu.Lock() + d.net.phase = phaseNone + d.net.offlineSince = time.Now() // re-arm the grace instead of retrying every tick + d.mu.Unlock() + return + } + d.mu.Lock() + d.net.phase = phaseHotspot + d.net.apSSID = ssid + d.net.apSince = time.Now() + if nets != nil { + d.net.networks = nets + } + d.mu.Unlock() + d.log.Printf("boxd: wifi: %s; setup hotspot %s up at %s (%d networks in range)", reason, ssid, wifi.APAddress, len(nets)) +} + +// stopHotspot takes the AP down. Called with netOp held. +func (d *Daemon) stopHotspot(reason string) { + if err := d.wifi.StopAP(); err != nil { + d.log.Printf("boxd: wifi: stop hotspot: %v", err) + } + d.mu.Lock() + d.net.phase = phaseNone + d.mu.Unlock() + d.log.Printf("boxd: wifi: %s; setup hotspot down", reason) +} + +// recheck gives the radio back for a moment so NetworkManager can find a +// saved network that has come back; if none turns up, the hotspot +// returns (with a fresh scan). Called with netOp held. +func (d *Daemon) recheck() { + d.mu.Lock() + d.net.phase = phaseChecking + d.mu.Unlock() + d.log.Printf("boxd: wifi: hotspot down for up to %s to look for a saved network", recheckWait) + d.wifi.StopAP() + deadline := time.Now().Add(recheckWait) + for time.Now().Before(deadline) { + time.Sleep(recheckPoll) + if ok, _ := d.wifi.Online(); ok { + d.mu.Lock() + d.net.phase = phaseNone + d.net.offlineSince = time.Time{} + d.mu.Unlock() + d.log.Printf("boxd: wifi: a saved network is back; setup hotspot stays down") + return + } + } + d.startHotspot("no saved network in range") +} + +// joinWiFi is the save from the portal, run after the phone got its +// answer: give the radio back, join, and on success persist the network +// and (if the form had it) the server URL. On failure the hotspot comes +// back and the TV shows why. +func (d *Daemon) joinWiFi(ssid, password string, hidden bool, serverURL, name string) { + d.netOp.Lock() + defer d.netOp.Unlock() + time.Sleep(joinDelay) + + d.mu.Lock() + dev := d.net.device + d.net.phase = phaseNone + d.mu.Unlock() + d.wifi.StopAP() + d.log.Printf("boxd: wifi: joining %s", ssid) + + if err := d.wifi.Connect(dev, ssid, password, hidden); err != nil { + d.log.Printf("boxd: wifi: %v", err) + d.mu.Lock() + d.net.joining = "" + d.net.lastError = capitalize(err.Error()) + d.mu.Unlock() + d.startHotspot("the join failed") + return + } + + d.mu.Lock() + d.net.joining, d.net.lastError = "", "" + d.net.offlineSince = time.Time{} + first := !d.net.seenOnline + d.net.seenOnline = true + d.mu.Unlock() + if first { + d.markSeenOnline() + } + d.log.Printf("boxd: wifi: joined %s", ssid) + // The connection now sits in /etc/NetworkManager — the overlay's + // tmpfs. Snapshot it to /data now rather than at the next 15-minute + // save, so a power pull right after setup doesn't forget it. + if err := d.run(dataPersist, "save"); err != nil { + d.log.Printf("boxd: wifi: data-persist save: %v", err) + } + + if serverURL == "" || d.configured() { + return + } + for i := 0; i < healthzTries; i++ { + if i > 0 { + time.Sleep(healthzRetry) + } + if checkHealthz(serverURL) { + if err := d.saveServer(serverURL, name, "the Wi-Fi setup"); err == nil { + return + } + break + } + } + 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() +} + +func capitalize(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +// servePortal is the LAN listener while the hotspot is up: the captive +// portal. Every name resolves to the box (the dnsmasq catch-all), so a +// phone's connectivity probe lands here; anything that isn't the form +// at the box's own address is redirected to it, which is what makes the +// phone pop the portal open. +func (d *Daemon) servePortal(w http.ResponseWriter, r *http.Request) { + d.mu.Lock() + d.net.lastPortal = time.Now() + d.mu.Unlock() + host := r.Host + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + if host != wifi.APAddress { + http.Redirect(w, r, "http://"+wifi.APAddress+"/", http.StatusFound) + return + } + switch { + case r.URL.Path == "/" && r.Method == http.MethodGet: + writePage(w, wifiFormHTML(d.wifiForm(""))) + case r.URL.Path == "/wifi" && r.Method == http.MethodPost: + d.handleWiFiSave(w, r) + default: + http.Redirect(w, r, "/", http.StatusFound) + } +} + +func (d *Daemon) wifiForm(errMsg string) wifiFormData { + d.mu.Lock() + data := wifiFormData{ + APSSID: d.net.apSSID, + Networks: append([]wifi.Network(nil), d.net.networks...), + AskServer: d.cfg.ServerURL == "", + Error: errMsg, + } + if data.Error == "" { + data.Error = d.net.lastError + } + d.mu.Unlock() + data.Name = d.displayName() + return data +} + +func (d *Daemon) handleWiFiSave(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + fail := func(msg string) { + w.WriteHeader(http.StatusBadRequest) + writePage(w, wifiFormHTML(d.wifiForm(msg))) + } + ssid := strings.TrimSpace(r.PostFormValue("other_ssid")) + if ssid == "" { + ssid = r.PostFormValue("ssid") + } + password := r.PostFormValue("password") // spaces are legal in a passphrase + serverURL := strings.TrimSpace(r.PostFormValue("server_url")) + name := strings.TrimSpace(r.PostFormValue("name")) + + if ssid == "" { + fail("Pick your Wi-Fi network, or type its name.") + return + } + d.mu.Lock() + known, secure := false, false + for _, nw := range d.net.networks { + if nw.SSID == ssid { + known, secure = true, nw.Secure + } + } + askServer := d.cfg.ServerURL == "" + d.mu.Unlock() + if secure && password == "" { + fail(ssid + " needs its Wi-Fi password.") + return + } + if password != "" && (len(password) < 8 || len(password) > 64) { + fail("Wi-Fi passwords are 8 to 63 characters — check it and try again.") + return + } + if !askServer { + serverURL, name = "", "" + } + if serverURL != "" { + u, err := url.Parse(serverURL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + fail("That doesn't look like a server address — it should be like https://syncphony.example.com") + return + } + } + + d.mu.Lock() + if busy := d.net.joining; busy != "" { + d.mu.Unlock() + fail("Already joining " + busy + " — watch the TV.") + return + } + d.net.joining = ssid + d.net.lastError, d.net.notice = "", "" + apSSID := d.net.apSSID + d.mu.Unlock() + + d.log.Printf("boxd: wifi: join %s requested from %s", ssid, r.RemoteAddr) + writePage(w, joiningPageHTML(ssid, apSSID)) + go d.joinWiFi(ssid, password, !known, serverURL, name) +} + +// handleHotspotScreen is the TV side: join the box's network (QR), then +// a page opens on the phone. It follows the join live and goes back to +// the start page once the hotspot is done. +func (d *Daemon) handleHotspotScreen(w http.ResponseWriter, r *http.Request) { + if !d.wifiSetup() { + http.Redirect(w, r, "/start", http.StatusFound) + return + } + d.mu.Lock() + data := hotspotScreenData{ + SSID: d.net.apSSID, + Phase: string(d.net.phase), + Joining: d.net.joining, + Error: d.net.lastError, + Address: "http://" + wifi.APAddress, + Checking: d.net.phase == phaseChecking, + } + d.mu.Unlock() + if data.SSID != "" { + qr, err := qrPNG(wifi.JoinString(data.SSID)) + if err != nil { + d.log.Printf("boxd: qr: %v", err) + } + data.QR = qr + } + writePage(w, hotspotScreenHTML(data)) +} + +// wifiStatus is the hotspot's part of /status. +func (d *Daemon) wifiStatus() map[string]any { + d.mu.Lock() + defer d.mu.Unlock() + return map[string]any{ + "phase": string(d.net.phase), + "hotspot": d.net.apSSID, + "joining": d.net.joining, + "error": d.net.lastError, + "notice": d.net.notice, + } +} diff --git a/boxd/internal/daemon/hotspot_test.go b/boxd/internal/daemon/hotspot_test.go new file mode 100644 index 0000000..d6c777b --- /dev/null +++ b/boxd/internal/daemon/hotspot_test.go @@ -0,0 +1,429 @@ +package daemon + +import ( + "errors" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/madeofpendletonwool/syncphony-box/boxd/internal/config" + "github.com/madeofpendletonwool/syncphony-box/boxd/internal/wifi" +) + +// fakeNet plays NetworkManager for the hotspot tests. +type fakeNet struct { + mu sync.Mutex + online bool + saved bool + nets []wifi.Network + connectErr error + apUp bool + calls []string + // onConnect runs inside Connect (a join can bring the box online). + onConnect func() +} + +func (f *fakeNet) log(s string) { + f.calls = append(f.calls, s) +} + +func (f *fakeNet) Device() (string, error) { return "wlan0", nil } +func (f *fakeNet) Online() (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.online, nil +} +func (f *fakeNet) SavedWiFi() bool { + f.mu.Lock() + defer f.mu.Unlock() + return f.saved +} +func (f *fakeNet) APSSID(string) string { return "Syncphony-Box-ABCD" } +func (f *fakeNet) Scan(string) ([]wifi.Network, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.log("scan") + return f.nets, nil +} +func (f *fakeNet) StartAP(dev, ssid string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.log("start " + ssid) + f.apUp = true + return nil +} +func (f *fakeNet) StopAP() error { + f.mu.Lock() + defer f.mu.Unlock() + f.log("stop") + f.apUp = false + return nil +} +func (f *fakeNet) Connect(dev, ssid, password string, hidden bool) error { + f.mu.Lock() + f.log("connect " + ssid + " " + password) + err, on := f.connectErr, f.onConnect + f.mu.Unlock() + if err == nil && on != nil { + on() + } + return err +} +func (f *fakeNet) snapshot() (calls []string, apUp bool) { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.calls...), f.apUp +} + +func newHotspotDaemon(t *testing.T, txt string) (*Daemon, *recorder, *fakeNet) { + t.Helper() + d, rec := newTestDaemon(t, txt) + f := &fakeNet{nets: []wifi.Network{ + {SSID: "Home", Signal: 80, Secure: true}, + {SSID: "Cafe", Signal: 40}, + }} + d.wifi = f + d.net.device = "wlan0" + joinDelay, recheckWait, recheckPoll, healthzRetry = 0, 50*time.Millisecond, 5*time.Millisecond, 0 + t.Cleanup(func() { + joinDelay, recheckWait, recheckPoll, healthzRetry = time.Second, 45*time.Second, 3*time.Second, 5*time.Second + }) + return d, rec, f +} + +// --- the policy ----------------------------------------------------------------- + +func TestDecideNet(t *testing.T) { + t0 := time.Date(2026, 10, 15, 20, 0, 0, 0, time.UTC) + for _, tc := range []struct { + name string + v netView + want netAction + }{ + {"online: nothing", netView{now: t0, online: true}, netNothing}, + {"fresh box, 59 s offline", netView{now: t0, offlineSince: t0.Add(-59 * time.Second)}, netNothing}, + {"fresh box, 60 s offline: hotspot", netView{now: t0, offlineSince: t0.Add(-60 * time.Second)}, netStartAP}, + {"seen online, 60 s offline: still the offline screen", + netView{now: t0, seenOnline: true, offlineSince: t0.Add(-60 * time.Second)}, netNothing}, + {"seen online, 4m59s offline", netView{now: t0, seenOnline: true, offlineSince: t0.Add(-299 * time.Second)}, netNothing}, + {"seen online, 5 min offline: hotspot", + netView{now: t0, seenOnline: true, offlineSince: t0.Add(-5 * time.Minute)}, netStartAP}, + {"hotspot up, Ethernet comes up: stop", + netView{now: t0, phase: phaseHotspot, online: true}, netStopAP}, + {"hotspot up, nothing saved: stay", + netView{now: t0, phase: phaseHotspot, apSince: t0.Add(-time.Hour)}, netNothing}, + {"hotspot up 5 min, a saved network, portal quiet: recheck", + netView{now: t0, phase: phaseHotspot, savedWiFi: true, apSince: t0.Add(-5 * time.Minute)}, netRecheck}, + {"hotspot up, a saved network, but a phone is on the portal", + netView{now: t0, phase: phaseHotspot, savedWiFi: true, apSince: t0.Add(-time.Hour), + lastPortal: t0.Add(-time.Minute)}, netNothing}, + {"hotspot up 4 min, a saved network: not yet", + netView{now: t0, phase: phaseHotspot, savedWiFi: true, apSince: t0.Add(-4 * time.Minute)}, netNothing}, + {"joining: hands off", + netView{now: t0, joining: true, offlineSince: t0.Add(-time.Hour)}, netNothing}, + {"checking: hands off", + netView{now: t0, phase: phaseChecking, offlineSince: t0.Add(-time.Hour)}, netNothing}, + } { + if got, _ := decideNet(tc.v); got != tc.want { + t.Errorf("%s: decideNet = %q, want %q", tc.name, got, tc.want) + } + } +} + +// --- the watch loop ---------------------------------------------------------------- + +func TestNetTickStartsHotspotAfterBootGrace(t *testing.T) { + d, _, f := newHotspotDaemon(t, "") + t0 := time.Now() + d.netTick(t0) + d.netTick(t0.Add(30 * time.Second)) + if _, up := f.snapshot(); up { + t.Fatal("hotspot up before the boot grace") + } + d.netTick(t0.Add(bootGrace)) + calls, up := f.snapshot() + if !up || !d.hotspotUp() { + t.Fatalf("hotspot not up after the boot grace: %q", calls) + } + if calls[0] != "scan" { + t.Errorf("calls = %q, want a scan before the AP takes the radio", calls) + } + if !d.wifiSetup() { + t.Error("the TV should belong to the hotspot screen") + } + + // Ethernet plugged in: the hotspot goes away. + f.mu.Lock() + f.online = true + f.mu.Unlock() + d.netTick(t0.Add(bootGrace + 5*time.Second)) + if _, up := f.snapshot(); up || d.hotspotUp() { + t.Fatal("hotspot still up with a connection") + } +} + +func TestNetTickRemembersBeingOnline(t *testing.T) { + d, _, f := newHotspotDaemon(t, "") + f.online = true + d.netTick(time.Now()) + if _, err := os.Stat(seenMarker()); err != nil { + t.Fatalf("marker: %v", err) + } + // A restarted boxd (a reboot) reads it back: the 5-minute grace. + dir := boxdDir() + d2, _ := newTestDaemon(t, "") + if d2.net.seenOnline { + t.Fatal("a fresh /data claims the box has been online") + } + t.Setenv("SB_BOXD_DIR", dir) + if d3 := New("test-version", d2.log, d2.run); !d3.net.seenOnline { + t.Fatal("seenOnline not restored from /data") + } +} + +func TestRecheckFindsSavedNetwork(t *testing.T) { + d, _, f := newHotspotDaemon(t, "") + d.netTick(time.Now()) + d.netTick(time.Now().Add(bootGrace)) + if !d.hotspotUp() { + t.Fatal("no hotspot") + } + f.mu.Lock() + f.saved = true + f.online = true // the router came back + f.mu.Unlock() + d.recheck() + if d.wifiSetup() { + t.Fatal("still in setup after the saved network came back") + } + if _, up := f.snapshot(); up { + t.Fatal("AP still up") + } +} + +func TestRecheckBringsHotspotBack(t *testing.T) { + d, _, f := newHotspotDaemon(t, "") + d.netTick(time.Now()) + d.netTick(time.Now().Add(bootGrace)) + d.recheck() + calls, up := f.snapshot() + if !up || !d.hotspotUp() { + t.Fatalf("hotspot not back: %q", calls) + } +} + +// --- the captive portal ------------------------------------------------------------- + +func lanGet(t *testing.T, d *Daemon, host, path string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Host = host + rec := httptest.NewRecorder() + d.lanMux().ServeHTTP(rec, req) + return rec +} + +func lanPost(t *testing.T, d *Daemon, path string, form url.Values) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode())) + req.Host = wifi.APAddress + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + d.lanMux().ServeHTTP(rec, req) + return rec +} + +func upHotspot(t *testing.T, d *Daemon) { + t.Helper() + d.netOp.Lock() + d.startHotspot("test") + d.netOp.Unlock() +} + +func TestPortalRedirectsProbes(t *testing.T) { + d, _, _ := newHotspotDaemon(t, "server_url=https://s.example\n") + upHotspot(t, d) + for _, probe := range []struct{ host, path string }{ + {"connectivitycheck.gstatic.com", "/generate_204"}, + {"captive.apple.com", "/hotspot-detect.html"}, + {"www.msftconnecttest.com", "/connecttest.txt"}, + } { + rec := lanGet(t, d, probe.host, probe.path) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "http://10.42.0.1/" { + t.Errorf("%s%s: %d → %q, want a redirect to the portal", probe.host, probe.path, + rec.Code, rec.Header().Get("Location")) + } + } +} + +func TestPortalFormListsNetworks(t *testing.T) { + d, _, _ := newHotspotDaemon(t, "") + upHotspot(t, d) + rec := lanGet(t, d, wifi.APAddress, "/") + body := rec.Body.String() + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + for _, want := range []string{`value="Home"`, `value="Cafe"`, `name="password"`, `name="server_url"`, "Syncphony-Box-ABCD"} { + if !strings.Contains(body, want) { + t.Errorf("form missing %q", want) + } + } + + // A configured box: the form is only about Wi-Fi. + d2, _, _ := newHotspotDaemon(t, "server_url=https://s.example\n") + upHotspot(t, d2) + if body := lanGet(t, d2, wifi.APAddress, "/").Body.String(); strings.Contains(body, `name="server_url"`) { + t.Error("configured box still asks for the server") + } + // And the normal LAN form is not what the portal serves. + if body := lanGet(t, d2, wifi.APAddress, "/").Body.String(); strings.Contains(body, "already set up") { + t.Error("portal served the closed page") + } +} + +func TestPortalRejectsBadInput(t *testing.T) { + d, _, f := newHotspotDaemon(t, "") + upHotspot(t, d) + for _, tc := range []struct { + name string + form url.Values + want string + }{ + {"no network", url.Values{}, "Pick your Wi-Fi"}, + {"secure without password", url.Values{"ssid": {"Home"}}, "needs its Wi-Fi password"}, + {"short password", url.Values{"ssid": {"Home"}, "password": {"short"}}, "8 to 63"}, + {"bad server", url.Values{"ssid": {"Cafe"}, "server_url": {"ftp://x"}}, "look like a server address"}, + } { + rec := lanPost(t, d, "/wifi", tc.form) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), tc.want) { + t.Errorf("%s: %d, body missing %q", tc.name, rec.Code, tc.want) + } + } + if calls, _ := f.snapshot(); strings.Contains(strings.Join(calls, ";"), "connect") { + t.Errorf("a rejected form still joined: %q", calls) + } +} + +func TestPortalJoinWithServer(t *testing.T) { + d, rec, f := newHotspotDaemon(t, "") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + })) + defer srv.Close() + f.onConnect = func() { + f.mu.Lock() + f.online = true + f.mu.Unlock() + } + upHotspot(t, d) + + resp := lanPost(t, d, "/wifi", url.Values{ + "ssid": {"Home"}, "password": {"hunter2222"}, "server_url": {srv.URL}, "name": {"Den TV"}, + }) + if resp.Code != http.StatusOK || !strings.Contains(resp.Body.String(), "Connecting to Home") { + t.Fatalf("join: %d %s", resp.Code, resp.Body) + } + waitFor(t, func() bool { return !d.wifiSetup() }) + + calls, up := f.snapshot() + if up { + t.Fatal("AP still up after a good join") + } + if !strings.Contains(strings.Join(calls, ";"), "connect Home hunter2222") { + t.Errorf("calls = %q", calls) + } + // The new Wi-Fi went to /data right away, the server URL into + // syncphony.txt, and the kiosk to the server. + waitFor(t, func() bool { + return strings.Contains(strings.Join(rec.calls(), ";"), "systemctl restart syncphony-kiosk.service") + }) + if !strings.Contains(strings.Join(rec.calls(), ";"), dataPersist+" save") { + t.Errorf("no data-persist save: %q", rec.calls()) + } + if c := config.ParseFile(config.PathsFromEnv().Txt); c.ServerURL != srv.URL || c.Name != "Den TV" { + t.Fatalf("saved config: %+v", c) + } + if _, err := os.Stat(seenMarker()); err != nil { + t.Errorf("the join didn't record the box as having been online: %v", err) + } +} + +func TestPortalJoinUnreachableServerLeavesNotice(t *testing.T) { + d, _, f := newHotspotDaemon(t, "") + f.onConnect = func() { + f.mu.Lock() + f.online = true + f.mu.Unlock() + } + upHotspot(t, d) + lanPost(t, d, "/wifi", url.Values{"ssid": {"Cafe"}, "server_url": {"http://127.0.0.1:1"}}) + waitFor(t, func() bool { + d.mu.Lock() + defer d.mu.Unlock() + return d.net.notice != "" + }) + if d.configured() { + t.Fatal("an unreachable server was saved") + } + // The TV's setup screen says what's left to do. + if body := get(t, d.bridgeMux(), "/setup").Body.String(); !strings.Contains(body, "couldn't reach") { + t.Errorf("setup screen without the notice:\n%s", body) + } +} + +func TestPortalJoinFailureBringsHotspotBack(t *testing.T) { + d, _, f := newHotspotDaemon(t, "server_url=https://s.example\n") + f.connectErr = errors.New("couldn't join Home — is the password right?") + upHotspot(t, d) + + lanPost(t, d, "/wifi", url.Values{"ssid": {"Home"}, "password": {"wrongpass"}}) + waitFor(t, func() bool { + d.mu.Lock() + defer d.mu.Unlock() + return d.net.joining == "" && d.net.phase == phaseHotspot + }) + if _, up := f.snapshot(); !up { + t.Fatal("AP not back after a failed join") + } + // The TV says why; so does the form when the phone comes back. + tv := get(t, d.bridgeMux(), "/hotspot").Body.String() + if !strings.Contains(tv, "is the password right?") { + t.Errorf("TV screen without the error:\n%s", tv) + } + if form := lanGet(t, d, wifi.APAddress, "/").Body.String(); !strings.Contains(form, "is the password right?") { + t.Error("form without the error") + } +} + +// --- the TV screens --------------------------------------------------------------- + +func TestScreensFollowTheHotspot(t *testing.T) { + d, _, _ := newHotspotDaemon(t, "server_url=https://s.example\n") + if rec := get(t, d.bridgeMux(), "/hotspot"); rec.Code != http.StatusFound || rec.Header().Get("Location") != "/start" { + t.Fatalf("/hotspot without a hotspot: %d %q", rec.Code, rec.Header().Get("Location")) + } + upHotspot(t, d) + for _, path := range []string{"/start", "/setup"} { + rec := get(t, d.bridgeMux(), path) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/hotspot" { + t.Errorf("%s with the hotspot up: %d %q", path, rec.Code, rec.Header().Get("Location")) + } + } + rec := get(t, d.bridgeMux(), "/hotspot") + body := rec.Body.String() + if rec.Code != http.StatusOK || !strings.Contains(body, "Syncphony-Box-ABCD") || + !strings.Contains(body, "data:image/png;base64,") || !strings.Contains(body, "http://10.42.0.1") { + t.Fatalf("hotspot screen: %d\n%s", rec.Code, body) + } + + st := get(t, d.bridgeMux(), "/status").Body.String() + if !strings.Contains(st, `"wifiSetup":true`) || !strings.Contains(st, `"hotspot":"Syncphony-Box-ABCD"`) { + t.Errorf("/status = %s", st) + } +} diff --git a/boxd/internal/daemon/pages.go b/boxd/internal/daemon/pages.go index 9cd1bb6..034b086 100644 --- a/boxd/internal/daemon/pages.go +++ b/boxd/internal/daemon/pages.go @@ -33,6 +33,11 @@ var pageStyle = ` margin: 2rem auto; width: 28rem; max-width: 80vw; } .qr img { width: 100%; image-rendering: pixelated; } + .notice { + background: #2a1518; border: 1px solid #5c2a30; border-radius: 1rem; + color: #f1b8ba; padding: 1rem 1.5rem; + } + .steps { text-align: left; display: inline-block; } ` type setupScreenData struct { @@ -40,6 +45,7 @@ type setupScreenData struct { URL string IPs []string Network string + Notice string // after a hotspot join that couldn't save the server URL } func setupScreenHTML(d setupScreenData) string { @@ -64,14 +70,30 @@ func setupScreenHTML(d setupScreenData) string {

or open {{.URL}}

{{if .IPs}}

or try {{range .IPs}}{{.}} {{end}}

{{end}}

There you can set which Syncphony server this box shows.

+ {{if .Notice}}

{{.Notice}}

{{end}} + {{if .NoNetwork}}

No network yet — without Ethernet or Wi-Fi, this box + starts its own setup Wi-Fi in a minute.

{{end}} + `)) t.Execute(b, struct { - QR template.URL // boxd generates this data URI itself; html/template would otherwise rewrite it - URL string - IPs []string - }{template.URL(d.QR), d.URL, ips}) + QR template.URL // boxd generates this data URI itself; html/template would otherwise rewrite it + URL string + IPs []string + Notice string + NoNetwork bool + }{template.URL(d.QR), d.URL, ips, d.Notice, d.Network == "offline"}) return b.String() } @@ -236,6 +258,7 @@ func startScreenHTML(d startScreenData) string { const r = await fetch('/status', { cache: 'no-store' }); const s = await r.json(); if (s.reachable && s.target) { location.replace(s.target); return; } + if (s.wifiSetup) { location.replace('/hotspot'); return; } connecting.hidden = true; offline.hidden = false; } catch (e) { /* boxd hiccup: keep the current screen */ } @@ -253,6 +276,84 @@ func startScreenHTML(d startScreenData) string { return b.String() } +type hotspotScreenData struct { + SSID string + QR string + Phase string + Joining string + Error string + Address string + Checking bool +} + +// hotspotScreenHTML is the TV during Wi-Fi setup (MAD-808): join the +// box's own network from the QR code, then the setup page opens on the +// phone. It follows a join live ("Joining Home…", then either the room +// or the hotspot again with the reason) by polling /status. +func hotspotScreenHTML(d hotspotScreenData) string { + b := &strings.Builder{} + t := template.Must(template.New("hotspot").Parse(` + + + + +Syncphony Box — Wi-Fi setup + + + +
+{{if .Joining}} +

Joining {{.Joining}}

+

Your phone has left this box's setup network. If the box can't + join, the setup network comes back here with the reason.

+{{else if .Checking}} +

Looking for a known network

+

The setup network is back in a moment if none turns up.

+{{else}} +

Connect this box to Wi-Fi

+ {{if .Error}}

{{.Error}}

{{end}} +
{{if .QR}}QR code{{end}}
+
+

1. Scan to join {{.SSID}} (no password)

+

2. A setup page opens — pick your Wi-Fi

+
+

If the page doesn't open, go to {{.Address}}

+{{end}} +
+ + +`)) + state := d.Phase + "|" + d.Joining + "|" + d.Error + stateJS, err := json.Marshal(state) + if err != nil { + stateJS = []byte(`""`) + } + t.Execute(b, struct { + SSID string + QR template.URL // generated by boxd, see setupScreenHTML + Joining string + Error string + Address string + Checking bool + State template.JS + }{d.SSID, template.URL(d.QR), d.Joining, d.Error, d.Address, d.Checking, template.JS(stateJS)}) + return b.String() +} + func writePage(w http.ResponseWriter, html string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") diff --git a/boxd/internal/daemon/screens.go b/boxd/internal/daemon/screens.go index fdd0336..36d43df 100644 --- a/boxd/internal/daemon/screens.go +++ b/boxd/internal/daemon/screens.go @@ -26,6 +26,8 @@ func (d *Daemon) handleStatus(w http.ResponseWriter, r *http.Request) { "ips": boxIPs(), "network": networkState(), "storage": storageHealthy(), + "wifiSetup": d.wifiSetup(), + "wifi": d.wifiStatus(), } if th.valid() { for k, v := range th.infoFields() { @@ -52,6 +54,10 @@ func (d *Daemon) handleSetupScreen(w http.ResponseWriter, r *http.Request) { d.handleStorageScreen(w, r) return } + if d.wifiSetup() { + http.Redirect(w, r, "/hotspot", http.StatusFound) + return + } if d.configured() { // The kiosk only lands here before the first navigate succeeds; if // the server URL is set, the start page is the right entry. @@ -63,11 +69,15 @@ func (d *Daemon) handleSetupScreen(w http.ResponseWriter, r *http.Request) { if err != nil { d.log.Printf("boxd: qr: %v", err) } + d.mu.Lock() + notice := d.net.notice + d.mu.Unlock() writePage(w, setupScreenHTML(setupScreenData{ QR: data, URL: setupURL, IPs: boxIPs(), Network: networkState(), + Notice: notice, })) } @@ -82,6 +92,10 @@ func (d *Daemon) handleStartScreen(w http.ResponseWriter, r *http.Request) { d.handleStorageScreen(w, r) return } + if d.wifiSetup() { + http.Redirect(w, r, "/hotspot", http.StatusFound) + return + } if !d.configured() { http.Redirect(w, r, "/setup", http.StatusFound) return diff --git a/boxd/internal/daemon/setup.go b/boxd/internal/daemon/setup.go index 4d54fc9..5a39db1 100644 --- a/boxd/internal/daemon/setup.go +++ b/boxd/internal/daemon/setup.go @@ -8,15 +8,23 @@ import ( "strings" "github.com/madeofpendletonwool/syncphony-box/boxd/internal/config" + "github.com/madeofpendletonwool/syncphony-box/boxd/internal/wifi" ) // lanMux serves the phone-facing setup form on the LAN — and only while the // window is open (the box is unconfigured, or the window was opened with -// SIGUSR1, later the OK long press). It never offers anything beyond -// setting the server URL and the box's name. +// SIGUSR1 or the OK long press). It never offers anything beyond setting +// the server URL and the box's name — or, while the box is its own setup +// hotspot, the house Wi-Fi (MAD-808). func (d *Daemon) lanMux() *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if d.hotspotUp() { + // No network at all: the box is its own Wi-Fi network and + // this listener is its captive portal (MAD-808). + d.servePortal(w, r) + return + } if !d.setupWindowOpen() { w.WriteHeader(http.StatusNotFound) writePage(w, closedPageHTML) @@ -61,21 +69,31 @@ func (d *Daemon) handleSetupSave(w http.ResponseWriter, r *http.Request) { return } + if err := d.saveServer(serverURL, name, r.RemoteAddr); err != nil { + d.renderFormError(w, "Couldn't save the setting on the box's boot partition. Try again, or edit syncphony.txt on the SD card.") + return + } + writePage(w, savedPageHTML(serverURL)) +} + +// saveServer writes server_url (and name=, when given) to syncphony.txt +// and hands the TV to the server. The caller has validated the URL and +// checked its /healthz; from says who asked, for the log. +func (d *Daemon) saveServer(serverURL, name, from string) error { pairs := []config.KV{{Key: "server_url", Value: serverURL}} if name != "" { pairs = append(pairs, config.KV{Key: "name", Value: name}) } // syncphony.txt sits on the read-only boot partition (ADR 0007): the // save goes through the remount dance (rw, write, sync, ro). - err = config.WithMountWritable(d.paths.Txt, d.run, d.log, func() error { + err := config.WithMountWritable(d.paths.Txt, d.run, d.log, func() error { return config.SetKeys(d.paths.Txt, pairs...) }) if err != nil { - d.renderFormError(w, "Couldn't save the setting on the box's boot partition. Try again, or edit syncphony.txt on the SD card.") d.log.Printf("boxd: setup: save failed: %v", err) - return + return err } - d.log.Printf("boxd: setup: server_url=%s saved from %s", serverURL, r.RemoteAddr) + d.log.Printf("boxd: setup: server_url=%s saved from %s", serverURL, from) // syncphony.txt is the source of truth: re-read it, refresh config.env // for the kiosk, apply a new name= to the hostname, and hand the TV to @@ -95,8 +113,7 @@ func (d *Daemon) handleSetupSave(w http.ResponseWriter, r *http.Request) { } d.closeSetupWindow() d.restartKiosk("setup saved; entering the server through the start page", false) - - writePage(w, savedPageHTML(serverURL)) + return nil } func (d *Daemon) renderFormError(w http.ResponseWriter, message string) { @@ -209,3 +226,99 @@ const closedPageHTML = ` ` + +// --- the captive portal (MAD-808) --------------------------------------------- + +type wifiFormData struct { + APSSID string + Networks []wifi.Network + AskServer bool // the box has no server_url yet: the form asks for it too + Name string + Error string +} + +// wifiFormHTML is the portal page a phone opens on the box's setup +// network: the house Wi-Fi (scanned list, or typed for a hidden one), +// its password, and the server URL when the box doesn't have one yet. +func wifiFormHTML(d wifiFormData) string { + b := &strings.Builder{} + t := template.Must(template.New("wifi").Parse(` + + + + +Set up your Syncphony Box + + + +
+

Connect this box to Wi-Fi

+

Pick the Wi-Fi this box should use. Your phone will leave + {{if .APSSID}}{{.APSSID}}{{else}}the box's setup network{{end}} when you save.

+ {{if .Error}}
{{.Error}}
{{end}} +
+ + + + + + + + {{if .AskServer}} + + +

Optional — you can also set it later from a phone on your Wi-Fi.

+ + + {{end}} + +
+
+ +`)) + t.Execute(b, d) + return b.String() +} + +func joiningPageHTML(ssid, apSSID string) string { + b := &strings.Builder{} + t := template.Must(template.New("joining").Parse(` + + + + +Connecting + + + +
+

Connecting to {{.SSID}}

+

Your phone will now leave {{if .AP}}{{.AP}}{{else}}the box's setup network{{end}} — that's expected. + Watch the TV: it shows when the box is on your Wi-Fi.

+

If it can't join, the setup network comes back and the TV says why — + join it again to retry.

+
+ +`)) + t.Execute(b, struct{ SSID, AP string }{ssid, apSSID}) + return b.String() +} diff --git a/boxd/internal/wifi/wifi.go b/boxd/internal/wifi/wifi.go new file mode 100644 index 0000000..58f9e32 --- /dev/null +++ b/boxd/internal/wifi/wifi.go @@ -0,0 +1,314 @@ +// Package wifi is boxd's NetworkManager half (MAD-808, ADR 0010): just +// enough nmcli to set up a box that has no network at all. It reports +// whether the box is online (an activated Ethernet or Wi-Fi connection), +// scans for networks, brings up the open setup access point +// Syncphony-Box-XXXX in NetworkManager's shared mode (DHCP and DNS from +// NetworkManager's own dnsmasq; the image's dnsmasq-shared.d drop-in +// turns that DNS into a catch-all pointing at boxd), and joins the +// network a phone chose. +// +// Everything goes through one nmcli func field, stubbed in tests, in the +// spirit of the cec package. A machine without NetworkManager (a dev +// box) gets errors back and boxd runs with all of this inert. +package wifi + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "sort" + "strconv" + "strings" + "time" +) + +const ( + // APConnection is the NetworkManager connection the setup access + // point runs as. It is created in memory only (save no), so it never + // reaches /etc/NetworkManager/system-connections — nor /data, which + // data-persist snapshots from there. + APConnection = "syncphony-setup" + + // APAddress is the box's address on its own access point: the + // gateway, the DHCP server, the DNS catch-all's answer and the + // captive portal. Pinned rather than left to NetworkManager's + // shared-mode default, so the dnsmasq drop-in can name it. + APAddress = "10.42.0.1" + + // APChannel is a 2.4 GHz channel legal in every regulatory domain, + // including the "world" domain a box without a Wi-Fi country runs in. + APChannel = "6" + + // connectWait bounds a join attempt: association, the 4-way + // handshake and DHCP on a slow router fit comfortably. + connectWait = 45 +) + +// Network is one scanned Wi-Fi network, deduplicated by SSID. +type Network struct { + SSID string + Signal int // 0-100 + Secure bool // needs a password (WPA/WEP); false for an open network +} + +// NM drives NetworkManager through nmcli. +type NM struct { + // nmcli runs nmcli with args and returns its stdout; on failure the + // error carries nmcli's message. A func field so tests can script it. + nmcli func(args ...string) (string, error) + // readFile reads /sys (the Wi-Fi MAC); stubbed in tests. + readFile func(string) ([]byte, error) +} + +// New returns an NM that runs the real nmcli. +func New() *NM { + return &NM{nmcli: runNmcli, readFile: os.ReadFile} +} + +// NewWith returns an NM over a scripted nmcli (tests, and the daemon's +// own tests through it). +func NewWith(nmcli func(args ...string) (string, error), readFile func(string) ([]byte, error)) *NM { + return &NM{nmcli: nmcli, readFile: readFile} +} + +func runNmcli(args ...string) (string, error) { + // The join waits up to connectWait itself; everything else is quick. + ctx, cancel := context.WithTimeout(context.Background(), (connectWait+15)*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "nmcli", args...) + var stderr strings.Builder + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + if msg := strings.TrimSpace(stderr.String()); msg != "" { + return string(out), errors.New(msg) + } + return string(out), err + } + return string(out), nil +} + +// Device returns the first Wi-Fi interface NetworkManager manages +// (wlan0 on a Pi), or an error when there is none or no NetworkManager. +func (n *NM) Device() (string, error) { + out, err := n.nmcli("-t", "-f", "DEVICE,TYPE", "device", "status") + if err != nil { + return "", err + } + for _, line := range strings.Split(out, "\n") { + f := splitTerse(line) + if len(f) >= 2 && f[1] == "wifi" { + return f[0], nil + } + } + return "", errors.New("no Wi-Fi device") +} + +// Online reports whether an Ethernet or Wi-Fi connection — other than +// the setup access point — is fully activated (link up and addressed). +func (n *NM) Online() (bool, error) { + out, err := n.nmcli("-t", "-f", "NAME,TYPE,STATE", "connection", "show", "--active") + if err != nil { + return false, err + } + return parseOnline(out), nil +} + +func parseOnline(out string) bool { + for _, line := range strings.Split(out, "\n") { + f := splitTerse(line) + if len(f) < 3 || f[0] == APConnection || f[2] != "activated" { + continue + } + if f[1] == "802-3-ethernet" || f[1] == "802-11-wireless" { + return true + } + } + return false +} + +// SavedWiFi reports whether NetworkManager knows any Wi-Fi network to +// join on its own (the Imager settings, an earlier setup) — the reason +// to give the access point up now and then and look for it again. +func (n *NM) SavedWiFi() bool { + out, err := n.nmcli("-t", "-f", "NAME,TYPE", "connection", "show") + if err != nil { + return false + } + for _, line := range strings.Split(out, "\n") { + f := splitTerse(line) + if len(f) >= 2 && f[0] != APConnection && f[1] == "802-11-wireless" { + return true + } + } + return false +} + +// APSSID is the setup network's name: Syncphony-Box- and the last four +// hex digits of the Wi-Fi MAC, so two boxes in one house stay apart. +func (n *NM) APSSID(dev string) string { + data, err := n.readFile("/sys/class/net/" + dev + "/address") + hex := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(string(data)), ":", "")) + if err != nil || len(hex) < 4 { + return "Syncphony-Box" + } + return "Syncphony-Box-" + hex[len(hex)-4:] +} + +// Scan rescans and lists the networks in range, strongest first, one +// entry per SSID. It needs the radio free: call it before the access +// point comes up. +func (n *NM) Scan(dev string) ([]Network, error) { + n.nmcli("radio", "wifi", "on") + out, err := n.nmcli("-t", "-f", "SSID,SIGNAL,SECURITY", "device", "wifi", "list", + "ifname", dev, "--rescan", "yes") + if err != nil { + return nil, err + } + return parseScan(out), nil +} + +func parseScan(out string) []Network { + best := map[string]Network{} + for _, line := range strings.Split(out, "\n") { + f := splitTerse(line) + if len(f) < 3 || f[0] == "" { + continue // hidden networks show no SSID; the form has a field for those + } + sig, _ := strconv.Atoi(f[1]) + sec := f[2] != "" && f[2] != "--" + if cur, ok := best[f[0]]; !ok || sig > cur.Signal { + best[f[0]] = Network{SSID: f[0], Signal: sig, Secure: sec} + } + } + nets := make([]Network, 0, len(best)) + for _, nw := range best { + nets = append(nets, nw) + } + sort.Slice(nets, func(i, j int) bool { + if nets[i].Signal != nets[j].Signal { + return nets[i].Signal > nets[j].Signal + } + return nets[i].SSID < nets[j].SSID + }) + return nets +} + +// StartAP brings up the open setup network on dev: in memory only, 2.4 +// GHz, shared mode at APAddress. +func (n *NM) StartAP(dev, ssid string) error { + n.nmcli("radio", "wifi", "on") // a box flashed without a Wi-Fi country starts soft-blocked + n.nmcli("connection", "delete", "id", APConnection) + if _, err := n.nmcli("connection", "add", "save", "no", + "type", "wifi", "ifname", dev, "con-name", APConnection, + "autoconnect", "no", "ssid", ssid, + "802-11-wireless.mode", "ap", + "802-11-wireless.band", "bg", + "802-11-wireless.channel", APChannel, + "ipv4.method", "shared", + "ipv4.addresses", APAddress+"/24", + "ipv6.method", "disabled"); err != nil { + return fmt.Errorf("add access point: %w", err) + } + if _, err := n.nmcli("connection", "up", "id", APConnection); err != nil { + n.nmcli("connection", "delete", "id", APConnection) + return fmt.Errorf("start access point: %w", err) + } + return nil +} + +// StopAP takes the setup network down and forgets it. +func (n *NM) StopAP() error { + _, err := n.nmcli("connection", "delete", "id", APConnection) + return err +} + +// Connect joins ssid and saves it as a NetworkManager connection named +// after the network, replacing any earlier one of that name, so it +// autoconnects from now on. hidden is for a network that doesn't +// broadcast its name. On failure the half-made connection is removed +// and the error says what went wrong in words for the TV. +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) + args := []string{"--wait", strconv.Itoa(connectWait), "device", "wifi", "connect", ssid} + if password != "" { + args = append(args, "password", password) + } + args = append(args, "ifname", dev, "name", ssid) + if hidden { + args = append(args, "hidden", "yes") + } + if _, err := n.nmcli(args...); err != nil { + n.nmcli("connection", "delete", "id", ssid) + return friendlyError(ssid, err) + } + return nil +} + +// friendlyError turns nmcli's activation errors into something a person +// on a couch can act on. +func friendlyError(ssid string, err error) error { + msg := err.Error() + switch { + case strings.Contains(msg, "Secrets were required"), + strings.Contains(msg, "802.1X supplicant"), + strings.Contains(msg, "802-11-wireless-security.psk"): + return fmt.Errorf("couldn't join %s — is the password right?", ssid) + case strings.Contains(msg, "No network with SSID"): + return fmt.Errorf("couldn't find %s — is it in range, and is the name exact?", ssid) + case strings.Contains(msg, "IP configuration could not be reserved"), + strings.Contains(msg, "ip-config-unavailable"): + return fmt.Errorf("joined %s but got no address from the router", ssid) + case strings.Contains(msg, "Timeout"), strings.Contains(msg, "timed out"): + return fmt.Errorf("couldn't join %s in time — is it in range?", ssid) + } + return fmt.Errorf("couldn't join %s (%s)", ssid, msg) +} + +// JoinString is the Wi-Fi QR payload for an open network: a phone camera +// offers to join it straight from the TV. +func JoinString(ssid string) string { + return "WIFI:S:" + escapeQR(ssid) + ";T:nopass;;" +} + +func escapeQR(s string) string { + var b strings.Builder + for _, r := range s { + if strings.ContainsRune(`\;,:"`, r) { + b.WriteByte('\\') + } + b.WriteRune(r) + } + return b.String() +} + +// splitTerse splits one line of nmcli's terse (-t) output: fields are +// separated by ':', and ':' and '\' inside a field are escaped with '\'. +func splitTerse(line string) []string { + line = strings.TrimRight(line, "\r") + if line == "" { + return nil + } + var fields []string + var cur strings.Builder + esc := false + for _, r := range line { + switch { + case esc: + cur.WriteRune(r) + esc = false + case r == '\\': + esc = true + case r == ':': + fields = append(fields, cur.String()) + cur.Reset() + default: + cur.WriteRune(r) + } + } + return append(fields, cur.String()) +} diff --git a/boxd/internal/wifi/wifi_test.go b/boxd/internal/wifi/wifi_test.go new file mode 100644 index 0000000..f7c99c7 --- /dev/null +++ b/boxd/internal/wifi/wifi_test.go @@ -0,0 +1,216 @@ +package wifi + +import ( + "errors" + "reflect" + "strings" + "testing" +) + +// script is a scripted nmcli: replies by the joined args' prefix, and a +// log of every call. +type script struct { + calls []string + replies map[string]string + fail map[string]error +} + +func (s *script) nmcli(args ...string) (string, error) { + call := strings.Join(args, " ") + s.calls = append(s.calls, call) + for prefix, err := range s.fail { + if strings.HasPrefix(call, prefix) { + return "", err + } + } + for prefix, out := range s.replies { + if strings.HasPrefix(call, prefix) { + return out, nil + } + } + return "", nil +} + +func newScripted(s *script) *NM { + return NewWith(s.nmcli, func(string) ([]byte, error) { + return []byte("dc:a6:32:12:ab:cd\n"), nil + }) +} + +func TestSplitTerse(t *testing.T) { + for _, tc := range []struct { + in string + want []string + }{ + {"wlan0:wifi", []string{"wlan0", "wifi"}}, + {`My\:Net:70:WPA2`, []string{"My:Net", "70", "WPA2"}}, + {`back\\slash::`, []string{`back\slash`, "", ""}}, + {"", nil}, + } { + if got := splitTerse(tc.in); !reflect.DeepEqual(got, tc.want) { + t.Errorf("splitTerse(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestDevice(t *testing.T) { + s := &script{replies: map[string]string{ + "-t -f DEVICE,TYPE device status": "eth0:ethernet\nlo:loopback\np2p-dev-wlan0:wifi-p2p\nwlan0:wifi\n", + }} + dev, err := newScripted(s).Device() + if err != nil || dev != "wlan0" { + t.Fatalf("Device() = %q, %v; want wlan0", dev, err) + } + + s = &script{replies: map[string]string{"-t -f DEVICE,TYPE device status": "eth0:ethernet\n"}} + if _, err := newScripted(s).Device(); err == nil { + t.Fatal("Device() with no Wi-Fi: want an error") + } +} + +func TestOnline(t *testing.T) { + for _, tc := range []struct { + name, out string + want bool + }{ + {"nothing", "lo:loopback:activated\n", false}, + {"ethernet", "Wired connection 1:802-3-ethernet:activated\nlo:loopback:activated\n", true}, + {"wifi", "preconfigured:802-11-wireless:activated\n", true}, + {"wifi still activating", "preconfigured:802-11-wireless:activating\n", false}, + {"only the setup AP", APConnection + ":802-11-wireless:activated\n", false}, + {"AP and ethernet", APConnection + ":802-11-wireless:activated\nWired:802-3-ethernet:activated\n", true}, + } { + if got := parseOnline(tc.out); got != tc.want { + t.Errorf("%s: parseOnline = %v, want %v", tc.name, got, tc.want) + } + } +} + +func TestSavedWiFi(t *testing.T) { + s := &script{replies: map[string]string{"-t -f NAME,TYPE connection show": APConnection + ":802-11-wireless\nWired:802-3-ethernet\n"}} + if newScripted(s).SavedWiFi() { + t.Error("the setup AP alone is not a saved network") + } + s = &script{replies: map[string]string{"-t -f NAME,TYPE connection show": "Home:802-11-wireless\n"}} + if !newScripted(s).SavedWiFi() { + t.Error("Home is a saved network") + } +} + +func TestAPSSID(t *testing.T) { + n := newScripted(&script{}) + if got := n.APSSID("wlan0"); got != "Syncphony-Box-ABCD" { + t.Errorf("APSSID = %q, want Syncphony-Box-ABCD", got) + } + n = NewWith((&script{}).nmcli, func(string) ([]byte, error) { return nil, errors.New("no") }) + if got := n.APSSID("wlan0"); got != "Syncphony-Box" { + t.Errorf("APSSID without a MAC = %q, want Syncphony-Box", got) + } +} + +func TestParseScan(t *testing.T) { + out := strings.Join([]string{ + "Home:54:WPA2", + "Home:81:WPA2", // the same network from a second AP: keep the stronger + "Cafe:60:", + "Old:30:--", + ":90:WPA2", // hidden + `Colon\:Net:40:WPA1 WPA2`, + }, "\n") + got := parseScan(out) + want := []Network{ + {SSID: "Home", Signal: 81, Secure: true}, + {SSID: "Cafe", Signal: 60, Secure: false}, + {SSID: "Colon:Net", Signal: 40, Secure: true}, + {SSID: "Old", Signal: 30, Secure: false}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("parseScan =\n%+v\nwant\n%+v", got, want) + } +} + +func TestStartAP(t *testing.T) { + s := &script{} + if err := newScripted(s).StartAP("wlan0", "Syncphony-Box-ABCD"); err != nil { + t.Fatal(err) + } + var add string + for _, c := range s.calls { + if strings.HasPrefix(c, "connection add") { + add = c + } + } + for _, want := range []string{ + "save no", "ifname wlan0", "con-name " + APConnection, "ssid Syncphony-Box-ABCD", + "802-11-wireless.mode ap", "ipv4.method shared", "ipv4.addresses 10.42.0.1/24", + "autoconnect no", + } { + if !strings.Contains(add, want) { + t.Errorf("connection add missing %q: %s", want, add) + } + } + if strings.Contains(add, "wifi-sec") { + t.Error("the setup AP must be open") + } + if last := s.calls[len(s.calls)-1]; last != "connection up id "+APConnection { + t.Errorf("last call = %q, want the AP brought up", last) + } + if s.calls[0] != "radio wifi on" { + t.Errorf("first call = %q, want the radio unblocked first", s.calls[0]) + } +} + +func TestStartAPFailureCleansUp(t *testing.T) { + s := &script{fail: map[string]error{"connection up": errors.New("no AP support")}} + if err := newScripted(s).StartAP("wlan0", "X"); err == nil { + t.Fatal("want an error") + } + if last := s.calls[len(s.calls)-1]; last != "connection delete id "+APConnection { + t.Errorf("last call = %q, want the half-made AP deleted", last) + } +} + +func TestConnect(t *testing.T) { + s := &script{} + if err := newScripted(s).Connect("wlan0", "Home", "hunter22", false); err != nil { + t.Fatal(err) + } + want := "--wait 45 device wifi connect Home password hunter22 ifname wlan0 name Home" + found := false + for _, c := range s.calls { + if c == want { + found = true + } + } + if !found { + t.Errorf("no %q in %q", want, s.calls) + } + + s = &script{} + newScripted(s).Connect("wlan0", "Open", "", true) + if c := s.calls[len(s.calls)-1]; c != "--wait 45 device wifi connect Open ifname wlan0 name Open hidden yes" { + t.Errorf("open hidden connect = %q", c) + } +} + +func TestConnectFailure(t *testing.T) { + s := &script{fail: map[string]error{ + "--wait": errors.New("Error: Connection activation failed: Secrets were required, but not provided."), + }} + err := newScripted(s).Connect("wlan0", "Home", "wrong", false) + if err == nil || !strings.Contains(err.Error(), "password") { + t.Fatalf("err = %v, want a password hint", err) + } + if last := s.calls[len(s.calls)-1]; last != "connection delete id Home" { + t.Errorf("last call = %q, want the failed connection removed", last) + } +} + +func TestJoinString(t *testing.T) { + if got := JoinString("Syncphony-Box-ABCD"); got != "WIFI:S:Syncphony-Box-ABCD;T:nopass;;" { + t.Errorf("JoinString = %q", got) + } + if got := JoinString(`a;b:c`); got != `WIFI:S:a\;b\:c;T:nopass;;` { + t.Errorf("JoinString escaping = %q", got) + } +} diff --git a/docs/adr/0005-boxd.md b/docs/adr/0005-boxd.md index d3af365..d262937 100644 --- a/docs/adr/0005-boxd.md +++ b/docs/adr/0005-boxd.md @@ -40,7 +40,7 @@ dependencies, built by `./build.sh` (from `git describe`, so | listener | what | who | |---|---|---| | `127.0.0.1:8099` | the box bridge (`/v1/*`, Syncphony ADR 0016) + the kiosk screens: `/setup`, `/start`, `/status`, `/healthz` | Chromium on the box, curl | -| `:80` on the LAN | the phone setup form (one field: the server URL, plus the box's name) | a phone on the same network | +| `:80` on the LAN | the phone setup form (one field: the server URL, plus the box's name); while the box is its own setup hotspot, the captive portal for the house Wi-Fi (ADR 0010) | a phone on the same network | The LAN listener is the only thing exposed, and it is open **only while the box is unconfigured**, or for 10 minutes after `SIGUSR1` (`SIGUSR2` closes diff --git a/docs/adr/0010-wifi-setup-hotspot.md b/docs/adr/0010-wifi-setup-hotspot.md new file mode 100644 index 0000000..fc216d3 --- /dev/null +++ b/docs/adr/0010-wifi-setup-hotspot.md @@ -0,0 +1,131 @@ +# ADR 0010: Wi-Fi setup hotspot + +- Status: Accepted (hardware verification pending — see docs/testing.md) +- Date: 2026-10-15 +- Deciders: Phase 11 plan (MAD-797), Wi-Fi setup issue (MAD-808) + +## Context + +The setup screen (ADR 0005) assumes the box is already on the network: a +phone on the same Wi-Fi scans the QR and sets the server. Raspberry Pi +Imager's Wi-Fi settings get a fresh card there, but a box flashed without +them, or moved to another house, has no network at all and nothing on +the TV can fix that. The box needs to set itself up with a phone and +nothing else. + +## Decision + +### The box becomes its own Wi-Fi network + +If the box has no Ethernet and no Wi-Fi connection, boxd brings up an +open access point, `Syncphony-Box-XXXX` (the last four hex digits of the +Wi-Fi MAC, so two boxes in one house stay apart). NetworkManager runs it, +since it is the Pi OS default and already owns the radio. The connection +(`syncphony-setup`) is added with `save no`, so it exists only in memory. +It never reaches `/etc/NetworkManager/system-connections`, and so +data-persist never copies it to `/data`. Its settings are 2.4 GHz, +channel 6 (legal in every regulatory domain, including the "world" +domain of a box flashed without a Wi-Fi country), `ipv4.method shared` +at a fixed 10.42.0.1/24, and no IPv6. boxd turns the radio on +(`nmcli radio wifi on`) before using it, because a card flashed without +a Wi-Fi country starts with Wi-Fi soft-blocked. + +The network is **open**. It only exists during setup, and it serves +nothing but the form below. A password printed on the TV would protect +nothing that "be in the room" doesn't already protect. + +### When: one minute for a new box, five for one that was online + +- A box that has **never been online** starts the hotspot when it has + had no connection for **60 s** after boxd starts. That is enough time + for Ethernet DHCP and an Imager-configured Wi-Fi to come up. +- A box that **has been online before** waits **5 minutes**, whether + the network is missing at boot or drops mid-session. During that time + the TV shows the offline screen. A router reboot must never put the + box into setup mode. "Online before" is a marker file, + `/data/boxd/network-seen`, written the first time a connection + activates. +- "Online" means NetworkManager has an *activated* Ethernet or Wi-Fi + connection (link up and addressed) other than the access point. +- The hotspot **goes away** once the box joins a network from the form, + or when Ethernet comes up while it is running. +- With the access point up, the radio cannot look for the house network. + So if a Wi-Fi network is saved, boxd releases the radio every + **5 minutes**, for up to 45 s, to let NetworkManager find it (the + router may simply have come back). It only does this when nobody has + used the portal for 2 minutes, so a phone in the middle of the form is + never dropped. + +The policy is one pure function (`decideNet`), the same pattern as the +watchdog's `decide`, and it is pinned by a table test. + +### The captive portal + +NetworkManager's shared mode serves DHCP and DNS on the access point +through its own dnsmasq, and it reads `/etc/NetworkManager/dnsmasq-shared.d`. +The image's drop-in there resolves **every name to 10.42.0.1** and sends +DHCP option 114 (RFC 8910, the captive portal URI). While the hotspot is +up, boxd's existing LAN listener on `:80` is the portal. Every request +not addressed to `10.42.0.1/` is redirected there. That covers the +phones' connectivity probes (`generate_204`, `hotspot-detect.html`), +which is why the phone opens the setup page by itself. + +The form lists the networks from a scan taken just before the access +point came up (the radio is busy afterwards). It also has a field for a +network that isn't listed (joined as hidden), a password field, and, +only when the box has no `server_url` yet, the server URL and the box's +name. Passwords for listed secured networks are checked for presence and +length before anything changes, so the phone gets those errors while it +is still connected. + +### The TV + +`/hotspot` is a new kiosk screen. It shows the join QR code with the +standard Wi-Fi payload (`WIFI:S:Syncphony-Box-XXXX;T:nopass;;`, so the +phone camera offers to join), "a setup page opens", and `http://10.42.0.1` +as a fallback. `/setup` and `/start` hand over to it whenever the +hotspot is running, and it hands back to `/start` when the hotspot ends. +The setup screen used to be static. It now polls `/status` to make those +handovers. `/status` gains `wifiSetup` and a `wifi` object with the +phase, the access point's name, the network being joined, and the last +error. + +### Saving: answer first, then join + +The access point and the house network share one radio. So boxd answers +the phone first ("Connecting to Home — your phone will leave this +network"), then takes the access point down and joins with +`nmcli device wifi connect … --wait 45`. The connection is named after +the network, so it autoconnects from then on. + +- **Success:** boxd runs `data-persist save` immediately, rather than + waiting for the 15-minute timer, so a power pull right after setup + keeps the network. If the form included a server URL, boxd checks its + `/healthz` (retrying for about 30 s while DHCP, DNS and the clock + settle). If it answers, boxd saves it through the same path as the + LAN form, and the TV goes on to pairing. If it doesn't answer, the URL + is not saved (the same rule as ADR 0005), and the setup screen says + "Joined Home, but couldn't reach … — set the server from a phone on + Home". +- **Failure:** boxd deletes the half-created connection, brings the + access point back with a fresh scan, and shows the reason on the TV + ("Couldn't join Home — is the password right?"). The phone sees the + same message on the form when it rejoins. + +## Consequences + +- `network-manager` and `dnsmasq-base` are explicit image packages, and + boxd is ordered after NetworkManager (stage-syncphony/09-wifi-setup). +- Imager's Wi-Fi settings still work and are still the fast path. The + hotspot is the fallback for a box that was moved, or flashed without + them. +- With no NetworkManager or no Wi-Fi device (a dev machine, or a + Pi without Wi-Fi), the hotspot logs once and stays inert. +- An open setup network means a neighbor within Wi-Fi range could, in + the window when a box is unconfigured *and* offline, point it at their + own Wi-Fi. They would gain a TV kiosk that shows a server URL they + choose, which the owner fixes the same way, from the TV. This is + accepted, as with the unconfigured LAN form in ADR 0005. +- What CI can't cover: real phones' captive-portal behavior (iOS, + Android), the Pi's brcmfmac access point, and the end-to-end setup. + The procedure is in docs/testing.md and is still pending a run. diff --git a/docs/persistent-paths.md b/docs/persistent-paths.md index 344e8fd..4aa9eda 100644 --- a/docs/persistent-paths.md +++ b/docs/persistent-paths.md @@ -10,13 +10,13 @@ to or moved off `/data`, this list changes in the same commit. |---|---|---|---| | Chromium profile (pairing cookie, speaker flag) | `/data/chromium` | kiosk user; path pinned by boxd's `config.DefaultProfileDir` | the box's whole identity — the room pairing | | applied config | `/data/syncphony-box/config.env` | `boxd config apply` / the setup form | parsed `syncphony.txt`; `/etc/syncphony-box/config.env` is a symlink to it | -| boxd's own state | `/data/boxd` | boxd (as needed) | reserved for daemon state (e.g. learned CEC addresses) | +| boxd's own state | `/data/boxd` | boxd (as needed) | daemon state; today `network-seen`, the marker that this box has been online once (the setup hotspot then waits 5 minutes, not 1 — ADR 0010) | | journald logs | `/data/journal` | journald (`/var/log/journal` → symlink) | persistent logs, capped at 50 MB (`SystemMaxUse=50M`) | | machine identity | `/data/system/machine-id` | `data-persist save` (the bound id) | seeded into `/etc/machine-id` by the initramfs overlay script | | cloud-init state | `/data/system/cloud` | cloud-init (`/var/lib/cloud` → symlink) | without it cloud-init would re-run its first boot every boot | | 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` | NetworkManager `system-connections` (Wi-Fi credentials) | +| 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 | Not on `/data`, still persistent: diff --git a/docs/testing.md b/docs/testing.md index 881484e..a53194c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -211,3 +211,45 @@ Remote keys are per-TV uneven: if arrows arrive but OK doesn't (or OK arrives as something odd), capture `cec-ctl -d /dev/cec0 --monitor` output while pressing keys and add the TV to the table in [docs/hardware.md](hardware.md). + +## The Wi-Fi setup run (MAD-808) + +**Done-when:** a box flashed with no Wi-Fi settings and no cable can be +set up end to end from a phone alone. Run it once with an iPhone and once +with an Android phone, because their captive-portal behavior differs. + +Flash with Raspberry Pi Imager, setting a user but **no Wi-Fi**, and no +Ethernet cable. A serial console or a keyboard helps you watch +`journalctl -u boxd -f`, but you don't need one. + +1. **The hotspot appears.** Boot. The TV shows the setup screen, then + about a minute after boot it switches to "Connect this box to Wi-Fi" + with a QR code and `Syncphony-Box-XXXX`. boxd logs + `setup hotspot Syncphony-Box-XXXX up at 10.42.0.1`. +2. **The portal opens.** Scan the QR with the phone camera and join (no + password). The phone's captive-portal sheet opens the setup page by + itself. If it doesn't, open `http://10.42.0.1`: the portal works + either way, but note which phone needed it. +3. **A wrong password.** Pick the house network and type a wrong + password. The phone shows "Connecting…" and drops off. The TV shows + "Joining…", then the hotspot again with "Couldn't join … — is the + password right?". Rejoin from the phone: the form shows the same + error. +4. **The real join.** Pick the network, enter the right password, and + the server URL. The TV shows "Joining…" and then the server's pairing + code. Pair from the app. `nmcli connection show` lists the network, + and `ls /data/system/network/` already has it, without waiting + 15 minutes. +5. **It sticks.** Pull the power and boot. The box comes back on the + Wi-Fi, paired, with no hotspot. +6. **Router reboot.** Power the router off. The TV shows the offline + screen. Power it back on within 4 minutes: the box comes back to the + room and the hotspot never appeared. +7. **Moved house.** Leave the router off for more than 5 minutes. The + hotspot comes up (the box has been online before, so it waited 5 + minutes, not 1). Then, with nobody on the portal, power the router + back on. Within about 5 minutes the box releases its hotspot, finds + the saved network, and returns to the room. +8. **Ethernet wins.** With the hotspot up, plug in a cable. The hotspot + goes down within seconds and the TV goes back to the room (or the + setup screen, if no server is set). diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 759fcd4..9e7c0c3 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -21,7 +21,9 @@ # drop-in, timesyncd enabled and ordered before the kiosk, and vcgencmd # for boxd's thermals. The HDMI-CEC pieces are in place too: cec-ctl # installed, the cec group with its udev rule for /dev/cec* and -# /dev/uinput, boxd granted the group, and uinput loaded at boot. +# /dev/uinput, boxd granted the group, and uinput loaded at boot. And the +# Wi-Fi setup hotspot's: NetworkManager and dnsmasq, the captive-portal +# DNS catch-all, and boxd ordered after NetworkManager. set -euo pipefail @@ -314,6 +316,34 @@ else check "uinput loaded at boot" "modules-load entry missing" fi +# --- Wi-Fi setup hotspot (MAD-808) -------------------------------------------- +if sudo test -x "${WORK}/root/usr/bin/nmcli" && + sudo test -x "${WORK}/root/usr/sbin/dnsmasq"; then + check "NetworkManager and dnsmasq installed (hotspot)" ok +else + check "NetworkManager and dnsmasq installed (hotspot)" "nmcli or dnsmasq missing" +fi + +if sudo grep -q '^address=/#/10.42.0.1$' \ + "${WORK}/root/etc/NetworkManager/dnsmasq-shared.d/syncphony-captive.conf"; then + check "hotspot DNS catch-all points at boxd" ok +else + check "hotspot DNS catch-all points at boxd" "dnsmasq-shared.d drop-in missing or wrong" +fi + +if sudo grep -q '^After=NetworkManager.service' \ + "${WORK}/root/etc/systemd/system/boxd.service.d/60-network.conf"; then + check "boxd starts after NetworkManager" ok +else + check "boxd starts after NetworkManager" "drop-in missing or wrong" +fi + +if sudo test -L "${WORK}/root/etc/systemd/system/multi-user.target.wants/NetworkManager.service"; then + check "NetworkManager enabled" ok +else + check "NetworkManager enabled" "not enabled" +fi + # The boot config apply is boxd now: same unit, new engine. if sudo grep -q '^ExecStart=/usr/lib/syncphony-box/boxd config apply$' \ "${WORK}/root/etc/systemd/system/syncphony-box-config.service"; then diff --git a/stage-syncphony/09-wifi-setup/00-packages b/stage-syncphony/09-wifi-setup/00-packages new file mode 100644 index 0000000..925d568 --- /dev/null +++ b/stage-syncphony/09-wifi-setup/00-packages @@ -0,0 +1,5 @@ +# The Wi-Fi setup hotspot (MAD-808, ADR 0010): NetworkManager (the Pi OS +# default; listed so the dependency is explicit) runs the access point, +# and its shared mode needs dnsmasq-base for DHCP and DNS on it. +network-manager +dnsmasq-base diff --git a/stage-syncphony/09-wifi-setup/00-run.sh b/stage-syncphony/09-wifi-setup/00-run.sh new file mode 100755 index 0000000..5d05b5e --- /dev/null +++ b/stage-syncphony/09-wifi-setup/00-run.sh @@ -0,0 +1,31 @@ +#!/bin/bash -e + +# The Wi-Fi setup hotspot (MAD-808, ADR 0010): a box with no Ethernet and +# no Wi-Fi becomes its own open network, Syncphony-Box-XXXX, so a phone +# can give it the house Wi-Fi. The image-level pieces: +# +# - NetworkManager + dnsmasq-base (00-packages): the access point runs +# in NetworkManager's shared mode, which serves DHCP and DNS through +# dnsmasq. +# - The dnsmasq-shared.d drop-in: on that network every name resolves +# to the box and DHCP names the portal — the captive portal. +# - boxd ordered after NetworkManager. +# +# boxd itself (when to start the hotspot, the portal form, the join, the +# TV screen) lives in boxd/internal/daemon/hotspot.go and +# boxd/internal/wifi. The access point's connection is created in memory +# only, so it never lands in system-connections — or on /data. + +FILES="$(dirname "$0")/files" + +install -v -d -m 0755 "${ROOTFS_DIR}/etc/NetworkManager/dnsmasq-shared.d" +install -v -m 0644 "${FILES}/syncphony-captive.conf" \ + "${ROOTFS_DIR}/etc/NetworkManager/dnsmasq-shared.d/syncphony-captive.conf" + +install -v -d -m 0755 "${ROOTFS_DIR}/etc/systemd/system/boxd.service.d" +install -v -m 0644 "${FILES}/60-network.conf" \ + "${ROOTFS_DIR}/etc/systemd/system/boxd.service.d/60-network.conf" + +on_chroot << EOF +systemctl enable NetworkManager.service +EOF diff --git a/stage-syncphony/09-wifi-setup/files/60-network.conf b/stage-syncphony/09-wifi-setup/files/60-network.conf new file mode 100644 index 0000000..ce26070 --- /dev/null +++ b/stage-syncphony/09-wifi-setup/files/60-network.conf @@ -0,0 +1,7 @@ +# boxd drives NetworkManager for the Wi-Fi setup hotspot (MAD-808): start +# after it so the first look at the network finds it up. Wants, not +# Requires — a box whose NetworkManager failed still runs its bridge and +# screens. +[Unit] +After=NetworkManager.service +Wants=NetworkManager.service diff --git a/stage-syncphony/09-wifi-setup/files/syncphony-captive.conf b/stage-syncphony/09-wifi-setup/files/syncphony-captive.conf new file mode 100644 index 0000000..115901d --- /dev/null +++ b/stage-syncphony/09-wifi-setup/files/syncphony-captive.conf @@ -0,0 +1,16 @@ +# The Wi-Fi setup hotspot's captive portal (MAD-808, ADR 0010). +# +# NetworkManager's shared mode runs dnsmasq for the box's setup access +# point (connection syncphony-setup, 10.42.0.1/24) and reads this +# directory into it. Two lines turn that network into a captive portal: +# +# - every name resolves to the box, so a phone's connectivity probe +# (connectivitycheck.gstatic.com, captive.apple.com, ...) reaches +# boxd's listener on :80, which redirects it to the setup form; +# - DHCP option 114 (RFC 8910) names the portal outright, for phones +# that ask for it instead of probing. +# +# The access point is the only shared-mode connection on the box, so this +# applies to nothing else. +address=/#/10.42.0.1 +dhcp-option=114,"http://10.42.0.1/"