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}}
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}} +