10 Commits

Author SHA1 Message Date
4f621e3973 Declare the Captive Portal page in the manifest
The page and all nine of its routes exist and nothing links to them, on dev and
equally on production at 6287054. It is not a dev-only gap.

One manifest row, no source, no routes, no migration. It goes here rather than in
a shell migration because syncNavFolder() PRUNES nav rows the manifest does not
declare — a hand-inserted row would be deleted by the next app:install, which is
pinned by a negative-control test in the shell so nobody rediscovers it.

Held: this repo is on .promote-hold for Plan 1 BYOD quotas, unrelated to this
screen. Committed, not promoted; the row appears the moment the hold lifts.

Permission is unifi.auth, which the portal route group requires — NOT
unifi.settings. A person can legitimately hold unifi.settings and be 403'd here.
2026-08-29 11:31:54 -04:00
868f5edeed Design: captive portal splash designer
Let an operator lay out what a pupil sees at the portal, with the device's own facts
as variables, instead of the four hardcoded strings there are today.

Mostly assembly rather than invention. TemplateRender already does {token}
substitution with {if}/{else} and value escaping; it already has a helper that
groups a token map into an editor sidebar; there is already a JS mirror for live
preview; and ActiveSessionController::device() already turns a User-Agent into
"Safari · iPhone" and already separates iPhone/iPad/Android from the desktop
platforms. The genuinely new parts are a public Blade route, a templates table and
the editor screen.

Four decisions worth recording because they were not obvious.

The splash cannot be an Inertia page. Every other page in this app is, but the
captive-network mini-browser is a stripped-down WebView where SPA bootstrapping is
unreliable — so the splash is plain server-rendered Blade and only the after-login
pages stay Inertia.

The frame is fixed and the sign-in button is not part of the editable body. A bad
edit would otherwise brick the portal for everybody at once.

Every substituted value is escaped, with no rawKeys. {browser} and {device_type}
derive from the User-Agent, which the pupil controls, on a page served without
authentication. The engine defaults escaping OFF, correctly for its email and PDF
callers, so this is an explicit call at the call site.

And the constraint most likely to break a rollout: the splash is served BEFORE the
device has internet, so any external asset silently fails and the pupil gets an
unstyled page at the worst moment. All assets local, walled garden must allow this
host, and the editor restricts images rather than merely advising — because a
well-meaning operator pasting a remote logo URL is the likeliest way this breaks.

No HTML sanitiser, deliberately: there is no library here and a homemade allowlist
is leaky in ways nobody audits. The residual risk is stated rather than glossed — an
admin with unifi.manage can put arbitrary HTML on a public page, which is
trusted-insider rather than escalation, but it differs from signatures because those
render into email and this renders onto the open internet. Revisit if portal editing
is ever delegated.

Queues behind the six open findings from the Plan 1 review.
2026-08-21 15:32:10 -04:00
33e465898c Review findings for Plan 1, and two corrections from production evidence
Recorded because these outlived the session that produced them and every one is
about code already committed.

Six findings, most severe first. The reconciler as written would revoke every guest
authorization on the whole UniFi site, not just the ones this portal created —
front-desk vouchers, contractors, anything an admin authorized by hand — and
re-authorize anything an admin deliberately revoked. That is why "not scheduled" is
currently load-bearing rather than cautious. The pre-DHCP retry cannot succeed
because all three attempts read one cached client snapshot, and the first failure
warms that cache with a snapshot lacking the device, so "try again" stays refused
for the TTL while holding a worker 1.5s per attempt. Every portal visit blanks the
device fingerprint, because the controller writes three request parameters UniFi
never sends. Session windows slide on every visit, so a bookmarked callback never
ages out. And with no unique index on (user_id, mac_address) and no locking,
concurrent callbacks can produce either two authorizations against a one-device
limit or two rows for one phone that permanently consume two slots.

Four tests pass for the wrong reason, including the headline quota-leak regression
test: it asserts a component name that four different branches render, so it stays
green with no role and no mapping at all.

TWO CORRECTIONS from production evidence the operator supplied.

The earlier claim that the request IP is forgeable was demonstrated by sending a
header directly to nginx on :80. Clients cannot do that — the Wi-Fi VLAN is
firewalled to Traefik only — so that demonstration does not show a live exploit and
must not be cited as one. What survives is narrower: the app believes any
X-Forwarded-For that reaches it, and whether a client can deliver a forged one
depends on one forwardedHeaders setting on a Traefik host that is not reachable from
here.

And spec section E had its release-gate risk backwards. It feared TrustProxies
would be too strict and lock everybody out. Production shows the opposite: the
Logged-in Users list reports real per-client addresses, 10.22.5.x for on-site staff
and a public address for an off-site phone. The IP-to-MAC approach is viable, with
evidence, and that item is not a blocker.

Also added: device type should come from the User-Agent, not from UniFi. The
platform already parses "Safari · iPhone" and "Chrome · macOS" in
ActiveSessionController::device(), and its OS table already separates iPhone, iPad
and Android from the desktop platforms. That is available at authorization time,
which UniFi's traffic-inferred dev_cat is not, and it is known to be populated,
which dev_cat in this environment never was.
2026-08-21 13:50:19 -04:00
a8d7b34266 Reconcile Wi-Fi sessions against UniFi authorizations
Necessary because session windows are now measured in months. Any drift between
our rows and UniFi's authorizations used to be washed away by short windows and
frequent portal visits; now it persists.

Three jobs. Expire rows past expires_at, which was historically written and never
read, so a finished row kept consuming one of the person's device slots forever.
Reconcile both directions: revoke an authorization UniFi holds with no live row of
ours — a device on the network nobody can see and nobody's quota is paying for,
which is exactly what the old presence-based sweep produced — and re-assert a row
we consider live that UniFi has forgotten, so a controller upgrade does not bounce
somebody back to the portal for something that was not their doing.

Third, record what UniFi thinks each device is. Reported only, nothing acts on it:
UniFi infers dev_cat from observed traffic, so it does not exist at the moment of
authorization and the reconciler is the first point at which there is anything to
record. It only ever fills in — an unfingerprinted client must not blank a type
already recorded, or the answer is lost every time the device happens to be quiet.

A controller we cannot reach is not evidence that anything should change, so a
failed read returns after the local expiry work rather than concluding that every
authorization is an orphan.

NOT scheduled. It calls unauthorizeGuest against a live controller, and nothing
here has been tested against real hardware — scheduling belongs with the
supervised verification session, not with the code.
2026-08-21 13:31:26 -04:00
030fac3961 Record whether a portal session used a private Wi-Fi address 2026-08-21 13:21:49 -04:00
b59b5bf6be Spec: proxy_untrusted is not a sufficient outage alarm
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.
2026-08-21 13:20:06 -04:00
4322d07b40 The portal derives the device MAC instead of trusting the caller 2026-08-21 13:18:30 -04:00
ccd6fc49b2 Wi-Fi access becomes an explicit per-role allow-list 2026-08-21 13:13:15 -04:00
655d8dd50c Network presence no longer frees a device slot 2026-08-21 13:07:16 -04:00
a74818bdaf A live authorization is what consumes a Wi-Fi device slot
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ec3REU4LQmhuchRRn6dZHk
2026-08-21 13:04:19 -04:00
12 changed files with 946 additions and 58 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "dashboard/unifi", "name": "dashboard/unifi",
"description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform", "description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform",
"version": "1.13.4", "version": "1.13.5",
"type": "library", "type": "library",
"license": "MIT", "license": "MIT",
"autoload": { "autoload": {
@@ -29,6 +29,7 @@
{ "label": "Devices", "route_name": "unifi.devices", "icon": "cpu-chip", "permission": "unifi.stats", "sort_order": 3 }, { "label": "Devices", "route_name": "unifi.devices", "icon": "cpu-chip", "permission": "unifi.stats", "sort_order": 3 },
{ "label": "Clients", "route_name": "unifi.clients", "icon": "users", "permission": "unifi.stats", "sort_order": 4 }, { "label": "Clients", "route_name": "unifi.clients", "icon": "users", "permission": "unifi.stats", "sort_order": 4 },
{ "label": "WiFi Networks", "route_name": "unifi.wifi", "icon": "wifi", "permission": "unifi.manage", "sort_order": 5 }, { "label": "WiFi Networks", "route_name": "unifi.wifi", "icon": "wifi", "permission": "unifi.manage", "sort_order": 5 },
{ "label": "Captive Portal", "route_name": "unifi.portal.settings", "icon": "shield-check", "permission": "unifi.auth", "sort_order": 6 },
{ "label": "Settings", "route_name": "unifi.settings", "icon": "cog-6-tooth", "permission": "unifi.settings", "sort_order": 99 } { "label": "Settings", "route_name": "unifi.settings", "icon": "cog-6-tooth", "permission": "unifi.settings", "sort_order": 99 }
], ],
"permissions": [ "permissions": [

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Whether the device presented a private (randomized) address.
*
* Nullable, because rows written before this existed genuinely do not know —
* defaulting them to false would assert something untrue about every historical
* session.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('unifi_portal_sessions', function (Blueprint $table) {
$table->boolean('is_randomized')->nullable()->after('mac_address');
});
}
public function down(): void
{
Schema::table('unifi_portal_sessions', function (Blueprint $table) {
$table->dropColumn('is_randomized');
});
}
};

View File

@@ -0,0 +1,322 @@
# Plan 1 review findings — BYOD Wi-Fi access model
**Date:** 2026-08-21
**Reviewed:** the six commits implementing
`docs/superpowers/plans/2026-08-21-byod-wifi-access-model.md`
(`central-unifi` `a74818b`..`a8d7b34`, `dashboard-shell` `f3de4aa`..HEAD)
**Verdict:** CHANGES NEEDED. Nothing needs redesigning; the list below needs fixing.
**Suite at time of review:** 48 passed in `tests/Feature/Unifi/`; full suite
6293 passed / 10 failed, all in the unrelated pre-existing `Logs\ScriptRunsTest`.
Recorded here because these findings outlived the session that produced them and
every one of them is about code that is already committed.
---
## 0. The request IP is trusted, and the app has no opinion of its own
`dashboard-shell/bootstrap/app.php:14` — `$middleware->trustProxies(at: '*')`.
Laravel routes `'*'` to `setTrustedProxyIpAddressesToTheCallingIp()`, so **the
immediate peer is unconditionally trusted as a proxy** and its `X-Forwarded-For`
becomes `$request->ip()`. `docker/nginx/default.conf` sets no `real_ip_header` or
`X-Forwarded-For` of its own, so a client-supplied header reaches PHP verbatim.
`DeviceResolver::resolve()` reads `$request->ip()` and derives the MAC from it, so
if a client can deliver a forged header the resolver identifies a device of the
attacker's choosing. The AP check does not help: `ap` is also a request parameter,
so an attacker forges both.
### What is NOT established
An earlier session demonstrated forgery by sending `X-Forwarded-For` **directly to
nginx on `:80`**. That path is not available to a real client: the Wi-Fi VLAN is
firewalled off from the server and can reach only Traefik. So that demonstration
does **not** show a live exploit, and it should not be cited as one.
### What IS established: the legitimate value arrives correctly
Confirmed by the operator on 2026-08-21 from the production Logged-in Users list,
served through Traefik: sessions show **real per-client addresses** —
`10.22.5.254` and `10.22.5.154` for on-site staff, and a public address for an
off-site phone. So `TrustProxies` plus Traefik does yield the true client IP, and
UniFi can map an on-site `10.x` address to a MAC.
That settles two things:
- **The IP → MAC approach is viable.** This was the operator's original question and
the answer is yes, with production evidence.
- **Spec §E had the release-gate risk backwards.** It called "whether TrustProxies
yields the true client IP" the thing that gates release, fearing a fail-closed
outage where nobody can get online. That fear is unfounded — it demonstrably
works. Remove it as a blocker.
Note the off-site public address: a portal request only ever originates on-site, so
a request whose IP is public will correctly find no UniFi client and refuse. That is
the right behaviour, not a gap.
### The open question, which is narrower than it first appeared
**A legitimate value arriving correctly and a forged value being rejected are
different properties.** The evidence above establishes the first. It does not
establish the second, because nothing in that list would look wrong if Traefik were
also passing through client-supplied headers.
So one question remains, and it is a one-line check
Whether a client can deliver a forged header depends entirely on Traefik's
`forwardedHeaders` for the entry point, on the Traefik host (162.223.102.234 —
a separate LXC, not reachable from the dev box):
- `forwardedHeaders.insecure: true`, or `trustedIPs` covering client ranges
→ client headers are passed through → **the resolver's guarantee is void**
- unset (Traefik's default) → Traefik replaces client-supplied forwarded headers
→ the design works as intended
**Treat this as the release gate.** Narrowing `trustProxies` from `*` to the
Traefik network is worth doing either way as defence in depth: as written, the
application delegates the entire question to whoever happens to be talking to it.
### The consequence that was missed, if it is forgeable
Not a quota bypass — quotas key on `user_id`, so an attacker cannot exceed their
own limit. It is worse in a different way. `updateOrCreate` writes a row keyed
`(attacker_user_id, victim_mac)`, making the attacker the legitimate **owner** of a
session pointing at somebody else's device. `disconnectSession` then passes its
owner check and runs `kickClient` + `unauthorizeGuest` on the victim's MAC.
The spec declared `disconnectSession` "already correct and not being changed". That
was true of the function and false of its inputs — the one component held out of
scope as safe is what turns this into an eviction primitive. Worth remembering as a
general lesson about scoping something out on the strength of its internals.
Also note the three distinct refusal reasons (`no_client` / `ap_mismatch` / success)
form an oracle: iterating candidate IPs enumerates the live client table with MAC
and AP. They were added for operability and double as reconnaissance.
**Nothing in the six commits is defective on this point.** `DeviceResolver` is a
correct implementation of "derive the MAC from the source IP" sitting on a platform
assumption. Spec §E named this exact dependency as the release gate and got the
direction backwards — it worried `TrustProxies` would be too strict (a visible
outage) and never considered it being too permissive (silent fail-open). The
blind-spot note in `b59b5bf` reasons carefully and still only considers the strict
direction.
**Exposure is wider than this feature.** Every consumer of `$request->ip()` in the
shell inherits it — session client IP, the Logged-in Users list, anything keyed on
IP. File against `dashboard-shell`, not `central-unifi`.
### What would not help
Assessed and rejected, all reducing to trusting the same value:
- **Re-checking the client's reported IP** — circular; the record was *selected* by
that IP, so the check cannot fail.
- **Requiring the client to be unauthorized** — breaks the renewal path and with it
the spec's headline promise, while still leaving first-time connectors targetable.
- **Association recency** — narrows harvesting to a race the attacker wins by
polling, and wrongly refuses a device revisiting after a long window expires.
The durable fix is to stop making the request IP load-bearing: have the controller
vouch for the MAC (a signed redirect / HMAC over `mac, ap, timestamp`). That
survives any proxy misconfiguration. Whether this controller supports it is a
question for the hardware session — **add it as a fourth item to spec §E**, ranked
above the health line in Plan 2.
---
## 1. The reconciler would revoke every guest authorization on the whole site
`src/Console/ReconcilePortal.php` — `array_diff($authorized, $live)` then
`unauthorizeGuest` on each, where `$authorized` comes from `getAuthorizedGuestMacs()`
reading **site-wide** `/stat/guest`. Nothing scopes it to authorizations this portal
created.
A second guest SSID, UniFi's own voucher portal, front-desk visitors, contractors,
a gym rental, or anything an admin authorized by hand: all revoked on the first
scheduled run, and again every run. Inversely, a device an admin deliberately
un-authorized in the UniFi UI gets re-authorized — the dashboard silently
overriding the network admin, forever.
Fix: only revoke a MAC that appears in `unifi_portal_sessions` at all, or filter by
the portal's SSID/VLAN. **This is why "not scheduled" is currently load-bearing
rather than merely cautious.**
Unverifiable without hardware, and worth adding to §E: what window does
`GET /stat/guest` return with no `within`, and does the controller rewrite `end`
when an authorization is revoked? If not, job 3 silently stops re-asserting.
## 2. The pre-DHCP retry cannot succeed, and costs a worker 1.5s per failure
`DeviceResolver` retries three times, but `findClientByIp()` → `getActiveClients()`
is `Cache::remember('unifi:clients', 30s)`. All three attempts read one snapshot.
Worse than a no-op: the first attempt **warms** the cache with a snapshot lacking
the connecting device, so "try again" stays refused for the rest of the TTL, for
that pupil and everyone else. Meanwhile each failure holds a PHP worker for 1.5s of
`usleep` — the exact cost the comment claims to bound, with none of the benefit. A
class of 25 arriving together is 25 workers asleep on one stale array.
`UnifiApiClient`'s own comment justifies the cache on the grounds that "the portal
calls this once per connection attempt". The resolver calls it three times.
Fix: `Cache::forget('unifi:clients')` between retries, or an uncached read path.
## 3a. Device type should come from the User-Agent, not from UniFi
Raised by the operator on 2026-08-21, from the same production screenshot: the
Logged-in Users list already labels sessions "Safari · iPhone" and
"Chrome · macOS", and does it well.
That is parsed from the **User-Agent**, by a hand-rolled private method
`ActiveSessionController::device(string $ua)` — no library involved. Its OS table
already distinguishes `iPhone`, `iPad`, `Android` from `Windows`, `macOS`,
`Linux`, `CrOS`, which is precisely the phone-versus-laptop distinction the
no-phones-for-students policy needs.
**This is a better source than UniFi's fingerprint for this purpose**, and it
removes a dependency the spec could never verify:
- **Available at authorization time.** A portal request comes *from the device*, so
its User-Agent is the device's own. UniFi's `dev_cat` is inferred from observed
traffic and does not exist yet at that moment — the reason §C had to push capture
into the reconciler at all.
- **Known to be populated.** `unifi_client_snapshots` has zero rows on dev, so
whether `dev_cat` is ever filled in this environment was never established. The
User-Agent is always present.
- **It fixes finding 3 with a value that is actually there.** Replacing the three
always-NULL `$request->input(...)` writes with the parsed label gives the denial
page real device names, which is the single case that list exists for.
Recommendation: extract `device()` into a shared support class (e.g.
`App\Support\UserAgent::describe()`) and have both the Logged-in Users controller
and the portal call it. Small refactor, one owner for the parsing, no duplication.
**The spoofing caveat stands and does not weaken this.** A mobile browser's
"Request Desktop Site" toggle changes the User-Agent, so this is sound for
*reporting* and unsound for *enforcement* — which is exactly decision 6
(report-only). Keep UniFi's `dev_cat` capture in the reconciler as a corroborating
second opinion: it is not client-controlled, so a disagreement between the two is
itself a useful signal if enforcement is ever wanted.
## 3. Every portal visit blanks the fingerprint the reconciler filled in
`PortalController` writes `device_hostname`, `device_os` and `device_type` from
`$request->input('hostname'/'os'/'dev_cat')`. UniFi's redirect sends `id`, `ap`,
`t`, `url`, `ssid` — the controller's own comment says so — so all three are
written NULL on every authorization.
The exact mirror of the bug `captureFingerprints()` goes out of its way to prevent.
Consequence: the denial page's device list always reads "Unknown device", so a pupil
at their limit chooses between two unlabelled MACs — the single case that list
exists for. Plan 2's Wi-Fi Devices page inherits it.
`$device->client` already carries UniFi's `hostname`/`os_name`/`dev_cat` and is
never read. Use it. These are also the last request-supplied values written to a
session, so `?hostname=Principal's Laptop` is shown to admins as fact.
## 4. Windows slide, contradicting the spec
`expires_at => now()->addMinutes($minutes)` on the renewal branch grants a fresh
full window on every visit. With `session_minutes` set to a term, anyone who
bookmarks the callback never ages out — which is what §C's fixed-window rule exists
to prevent. Either preserve `expires_at` on renewal, or amend the spec and say why
in a comment; today there is no comment where a reader would ask.
## 5. Two routes to a wrong quota
No unique index on `(user_id, mac_address)` (confirmed: only PRIMARY,
`(user_id,is_active)`, `(mac_address)`) and no lock around read-then-authorize.
- **Over quota:** two devices of a 1-device pupil hit the callback concurrently,
both read zero live sessions, both are authorized. The reconciler cannot correct
it — both rows are live and both authorized.
- **Phantom slots:** two concurrent renewals of the same MAC each miss the other and
insert twice. One phone, two live rows, permanently. The reconciler's
`keyBy(mac)` silently collapses the pair so neither direction sees it, and
self-disconnect deactivates only the row that was clicked.
Fix: unique index plus `lockForUpdate` in a transaction. Consider a reconciler step
trimming a user's live rows to their quota, newest first — nothing currently
enforces the quota against our own rows.
## 6. Denial copy contradicts itself on the commonest path
Controller says "Still connecting — wait a moment and try again"; `PortalDenied.vue`
then prints "Please contact the school office or IT for help getting online". Two
instructions in one view, on the most frequent denial. `resolveFailure` is passed
and never declared as a prop.
---
## Tests that pass for the wrong reason
1. **The headline quota-leak regression test** asserts `component('Unifi/PortalDenied')`
and a live count — but four branches render that component. It passes with no
role and no mapping at all, via "No WiFi access configured". Fix: assert the
`reason` string.
2. **The proxy-outage test** sets `X-Forwarded-For` and `REMOTE_ADDR` to the same
value, so its condition holds by construction; mutating the production
condition to `($forwarded)` alone still passes. (Note: under
`trustProxies(at: '*')`, `$ip === $raw` only holds when no XFF was sent, so
`proxy_untrusted` is unreachable in this environment — a consequence of #0.)
3. **The reconciler re-assert test** constrains neither MAC nor minutes. A sign
error in `now()->diffInMinutes($expires_at, false)` would clamp every
re-assertion to one minute and the test would not notice — and Carbon 3.13.1
changed that method's return type and default `$absolute`.
4. **The `sort_order` precedence test** inserts the low-`sort_order` row first, so
replacing `orderBy('sort_order')` with `all()` still passes. It also never
asserts the user holds `parent`.
Untested: the unreachable-controller guard — the fail-safe between a network blip
and mass revocation. Worth a test now, and more so once #1 is fixed.
---
## Smaller notes
- `PortalDeniedTest`'s docblock explains why a device "must look CONNECTED, or the
controller treats the session as stale, frees the slot and authorises" —
documenting behaviour this work **deleted**. Most confidently-worded comment in
the suite and now false. The dead `isClientConnected` stub goes with it.
- `ResolvedDevice::$client` is never read (see #3). `$macParameterDisagreed` is
asserted in a test and consumed nowhere — surface it or drop it and keep the log.
- The disagreement test asserts the flag, not that `Log::warning` fired, which is
what §B actually asks for.
- Unused `use ...DB;` in two test files, copied from the plan.
- Three resolver tests burn 1.5s of real `usleep` each; injecting the pause would
pay for itself.
- `UnifiApiClient::isClientConnected()` is now referenced nowhere in `src/`.
Correct per spec (display-only, Plan 2) — say so in a comment or the next
dead-code sweep deletes it.
## Release sequencing — not a finding, but it changes the plan
`Pages/Unifi/Portal.vue` still offers "Default (catch-all)", which now 422s, and
offers **no `role` option**. Wi-Fi quotas are therefore *unconfigurable* today:
the reachable match types are `ou`/`group`/`email_domain`. Spec §F step 3
("configure `role` → student / staff") cannot be carried out until Plan 2, which
makes **Plan 2 a hard prerequisite for release rather than a follow-up**.
---
## What is good and should not be disturbed
- **`scopeLive`** is the right shape in the right place: the model owns the
definition of a consumed slot, a NULL expiry is not live, and the *why* is in the
docblock. Leak 2 is fixed at the query level and does not depend on the
unscheduled reconciler. The most valuable thing in the six commits, and untouched
by #0.
- **`?mac=` really is dead.** Verified end to end: with a supplied MAC present,
`authorizeGuest` receives the derived one and no other row is written. No
fallback anywhere; an absent `?ap=` fails closed as `ap_mismatch` rather than
skipping the check. The fail-closed discipline is genuine and thorough — it is
the input that betrays it, not the logic.
- **`DeviceResolver` split out of the controller** — four named refusal reasons, no
quota knowledge, no Inertia. When the platform is fixed this is the right object
in the right place, and where an eventual HMAC check belongs.
- **`captureFingerprints()`' fill-only rule** and its reasoning. #3 is the
controller violating this rule, not this code being wrong.
- **The Mockery declaration-order docblock** in the reconciler test will save the
next person an hour.
- **Removing `default` while `unifi_vlan_mappings` was empty**, and recording in
code why doing it later would be worse.

View File

@@ -137,6 +137,20 @@ 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 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. 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 ### MAC randomization
Normally stable per SSID, so it does not break the design. When it does rotate, Normally stable per SSID, so it does not break the design. When it does rotate,

View File

@@ -0,0 +1,214 @@
# Captive portal splash designer
**Date:** 2026-08-21
**Repos:** `central-unifi` (routes, controller, model, migration), `dashboard-shell` (editor page, Blade splash view, tests)
**Status:** design approved, no implementation plan yet
**Sequencing:** queues behind the six open findings in
`docs/superpowers/reviews/2026-08-21-plan1-review.md`. Nothing here depends on
them, but they touch the same controller.
---
## What this is
Let an operator lay out what a pupil sees at the captive portal, with the device's
own facts available as variables — IP, MAC, browser, machine type — rather than the
four hardcoded strings the portal has today.
## What already exists and is being reused
| Need | Existing thing |
|---|---|
| `{token}` substitution + `{if}/{else}` conditionals | `App\Support\TemplateRender::apply()` — already used by signatures, NoE PDFs, payment receipts |
| Value escaping | the same method's `$escapeValues` parameter; signatures already pass it |
| Grouped variable sidebar | `TemplateRender`'s helper that turns a token→label map into grouped, alphabetised lists for a template editor |
| Live preview | the JS mirror `resources/js/utils/template.js` (`applyTemplate`) |
| Visual, source-free editing | the shared EmailComposer's anatomy (visual-only, no HTML source) |
| Device type from a User-Agent | `ActiveSessionController::device()` — already yields "Safari · iPhone", and its OS table separates iPhone/iPad/Android from the desktop platforms |
So this is mostly assembly. The genuinely new parts are a public Blade route, a
templates table, and the editor screen.
**Prerequisite:** `ActiveSessionController::device()` is a private method on a
controller. Extract it to `App\Support\UserAgent::describe()` and have the Logged-in
Users page and the portal both call it. This is also finding 3a of the Plan 1
review, which wants the same extraction for a different reason.
---
## Decisions
| # | Decision | Rationale |
|---|---|---|
| 1 | The designer covers **four** templates: splash, success, over-limit, refused | The after-login pages are where a pupil is actually stuck, and they can use quota facts the splash cannot |
| 2 | **Visual editor inside a fixed frame** | The sign-in button must not be removable; a bad edit would brick the portal for everyone at once |
| 3 | Device variables everywhere; person and quota variables on after-login pages only | Pre-login there is no signed-in user, so they would render blank |
| 4 | The splash is a **plain server-rendered Blade page**, not Inertia | The captive-network mini-browser is a stripped-down WebView; SPA bootstrapping is unreliable there |
| 5 | **Escape every substituted value; no `rawKeys`** | `{browser}` and `{device_type}` derive from a client-controlled header, on a public page |
| 6 | **No HTML sanitiser** on the operator's body | No library present, and a homemade allowlist is worse than none. Mitigated by a source-free editor and the `unifi.manage` gate. Residual risk stated below |
---
## A. The four templates
| Template key | Rendered by | Auth | Variables |
|---|---|---|---|
| `splash` | new public route, Blade | none | device only |
| `success` | `captiveCallback`, after authorizing | auth | device + person/quota |
| `over_limit` | `captiveCallback`, quota branch | auth | device + person/quota + the device list |
| `refused` | `captiveCallback`, no-mapping or resolver refusal | auth | device only — there may be no mapping to quote |
**The splash needs a new public route.** `captiveCallback` sits behind `['web','auth']`
today, so an unauthenticated hit does `redirect()->route('login')` and the pupil
lands on the ordinary shell login. That is why there is no splash at all now.
Flow: UniFi redirects → public splash (device facts, "Sign in with Google") →
Google → `captiveCallback` → success / over-limit / refused.
The three after-login pages stay Inertia: by then the pupil has been through Google
in a real browser.
### Storage
A `unifi_portal_templates` table, one row per key: `key`, `body`, `updated_at`.
Not `Setting`, because these are long HTML bodies and a table gives a per-template
`updated_at` and a clean per-template reset to default.
### Variables
Device — available on all four:
| Token | Source | Trustworthy? |
|---|---|---|
| `{ip}` | `$request->ip()` via Traefik | Yes in practice — production shows real per-client addresses |
| `{mac}` | derived from the IP via UniFi's client table | Yes — server-derived |
| `{is_randomized}` | locally-administered bit on the derived MAC | Yes — server-derived |
| `{browser}` | `App\Support\UserAgent::describe()` | **No — client-controlled** |
| `{device_type}` | same | **No — client-controlled** |
| `{ssid}`, `{ap_name}` | the redirect / the client record | Yes |
Person and quota — after-login pages only: `{name}`, `{role}`, `{devices_used}`,
`{device_limit}`, `{expires_on}`.
On the splash template the person and quota tokens are **shown disabled with the
reason**, not hidden and not silently rendering blank. Hiding them invites the
question "why can't I show their name"; blanking them ships a broken page.
---
## B. The editor
A new tab on the existing Unifi → Portal settings page, gated on `unifi.manage` —
the key that already guards portal settings.
- **Template switcher** across the four keys. One screen, not four.
- **Fixed frame.** Logo slot, editable content region, sign-in button, footer. The
frame and the button are our markup and are not part of the editable body, so no
edit can remove the way in.
- **Variable picker** beside the editor, grouped Device / Person & Quota, built from
`TemplateRender`'s grouping helper.
- **Live preview** through the JS mirror, fed sample values. The token set is
defined once, server-side, and handed to the page — the memory on this engine is
explicit that the JS mirror and the PHP engine must change together, so a single
source for the token list is the point.
- **Images must be local.** The picker offers uploaded images only, and pasting an
external URL warns. See section C — a remote asset cannot load on the splash.
- **Reset to default** per template.
---
## C. Rendering safety
### Escape everything
`TemplateRender::apply($body, $values, escapeValues: true)` with **no `rawKeys`**.
`{browser}` and `{device_type}` come from the User-Agent, so a crafted header would
otherwise be reflected XSS on an unauthenticated page. `{ip}` and `{mac}` are
server-derived and escaped anyway, because there is no reason not to. Nothing needs
to bypass escaping: with a fixed frame the sign-in button is markup, not a token.
The engine defaults `escapeValues` to false, which is correct for its email and PDF
callers. This is therefore an explicit decision at the call site, and the reason
belongs in a comment there.
### No sanitiser on the operator's body, and what that leaves
There is no HTML sanitising library in the project and no allowlist helper. Writing
one is rejected: a homemade allowlist is leaky in ways nobody audits, which is worse
than not having one.
Mitigations: the editor is visual-only and offers no source view or script
insertion, and the body is editable only by `unifi.manage` holders.
**Residual risk, stated plainly:** an admin holding `unifi.manage` can put arbitrary
HTML on a public page. That is a trusted-insider scenario rather than an escalation —
the same admin already controls the portal, the VLAN mappings and the device quotas.
But it is a real difference from signatures, which use the same engine and render
into email rather than onto the open internet. If the trust boundary ever changes —
say portal editing is delegated to a non-admin role — this decision must be revisited
first.
### The constraint most likely to break a rollout
**The splash is served before the device has internet access.** That is what a
captive portal is. Any external asset — a CDN, Google Fonts, a remote logo — silently
fails to load, and the pupil gets an unstyled page at the worst possible moment.
Therefore: all CSS and images local, no external fonts, and UniFi's pre-auth walled
garden must allow this host. The likeliest way this breaks in practice is a
well-meaning operator pasting a remote logo URL into the editor, which is why
section B restricts images rather than merely advising.
### Fallback
If a template row is missing, empty, or throws while rendering, render a built-in
default body. A blank or broken template must never leave a pupil with no way in —
the same reasoning as the fixed frame, applied to content instead of structure.
### Headers
The splash carries a device's MAC and IP, so: `Cache-Control: no-store` and
`noindex`.
---
## D. Testing
Covered here:
- Escaping: a hostile User-Agent (`<script>` and an `onerror=` attribute) renders
escaped in the splash body.
- The public splash route renders with **no** authentication.
- Person and quota tokens on the splash render as their disabled placeholder rather
than leaking a blank or a previous user's value.
- A missing template row, and an empty body, each fall back to the default.
- The after-login pages render quota values — `devices_used`, `device_limit`,
`expires_on`.
- **Rendered splash HTML references no external hosts.** This is the regression most
likely to creep back in, and it is invisible on a developer machine that has
internet.
- `UserAgent::describe()` against real iOS, Android, macOS and Windows strings, plus
an empty one.
Browser pass on the splash in a plain, non-Inertia context.
**Not provable here** — same hardware list as the access-model spec: whether UniFi's
walled garden lets this host through pre-auth, and how the real Captive Network
Assistant behaves with the page.
---
## Out of scope
- **Drag-and-drop or free positioning.** A captive portal is a single narrow column
on a phone; absolute positioning is actively wrong for it.
- **Conditionals on `{device_type}` as a decision.** The engine supports
`{if device_type == 'iPhone'}` and the picker will not encourage it: it puts a
spoofable value into a decision rather than a display, which contradicts the
report-only stance agreed for device type.
- **Enforcement of any kind.** This designer changes what a pupil is told, never
what they are allowed.
- **Per-role or per-SSID template variants.** One template per key. Add variants
when somebody asks for them.
- **A block builder.** Considered and rejected: it invents a pattern this codebase
has no precedent for, and the layout freedom it buys is limited on a phone.

View File

@@ -0,0 +1,110 @@
<?php
namespace Dashboard\Unifi\Console;
use Dashboard\Unifi\Models\PortalSession;
use Dashboard\Unifi\Services\UnifiApiClient;
use Illuminate\Console\Command;
/**
* Keep our rows and UniFi's authorizations in step.
*
* Necessary because session windows are now measured in months: any drift that
* used to be washed away by short sessions and frequent portal visits now
* persists. Three jobs, and the middle one is the one that matters most.
*/
class ReconcilePortal extends Command
{
protected $signature = 'unifi:reconcile-portal';
protected $description = 'Expire finished Wi-Fi sessions and reconcile them against UniFi authorizations';
public function handle(UnifiApiClient $unifi): int
{
// 1. Expiry. expires_at used to be written and never read, so a finished
// row kept consuming a slot indefinitely.
$expired = PortalSession::where('is_active', true)
->whereNotNull('expires_at')
->where('expires_at', '<=', now())
->update(['is_active' => false]);
if ($expired) {
$this->info("Expired {$expired} finished session(s).");
}
try {
$authorized = $unifi->getAuthorizedGuestMacs();
} catch (\Throwable $e) {
// A controller we cannot reach is not evidence that anything should
// change. Expiry above is local and already done.
$this->error('Could not read UniFi authorizations: ' . $e->getMessage());
return self::SUCCESS;
}
$live = PortalSession::live()->get()->keyBy(fn ($s) => strtolower($s->mac_address));
// 2. Orphans: authorized on the network with no live row of ours. These
// are what the old presence-based sweep produced — a device nobody
// can see in the UI and nobody's quota is paying for.
foreach (array_diff($authorized, $live->keys()->all()) as $orphan) {
$unifi->unauthorizeGuest($orphan);
$this->warn("Revoked orphan authorization {$orphan}.");
}
// 3. The other direction: we consider it live but UniFi has forgotten it
// (upgrade, restore). Re-assert rather than making the person sign in
// again for something that was not their doing.
foreach ($live->keys()->diff($authorized) as $mac) {
$session = $live->get($mac);
$minutes = max(1, (int) now()->diffInMinutes($session->expires_at, false));
$unifi->authorizeGuest($mac, $minutes);
$this->warn("Re-asserted authorization for {$mac} ({$minutes}m remaining).");
}
$this->captureFingerprints($unifi, $live);
return self::SUCCESS;
}
/**
* Record what UniFi thinks each device IS. Reported only — nothing acts on
* it, per the decision that enforcing on unvalidated fingerprint data would
* lock out the wrong people.
*
* This is the earliest point the data exists: UniFi infers dev_cat from
* observed traffic, so it is absent at the moment of authorization.
*
* @param \Illuminate\Support\Collection $live keyed by lower-case MAC
*/
private function captureFingerprints(UnifiApiClient $unifi, $live): void
{
try {
$clients = collect($unifi->getActiveClients())
->keyBy(fn ($c) => strtolower((string) ($c['mac'] ?? '')));
} catch (\Throwable $e) {
$this->error('Could not read clients for fingerprints: ' . $e->getMessage());
return;
}
foreach ($live as $mac => $session) {
$client = $clients->get($mac);
if (! $client) {
continue;
}
// Only ever fill in. An unfingerprinted client must not blank a type
// we already recorded — that would lose the answer every time the
// device happened to be quiet.
$updates = array_filter([
'device_type' => $client['dev_cat'] ?? null,
'device_os' => $client['os_name'] ?? null,
'device_hostname' => $client['hostname'] ?? null,
], fn ($v) => $v !== null && $v !== '');
if ($updates) {
$session->update($updates);
}
}
}
}

View File

@@ -32,7 +32,7 @@ class PortalController extends Controller
{ {
$data = $request->validate([ $data = $request->validate([
'group_name' => 'required|string|max:255', 'group_name' => 'required|string|max:255',
'match_type' => 'required|in:ou,group,email_domain,default', 'match_type' => 'required|in:ou,group,email_domain,role',
'match_value' => 'required|string|max:255', 'match_value' => 'required|string|max:255',
'vlan_id' => 'required|integer|min:1|max:4094', 'vlan_id' => 'required|integer|min:1|max:4094',
'max_devices' => 'required|integer|min:1|max:50', 'max_devices' => 'required|integer|min:1|max:50',
@@ -51,7 +51,7 @@ class PortalController extends Controller
{ {
$data = $request->validate([ $data = $request->validate([
'group_name' => 'required|string|max:255', 'group_name' => 'required|string|max:255',
'match_type' => 'required|in:ou,group,email_domain,default', 'match_type' => 'required|in:ou,group,email_domain,role',
'match_value' => 'required|string|max:255', 'match_value' => 'required|string|max:255',
'vlan_id' => 'required|integer|min:1|max:4094', 'vlan_id' => 'required|integer|min:1|max:4094',
'max_devices' => 'required|integer|min:1|max:50', 'max_devices' => 'required|integer|min:1|max:50',
@@ -133,30 +133,24 @@ class PortalController extends Controller
return redirect()->route('login'); return redirect()->route('login');
} }
$clientMac = strtolower($request->input('mac', $request->input('id', ''))); $apMac = (string) $request->input('ap', '');
$ssid = $request->input('ssid', ''); $ssid = (string) $request->input('ssid', '');
$apMac = $request->input('ap', '');
if (! $clientMac) { $device = app(\Dashboard\Unifi\Services\DeviceResolver::class)->resolve($request, $apMac);
return response('Missing MAC address', 400);
if (! $device->ok) {
return Inertia::render('Unifi/PortalDenied', [
'reason' => match ($device->reason) {
'ap_mismatch' => 'That device is not connected to this access point. Reconnect and try again.',
'proxy_untrusted' => 'Wi-Fi sign-in is misconfigured and cannot identify your device. Please tell IT.',
default => 'Still connecting — wait a moment and try again.',
},
'resolveFailure' => $device->reason,
]);
} }
// SECURITY: everything identifying the device is request-supplied, and this $clientMac = $device->mac;
// endpoint WRITES network state (authorizeGuest puts a MAC on the mapped VLAN). $isRandomized = $device->isRandomized;
// Before this check any signed-in account — a student, a parent — could call
// GET /portal/wifi/callback?mac=<someone else's MAC>&url=…
// and admit a device they do not own, up to max_devices.
//
// A well-formed MAC is the floor, not a fix: it stops the parameter being used
// to smuggle junk into authorizeGuest and into the PortalSession row, but it
// cannot prove the caller owns the device. Properly binding it needs the AP
// handshake — UniFi can sign the redirect, or we ask the controller whether this
// MAC is CURRENTLY associated to $apMac before admitting it. Both need testing
// against real hardware, so they are deliberately not guessed at here.
$clientMac = str_replace('-', ':', $clientMac);
if (! preg_match('/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/', $clientMac)) {
return response('Malformed MAC address', 400);
}
// Find the VLAN mapping for this user's group/OU // Find the VLAN mapping for this user's group/OU
$mapping = $this->resolveMapping($user); $mapping = $this->resolveMapping($user);
@@ -164,28 +158,30 @@ class PortalController extends Controller
return Inertia::render('Unifi/PortalDenied', ['reason' => 'No WiFi access configured for your account.']); return Inertia::render('Unifi/PortalDenied', ['reason' => 'No WiFi access configured for your account.']);
} }
// Check device limit /*
$activeSessions = PortalSession::where('user_id', $user->id) * Presence is NOT the question — a live authorization is.
->where('is_active', true) *
->get(); * This used to walk the user's sessions and mark any device UniFi did not
* currently see as stale, freeing its slot. But UniFi holds an
* authorization for its whole window regardless of whether the device is
* in the building, so a phone that went home had its slot freed while
* still being authorized: the owner then authorized a second device and
* one of the two was an orphan nothing tracked. The longer
* session_minutes is — and it is now meant to be a term — the more of
* those accumulate.
*
* Slots are now freed by eviction, by expiry, or by ReconcilePortal.
*/
$live = PortalSession::live()->where('user_id', $user->id)->get();
foreach ($activeSessions as $session) { // The same device coming back is a renewal, not a new device: it keeps
if ($session->mac_address === $clientMac) { // its slot and its row, and simply gets a fresh window.
// Same device reconnecting — refresh $existing = $live->firstWhere('mac_address', $clientMac);
$session->update(['is_active' => false]);
continue;
}
// Check if other device is still actually connected
if (! $unifi->isClientConnected($session->mac_address)) {
$session->update(['is_active' => false]); // stale session
}
}
$activeCount = PortalSession::where('user_id', $user->id)->where('is_active', true)->count(); if (! $existing && $live->count() >= $mapping->max_devices) {
if ($activeCount >= $mapping->max_devices) {
return Inertia::render('Unifi/PortalDenied', [ return Inertia::render('Unifi/PortalDenied', [
'reason' => "You already have {$activeCount} device(s) connected (limit: {$mapping->max_devices}).", 'reason' => "You already have {$live->count()} device(s) connected (limit: {$mapping->max_devices}).",
'sessions' => $activeSessions->map(fn ($s) => [ 'sessions' => $live->map(fn ($s) => [
'id' => $s->id, 'id' => $s->id,
'mac' => $s->mac_address, 'mac' => $s->mac_address,
'hostname' => $s->device_hostname, 'hostname' => $s->device_hostname,
@@ -200,10 +196,9 @@ class PortalController extends Controller
$minutes = $mapping->session_minutes; $minutes = $mapping->session_minutes;
$unifi->authorizeGuest($clientMac, $minutes); $unifi->authorizeGuest($clientMac, $minutes);
// Record the session PortalSession::updateOrCreate(
PortalSession::create([ ['user_id' => $user->id, 'mac_address' => $clientMac],
'user_id' => $user->id, [
'mac_address' => $clientMac,
'device_hostname' => $request->input('hostname'), 'device_hostname' => $request->input('hostname'),
'device_os' => $request->input('os'), 'device_os' => $request->input('os'),
'device_type' => $request->input('dev_cat'), 'device_type' => $request->input('dev_cat'),
@@ -213,7 +208,9 @@ class PortalController extends Controller
'is_active' => true, 'is_active' => true,
'authorized_at' => now(), 'authorized_at' => now(),
'expires_at' => now()->addMinutes($minutes), 'expires_at' => now()->addMinutes($minutes),
]); 'is_randomized' => $isRandomized,
]
);
// Redirect to the original URL the user was trying to reach. // Redirect to the original URL the user was trying to reach.
// //
@@ -254,7 +251,16 @@ class PortalController extends Controller
'email_domain' => str_ends_with($user->email ?? '', '@' . $mapping->match_value), 'email_domain' => str_ends_with($user->email ?? '', '@' . $mapping->match_value),
'group' => $user->groups?->contains('name', $mapping->match_value) ?? false, 'group' => $user->groups?->contains('name', $mapping->match_value) ?? false,
'ou' => str_contains($user->ou ?? '', $mapping->match_value), 'ou' => str_contains($user->ou ?? '', $mapping->match_value),
'default' => true, /*
* Roles are the platform's own authority and need no external
* sync to be correct, which is why quotas key off them.
*
* `default` is deliberately absent. It matched everybody, so its
* existence made this an advisory list rather than an allow-list.
* Returning null here is already the deny path: captiveCallback
* renders "No WiFi access configured for your account."
*/
'role' => $user->hasRole($mapping->match_value),
default => false, default => false,
}; };
if ($matched) return $mapping; if ($matched) return $mapping;

View File

@@ -11,11 +11,13 @@ class PortalSession extends Model
protected $fillable = [ protected $fillable = [
'user_id', 'mac_address', 'device_hostname', 'device_os', 'device_type', 'user_id', 'mac_address', 'device_hostname', 'device_os', 'device_type',
'ssid', 'vlan_id', 'ap_mac', 'is_active', 'authorized_at', 'expires_at', 'ssid', 'vlan_id', 'ap_mac', 'is_active', 'authorized_at', 'expires_at',
'is_randomized',
]; ];
protected $casts = [ protected $casts = [
'is_active' => 'boolean', 'is_active' => 'boolean',
'authorized_at' => 'datetime', 'authorized_at' => 'datetime',
'expires_at' => 'datetime', 'expires_at' => 'datetime',
'is_randomized' => 'boolean',
]; ];
public function user(): BelongsTo public function user(): BelongsTo
@@ -23,6 +25,23 @@ class PortalSession extends Model
return $this->belongsTo(\App\Models\User::class); return $this->belongsTo(\App\Models\User::class);
} }
/**
* The rows that consume a device slot.
*
* Presence on the network is deliberately NOT part of this. UniFi holds an
* authorization for its full window whether or not the device is in the
* building, so a phone that went home still owns its slot — freeing it here
* is what let one person hold two authorizations against a one-device quota.
*
* A NULL expires_at is not live: it predates this being enforced.
*/
public function scopeLive($query)
{
return $query->where('is_active', true)
->whereNotNull('expires_at')
->where('expires_at', '>', now());
}
public function setMacAddressAttribute($value) public function setMacAddressAttribute($value)
{ {
$this->attributes['mac_address'] = strtolower($value); $this->attributes['mac_address'] = strtolower($value);

View File

@@ -0,0 +1,104 @@
<?php
namespace Dashboard\Unifi\Services;
use Dashboard\Unifi\Support\MacAddress;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
/** The outcome of trying to identify the device making a portal request. */
class ResolvedDevice
{
public function __construct(
public bool $ok,
public ?string $mac = null,
public ?string $reason = null,
public bool $isRandomized = false,
public bool $macParameterDisagreed = false,
public array $client = [],
) {}
}
/**
* Identify the device that made a captive-portal request, or refuse.
*
* The `?mac=` parameter is NOT trusted. It is read only so a disagreement with
* the derived value can be logged — that disagreement is somebody trying to
* authorize a device they do not own, and before this nothing anywhere noticed.
*
* Every path refuses rather than guessing. The cost of that is real: if
* TrustProxies is misconfigured, `proxy_untrusted` is returned for everyone and
* nobody gets online, which is why it is a distinct reason the portal page can
* report instead of a generic miss.
*/
class DeviceResolver
{
public function __construct(private UnifiApiClient $unifi) {}
/** Attempts, and the pause between them, for a device that has just associated. */
private const ATTEMPTS = 3;
private const PAUSE_US = 750_000;
public function resolve(Request $request, string $apMac): ResolvedDevice
{
$ip = $request->ip();
/*
* A device that has only just associated may have no IP yet, so UniFi's
* client table does not know it. Retry briefly rather than telling a
* pupil their device is not connected when it is about to be.
*
* 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.
*/
$client = null;
for ($attempt = 1; $attempt <= self::ATTEMPTS; $attempt++) {
$client = $this->unifi->findClientByIp($ip);
if ($client) {
break;
}
if ($attempt < self::ATTEMPTS) {
usleep(self::PAUSE_US);
}
}
if (! $client) {
// A forwarded header present while the request IP is still the raw
// socket address means TrustProxies is not applying, so every lookup
// here is doomed for the same reason. Say which failure this is.
$forwarded = $request->headers->get('X-Forwarded-For');
$raw = $request->server('REMOTE_ADDR');
$reason = ($forwarded && $ip === $raw) ? 'proxy_untrusted' : 'no_client';
return new ResolvedDevice(ok: false, reason: $reason);
}
$mac = MacAddress::normalise($client['mac'] ?? null);
if (! $mac) {
return new ResolvedDevice(ok: false, reason: 'no_client');
}
$clientAp = MacAddress::normalise($client['ap_mac'] ?? null);
if (! $clientAp || $clientAp !== MacAddress::normalise($apMac)) {
return new ResolvedDevice(ok: false, reason: 'ap_mismatch');
}
$supplied = MacAddress::normalise($request->input('mac'));
$disagreed = $supplied !== null && $supplied !== $mac;
if ($disagreed) {
Log::warning('unifi.portal: supplied MAC disagreed with the derived one', [
'derived' => $mac, 'supplied' => $supplied, 'ip' => $ip, 'ap' => $apMac,
]);
}
return new ResolvedDevice(
ok: true,
mac: $mac,
isRandomized: MacAddress::isRandomized($mac),
macParameterDisagreed: $disagreed,
client: $client,
);
}
}

View File

@@ -253,6 +253,24 @@ class UnifiApiClient
return ! empty($data); return ! empty($data);
} }
/**
* The client currently holding this IP, or null.
*
* Reads the cached client list rather than querying per IP: the portal calls
* this once per connection attempt, and a class arriving at once would
* otherwise be one controller round trip each.
*/
public function findClientByIp(string $ip): ?array
{
foreach ($this->getActiveClients() as $client) {
if (($client['ip'] ?? null) === $ip) {
return $client;
}
}
return null;
}
public function kickClient(string $mac): array public function kickClient(string $mac): array
{ {
Cache::forget('unifi:clients'); Cache::forget('unifi:clients');
@@ -286,6 +304,25 @@ class UnifiApiClient
return $this->post('/cmd/stamgr', ['cmd' => 'unauthorize-guest', 'mac' => strtolower($mac)]); return $this->post('/cmd/stamgr', ['cmd' => 'unauthorize-guest', 'mac' => strtolower($mac)]);
} }
/**
* The MACs UniFi currently holds an active guest authorization for.
*
* Read from /stat/guest, which is the authorization list — deliberately not
* /stat/sta, which is who is associated right now. Those are the two facts
* this whole feature had been conflating.
*/
public function getAuthorizedGuestMacs(): array
{
$rows = $this->get('/stat/guest');
return collect($rows)
->filter(fn ($g) => (int) ($g['end'] ?? 0) > now()->timestamp)
->pluck('mac')
->map(fn ($m) => strtolower((string) $m))
->values()
->all();
}
private function delete(string $path): void private function delete(string $path): void
{ {
$this->init(); $this->init();

View File

@@ -0,0 +1,31 @@
<?php
namespace Dashboard\Unifi\Support;
/**
* Pure MAC helpers. No dependencies, so they are cheap to test and safe to call
* from anywhere.
*/
class MacAddress
{
/** Lower-case, colon-separated, or null if it is not a MAC at all. */
public static function normalise(?string $mac): ?string
{
$mac = strtolower(str_replace('-', ':', trim((string) $mac)));
return preg_match('/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/', $mac) ? $mac : null;
}
/**
* Is this a private (randomized) address rather than a real manufacturer one?
*
* The locally-administered bit is the second-least-significant bit of the
* first octet. iOS "Private Wi-Fi Address" and Android per-network
* randomization both set it. This is why OUI is useless for identifying
* device types: a randomized MAC has no real manufacturer prefix.
*/
public static function isRandomized(string $mac): bool
{
return (bool) (hexdec(substr($mac, 0, 2)) & 0b10);
}
}

View File

@@ -68,6 +68,7 @@ class UnifiServiceProvider extends ServiceProvider
Console\RebootAllAps::class, Console\RebootAllAps::class,
Console\RotatePasswords::class, Console\RotatePasswords::class,
Console\SyncPpskSchedules::class, Console\SyncPpskSchedules::class,
Console\ReconcilePortal::class,
]); ]);
$this->publishes([ $this->publishes([
__DIR__ . '/../config/unifi.php' => config_path('unifi.php'), __DIR__ . '/../config/unifi.php' => config_path('unifi.php'),