11 Commits

Author SHA1 Message Date
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
dbebb6c964 Plan 1 of 2: the BYOD Wi-Fi access model
Six TDD tasks covering the back-end half of the approved spec: the live-authorization
quota scope, dropping network presence as an eviction trigger, the role allow-list
with `default` removed, the IP-derived device resolver, randomized-address
detection, and the reconciler.

Split from the UI deliberately. Plan 1 leaves the portal correct and safe while
still being configured through the existing mappings CRUD, so it is worth shipping
on its own; Plan 2 covers the settings screen, the denial-page coaching copy and
the Wi-Fi Devices page.

Three things the self-review caught and the plan now handles:

  · the resolver had no retry, so a device that had only just associated — and so
    had no IP in UniFi's table yet — would have been told it was not connected at
    the exact moment it was connecting;
  · nothing captured the device fingerprint, which the report-only device-type
    decision depends on. The reconciler now does it, and 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;
  · one task's test needed a method a later task introduced, and the first draft
    said to leave it red. Under subagent-driven execution a fresh agent per task
    would have seen red and tried to fix it, so the test now uses the parameter
    flow that exists at that point and the later task migrates it.

Also records what NOT to do: do not schedule the reconciler (it calls
unauthorizeGuest against a live controller that nothing here has been tested
against), do not register the unifi app, and do not touch disconnectSession.
2026-08-21 12:56:23 -04:00
c745aa8b2c Spec: Wi-Fi access becomes an explicit allow-list, configured in settings
Two changes from the operator.

Session length and devices-per-role are set in the settings UI, on the existing
Portal settings page rather than a new one. Two presentation decisions that are
really correctness decisions:

  · the role is picked from a DROPDOWN of seeded roles, not typed. match_value is
    compared exactly, so "Student" or "studnet" matches nobody and the failure
    surfaces as a pupil with no Wi-Fi and nothing logged anywhere. A dropdown
    makes an unmatchable value impossible to enter.
  · session length is entered in DAYS with presets. session_minutes stays the
    stored unit, but a term is 172,800 minutes and nobody should type that — the
    same reasoning that presents cron schedules as presets instead of raw strings.

And access is now an explicit allow-list: no role added means no Wi-Fi. That makes
`default` an anti-feature, because a default row matches everybody and turns the
allow-list into a suggestion, so it is removed from the available match types.
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 as soon as the catch-all cannot be created.

Safe to do now specifically because unifi_vlan_mappings has ZERO rows in every
environment — nothing is revoked from anybody. The same change made after a school
had leaned on a catch-all would cut off every unmapped person at once.

VLAN stays required per row. It was not in the request, so it is called out in the
spec rather than quietly dropped: the network still has to know where to put the
device.
2026-08-21 12:48:47 -04:00
79a8360714 Design: harden the BYOD Wi-Fi captive portal
Brainstormed spec for registered guest/BYOD Wi-Fi. The headline finding is that
this is not a new feature: the portal, per-cohort device limits, per-cohort VLANs,
session length, self-service eviction and admin eviction all already exist here,
and CaptureSnapshots is already collecting device fingerprints. So the spec is
about what is broken and what is missing, not what to build from scratch.

Two defects in quota accounting, in opposite directions, both of which get worse
the longer session_minutes is — and long sessions are the whole point of "do not
make users re-do the portal every connection":

  · a slot is freed when isClientConnected() is false, so a phone that leaves the
    building frees its slot while UniFi still holds it authorized — the user then
    authorizes a second device and one of them is an orphan nothing tracks;
  · expires_at is written and never read, and no job expires sessions, so a row
    keeps consuming a slot long after UniFi stopped honouring it.

The fix is to count live AUTHORIZATIONS rather than present devices, and to let a
reconciler repair both directions.

Also specified: closing the MAC-ownership hole the original author documented and
declined to guess at, by deriving the MAC from the request's source IP and
verifying AP association instead of trusting ?mac=. That fails closed, which is
correct — and the spec is explicit that it can therefore fail closed for
EVERYONE if TrustProxies is wrong behind Traefik, so the resolver has to report
that case distinctly and it is the one thing to verify against real hardware
before release.

Device-type detection (keeping students off phones) is specified as report-only.
UniFi's fingerprint is derived from observed traffic and so is not available at
the moment of authorization, and unifi_client_snapshots has zero rows on dev, so
there is no evidence dev_cat is populated in this environment. Enforcing on
unvalidated data would lock out the wrong people. MAC OUI is rejected outright:
a randomized MAC has no real OUI, so it would fail on phones while working on the
laptops that should not be blocked.

No version bump: documentation only, no runtime change, and bumping would put the
repo ahead of prod for a file that cannot affect it.
2026-08-21 12:43:21 -04:00
12 changed files with 2434 additions and 58 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "dashboard/unifi",
"description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform",
"version": "1.13.4",
"version": "1.13.5",
"type": "library",
"license": "MIT",
"autoload": {

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');
});
}
};

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,302 @@
# 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 stays `sort_order`, so a mapping for
`staff` above one for `parent` gives a staff-parent 4 devices.
---
## B. Identifying the device
### Flow
1. UniFi redirects the device to `/portal/wifi/callback?ap=<ap_mac>&ssid=…&url=…`.
2. Google auth via the shell — unchanged.
3. Resolve the device **server-side**: match the request's client IP against
UniFi's live client table (`getActiveClients()`, on `ip`) to obtain the real
MAC, its `ap_mac`, and its authorization state.
4. Require that the resolved client's `ap_mac` equals the `ap` in the redirect —
the device must be on the AP that sent it here.
5. `?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.
6. 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:
1. **Expire** rows past `expires_at` — fixes leak 2.
2. **Reconcile both directions** — revoke UniFi authorizations with no live row
(the orphans leak 1 produced), and expire rows UniFi no longer holds.
3. **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:
- **`default` is removed from the available match types.** A `default` row 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_mappings` in 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`, `group` and `ou` stay 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-mono` as 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-danger` row action behind the shared
confirm modal. Never a native `confirm()`.
- 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.
- `role` matching, and `sort_order` precedence 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_cat` is populated for these clients — `unifi_client_snapshots` has
zero rows on dev, so there is no evidence either way.
- Whether `TrustProxies` yields 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
1. **Register the app.** Every route here 404s today, because `unifi` has no
`dashboard_apps` row and `app.access:unifi` aborts 404 when the lookup misses.
Nothing in this spec is reachable until that is resolved. Note `central-unifi`
declares `stability: 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.**
2. Verify the three hardware-dependent items above against the live controller.
3. Configure mappings: `role` → student (1 device) and staff (4 devices), with
`sort_order` set so multi-role people land deliberately.
4. 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.

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([
'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',
'vlan_id' => 'required|integer|min:1|max:4094',
'max_devices' => 'required|integer|min:1|max:50',
@@ -51,7 +51,7 @@ class PortalController extends Controller
{
$data = $request->validate([
'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',
'vlan_id' => 'required|integer|min:1|max:4094',
'max_devices' => 'required|integer|min:1|max:50',
@@ -133,30 +133,24 @@ class PortalController extends Controller
return redirect()->route('login');
}
$clientMac = strtolower($request->input('mac', $request->input('id', '')));
$ssid = $request->input('ssid', '');
$apMac = $request->input('ap', '');
$apMac = (string) $request->input('ap', '');
$ssid = (string) $request->input('ssid', '');
if (! $clientMac) {
return response('Missing MAC address', 400);
$device = app(\Dashboard\Unifi\Services\DeviceResolver::class)->resolve($request, $apMac);
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
// endpoint WRITES network state (authorizeGuest puts a MAC on the mapped VLAN).
// 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);
}
$clientMac = $device->mac;
$isRandomized = $device->isRandomized;
// Find the VLAN mapping for this user's group/OU
$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.']);
}
// Check device limit
$activeSessions = PortalSession::where('user_id', $user->id)
->where('is_active', true)
->get();
/*
* Presence is NOT the question — a live authorization is.
*
* 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) {
if ($session->mac_address === $clientMac) {
// Same device reconnecting — refresh
$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
}
}
// The same device coming back is a renewal, not a new device: it keeps
// its slot and its row, and simply gets a fresh window.
$existing = $live->firstWhere('mac_address', $clientMac);
$activeCount = PortalSession::where('user_id', $user->id)->where('is_active', true)->count();
if ($activeCount >= $mapping->max_devices) {
if (! $existing && $live->count() >= $mapping->max_devices) {
return Inertia::render('Unifi/PortalDenied', [
'reason' => "You already have {$activeCount} device(s) connected (limit: {$mapping->max_devices}).",
'sessions' => $activeSessions->map(fn ($s) => [
'reason' => "You already have {$live->count()} device(s) connected (limit: {$mapping->max_devices}).",
'sessions' => $live->map(fn ($s) => [
'id' => $s->id,
'mac' => $s->mac_address,
'hostname' => $s->device_hostname,
@@ -200,10 +196,9 @@ class PortalController extends Controller
$minutes = $mapping->session_minutes;
$unifi->authorizeGuest($clientMac, $minutes);
// Record the session
PortalSession::create([
'user_id' => $user->id,
'mac_address' => $clientMac,
PortalSession::updateOrCreate(
['user_id' => $user->id, 'mac_address' => $clientMac],
[
'device_hostname' => $request->input('hostname'),
'device_os' => $request->input('os'),
'device_type' => $request->input('dev_cat'),
@@ -213,7 +208,9 @@ class PortalController extends Controller
'is_active' => true,
'authorized_at' => now(),
'expires_at' => now()->addMinutes($minutes),
]);
'is_randomized' => $isRandomized,
]
);
// 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),
'group' => $user->groups?->contains('name', $mapping->match_value) ?? false,
'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,
};
if ($matched) return $mapping;

View File

@@ -11,11 +11,13 @@ class PortalSession extends Model
protected $fillable = [
'user_id', 'mac_address', 'device_hostname', 'device_os', 'device_type',
'ssid', 'vlan_id', 'ap_mac', 'is_active', 'authorized_at', 'expires_at',
'is_randomized',
];
protected $casts = [
'is_active' => 'boolean',
'authorized_at' => 'datetime',
'expires_at' => 'datetime',
'is_randomized' => 'boolean',
];
public function user(): BelongsTo
@@ -23,6 +25,23 @@ class PortalSession extends Model
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)
{
$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);
}
/**
* 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
{
Cache::forget('unifi:clients');
@@ -286,6 +304,25 @@ class UnifiApiClient
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
{
$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\RotatePasswords::class,
Console\SyncPpskSchedules::class,
Console\ReconcilePortal::class,
]);
$this->publishes([
__DIR__ . '/../config/unifi.php' => config_path('unifi.php'),