Found while implementing the resolver. The spec claimed the difference between "no client matched this IP" and "this IP is the proxy" made a TrustProxies misconfiguration one glance to diagnose. That holds only for the case where the forwarded header arrives and TrustProxies fails to apply it. If Traefik is misconfigured such that no X-Forwarded-For is sent at all, the resolver sees a plain unmatched IP and reports no_client — which reads to the person in front of it as "your device is not on the network", and to an operator as nothing at all. The alarm is silent in precisely one of the two ways the thing it guards can break. Recorded rather than patched, because the fix belongs to Plan 2's health line: it should show the resolved client IP and say what it is, instead of reporting a reason code. And the hardware validation step now has to confirm the true client IP actually arrives — confirming the alarm is quiet is not evidence, since a quiet alarm is what this blind spot produces.
16 KiB
Registered guest / BYOD Wi-Fi — hardening the captive portal
Date: 2026-08-21
Repos: central-unifi (most of it), dashboard-shell (tests, one Vue page)
Status: design approved, no implementation plan yet
Why this document is short on new features
Nearly everything asked for already exists in central-unifi v1.13.4 and has for
some time. The request was "design registered guest Wi-Fi"; the finding is that
the feature is largely built, partly broken in ways that only show up under real
use, and currently unreachable. So this is a hardening spec, not a greenfield
one.
What already works
| Requirement | Where it lives |
|---|---|
| Captive portal + Google auth | PortalController::captiveCallback, /portal/wifi/callback; the shell's OAuth has already authenticated the user by then |
| Per-cohort device limits | unifi_vlan_mappings.max_devices |
| Per-cohort VLAN | unifi_vlan_mappings.vlan_id |
| Session length | unifi_vlan_mappings.session_minutes (validated 1..525600) |
| Self-service eviction | Unifi/PortalDenied page lists the user's devices with can_disconnect, plus unifi.portal.self-disconnect |
| Admin eviction | unifi.portal.sessions.disconnect behind app.access:unifi + permission:unifi.auth |
| Device fingerprint capture | CaptureSnapshots already writes dev_cat and os_name per MAC into unifi_client_snapshots |
Eviction authorisation is already correct and is not being changed:
disconnectSession() does kickClient + unauthorizeGuest and authorises
owner-or-unifi.auth inside the action, so a student can free their own slot and
nobody else's.
The answer to "must users re-do the portal every time?"
No, and the mechanism is already wired. authorizeGuest($mac, $minutes) sends
UniFi cmd: authorize-guest with a minutes value taken from
session_minutes. UniFi holds that MAC as authorized for the whole window and
does not redirect it again. Set the window to a term and a student signs in once
a term.
The portal is not the obstacle. MAC randomization is — see decision 2.
Decisions
| # | Decision | Rationale |
|---|---|---|
| 1 | Harden and finish the existing portal rather than redesign | 80% exists and the schema shape is right; the defects are in accounting and trust, not structure |
| 2 | Detect a randomized MAC and coach the user; keep manual eviction | Manual choice is the stated requirement and already built; randomization is detectable, so the confusing case can be explained rather than hidden |
| 3 | Derive the MAC from the request's source IP and verify AP association; ignore ?mac= |
Closes the ownership hole by construction — the caller never gets to name a MAC |
| 4 | Add role as a match_type |
Quotas were asked for per role; roles are the platform's own authority and need no external sync |
| 5 | One Wi-Fi Devices page grouped by person; no profile card | Matches the requirement exactly; keeps /admin/users as the one directory |
| 6 | Device type is reported, not enforced, for now | Whether dev_cat is populated in this environment is unverified; enforcing on unvalidated data can lock out the wrong people |
| 7 | Explicit allow-list only — no default mapping |
No Wi-Fi unless an admin has added that role and set an amount. A default row silently grants everyone, which is the opposite of the requirement |
| 8 | Roles are chosen from a dropdown; session length entered in days | A free-text role slug that matches nothing fails silently — it presents as "this student has no Wi-Fi" with no error anywhere to explain it |
A. What consumes a device slot
The defect
A slot is currently freed when isClientConnected($mac) is false. That conflates
connected right now with authorized, and they are different facts. Two
leaks follow, in opposite directions, and both get worse the longer
session_minutes is — which is exactly the direction this design pushes it.
Leak 1 — offline treated as unauthorized. A phone leaves the building. Its session row is marked inactive, freeing a slot, while UniFi still holds the MAC authorized for the rest of its window. The user authorizes a second device. Two devices are now on the network against a one-device quota, and one of them is an orphan nothing tracks.
Leak 2 — expires_at is written and never read. No scheduled job expires
sessions. A row stays is_active long after UniFi stopped honouring it, so the
user is refused by slots that do not exist.
They compound: the same student can be simultaneously over-provisioned on the network and locked out of the portal.
The model
- A slot is consumed by a live authorization:
is_active AND expires_at > now(). Presence on the network is irrelevant to the count. - A slot is freed by exactly three things: eviction (user or admin), expiry, or the reconciler finding UniFi no longer holds it.
isClientConnected()is no longer an eviction trigger. It becomes a display value only — "connected" / "away" on the admin page.- Quota resolution gains
role. Precedence stayssort_order, so a mapping forstaffabove one forparentgives a staff-parent 4 devices.
B. Identifying the device
Flow
- UniFi redirects the device to
/portal/wifi/callback?ap=<ap_mac>&ssid=…&url=…. - Google auth via the shell — unchanged.
- Resolve the device server-side: match the request's client IP against
UniFi's live client table (
getActiveClients(), onip) to obtain the real MAC, itsap_mac, and its authorization state. - Require that the resolved client's
ap_macequals theapin the redirect — the device must be on the AP that sent it here. ?mac=is ignored: not validated, not used. It is read for one purpose — if present and disagreeing with the derived MAC, log it. That disagreement is somebody attempting to authorize a device they do not own, and today nothing would notice.- Check the locally-administered bit on the derived MAC. Record it on the session; it is what triggers the coaching copy on the denial page.
This replaces the hole the original author documented and declined to guess at:
every identifying value was request-supplied, so any signed-in account could
authorize somebody else's MAC up to max_devices.
Failure modes, deliberately closed
- IP matches no client → refuse.
- IP resolves to the proxy → refuse, and report it distinctly (see below).
- AP mismatch → refuse.
- Client not in the table yet (associated, pre-DHCP) → retry twice at ~750ms, then show "still connecting, try again" rather than a hard error. Bounded on purpose: this runs inside a web request, and a longer wait would hold a worker per connecting device at exactly the moment a class of them arrives at once.
The operational risk this creates
Failing closed means it can fail closed for everyone. If TrustProxies is
not correct for Traefik, every request resolves to the proxy, no client ever
matches, and nobody can get on the Wi-Fi — a worse outage than the vulnerability
being fixed.
Mitigation: the resolver distinguishes "no client matched this IP" from "this IP is the proxy", and the second is surfaced as a health line on the Wi-Fi Devices page. The failure becomes one glance to diagnose instead of a mystery. This is also the single most important thing to verify against the live controller.
Known blind spot in that detection (found during implementation, 2026-08-21).
proxy_untrusted is inferred from X-Forwarded-For being present while
$request->ip() still equals the raw REMOTE_ADDR — that is, the header arrived
but TrustProxies did not apply it. It therefore does NOT catch a Traefik
misconfiguration that sends no forwarded header at all: that degrades to
no_client, which reads as "your device is not on the network" rather than "this
is broken, tell IT".
So proxy_untrusted is a useful signal but not a sufficient outage alarm. The
hardware validation step must confirm the true client IP actually arrives, rather
than confirming that the misconfiguration alarm is quiet — a quiet alarm is
exactly what the blind spot produces. Plan 2's health line should read the
resolved IP itself and say what it is, not merely report a reason code.
MAC randomization
Normally stable per SSID, so it does not break the design. When it does rotate,
the device looks new: it consumes a slot and the user is asked to evict what
appears to be the same phone. With max_devices: 1 this is the likeliest support
call.
Handling: when the derived MAC is randomized and the user is at limit, the denial page leads with an explanation — the device is using a private Wi-Fi address, which is why it looks new — followed by the iOS and Android steps to turn it off for this network, then the normal eviction list. Detection is the locally-administered bit; no guessing.
C. Persistence, and the reconciler
session_minutes is the lever for "no portal on every connection". Default to a
term (~120 days) rather than a year, so lost or handed-down devices age out.
A scheduled reconciler (following CaptureSnapshots and the unifi_cron_runs
pattern) does three jobs:
- Expire rows past
expires_at— fixes leak 2. - Reconcile both directions — revoke UniFi authorizations with no live row (the orphans leak 1 produced), and expire rows UniFi no longer holds.
- Capture device type onto the session from the client record — the report-only fingerprint data for decision 6.
Job 2 also heals controller-side loss: if UniFi forgets an authorization while our row is still live and in-window (upgrade, restore), the reconciler re-authorizes it. The user is not bounced to the portal for something that was not their doing.
Windows are fixed, not sliding — a device's authorization ends when it ends, so "one term" means one term.
C2. Settings: who gets Wi-Fi, and how much
Access is an explicit allow-list. A person gets Wi-Fi only if one of their roles has been added here with an amount. No role added means no Wi-Fi, and that is the intended answer rather than a gap to be filled by a catch-all.
Consequences, stated so nobody re-adds the hole later:
defaultis removed from the available match types. Adefaultrow matches everybody, so its existence makes the allow-list advisory.resolveMapping()already returns null when nothing matches, and null already produces the "No WiFi access configured for your account." denial — so deny-by-default is the behaviour once the catch-all cannot be created.- Safe to remove now: there are zero rows in
unifi_vlan_mappingsin any environment, so nothing is being revoked from anyone. Doing this later, after a school had leaned on a catch-all, would cut off every unmapped person at once. email_domain,groupandoustay available — they are narrow matches, not catch-alls, and removing working options is not part of this.
The settings screen
Extends the existing Portal settings page rather than adding another one. Per row:
| Field | Control | Why |
|---|---|---|
| Role | dropdown of real seeded roles | Free text is the defect. match_value is a string compared exactly, so Student or studnet matches nobody and the failure surfaces as a pupil with no Wi-Fi and nothing logged. The dropdown makes an unmatchable value impossible to enter. |
| Devices | number, 1..50 | The quota. Column is already bounded. |
| Session length | days, with presets (a week / a month / a term / a year) | session_minutes stays the stored unit, but a term is 172,800 minutes and nobody should type that. Same reasoning as presenting cron schedules as presets rather than raw strings. |
| VLAN | existing select | Still required — the network needs to know where to put the device, and this was not in the original request, so it is called out rather than quietly dropped. |
| Order | existing sort_order |
Precedence for multi-role people: staff above parent gives a staff-parent 4. |
Roles already in the system and available to add: staff, student, parent, oncall-staff, coach, alumni, applicant, board-member, contact, homeschool, homeschool-parent, homeschool-student, open-house, tuition-payee, forms-payer.
D. The Wi-Fi Devices page
New page under Unifi, gated on the existing unifi.auth, grouped by person.
- Person row: name, role, quota usage ("staff · 2 of 4").
- Device rows: hostname, device type (reported), MAC in
font-monoas a technical id, a "private address" marker when randomized, AP, SSID, authorized at, expires at, and connected/away — display only. - Per-device disconnect as a
.btn-icon-dangerrow action behind the shared confirm modal. Never a nativeconfirm(). - Search, plus a filter for over-quota people.
- The portal health line from section B.
Follows the house table and empty-state conventions in the shell's CLAUDE.md.
E. Testing
Covered here, with Http::fake() for the controller
- Both quota leaks, one regression test each: an offline device keeps its slot; an expired row stops consuming one.
rolematching, andsort_orderprecedence including the staff-parent case.- The reconciler in both directions, including the re-authorize-on-controller-loss path.
- Randomized-MAC bit detection, both values.
- Resolver fail-closed paths: no client match, IP resolves to the proxy, AP mismatch, client not yet in the table.
- That
?mac=is ignored, and that a disagreement is logged. - Authorization-not-connectivity: a slot survives the device going away.
Plus a browser pass over the denial page (including the randomized-MAC copy), self-service eviction, and the admin page — UI behaviour changes, so the before-push browser rule applies.
NOT provable without the live controller
- The actual parameters UniFi's redirect sends.
- Whether
dev_catis populated for these clients —unifi_client_snapshotshas zero rows on dev, so there is no evidence either way. - Whether
TrustProxiesyields the true client IP through Traefik.
These need a supervised session against real hardware, in the same spirit as the Bosch cutover. The third is the one that gates release: it is the difference between a closed hole and nobody getting online.
F. Rollout
- Register the app. Every route here 404s today, because
unifihas nodashboard_appsrow andapp.access:unifiaborts 404 when the lookup misses. Nothing in this spec is reachable until that is resolved. Notecentral-unifideclaresstability: stable, so unlike On-Call this is a gap rather than a decision — and it is still an open question, not settled by this document. - Verify the three hardware-dependent items above against the live controller.
- Configure mappings:
role→ student (1 device) and staff (4 devices), withsort_orderset so multi-role people land deliberately. - Pilot with staff, then students.
Explicitly out of scope
- Enforcing a no-phones rule. Decision 6 is report-only until the fingerprint data is shown to be trustworthy. When it is, enforcement belongs in the reconciler (authorize, then sweep), because UniFi's fingerprint is derived from observed traffic and is not available at the moment of authorization.
- MAC OUI as a device-type signal. A randomized MAC has no real OUI, so this would fail on phones — its intended target — while working on the school laptops that should not be blocked.
- Blocking on the portal's User-Agent. Defeated by one "Request Desktop Site" tap.
- Tethering. A blocked phone can share a laptop's connection and vice versa; no design here addresses that, and none is proposed.
- Any change to
disconnectSession, which is already correct.