Repository navigation
feat: bring back WiFi captive-portal setup on Screenly-Labs/wifi-connect - #3399
vpetersson-bot wants to merge 10 commits into
Conversation
There was a problem hiding this comment.
Copilot encountered an error: Your billing is not configured or you have Copilot licenses from multiple standalone organizations or enterprises. To use premium requests, select a billing entity via the GitHub site, under Settings > Copilot > Features.
Anthias dropped its captive portal in 2026.04: it was pinned to balena-os/wifi-connect v4.11.1, which had silently shipped no ARMv6 binary since v4.4.6, and the host wrapper's `set -e`-vs-`$?` bug made the portal branch unreachable for three years. nmcli/nmtui has been the only path since, which is fine for an operator with SSH and no help at all for a screen that has never been on a network. Screenly-Labs/wifi-connect v2026.10.0 is the ground-up replacement. This wires it in end-to-end, including the part the old integration never had: getting the access point's passphrase to the person standing in front of the screen. How it runs - `anthias-wifi-connect` in all three compose templates, on upstream's multi-arch image pinned to an exact CalVer release. Nothing is built here. - `restart: "no"`, plus a host unit (ansible network role, gated on manage_network) that starts one run per boot. NOT a restart loop: wifi-connect exits 0 the moment it finds the device online, so a policy of `always` both churns a container start a minute forever and — on a device whose NetworkManager connectivity check is blocked by a filtered LAN — re-raises the access point every ACTIVITY_TIMEOUT, which is an outage that never ends. `ACTIVITY_TIMEOUT=900` is the second half of that safety valve: a false offline reading costs one 16-minute window per boot instead of the device's uptime. - The portal listens on 8000, not 80: anthias-server publishes 80:8080 on the host, so binding there fails outright. Phones reach it through DHCP option 114 (RFC 8910) and through the address on the setup screen. How the passphrase reaches a user The access point is WPA2/WPA3 with a passphrase generated per run, so it has to be displayed — it is what gets you onto the network and therefore cannot be served over it. - wifi-connect writes its join QR to the shared resin-data volume. - `/hotspot/` renders it full-screen with the SSID, the passphrase and the portal address. Unauth'd, like /splash-page, and it withholds the credentials entirely unless the portal is actually broadcasting. - The viewer shows that page for as long as setup is running, ahead of the playlist, and leaves on its own when the portal comes down. Knowing the portal is up NetworkManager holds 192.168.42.1 on the WiFi interface for exactly as long as the access point is up. anthias-server already resolves the host's addresses for the splash page — host_agent on bare metal, the supervisor on balena — so detection is that resolver plus a token comparison, with no new transport and no timeout to tune. The QR files alone would not do: they are written at the start of every run, including the ones that exit a second later because the device turned out to be online. That poll runs for the life of an offline device, so it asks host_agent for a new no-probe refresh rather than the splash's. The existing one spends ~10s inside a retrying outbound request when the device is offline, and host_agent handles reboot/shutdown on that same single thread — reusing it would have queued reboots behind an HTTP retry loop most of the time, and put an outbound request on every Anthias device every 15 seconds, permanently. Also - The gateway address is stripped from the splash page's "open this on your phone" list; it is not somewhere to manage the device from. - upgrade_containers.sh no longer `docker rm -f`s anthias-wifi-connect — it is a live service again, and the sweep would tear down a portal mid-setup. - collect_debug.sh collects the container's logs and redacts the passphrase line out of the bundle. network_mode: host This is the documented exception to CLAUDE.md's rule, and the only one. That rule exists so host-only facts are routed through host_agent -> Redis instead of punching through bridge isolation to read them, and every Anthias service still obeys it. This service is not reading a host fact: it is serving DHCP, an L2 broadcast protocol that does not survive Docker's DNAT, and the interface pin that stops it answering leases on the customer's ethernet LAN has nothing to bind to in a bridge namespace. Anthias's own detection of the portal still goes the ordinary way. Verified - `ruff check` + `ruff format --check`: clean. - Strict `mypy .`: 212 files, 0 issues. - `ansible-lint ansible/`: passes at the production profile. - `systemd-analyze verify` on the unit: clean. - All three compose templates render under envsubst and parse via `docker compose config`. - `pytest -m "not integration"`: 2446 passed, 3 skipped. 57 of those are new, including tests pinning the compose templates to the Python constants — the port, the QR directory, the version pin, the restart policy and the activity timeout have no compiler between them and a drift there is silent. NOT verified on hardware, and it cannot be yet: the GHCR package ghcr.io/screenly-labs/wifi-connect is private, so no device can pull the image. The v2026.10.0 release run also failed in its snap jobs, so the `release` job never ran and there is no GitHub Release or tarball either. Both are upstream-side. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
49332e3 to
bfc7362
Compare
There was a problem hiding this comment.
Copilot encountered an error: Your billing is not configured or you have Copilot licenses from multiple standalone organizations or enterprises. To use premium requests, select a billing entity via the GitHub site, under Settings > Copilot > Features.
There was a problem hiding this comment.
Copilot encountered an error: Your billing is not configured or you have Copilot licenses from multiple standalone organizations or enterprises. To use premium requests, select a billing entity via the GitHub site, under Settings > Copilot > Features.
There was a problem hiding this comment.
Copilot encountered an error: Your billing is not configured or you have Copilot licenses from multiple standalone organizations or enterprises. To use premium requests, select a billing entity via the GitHub site, under Settings > Copilot > Features.
…ositives The setup page inlined the wifi-connect QR SVG with |safe. It now renders it as an <img> data: URI: still a single request on a device with no working network, but nothing in an SVG read off a shared volume can run as page markup. Also annotates genuine SonarCloud false positives (the 0644 systemd unit, wifi-connect's fixed setup-AP address) and moves a NOSONAR out of a prose comment, where Sonar parsed it as a malformed suppression. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
Two defects, both found running the integration end-to-end on the Pi 5
testbed rather than in a unit test.
1. The portal came up on a device with working Ethernet
---------------------------------------------------------
Reproduced twice out of two runs. `nmcli networking connectivity`
reported `full`, a forced re-check reported `full`, and wifi-connect
raised the access point anyway.
Root cause is not a bug in wifi-connect. It asks NetworkManager, and
NM answers over D-Bus:
ConnectivityCheckAvailable: false
ConnectivityCheckEnabled: false
ConnectivityCheckUri: ""
NM is built WITHOUT connectivity-check support, so nmrs raises
ConnectivityCheckDisabled, which wifi-connect maps to `Unknown` and
treats as a reason to show the portal. That is a reasonable default
for a tool that assumes NM can answer. On Anthias it is fatal: every
board in the QA fleet reports `Available: false` — Pi 5, Pi 4,
Pi 3 B+, Rock Pi 4 and x86 — so the portal would come up on every
boot of every device. On a device online over WiFi, raising the AP
takes the radio out of station mode and drops the network it was
using. No config fixes it; the capability is absent from the build,
not merely unconfigured.
So Anthias makes the call itself, in an ExecCondition on the unit:
bin/wifi_connect_needed.sh probes the internet (the same target
host_agent already uses, honouring a configured proxy) and exits
non-zero when the device is online, which systemd treats as "skip"
rather than "fail" — an online device shows `inactive (dead)`, not a
red unit on every boot. It retries across ~30s so a boot that outruns
DHCP doesn't raise an access point for no reason.
Measured on the Pi 5: online, the unit logs "Skipped due to
'exec-condition'" and no AP is raised. In a network namespace with no
route, the gate correctly reports the portal is needed.
2. A stale address cache pinned the setup screen on the display
---------------------------------------------------------------
`portal_is_up` reads the address list host_agent publishes into Redis.
That is a cache, and when host_agent stopped, the list froze with
192.168.42.1 still in it: the endpoint kept answering `active: true`
and the viewer held /hotspot full-screen for a portal that had been
down for minutes. Anything that interrupts the refresh does this.
The address list is now the cheap gate and wifi-connect's own RFC 8908
endpoint is the authority — it exists only while the portal is
serving, on an address that exists only while the AP is up, so it
cannot go stale. The probe is gated behind a positive address match,
so a device that is plainly online never sends a packet at
192.168.42.1.
Verified against exactly the state that produced the bug: cache
stale-positive, AP down, portal unreachable. Endpoint flipped to
`active: false`, /hotspot rendered "Nothing to set up", and the viewer
logged "WiFi setup portal is down; resuming normal playback". The
positive path still reports active with a live portal.
Also
- The module docstring claimed the address was "the one liveness
signal that cannot go stale". It is a cache; it says so now.
- docs/wifi-setup.md described the NetworkManager gate that does not
work here, and told operators to debug the wrong thing.
What the hardware run confirmed otherwise
- The now-public upstream image pulls and runs on a real arm64 device.
- QR -> volume -> /hotspot: the passphrase on the page matched the one
in the portal's log and in the QR payload, byte for byte.
- The viewer fetched /hotspot/ itself (server log, from the viewer
container's IP), so the display pipeline really does switch.
- The splash page's address list correctly excluded 192.168.42.1.
- The portal served on :8000 and answered RFC 8908.
- Stopping the container tore the AP down cleanly and left no stale
NetworkManager profile behind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2e67b8f to
595861c
Compare
Re-read Anthias's own pre-2026.04 implementation and balena's, and my gate was half of each. What they actually do --------------------- Anthias, bin/start_wifi_connect.sh: an internet probe (`wget --spider 1.1.1.1`), in a `while true; ... sleep $CHECK_CONN_FREQ` loop, exiting once the device came online. Compose set the interval to 10s. balena, scripts/start.sh: one check per container start, gated on `iwgetid -r` — is there an active WiFi connection. Their script ships a menu of four candidate conditions and that is the one they chose; `nmcli -t g | grep full` is in the list, commented out. Both check something they own rather than trusting NetworkManager's verdict. That is the part I had right, and balena having explicitly passed over the nmcli option is a good sign it was right for the same reason: NM is built without connectivity-check support on every platform Anthias supports. Two things I had wrong ---------------------- **The condition was too broad.** I gated on an internet probe alone. A screen whose WAN is down but whose WiFi is perfectly associated fails that probe, and "fixing" it by raising an access point tears down the working WiFi in order to offer to configure WiFi. The outage becomes ours. balena's association check is the guard against exactly this, and I had skipped it. The gate is now the union: skip if WiFi is already associated (balena), skip if a wired network is connected, skip if the internet is reachable (Anthias's original). An association check alone would be too narrow in the other direction — an Ethernet-only screen never associates, so it would be offered WiFi setup forever — which is why it is all three and not a swap. The portal's own access point is excluded by profile id. Verified on hardware that this matters: with the portal up, nmcli really does report `wlan0:wifi:connected:wifi-connect-portal`, so without the exclusion a running portal reads as "WiFi is configured". **It only ran once per boot.** Both predecessors re-checked; I had reduced it to a single shot. A screen nobody reaches inside one 15-minute window, or whose router is replaced later, had no way back short of a reboot — on a wall-mounted display that is a site visit. anthias-wifi-connect.timer restores the cadence: OnBootSec=30s, then OnUnitInactiveSec=5min. A timer rather than a shell loop so systemd owns the schedule and journald records each decision, and OnUnitInactiveSec rather than OnCalendar so a 15-minute portal session is never interrupted by the next tick. The service lost its [Install] section — the timer is the only way in, so a skipped check is always retried rather than being the end of it. This is only safe because the condition is now conservative. My earlier reason for refusing a loop was that it would re-raise the AP every ACTIVITY_TIMEOUT on a device NM called "unknown"; with Anthias deciding, and deciding on three grounds, that objection is gone. Verified on the Pi 5 testbed - Online and wired: `Skipped due to 'exec-condition'`, unit inactive/success, no AP raised. - Portal up: the association check correctly reports "not configured" rather than mistaking the portal's own AP for a configured network. - Starting the service while a portal is already running does not spawn a second one. - `systemctl enable anthias-wifi-connect.service` is refused: "The unit files have no installation config". - Timer schedules the next check 5 minutes after the last one finished. - No route at all: the gate correctly reports the portal is needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
There was a problem hiding this comment.
Copilot encountered an error: Your billing is not configured or you have Copilot licenses from multiple standalone organizations or enterprises. To use premium requests, select a billing entity via the GitHub site, under Settings > Copilot > Features.
…gate Code review of the whole branch, plus the hardware run it prompted. Nine findings; the two that matter most are the ones that made the feature either dangerous or inert. The portal never started at all ------------------------------- The unit ran `docker start --attach anthias-wifi-connect`. Compose names the container `anthias-anthias-wifi-connect-1`, so that is "No such container" and the portal never comes up. It survived three rounds of hardware testing because every one of them ended at the ExecCondition skip — the device under test was online each time, so `docker start` was never reached. The one case that matters, a device that is genuinely offline, was the one case never exercised. Pinned `container_name: anthias-wifi-connect` (also the pre-rebrand name, so journald tags and `docker logs anthias-wifi-connect` in the docs line up), and added a test that the unit, the compose file and upgrade_containers.sh all name the same container. Verified on the Pi 5: `docker start --attach` now brings the portal up (HTTP 200 on the RFC 8908 endpoint) and `docker stop --timeout 20` takes it down with no stale NetworkManager profile. ...and when it did, it was ungated ---------------------------------- `restart: "no"` governs restart-on-exit, not the initial start, so `docker compose up -d` in upgrade_containers.sh started the container directly and walked straight past the ExecCondition. On the premise this whole branch rests on — NM's connectivity check is unavailable, so wifi-connect's own gate always says "show the portal" — that is a WiFi-connected screen losing its network for a 15-minute portal session on every single upgrade. The service now sits behind a compose profile. Verified on hardware that a profiled service is not started, not created, and not reaped by `--remove-orphans` once it exists; upgrade_containers.sh creates it stopped by name so the unit still has something to start. balena is no longer shipped --------------------------- Same hole, no lid: the supervisor starts every service in the file on every boot and release apply, and there is no systemd to hang the condition on. Whether balenaOS even has the NM problem is untested — different OS, possibly fine — and that is the point. Removed from both balena templates with the rationale and the one-command check to re-enable it, rather than enabling it fleet-wide on an assumption. Smaller things -------------- - SuccessExitStatus omitted 6, which is upstream's Outcome::TimedOut — the ORDINARY end of an unattended portal session with ACTIVITY_TIMEOUT set. The unit went `failed` every time, which is the fastest way to teach an operator to ignore it. (6 is also PortalUnavailable; noted in the unit that this masks it there and the container log distinguishes them.) - `_poll_wifi_setup_once` caught RequestException/ValueError, but a 200 carrying a JSON list parses fine and then raises AttributeError on `.get`, escaping the handler and leaving the display stranded on the setup screen — the one thing the function's own comment says must not happen. - The gate's retry window is 25s, not the "~30s" claimed: the sleep falls between attempts, so six attempts means five sleeps. - QA checklist step 1 asked for journal output the ExecCondition skip makes impossible, contradicting step 9. - Narrowed the compose/constants parity tests to the template that actually ships the service. Reviewed and found sound: the WIFI: payload parser, portal_is_up / strip_portal_gateway, the data-URI QR path and its size cap, the _publish_refresh parameterisation and its existing callers, no circular import between app/views.py and api/views/v2.py, the /data volume sharing, the passphrase redaction regex against upstream's actual log line, and that upstream writes the QR before preflight. Known and not fixed: the host_agent `set_ip_addresses_fast` command has no fallback, so a new server container running against an old host_agent sees no cache refresh and the setup screen does not appear. The ansible path updates host_agent before the containers, so this is an upgrade-window transient rather than a steady state. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
There was a problem hiding this comment.
Copilot encountered an error: Your billing is not configured or you have Copilot licenses from multiple standalone organizations or enterprises. To use premium requests, select a billing entity via the GitHub site, under Settings > Copilot > Features.
The setup page on the device's own display was already Anthias — it is built on the splash design system. The page the user actually opens on their phone was not: it said "Anthias" and was upstream's blue. Upstream documented a white-labelling seam for exactly this (palette.css calls the role/primitive split "the white-labelling seam" and points at `--brand-css`; ui/dist/brand.example.css describes itself as served at /brand.css) but had never implemented it — no flag, no route, and the CSS inlined at build time with nothing to override. So it was implemented upstream and released as 2026.10.1 (Screenly-Labs/wifi-connect#3), and this consumes it: - ansible installs wifi-connect-brand.css to /etc/anthias/wifi-connect/, mounted read-only at /brand in the portal container. - WIFI_CONNECT_BRAND_CSS points at it, and the image pin moves to 2026.10.1. Earlier images ignore the variable silently, so the pin and the branding are one change, with a test tying them together. The stylesheet overrides ROLE TOKENS ONLY — four --color-* properties and nothing else. Upstream is explicit that the --c-* primitives and the markup are not a public surface: a rebrand that reaches into them comes adrift the next time the portal's layout changes, whereas one that only renames roles keeps working. A test enforces that every declaration in the file is a --color-* role. Values are Anthias's own (#ffe11a, the --color-sun-400 accent, on the #1f002a accent-text aubergine), copied rather than imported because the portal is a separate container with no access to Anthias's static files and is served to a phone over an access point with no internet. Verified on the Pi 5 with the actual released image - The portal answers 200 and the page links /brand.css exactly once. - No {{BRAND_CSS}} token leaked into the markup. - /brand.css is served as text/css; charset=utf-8 carrying --color-accent: #ffe11a. - The page title reads "Anthias setup". - Teardown left no access point and no stale NetworkManager profile. 2466 tests pass; ruff, mypy and ansible-lint clean; the rendered compose file parses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
There was a problem hiding this comment.
Copilot encountered an error: Your billing is not configured or you have Copilot licenses from multiple standalone organizations or enterprises. To use premium requests, select a billing entity via the GitHub site, under Settings > Copilot > Features.
There was a problem hiding this comment.
Copilot encountered an error: Your billing is not configured or you have Copilot licenses from multiple standalone organizations or enterprises. To use premium requests, select a billing entity via the GitHub site, under Settings > Copilot > Features.
There was a problem hiding this comment.
Copilot encountered an error: Your billing is not configured or you have Copilot licenses from multiple standalone organizations or enterprises. To use premium requests, select a billing entity via the GitHub site, under Settings > Copilot > Features.
…or Sonar The portal container reads the CSS through a read-only bind mount, so the directory has to stay traversable. Matches the NOSONAR the file and unit tasks beside it already carry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
The first pass at this set four accent tokens and called the portal branded. It was not: the page was a generic card UI with a yellow button that the portal's own compatibility fallback was overriding anyway, and it looked nothing like the setup screen on the display in front of the user. That fallback is fixed upstream in 2026.10.2, along with three UI defects and a --default-theme that had never been read. This consumes all of it. The stylesheet now maps the full role set to the Anthias design system rather than the accent alone: plum-800 canvas under a 4%-white card, white text at paper-65 for secondary, fields at paper-6 with a paper-18 border, sun-400 on aubergine for the one button that matters, and the splash gradient. Values come from palette.css and tailwind.css, copied rather than imported because the portal is a separate container with no access to Anthias's static files and no internet behind the hotspot. Still roles only, with one deliberate exception: the gradient is keyed on `body`, because there is no role for a background image and `body` is one of the two selectors a portal layout change cannot rename. A test pins that — every custom property must be a role, and no rule may name a component class. Also: - WIFI_CONNECT_BRAND_LOGO puts the Anthias mark in the header, installed by ansible from the same SVG the splash page uses. A test asserts the two files stay byte-identical rather than drifting into lookalikes. - WIFI_CONNECT_DEFAULT_THEME=dark pins the scheme, which also removes the portal's "Switch theme" control. The plum canvas is the brand; which scheme a two-minute setup page uses is not a question to put to someone trying to get a screen online. Verified on the Pi 5 against the released 2026.10.2 image - The page renders the Anthias mark, plum canvas and yellow Connect (#ffe11a on #1f002a) with a phone set to light AND to dark: the pin holds either way. - Three access points with two radios each produce three rows, not six. - No theme switcher in the markup. - /brand-logo serves image/svg+xml and the image actually loads. - Teardown left no access point and no stale NetworkManager profile. 2469 tests pass; ruff, mypy and ansible-lint clean; the rendered compose file parses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
e4c1a3d to
9c8d2d9
Compare
…n forever Review findings from the airgap pass on #3399. The gate's decisions were already right — a device on an airgapped LAN or associated to an airgapped SSID is correctly left alone, and the internet probe is never what decides it — but what happened after a correct "raise" was not. **The portal was re-raised every five minutes, forever.** On a site with nothing to join the gate says "raise" on every tick, the portal stays up for 15 minutes (ACTIVITY_TIMEOUT), and the viewer shows the setup screen for all of it. 15 minutes of setup page, 5 minutes of content, with no cap anywhere. A screen that was playing content perfectly well before the upgrade never played anything again. Now: three attempts at the timer's cadence — a first boot is someone standing there with a phone, and three goes covers them walking off to find the password — then one attempt every six hours. Not "give up": a network can appear and nobody would be there to notice, and four quiet attempts a day costs a minute in twenty-four of screen time. The count lives in the unit's RuntimeDirectory, so a reboot resets it, because power cycling is what an operator does when they want another go. Preserve=yes, or systemd would delete it between runs and the backoff would never engage. **A missing container made the unit fail every five minutes.** upgrade_containers.sh pre-creates it best-effort, so a device that could not reach ghcr.io — an airgapped site, a registry outage — has a compose file naming a container that does not exist, and ExecStart met "No such container" on every tick. The gate checks for it and skips with an explanation instead. **The 25-second retry window ran on every tick.** It exists for a boot that gets there before DHCP or association has finished; when nothing is in flight there is nothing to wait for. It now breaks early unless something is actually settling, which also removes 25 seconds from the first boot the whole feature is for. `unavailable` counts as settling for a wifi radio only — on an ethernet port it is what "no cable" looks like, which is the steady state on exactly the devices this is for, and treating it as transitional kept every WiFi-only screen sleeping out the full window. (The test suite went from 205s to 27s on that alone.) **A portal that could not bind looked like a timeout.** Upstream returned 6 for both, and 6 is in SuccessExitStatus because a timeout is an ordinary ending — so a port conflict, which recurs until a human changes something, was invisible in `systemctl --failed`. Split upstream as exit 7 (Screenly-Labs/wifi-connect#24, released in 2026.10.3) and the pin moves with it. Verified on the Pi 5 - Airgapped wired LAN: skip in 0s, probe never reached. - Container absent: skip with the reason, no failed unit. - State cleared when the device is found online. - `systemd-analyze verify` accepts RuntimeDirectory, Preserve and the SuccessExitStatus list on the board's own systemd. The gate's decision table is now covered by tests that shell out to the real script with nmcli, curl and docker stubbed, including the backoff, its expiry, a corrupt state file, and the settle-vs-wait timing. 2483 tests pass; ruff, mypy and ansible-lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
There was a problem hiding this comment.
Copilot encountered an error: Your billing is not configured or you have Copilot licenses from multiple standalone organizations or enterprises. To use premium requests, select a billing entity via the GitHub site, under Settings > Copilot > Features.
…the passphrase Thirty-seven commits upstream since 2026.10.3: upstream API parity (GET /networks, a JSON POST /connect), PORTAL_INTERFACE, static IPv4/IPv6 addressing, EAP-TTLS, WEP refused up front, a hash-based CSP with frame and sniffing protections, bounded slow and idle connections, and cosign-signed artifacts. Nothing Anthias sets was renamed or removed — all nine environment variables in the service block are still flags upstream — and the new CSP was written with the branding seam in mind: `style-src 'self'` for /brand.css, `img-src 'self' data:` for /brand-logo. Verified in a real browser with the policy enforced rather than assumed: no violations, no failed requests. One comment here was left stale by the upgrade. Upstream no longer logs the passphrase as text — it logs "the passphrase is in the join QR" and gates the plaintext behind --log-passphrase. The flag is deliberately not set: /hotspot renders the SSID and passphrase from the QR payload on the shared volume, which is the path an operator actually uses, and a WPA passphrase sitting in journald is worth avoiding. The comment now says what the logs really contain. End-to-end on the Pi 5 against the released image - Portal, /brand.css, /brand-logo and the new /networks all answer; CSP, X-Frame-Options and X-Content-Type-Options are present. - Branding survives the CSP: Connect is #ffe11a on #1f002a, the canvas is the plum gradient, the logo loads, the theme is pinned dark with no switcher, and three access points with two radios each are three rows. - The QR artifacts land in WIFI_CONNECT_QR_OUTPUT and Anthias's own parse_qr_payload reads them back (SSID and a 14-character passphrase). They are 0640 root:root, which anthias-server can read because it runs as root. - A join for a network that does not exist: POST /connect answers 202 with the branded connecting page, the AP drops, the join fails with SsidNotFound, the AP is back within ~30 seconds, /api/status reports a human-readable error, and no profile is left behind for the failed network. - The error banner renders in the brand's own lifted danger colour with role="alert", and the page is still fully branded afterwards. - Teardown left no access point and no stale NetworkManager profile. 2483 tests pass; ruff and mypy clean; the rendered compose file parses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
There was a problem hiding this comment.
Copilot encountered an error: Your billing is not configured or you have Copilot licenses from multiple standalone organizations or enterprises. To use premium requests, select a billing entity via the GitHub site, under Settings > Copilot > Features.
|



Anthias dropped its captive portal in 2026.04 ("chore: drop wifi-connect service end-to-end"): it was pinned to balena-os/wifi-connect v4.11.1, which had silently shipped no ARMv6 binary since v4.4.6, and the host wrapper's
set -e-vs-$?bug made the portal branch unreachable for three years.nmcli/nmtuihas been the only path since — fine for an operator with SSH, no help at all for a screen that has never been on a network.Screenly-Labs/wifi-connect v2026.10.0 is the ground-up replacement. This wires it in end-to-end, including the part the old integration never had: getting the access point's passphrase to the person standing in front of the screen.
How it runs
anthias-wifi-connectin all three compose templates, on upstream's multi-arch image pinned to an exact CalVer release. Nothing is built here.restart: "no", plus a host unit (ansible/roles/network, gated onmanage_network) that starts one run per boot.Deliberately not a restart loop. wifi-connect exits 0 the moment it finds the device online, so
restart: alwaysboth churns a container start a minute forever and — on a device whose NetworkManager connectivity check is blocked by a filtered corporate LAN — re-raises the access point everyACTIVITY_TIMEOUT, which is an outage that never ends.ACTIVITY_TIMEOUT=900is the other half of that safety valve: a false offline reading costs one ~16-minute window per boot rather than the device's uptime.The portal listens on 8000, not 80 —
anthias-serverpublishes80:8080on the host, so binding there fails outright (upstream reports it asPortalUnavailable, and names Anthias in the comment). Phones reach it through DHCP option 114 (RFC 8910, honoured by iOS 14+, Android 11+, macOS, Windows 11) and through the address printed on the setup screen.How the passphrase reaches a user
The access point is WPA2/WPA3 with a passphrase generated per run — a real improvement over the old open portal, but it means the passphrase has to be displayed: it is what gets you onto the network and therefore cannot be served over it.
resin-datavolume./hotspot/renders it full-screen with the SSID, the passphrase and the portal address. Unauth'd like/splash-page(the viewer has no credentials), and it withholds the credentials entirely unless the portal is actually broadcasting.Knowing the portal is up
NetworkManager holds
192.168.42.1on the WiFi interface (ipv4.method=manual) for exactly as long as the access point is up. anthias-server already resolves the host's addresses for the splash page — host_agent on bare metal, the supervisor on balena — so detection is that resolver plus a token comparison: no new transport, no timeout to tune, works identically on both deployments.The QR files alone would not do. They are written at the start of every run, including the runs that exit a second later because the device turned out to be online, so their presence would put a setup screen on a healthy device.
That poll runs for the life of an offline device, so it asks host_agent for a new no-probe refresh rather than the splash's. The existing one spends ~10s inside a retrying outbound request when the device is offline — exactly the device this poll exists for — and host_agent handles reboot/shutdown on that same single thread. Reusing it would have queued reboots behind an HTTP retry loop most of the time, and put an outbound request on every Anthias device every 15 seconds, permanently.
Also
upgrade_containers.shno longerdocker rm -fsanthias-wifi-connect— it is a live service again, and the legacy sweep would tear down a portal mid-setup.srly-ose-wifi-connectstays.collect_debug.shcollects the container's logs and redacts the passphrase line out of the bundle.On
network_mode: hostThis is the documented exception to CLAUDE.md's rule, and the only one. That rule exists so host-only facts (IPs, MACs, interfaces) are routed through host_agent → Redis instead of punching through bridge isolation to read them, and every Anthias service still obeys it.
This service is not reading a host fact: it is serving DHCP, an L2 broadcast protocol that does not survive Docker's DNAT, and the interface pin that stops it answering leases on the customer's ethernet LAN has nothing to bind to in a bridge namespace. The exception is bounded — nothing Anthias wrote runs in that namespace, the image is upstream's
FROM scratchbuild, and Anthias's own detection of the portal still goes the ordinary way.Verified
ruff check+ruff format --check: clean.mypy .: 212 files, 0 issues.ansible-lint ansible/: passes at theproductionprofile.systemd-analyze verifyon the unit: clean.envsubstand parse viadocker compose config.pytest -m "not integration": 2446 passed, 3 skipped. 57 are new, including tests that pin the compose templates to the Python constants — the port, the QR directory, the version pin, the restart policy and the activity timeout have no compiler between them, and a drift there is silent.sassvia bun).Not verified on hardware — and it can't be yet
Two upstream-side blockers, both outside this repo:
ghcr.io/screenly-labs/wifi-connectis a private package. An anonymous GHCR token request for it returns nothing, where the same request forghcr.io/screenly/anthias-serversucceeds. No device can pull the image until the package is made public.snapjobs, and thereleasejob needs[image, snap]— so it never ran. The tag and the container image exist; the tarballs, the systemd unit asset andSHA256SUMSdo not. Nothing here depends on those assets (we run the container, not the binary), but it is worth knowing the release is half-published.Happy to run the full QA-rig pass in this PR as soon as the package is public.
🤖 Generated with Claude Code