Skip to content

feat: bring back WiFi captive-portal setup on Screenly-Labs/wifi-connect - #3399

Open
vpetersson-bot wants to merge 10 commits into
masterfrom
agent/anthias-claude/5acb2d44e14d
Open

vpetersson-bot wants to merge 10 commits into
masterfrom
agent/anthias-claude/5acb2d44e14d

Conversation

@vpetersson-bot

@vpetersson-bot vpetersson-bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

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 / nmtui has 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-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/roles/network, gated on manage_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: always both 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 every ACTIVITY_TIMEOUT, which is an outage that never ends. ACTIVITY_TIMEOUT=900 is 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-server publishes 80:8080 on the host, so binding there fails outright (upstream reports it as PortalUnavailable, 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.

  • 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 (the viewer has no credentials), 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 (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

  • The gateway address is stripped from the splash page's "open this on your phone" list. It is a real address on the host while setup runs, but not one anyone should be told to manage the device on.
  • upgrade_containers.sh no longer docker rm -fs anthias-wifi-connect — it is a live service again, and the legacy sweep would tear down a portal mid-setup. srly-ose-wifi-connect stays.
  • collect_debug.sh collects the container's logs and redacts the passphrase line out of the bundle.
  • New docs page, a QA-checklist section, and an updated FAQ answer.

On network_mode: host

This 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 scratch build, and 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 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.
  • SCSS compiles (sass via bun).

Not verified on hardware — and it can't be yet

Two upstream-side blockers, both outside this repo:

  1. ghcr.io/screenly-labs/wifi-connect is a private package. An anonymous GHCR token request for it returns nothing, where the same request for ghcr.io/screenly/anthias-server succeeds. No device can pull the image until the package is made public.
  2. There is no v2026.10.0 GitHub Release. The release run (39287376021) failed in all three snap jobs, and the release job needs [image, snap] — so it never ran. The tag and the container image exist; the tarballs, the systemd unit asset and SHA256SUMS do 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

@vpetersson-bot
vpetersson-bot requested a review from a team as a code owner October 19, 2026 17:19
Copilot AI lite review requested due to automatic review settings October 19, 2026 17:19

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@vpetersson-bot
vpetersson-bot force-pushed the agent/anthias-claude/5acb2d44e14d branch from 49332e3 to bfc7362 Compare October 19, 2026 17:57
Copilot AI lite review requested due to automatic review settings October 19, 2026 18:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@vpetersson-bot
vpetersson-bot requested a lite review from Copilot October 19, 2026 18:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI lite review requested due to automatic review settings October 19, 2026 20:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

vpetersson-bot and others added 2 commits October 19, 2026 21:00
…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>
@vpetersson-bot
vpetersson-bot force-pushed the agent/anthias-claude/5acb2d44e14d branch from 2e67b8f to 595861c Compare October 19, 2026 21:00
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>
Copilot AI lite review requested due to automatic review settings October 19, 2026 21:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Copilot AI lite review requested due to automatic review settings October 20, 2026 11:15

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Copilot AI lite review requested due to automatic review settings October 20, 2026 17:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI lite review requested due to automatic review settings October 21, 2026 06:01

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI lite review requested due to automatic review settings October 21, 2026 10:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

vpetersson-bot and others added 2 commits October 21, 2026 10:19
…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>
@vpetersson-bot
vpetersson-bot force-pushed the agent/anthias-claude/5acb2d44e14d branch from e4c1a3d to 9c8d2d9 Compare October 21, 2026 10:19
…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>
Copilot AI lite review requested due to automatic review settings October 21, 2026 10:53

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Copilot AI lite review requested due to automatic review settings October 21, 2026 13:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@sonarqubecloud

Copy link
Copy Markdown

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants