38 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
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
6287054a2a Guest Wi-Fi card can say whether it is set up
configured() answers the three settings payload() needs as one question, so
the permission screens can say "granted, but not switched on yet" instead of
leaving a grantee staring at an unchanged home page.

The operator granted this key to two groups in production and saw nothing
(2026-08-17); the card had simply never been enabled and had no SSID. The
card's own behaviour is unchanged — it still self-hides, like every other
card — the sentence is said where somebody is deciding who gets the key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDA3fQbsa3gaHCFoV1Q8Gg
2026-08-17 18:27:40 -04:00
b0287f1b10 Bound session_minutes to its column so an oversized value 422s (#5)
`unifi_vlan_mappings.session_minutes` is `unsignedInteger` (ceiling 4294967295)
but the mapping validator only said `integer|min:1`. Anything larger reached the
INSERT and raised SQLSTATE 22003, so the request died as an unhandled 500 where
a validation error belongs — the same shape just fixed in central-oncall#7.
Adds `max:525600` (a year of minutes) to both storeMapping and updateMapping.

Found while fixing #5, which reported a *silent* rejection rather than a crash.
The VLAN ID validation the issue was actually about was already correct on the
server (`min:1|max:4094` on all three forms); what was missing was any way to
see it, and that half of the fix is in the shell's Unifi/Portal.vue. Sibling
numeric fields were checked at the same time: `vlan_id` (unsignedSmallInteger,
bounded 1–4094) and `max_devices` (unsignedTinyInteger, bounded 1–50) were
already safe against their columns; `session_minutes` was the only one that
could overflow.

Version 1.13.3 -> 1.13.4.

Test: dashboard-shell tests/Feature/Unifi/VlanGroupValidationTest.php

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 17:05:14 -04:00
186c1eaf2b Gate the guest Wi-Fi card on its own key, and gate its poll endpoint properly
The home card used to be grant-gated by the shell's card table, which left the
poll endpoint behind `auth` alone — and that endpoint returns a LIVE CREDENTIAL:
the guest network's current rotating password and its join QR code. "Any logged-in
user" was never an adequate gate for that; the card table was doing the work, and
the card table is gone.

New key rather than reuse: unifi.guest_wifi exists precisely so front-desk staff
can be given the guest password WITHOUT any other network access. Reusing
unifi.settings would have meant handing out controller configuration to get a
Wi-Fi password read.

Authorised inside the controller rather than by route middleware, so the endpoint
stays outside the unifi page-permission groups — it is a home-page widget, not a
unifi page.

Nobody holds the new key until it is granted, so the card is invisible until then.
That is the intended direction: capability is assigned deliberately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDA3fQbsa3gaHCFoV1Q8Gg
2026-07-30 12:15:05 -04:00
c93f73ce02 Remove the page-access matrix: routes, controller methods, model
The last of the hand-rolled Access tabs. Its UI went in the previous commit; this
removes what sat behind it — the five settings/pages-access routes, the controller
methods serving them, the grant model, and the orphaned nav-visibility resolver that
read it. The table itself is dropped by a shell migration.

Nothing consulted any of it. Page access is a permission held through a group.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDA3fQbsa3gaHCFoV1Q8Gg
2026-07-29 14:31:07 -04:00
cac47b74bd A2: retire the per-app grant layer (nav resolver + page-grant middleware)
Removes this app's NavVisibilityRegistry resolver and, where it had one, its
page-grant middleware. Sidebar visibility and route access now come from the
same place as everything else: a permission, held through a group.

Nav visibility already had a better answer than the resolvers. NavItem::
visibleTo() applies required_permission to any route prefix that is NOT
registered — central-notifications documents exactly that and is why it never
registered one — and HandleInertiaRequests then applies a safety net reading
the TARGET route's own `permission:` middleware. The resolvers were overriding
both with a lookup against a grant table that is now empty, so a permission
holder could open a page while its sidebar link stayed hidden. That
inconsistency is what this removes.

The middleware only ever NARROWED routes that already carry `permission:`,
with one exception worth naming: payments' legacy rule had NO permission
bypass, so a payments.view holder was denied the ledger it gates. Deleting it
fixes that rather than changing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDA3fQbsa3gaHCFoV1Q8Gg
2026-07-28 21:58:09 -04:00
7c6bf99201 Delete the bespoke page-grant middleware (A2)
This app attached an Enforce*PageGrant middleware at runtime via a
RouteMatched listener, consulting its own private *_page_grants table to
decide whether the request could proceed.

Safe to remove because the middleware only ever NARROWED routes that
already carry a `permission:` gate. Three independent audits agreed on
that for all eleven apps: no route relied on it as its sole
authorization, so deleting it cannot widen access — the permission the
route already declares still decides.

Two cases were not simply narrowing, and both were handled first:

- unifi's guest-Wi-Fi card poll was `auth`-only and leaked the live guest
  password to any signed-in account. Closed in 9cbc8e6 before this, so
  this commit does not reopen it.
- google's middleware had NO permission-holder bypass, so it could deny a
  legitimate google.manage holder. Deleting it restores their access.

What is deliberately NOT done here: the grant tables, models and the fold
in User::pageGrantedPermissions() all remain, so a grant row can still ADD
access. It can no longer 404 anybody. The fold goes last, together with
the characterisation tests that pin it.

Verified: pages in all four de-gated apps return 200, the affected suites
pass, and dashboard:doctor's advisories drop from 18 to 7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDA3fQbsa3gaHCFoV1Q8Gg
2026-07-28 19:19:39 -04:00
bc3f415647 SECURITY: the captive-portal callback admitted arbitrary MACs and redirected anywhere
Found while triaging every authenticated route that carries no authorisation
middleware. Of 91 such routes this was the ONLY one that proved nothing: 35 scope to
$request->user(), 51 check ownership in the controller, 4 carry a credential in the URL.

TWO DEFECTS.

1. Arbitrary device admission. Everything identifying the device is request-supplied —
   $clientMac = $request->input('mac', $request->input('id', '')) — and this endpoint
   WRITES network state: authorizeGuest() puts a MAC on the mapped VLAN and a
   PortalSession row is created. So any signed-in account, a student or a parent
   included, could call
       GET /portal/wifi/callback?mac=<a MAC they do not own>&url=…
   and admit that device, up to the mapping's max_devices. No signature, no nonce, no
   check that the MAC was even present on the network.

   Fixed HALFWAY, on purpose. A well-formed MAC is now required, which stops the
   parameter smuggling junk into authorizeGuest and into the session row — but it
   cannot prove the caller owns the device, and the comment says so rather than
   implying the hole is closed.

   The likely proper fix is already in this codebase: UnifiApiClient::isClientConnected()
   exists and is used a few lines below for stale-session cleanup, so requiring the MAC
   to be currently associated would bind it. I did NOT add it, because if a pre-auth
   guest reports as not-connected that check breaks WiFi onboarding for the entire
   school, and there is no hardware here to test against. It needs one deliberate test
   on real APs. The alternative is having UniFi sign the redirect.

2. Open redirect. `return redirect($request->input('url', '/'))`, unvalidated, on an
   authenticated route — a phishing primitive serving from this school's own domain.

   A captive portal exists to forward people to an ARBITRARY external site they were
   already trying to reach, so an allowlist is not available. safeRedirectTarget()
   instead accepts only absolute http/https URLs and site-relative paths, rejecting
   javascript:, data: and protocol-relative "//evil.example" values that redirect()
   would otherwise follow off-site. A rejected value falls back to '/' rather than
   erroring: the guest is already online by then, and a bad `url` should not look like
   a failed login.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDA3fQbsa3gaHCFoV1Q8Gg
2026-07-28 16:32:32 -04:00
9cbc8e682a SECURITY: the guest Wi-Fi card poll had no authorization at all
GET /app/unifi/guest-card/data carried `web,auth` and nothing else, and
GuestCardController::data() checked nobody. So ANY signed-in account could fetch the
live guest Wi-Fi password by requesting the URL — on this dev database that is 663
students and 418 parents, none of whom can see the card in the UI.

The route comment asserted the protection that did not exist:

    // ── Guest Wi-Fi home-card poll (any logged-in user; card visibility is
    // grant-gated by the shell). …

Card visibility IS grant-gated, but that gate is PRESENTATION only — DashboardController
decides which cards to render for a user; it has never constrained the endpoints those
cards poll. So the comment described the card, and the endpoint was open.

Now enforced server-side: you may poll it iff you may see the card
(DashboardCardGrant::accessibleCardKeysFor), with the usual super-admin pass. Kept as
the card gate rather than a permission so the widget stays grantable to non-unifi staff,
which is the whole point of a home-page card.

Verified: a non-super user holding only the MyDocuments card is refused for
unifi.guest_wifi, and an explicit grant admits the same user.

Found while auditing whether the bespoke page-grant middleware could be deleted. This
route sits inside that middleware's prefix match, so deleting it would have removed the
last non-auth check on the endpoint — it needed closing first either way.

Two things nearby that are NOT fixed here and want their own look:
  • unifi.portal.callback (`auth` only, no permission) checks only that somebody is
    logged in.
  • unifi.api.wifi.current-password is token-protected via hash_equals, which is fine,
    but it is the same secret behind a different door — worth confirming that token's
    rotation story.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDA3fQbsa3gaHCFoV1Q8Gg
2026-07-28 15:03:53 -04:00
f4631d1da0 Make snap-in Inertia page assertions real, and fix the page it found missing
config/inertia.php listed only the shell's resource_path('js/Pages'), so a strict
`component('Admissions/Guest/Inquiry')` failed "page component file does not
exist" for every snap-in page. The whole suite worked around it with
`component('X', false)` — and that flag SKIPS the existence check, so those
assertions had stopped verifying the component existed at all. Exactly the bug
class the comment at the top of that file describes.

paths now derives the installed snap-ins' roots from the packages/ symlinks:

    'paths' => array_merge(
        [resource_path('js/Pages')],
        glob(base_path('packages/*/resources/js/Pages'), GLOB_ONLYDIR) ?: [],
    ),

Derived, not hardcoded: packages/ holds one symlink per INSTALLED snap-in, so an
uninstalled app simply vanishes from the array and a newly installed one is
covered automatically. Shell path stays first, matching app.js's resolve order.
`ensure_pages_exist` stays false in the production block — this only affects tests.

49 assertions across 37 files tightened to strict. Zero failed, which is only
meaningful because the mechanism was mutation-checked: renaming a snap-in page
makes the corresponding test fail with "page component file does not exist"
(verified on two different package roots).

THE FINDING. A sweep of all 261 literal Inertia::render() targets turned up
`Unifi/PortalDenied`, which HAS NEVER EXISTED in git history. PortalController
renders it on two live branches of captiveCallback — no VLAN mapping for the
user, and over the device limit — on a route reachable by any authenticated user
redirected from the captive portal (GET /portal/wifi/callback, ['web','auth'], no
app gate). So a student who could not get on the WiFi received a client-side
Inertia resolve error instead of an explanation. No test covered the path, which
is why the suite never noticed.

Fixed properly rather than stubbed:
 - New Unifi/PortalDenied.vue, on GuestLayout (the audience is someone on a phone
   trying to join the WiFi, not an operator in the dashboard). Device-limit case
   lists the user's devices with a Disconnect action; the no-mapping case says who
   to contact instead of offering a dead-end button. Uses confirmDialog, never
   window.confirm.
 - A SECOND defect in the same feature: the page passes `can_disconnect: true`,
   but the only disconnect route sat behind `app.access:unifi` +
   `permission:unifi.auth`, so a student told "disconnect a device" was refused by
   middleware BEFORE disconnectSession()'s own owner check could allow it. Added
   `unifi.portal.self-disconnect` under `['web','auth']`, pointing at the same
   action. Safe because that method already authorises owner-or-unifi.auth itself,
   and a test pins that a stranger still gets 403. The admin route is untouched
   rather than loosened.

Also left alone deliberately: `Admin/Tables` is rendered by
CustomFieldsController::index() but has no GET route (the UI moved to a tab on
/admin/settings), so it is unreachable leftover rather than a wrong name.
And dashboard-expenses/dashboard-ticketing carry stale duplicate copies of pages
that actually live in and are served from the shell; nothing is served only from a
snap-in copy today, but a page added ONLY to those repos would now pass the test
while 404ing in the browser.

Tests: 4 new (PortalDeniedTest, strict component assertions on purpose), 623
green across the touched areas. doctor exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 17:42:20 -04:00
Claude
9413cf9ab9 Declare tier/stability in the manifest
Ship-dark and the dashboard:doctor deploy gate only protect apps that DECLARE
what they are; this app was falling through to defaults. Pure metadata, additive
only, no version bump (repo convention: manifest-only changes do not bump).

bosch is BETA deliberately: production last deployed 2026-07-16 and this app was
built on the 19th, so the next prod deploy is its FIRST install — it talks to a
real alarm panel and its supervised cutover day has not happened. Ship-dark means
it lands disabled until an operator turns it on. signinout is beta for the same
reason (planned, scaffold only). Everything long-live in prod is stable, because
wrongly tagging a live app beta could dark it, while stable never disables
anything.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:26:06 -04:00
070001ffff Guest Wi-Fi dashboard card (network + rotating password + join QR)
New home card 'unifi.guest_wifi': shows the guest SSID, the current
rotated password (unifi.password_rotation.last_password), and a WPA
join QR built from the WIFI: string. Polls app/unifi/guest-card/data
(auth) every 60s so it updates the moment the rotation engine changes
the password — the QR regenerates client-side to match. Self-hides
until enabled with an SSID and a rotated password; grant-gated via
Admin -> Settings -> Dashboard Cards. New settings (WiFi rotation
section): enable + network name + card title.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 19:37:44 -04:00
71807c8815 UniFi: settings tabs are deep-linkable path routes (/settings/{tab})
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jh8RnYXrC8E6z79LWs8ggd
2026-06-21 20:19:05 -04:00
462a1a3611 feat(1.13.0): page grants on the standard admissions-style pattern
unifi_page_grants gains role + default grantee types and can_view
(deny-by-default "Everyone else" row); enforcement moves from the
RouteMatched listener — where request->user() is always null and the
check silently failed open — into route-appended middleware with the
permission-holder pass. Pages-access endpoints gain role search +
default-row handling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-12 21:31:41 -04:00
ee27bee716 UniFi: degrade wan-status & client-ap-traffic to 200 'unavailable' when controller unreachable
Instead of returning HTTP 500 when the UniFi controller can't be reached, these JSON
data feeds now return 200 with an 'available: false' / status 'unavailable' payload so the
dashboard widgets show a friendly unavailable state and the WAN poll stops silently failing.
2026-06-11 17:38:24 -04:00
429cd44ac5 fix: register unifi pages with shell NavVisibilityRegistry; v1.12.1
The Access tab persists user/group grants in unifi_page_grants and the
existing RouteMatched listener honors them on the request path, but
NavItem::visibleTo() only consulted the page's required_permission —
so a granted user never saw the menu entry to reach the page. Register
the unifi.* prefix with the shell's NavVisibilityRegistry so the
sidebar lists exactly the pages the grant table allows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 12:12:58 -04:00
274f210337 release: 1.12.0 — rolls up 1.11.1 (reboot suppression hardening)
Bundled stable cut for prod. Contents since 1.11.0:

* fix(reboot): suppress webhook alerts during a fleet reboot
  regardless of cache driver. RebootAllAps now stamps a Setting
  (unifi.reboot_suppression_until) for 20 minutes at the start of a
  fleet reboot, and WebhookCheckService consults that Setting in
  checkDeviceTransition + checkReboot in addition to the existing
  per-MAC cache keys. Database-backed Setting always crosses
  container/process/cache-driver boundaries so the suppression can
  no longer be defeated by config differences.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:09:08 -04:00
c0f12ce931 fix(reboot): suppress webhook alerts during a fleet reboot, regardless of cache driver
Existing Cache::has('unifi:planned_reboot:{mac}') per-MAC suppression
relies on the cache driver being shared across the scheduler and the
snapshot-capture containers. In environments where the cache is
backed by something process-local (or where the keys expire before a
slow reboot completes), webhook alerts fire even though the dashboard
itself initiated the reboots.

RebootAllAps now also stamps a single Setting
(unifi.reboot_suppression_until) at the start of a fleet reboot,
covering a 20-minute window. WebhookCheckService checks this Setting
in addition to the per-MAC cache key, in checkDeviceTransition and
checkReboot. Setting is database-backed so it's always visible across
containers regardless of cache configuration.

v1.11.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:07:58 -04:00
dd4e0ca564 release: 1.11.0 — rolls up the 1.10.1/1.10.2/1.10.3/1.10.4 patches
Bundled stable cut for prod. Contents since 1.10.0:

* fix(banded ssid): treat "PPSK not on this band" as a quiet
  info-level skip rather than a failure (1.10.1).

* fix(ppsk sync): the WiFi modal's ingest sync now matches by NAME
  within a wlan before falling back to held-by-passphrase, and
  salvages rotate_password / schedule from held tombstones into the
  active row before pruning them. Prevents the modal from
  accumulating phantom "held" duplicates after every rotation and
  keeps the rotate flag on the row that's actually live (1.10.2).

* feat(grouped wifi): PPSK updates (both rotation and the manual
  modal edit) now follow user-defined SSID groups from the WiFi
  Networks page first, falling back to same-SSID-name detection.
  Lets the operator pair WLANs whose SSIDs have different names
  (e.g. "VCS Guest" and "VCS Guest 5G") (1.10.3).

* fix(name resolution): on this controller, embedded PPSKs don't
  carry a name field — the human "GUEST" label is the *network's*
  name and entries reference it via networkconf_id. updateEmbeddedPpsk
  and verifyEmbeddedPpsk now resolve name → networkconf_id and match
  on that, with entry-name and current-passphrase as fallbacks for
  other controller variants (1.10.4).

* feat(verify): after every rotation, each affected WLAN is
  re-fetched and the new passphrase is checked at the named network.
  Anything that didn't actually propagate (mismatch, fetch failure)
  shows up as a failed PPSK in the cron run details (1.10.4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 21:01:22 -04:00
f533208b37 feat(grouped wifi): route updates through user-defined SSID groups + verify
User-defined SSID groups (configured on the WiFi Networks page and
stored in unifi.ssid_groups) now drive PPSK sibling propagation. The
previous same-SSID-name detection missed cases where two grouped
WLANs have *different* names — e.g. "VCS Guest" on 2.4 and "VCS
Guest 5G" on 5GHz manually grouped by the operator. Falls back to
same-name siblings when no group is configured.

Match-by-name fix: embedded PPSKs on this controller don't carry a
name field — the human "GUEST" label is the *network's* name, with
the entry referenced via networkconf_id. updateEmbeddedPpsk and
verifyEmbeddedPpsk now resolve name → networkconf_id first and match
on that, with entry-name and current-passphrase as fallbacks for
other controller variants.

After every rotation we re-fetch each affected WLAN and verify the
new passphrase is actually present on the named network. Failures
("mismatch" or "fetch_failed" on the primary, anything other than
"not_found" on a sibling) surface in the cron run details as failed
PPSKs so the operator sees what didn't propagate.

v1.10.4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:58:10 -04:00
bb74edf4c1 fix(ppsk sync): match by name + salvage settings, prune dup tombstones
Every rotation changes an embedded PPSK's synthetic id (it's derived
from sha256(wlan_id : passphrase)). The ingest sync matched only by
unifi_id, so after rotation the row's id was "new" — the sync created
a fresh active row and marked the previous one held. Over multiple
rotations this accumulated: each rotation left a held tombstone, and
the rotate_password / schedule flags were stuck on the original
tombstone instead of transferring to the new active row.

Dev's GUEST PPSK had 3 rows after a few rotations: two held (with
rotate_password=true on the first), one active with rotate=false.
Future rotations would silently skip that PPSK because the active row
no longer had the rotate flag set.

Fix in three layers, all in WifiController::ppskIndex:

1. Match priority extended: unifi_id → name within wlan → held by
   passphrase. The name match means a passphrase change just updates
   the existing row in place. No more new-row creation per rotation.

2. Salvage step before pruning: for each active row, scan held
   tombstones with the same name and copy over rotate_password and
   schedule. Operator's rotation opt-in survives history.

3. Prune step: held rows with the same name as an active row in the
   same wlan are now hard-deleted (their settings were just salvaged,
   their data is stale). Keeps the WiFi modal clean instead of
   accumulating phantoms.

v1.10.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:49:26 -04:00
e5cc075938 fix(banded ssid): treat "PPSK not on this band" as a quiet skip
The sibling-rotation path's "Embedded PPSK not found" error was being
surfaced to the operator as a failure, but it's not — it just means
the PPSK isn't mirrored on that band (GUEST was configured on one
band only, which is a perfectly valid setup). Logging this as a
sibling failure also poisoned the cron run status to "partial".

Now: "not found"-style errors from updateEmbeddedPpsk on a sibling
become info-level log entries and the loop continues. Other errors
(API failures, permissions, etc.) still surface as warnings/failures.

v1.10.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:43:10 -04:00
4ec4a293c0 release: 1.10.0 — rolls up 1.9.1 (banded-SSID PPSK match by name)
Bundled stable cut for prod. Contents since 1.9.0:

* fix(banded ssid): updateEmbeddedPpsk now matches embedded PPSK
  entries by name first (e.g. "GUEST") and falls back to current
  passphrase. Name-matching survives any passphrase drift caused by
  pre-1.8.1 out-of-band manual edits — the sibling-rotation failure
  reported on prod after upgrading to 1.9.0 no longer happens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:40:13 -04:00
720e94c54a fix(banded ssid): match embedded PPSK by name first, passphrase fallback
The sibling-update path on prod failed with "Embedded PPSK not found
by current passphrase" because the DB-stored x_passphrase on the
unedited band was stale — earlier manual edits (pre-1.8.1) only
touched one band, leaving the other band's row out of sync. When
rotation then tried to use that stale passphrase to find the entry,
no match.

updateEmbeddedPpsk now takes an optional $name parameter and tries it
first. PPSK names within a WLAN are unique, so name-matching survives
any passphrase drift caused by historical out-of-band edits.
Passphrase matching stays as a fallback for callers that don't have
a name (none currently — both rotation and the manual modal pass it).

v1.9.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:38:10 -04:00
2be17c70db release: 1.9.0 — rolls up the 1.8.1 patch series
Bundled stable cut for prod. Contents since 1.8.0:

* fix(rotate): unifi.password_rotation.last_password is now saved on
  successful PPSK rotation as well as whole-SSID rotation. PPSK-only
  setups (typical guest-WiFi configurations) will populate the
  Settings → Tasks "current password" display and the
  /api/unifi/wifi/current-password endpoint after the next rotation.

* fix(banded-ssid): when an SSID is split across 2.4 and 5GHz bands
  (band-steering disabled — two wlanconf rows with the same name),
  rotating or manually editing a PPSK on one band now also updates
  the same-name PPSK on every sibling band. Previously the two halves
  drifted out of sync. Both the rotation scheduler and the WiFi modal
  use the new UnifiApiClient::getWlanSiblings helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:33:48 -04:00
31686a35d5 fix(rotate): record PPSK rotation password + sync banded-SSID siblings
Three bugs reported from prod after a PPSK rotation:

1. unifi.password_rotation.last_password was only saved after a
   whole-SSID rotation. PPSK-only setups (the typical guest-WiFi case)
   ran a successful rotation but the setting stayed empty, so the
   Settings → Tasks UI never showed the current password and the
   /api/unifi/wifi/current-password endpoint returned 404
   "no rotated password recorded yet". The PPSK loop now writes
   last_password on every successful PPSK rotation.

2. When an SSID is "banded" (band-steering disabled), UniFi splits it
   into one wlanconf per band — 2.4GHz and 5GHz each get their own _id
   and their own embedded PPSK array. Rotating the PPSK on one band
   left the other band with the old password. New
   UnifiApiClient::getWlanSiblings($wlanId) finds all wlanconfs that
   share an SSID name; both rotation and the manual modal edit now
   call updateEmbeddedPpsk on each sibling and update the matching
   UnifiPpsk DB rows.

3. The manual WiFi modal edit had the same band-blindness as #2 —
   editing the GUEST PPSK on the 2.4GHz half left the 5GHz half stale.
   WifiController::ppskUpdate now walks siblings the same way.

v1.8.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:32:15 -04:00
23 changed files with 3356 additions and 319 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.8.0",
"version": "1.13.5",
"type": "library",
"license": "MIT",
"autoload": {
@@ -16,6 +16,8 @@
]
},
"dashboard": {
"tier": "optional",
"stability": "stable",
"nav_folder": {
"label": "Unifi Network",
"icon": "wifi",
@@ -27,13 +29,15 @@
{ "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": "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 }
],
"permissions": [
{ "key": "unifi.stats", "label": "View Network Stats", "description": "View WiFi dashboards, AP stats, and client lists" },
{ "key": "unifi.manage", "label": "Manage Network", "description": "Reboot APs, manage SSIDs, change WiFi passwords" },
{ "key": "unifi.auth", "label": "Manage Portal Auth", "description": "Configure captive portal, VLAN mappings, MAC allowlist" },
{ "key": "unifi.settings", "label": "Network Settings", "description": "Configure UniFi controller connection" }
{ "key": "unifi.settings", "label": "Network Settings", "description": "Configure UniFi controller connection" },
{ "key": "unifi.guest_wifi", "label": "View Guest Wi-Fi Password", "description": "See the guest network name, its current rotating password, and the join QR code on the home page. Grants no other network access." }
]
}
},

View File

@@ -0,0 +1,62 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Brings unifi_page_grants to parity with adm_access_grants /
* directory_access_grants: widens grantee_type to include 'default' and
* 'role', allows a NULL grantee_id (used by the per-page default row),
* and adds a can_view column. The default row's can_view carries the
* deny/allow fallback for users not matched by a more specific grant
* (deny by default — no row means deny).
*
* Existing user/group rows keep working: adding can_view with a default
* of true backfills them as explicit allow grants.
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('unifi_page_grants')) return;
// Widen the enum and allow NULL grantee_id (default rows). MySQL
// enum changes require raw DDL.
DB::statement("ALTER TABLE unifi_page_grants MODIFY grantee_type ENUM('default','user','group','role') NOT NULL");
DB::statement("ALTER TABLE unifi_page_grants MODIFY grantee_id BIGINT UNSIGNED NULL");
if (! Schema::hasColumn('unifi_page_grants', 'can_view')) {
Schema::table('unifi_page_grants', function (Blueprint $table) {
$table->boolean('can_view')->default(true)->after('grantee_id');
});
// Belt and braces: make sure every pre-existing user/group row
// is an explicit allow grant.
DB::table('unifi_page_grants')->update(['can_view' => true]);
}
// The unique index on (nav_item_id, grantee_type, grantee_id)
// already exists from the create migration
// ('unifi_page_grants_unique') and still applies — grantee_id is
// NULL for default rows, and MySQL treats NULLs as distinct, so
// app code enforces one default row per nav_item.
}
public function down(): void
{
if (! Schema::hasTable('unifi_page_grants')) return;
if (Schema::hasColumn('unifi_page_grants', 'can_view')) {
Schema::table('unifi_page_grants', function (Blueprint $table) {
$table->dropColumn('can_view');
});
}
DB::table('unifi_page_grants')->whereIn('grantee_type', ['default', 'role'])->delete();
DB::statement("ALTER TABLE unifi_page_grants MODIFY grantee_type ENUM('user','group') NOT NULL");
DB::statement("ALTER TABLE unifi_page_grants MODIFY grantee_id BIGINT UNSIGNED NOT NULL");
}
};

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,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

@@ -80,16 +80,73 @@ class RotatePasswords extends Command
$newPass = $passwords[array_rand($passwords)];
try {
if (str_starts_with((string) $ppsk->unifi_id, 'emb_')) {
// Embedded PPSK: update inside the parent WLAN object.
// Synthetic ID is derived from the new passphrase, so update it too.
$unifi->updateEmbeddedPpsk($ppsk->wlan_id, $ppsk->x_passphrase, $newPass);
// Embedded PPSK: update inside the parent WLAN object,
// matched by name (synthetic id changes with the
// passphrase, so it's not a stable matcher).
$unifi->updateEmbeddedPpsk($ppsk->wlan_id, $ppsk->x_passphrase, $newPass, $ppsk->name);
$newUid = 'emb_' . substr(hash('sha256', $ppsk->wlan_id . ':' . $newPass), 0, 32);
$ppsk->update(['x_passphrase' => $newPass, 'unifi_id' => $newUid]);
// Update every grouped sibling (user-defined SSID
// groups take precedence; same-name fallback for
// installs that haven't grouped manually).
foreach ($unifi->getGroupedWlans($ppsk->wlan_id) as $siblingWlanId) {
$sibling = UnifiPpsk::where('wlan_id', $siblingWlanId)
->where('name', $ppsk->name)
->where('state', 'active')
->first();
try {
$unifi->updateEmbeddedPpsk($siblingWlanId, $sibling?->x_passphrase, $newPass, $ppsk->name);
if ($sibling) {
$sibling->update([
'x_passphrase' => $newPass,
'unifi_id' => 'emb_' . substr(hash('sha256', $siblingWlanId . ':' . $newPass), 0, 32),
]);
}
} catch (\Throwable $e) {
if (str_contains($e->getMessage(), 'not found')) {
\Illuminate\Support\Facades\Log::info('unifi.ppsk_sibling_skipped', [
'sibling_wlan' => $siblingWlanId,
'ppsk_name' => $ppsk->name,
]);
continue;
}
$this->error("Sibling rotate failed for wlan {$siblingWlanId}: {$e->getMessage()}");
$failedPpsks[] = ['name' => $ppsk->name . ' (sibling wlan ' . $siblingWlanId . ')', 'error' => $e->getMessage()];
}
}
// Verify that the new passphrase actually applied
// on every grouped WLAN. UniFi can 200 an update
// that doesn't stick (cluster sync race, etc).
// Anything we expected to rotate that didn't is a
// failure — surface it in the cron log.
$allWlanIds = array_merge([$ppsk->wlan_id], $unifi->getGroupedWlans($ppsk->wlan_id));
foreach ($allWlanIds as $checkWlanId) {
$result = $unifi->verifyEmbeddedPpsk($checkWlanId, $ppsk->name, $newPass);
if ($result['ok']) continue;
// 'not_found' on a sibling = PPSK isn't on that band — ignore
// (consistent with the skip in the update loop).
if ($result['reason'] === 'not_found' && $checkWlanId !== $ppsk->wlan_id) continue;
$failedPpsks[] = [
'name' => $ppsk->name . ' (verify wlan ' . $checkWlanId . ')',
'error' => 'verification ' . $result['reason'] . ($result['error'] ?? null ? ': ' . $result['error'] : ''),
];
}
} else {
$unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]);
$ppsk->update(['x_passphrase' => $newPass]);
}
$rotatedPpsks[] = $ppsk->name;
// Save the active password every time a rotation
// succeeds — covers PPSK-only rotation setups where
// there's no whole-SSID rotation. Last successful
// password wins if multiple PPSKs rotate in one run.
Setting::set('unifi.password_rotation.last_password', $newPass);
Setting::set('unifi.password_rotation.last_rotated_at', now()->toIso8601String());
} catch (\Throwable $e) {
$this->error("Failed to rotate PPSK \"{$ppsk->name}\": {$e->getMessage()}");
$failedPpsks[] = ['name' => $ppsk->name, 'error' => $e->getMessage()];

View File

@@ -0,0 +1,124 @@
<?php
namespace Dashboard\Unifi\Http\Controllers;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
/**
* The "Guest Wi-Fi" dashboard home card: network name, the current
* rotating password, and a QR code that re-generates when the password
* changes. The password is the rotation engine's last-applied value
* (unifi.password_rotation.last_password), so the card tracks whatever
* unifi:rotate-passwords sets. The card polls data() so it updates
* without a page reload.
*/
class GuestCardController extends Controller
{
/**
* Is this card set up enough to ever appear?
*
* The three things payload() needs, asked as one question, so the permission
* screens can say "granted, but not configured yet" instead of leaving
* somebody staring at a home page wondering what they got wrong. The
* operator granted this key to two groups in production and saw nothing
* (2026-08-17); the card was simply never switched on.
*
* Note the third clause: last_password is written ONLY by
* unifi:rotate-passwords, so a school that sets a static guest password by
* hand and never rotates has no card and no obvious reason why.
*/
public static function configured(): bool
{
return (bool) Setting::get('unifi.guest_card.enabled')
&& trim((string) Setting::get('unifi.guest_card.ssid', '')) !== ''
&& filled(Setting::get('unifi.password_rotation.last_password'));
}
/** Payload shared by the card resolver and the poll endpoint. */
public static function payload(): ?array
{
if (! Setting::get('unifi.guest_card.enabled')) {
return null;
}
$password = Setting::get('unifi.password_rotation.last_password');
$ssid = trim((string) Setting::get('unifi.guest_card.ssid', ''));
if (! $password || $ssid === '') {
return null; // nothing meaningful to show yet
}
$freq = Setting::get('unifi.password_rotation.frequency', 'weekly');
return [
'title' => trim((string) Setting::get('unifi.guest_card.title', '')) ?: 'Guest Wi-Fi',
'ssid' => $ssid,
'password' => $password,
'rotated_at' => Setting::get('unifi.password_rotation.last_rotated_at'),
// WPA/WPA2 join string a phone camera understands. Special
// chars in SSID/password must be backslash-escaped per the
// Wi-Fi QR spec.
'qr' => self::wifiQrString($ssid, $password),
'frequency' => in_array($freq, ['daily', 'weekly'], true) ? $freq : 'weekly',
];
}
/** The dashboard card this endpoint feeds — must match UnifiServiceProvider. */
private const CARD_KEY = 'unifi.guest_wifi';
/**
* SECURITY: authorize against the card's own visibility.
*
* This was `web,auth` only, and the route comment claimed "card visibility is
* grant-gated by the shell" — but that gate is PRESENTATION only
* (DashboardController decides which cards to render). The endpoint itself was
* unchecked, so any signed-in account — every student and parent included —
* could fetch the live guest Wi-Fi password by requesting the URL directly.
*
* The endpoint enforces what the comment always claimed: you may poll it iff
* you may see the card. Still asked as the CARD gate — via the registry, which
* now answers from the card's permission (unifi.guest_wifi) instead of the
* retired dashboard_card_grants table. Asking the registry rather than naming
* the key here is deliberate: the endpoint and the home page cannot drift.
* Super admins pass, as everywhere else (keysFor handles that).
*/
public function data(Request $request)
{
$user = $request->user();
abort_unless($user, 403);
abort_unless(
app(\App\Support\DashboardCardRegistry::class)->visibleTo($user, self::CARD_KEY),
403,
);
return response()->json(self::payload() ?? ['title' => null]);
}
/**
* Card registry resolver.
*
* Re-checks the permission the registry already gated on, the way bosch's and
* signinout's resolvers do. Redundant on the home-page path and deliberately
* kept: renderFor() renders whatever key list it is handed, and the payload
* here is a live credential — the one card where "the caller asked the right
* question" is not a good enough reason to hand it over.
*/
public static function resolve(User $user): ?array
{
if (! $user->can('unifi.guest_wifi')) {
return null;
}
return self::payload();
}
private static function wifiQrString(string $ssid, string $password): string
{
$esc = fn (string $v) => preg_replace('/([\\\\;,:"])/', '\\\\$1', $v);
return sprintf('WIFI:T:WPA;S:%s;P:%s;;', $esc($ssid), $esc($password));
}
}

View File

@@ -32,11 +32,15 @@ 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',
'session_minutes' => 'required|integer|min:1',
// Bounded by the column that stores it: unifi_vlan_mappings.session_minutes
// is unsignedInteger, so an unbounded value reached the INSERT and raised
// SQLSTATE 22003 — a 500 where a validation error belongs (central-unifi#5).
// 525600 minutes is a year, far more session than anyone will ever grant.
'session_minutes' => 'required|integer|min:1|max:525600',
]);
$data['sort_order'] = VlanMapping::max('sort_order') + 1;
VlanMapping::create($data);
@@ -47,11 +51,15 @@ 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',
'session_minutes' => 'required|integer|min:1',
// Bounded by the column that stores it: unifi_vlan_mappings.session_minutes
// is unsignedInteger, so an unbounded value reached the INSERT and raised
// SQLSTATE 22003 — a 500 where a validation error belongs (central-unifi#5).
// 525600 minutes is a year, far more session than anyone will ever grant.
'session_minutes' => 'required|integer|min:1|max:525600',
]);
$mapping->update($data);
return back()->with('success', 'Mapping updated.');
@@ -87,6 +95,35 @@ class PortalController extends Controller
// ── Captive portal callback (called by UniFi external portal redirect) ───
/**
* A redirect target that cannot be turned into a phishing or script payload.
*
* Accepts only absolute http/https URLs and site-relative paths. Rejects
* javascript:/data:/vbscript: schemes, and protocol-relative "//host" values which
* redirect() would happily send off-site. Anything rejected falls back to the
* dashboard rather than erroring — the guest is already online at this point, so a
* bad `url` parameter should not look like a failed login.
*/
private function safeRedirectTarget(?string $url): string
{
$url = trim((string) $url);
if ($url === '' || str_starts_with($url, '//')) {
return '/';
}
// Site-relative is always fine.
if (str_starts_with($url, '/')) {
return $url;
}
$scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
return in_array($scheme, ['http', 'https'], true) && parse_url($url, PHP_URL_HOST)
? $url
: '/';
}
public function captiveCallback(Request $request, UnifiApiClient $unifi)
{
// UniFi redirects guest to this URL with ?id=<ap_mac>&ap=<ap_mac>&t=<timestamp>&url=<original_url>&ssid=<ssid>
@@ -96,42 +133,55 @@ 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,
]);
}
$clientMac = $device->mac;
$isRandomized = $device->isRandomized;
// Find the VLAN mapping for this user's group/OU
$mapping = $this->resolveMapping($user);
if (! $mapping) {
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,
@@ -146,24 +196,32 @@ class PortalController extends Controller
$minutes = $mapping->session_minutes;
$unifi->authorizeGuest($clientMac, $minutes);
// Record the session
PortalSession::create([
'user_id' => $user->id,
'mac_address' => $clientMac,
'device_hostname' => $request->input('hostname'),
'device_os' => $request->input('os'),
'device_type' => $request->input('dev_cat'),
'ssid' => $ssid,
'vlan_id' => $mapping->vlan_id,
'ap_mac' => $apMac,
'is_active' => true,
'authorized_at' => now(),
'expires_at' => now()->addMinutes($minutes),
]);
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'),
'ssid' => $ssid,
'vlan_id' => $mapping->vlan_id,
'ap_mac' => $apMac,
'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
$redirectUrl = $request->input('url', '/');
return redirect($redirectUrl);
// Redirect to the original URL the user was trying to reach.
//
// SECURITY: this was `redirect($request->input('url', '/'))` — an unvalidated
// open redirect on an authenticated route, i.e. a phishing primitive wearing
// this school's domain. The whole point of a captive portal is to send someone
// to an ARBITRARY external site they were already trying to reach, so an
// allowlist is not available; what is available is refusing anything that is
// not a plain http(s) URL, which blocks javascript:, data: and protocol-relative
// //evil.example payloads.
return redirect($this->safeRedirectTarget($request->input('url')));
}
public function disconnectSession(Request $request, PortalSession $session, UnifiApiClient $unifi)
@@ -193,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

@@ -36,6 +36,7 @@ class StatsController extends Controller
if ($wanIp && str_starts_with($wanIp, '127.')) $wanIp = $gw['connect_request_ip'] ?? null;
return response()->json([
'available' => true,
'status' => $wan['status'] ?? 'unknown',
'tx_rate' => $wan['tx_bytes-r'] ?? 0,
'rx_rate' => $wan['rx_bytes-r'] ?? 0,
@@ -44,7 +45,19 @@ class StatsController extends Controller
'latency' => $wan['latency'] ?? $gw['wan1']['latency'] ?? null,
]);
} catch (\Throwable $e) {
return response()->json(['status' => 'error'], 500);
// The UniFi controller is unreachable. Degrade gracefully: return 200 with a
// friendly "unavailable" payload so the dashboard widget shows an unavailable
// state instead of the poll silently failing on a 500.
return response()->json([
'available' => false,
'status' => 'unavailable',
'message' => 'UniFi controller is unreachable.',
'tx_rate' => 0,
'rx_rate' => 0,
'isp' => null,
'wan_ip' => null,
'latency' => null,
]);
}
}
@@ -296,12 +309,21 @@ class StatsController extends Controller
->values();
return response()->json([
'available' => true,
'labels' => $series['labels'],
'traffic_rx' => $rx,
'traffic_tx' => $tx,
]);
} catch (\Throwable $e) {
return response()->json(['error' => $e->getMessage()], 500);
// UniFi controller unreachable — degrade gracefully (200 with empty series)
// so the chart shows an empty/unavailable state rather than erroring on a 500.
return response()->json([
'available' => false,
'message' => 'UniFi controller is unreachable.',
'labels' => [],
'traffic_rx' => [],
'traffic_tx' => [],
]);
}
}

View File

@@ -1,141 +0,0 @@
<?php
namespace Dashboard\Unifi\Http\Controllers;
use App\Models\DashboardApp;
use App\Models\Group;
use App\Models\NavItem;
use App\Models\User;
use Dashboard\Unifi\Models\UnifiPageGrant;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\DB;
/**
* Super-admin-only endpoints for managing per-page access on unifi
* pages. Pages here = nav_items where app_id = unifi's DashboardApp row.
*/
class UnifiPagesAccessController extends Controller
{
public function index()
{
$app = DashboardApp::where('slug', 'unifi')->first();
if (! $app) {
return response()->json(['pages' => [], 'users' => [], 'groups' => []]);
}
$pages = NavItem::where('app_id', $app->id)
->where('is_folder', false)
->whereNotNull('route_name')
->orderBy('sort_order')
->get(['id', 'label', 'route_name']);
$grants = UnifiPageGrant::whereIn('nav_item_id', $pages->pluck('id'))
->get()
->groupBy('nav_item_id');
// Only return users that ALREADY have grants. The full users list
// can be enormous (thousands of rows); the operator adds more via
// the searchUsers endpoint as needed.
$grantedUserIds = $grants->flatten(1)->where('grantee_type', 'user')->pluck('grantee_id')->unique();
$users = User::whereIn('id', $grantedUserIds)->orderBy('name')->get(['id', 'name', 'email']);
// Groups: always include super-admin groups (locked-on across all
// pages) plus any group with at least one grant. Other groups are
// added via searchGroups.
$grantedGroupIds = $grants->flatten(1)->where('grantee_type', 'group')->pluck('grantee_id')->unique();
$groups = Group::where(function ($q) use ($grantedGroupIds) {
$q->where('is_super', true)
->orWhereIn('id', $grantedGroupIds);
})->orderBy('name')->get(['id', 'name', 'is_super']);
return response()->json([
'pages' => $pages->map(fn ($p) => [
'id' => $p->id,
'label' => $p->label,
'route_name' => $p->route_name,
'user_ids' => $grants->get($p->id, collect())->where('grantee_type', 'user')->pluck('grantee_id')->all(),
'group_ids' => $grants->get($p->id, collect())->where('grantee_type', 'group')->pluck('grantee_id')->all(),
])->values(),
'users' => $users,
'groups' => $groups,
]);
}
/**
* Typeahead-style search for users to add to the access matrix.
* Returns up to 20 matches against name or email. Empty query returns
* an empty array — caller must enter at least 2 chars.
*/
public function searchUsers(Request $request)
{
$q = trim((string) $request->query('q', ''));
if (strlen($q) < 2) {
return response()->json(['users' => []]);
}
$users = User::where(function ($w) use ($q) {
$w->where('name', 'like', '%' . $q . '%')
->orWhere('email', 'like', '%' . $q . '%');
})
->orderBy('name')
->limit(20)
->get(['id', 'name', 'email']);
return response()->json(['users' => $users]);
}
/**
* Typeahead-style search for groups to add to the access matrix.
* Excludes super-admin groups (they're already in the matrix and
* locked-on across every page).
*/
public function searchGroups(Request $request)
{
$q = trim((string) $request->query('q', ''));
if (strlen($q) < 2) {
return response()->json(['groups' => []]);
}
$groups = Group::where('name', 'like', '%' . $q . '%')
->where(function ($w) { $w->where('is_super', false)->orWhereNull('is_super'); })
->orderBy('name')
->limit(20)
->get(['id', 'name', 'is_super']);
return response()->json(['groups' => $groups]);
}
public function update(Request $request, NavItem $navItem)
{
$app = DashboardApp::where('slug', 'unifi')->first();
if (! $app || $navItem->app_id !== $app->id) {
return response()->json(['error' => 'Not a unifi page.'], 422);
}
$data = $request->validate([
'user_ids' => 'present|array',
'user_ids.*' => 'integer|exists:users,id',
'group_ids' => 'present|array',
'group_ids.*' => 'integer|exists:groups,id',
]);
$grantedBy = $request->user()?->id;
DB::transaction(function () use ($navItem, $data, $grantedBy) {
UnifiPageGrant::where('nav_item_id', $navItem->id)->delete();
$rows = [];
$now = now();
foreach ($data['user_ids'] as $uid) {
$rows[] = ['nav_item_id' => $navItem->id, 'grantee_type' => 'user', 'grantee_id' => $uid, 'granted_by_user_id' => $grantedBy, 'created_at' => $now, 'updated_at' => $now];
}
foreach ($data['group_ids'] as $gid) {
$rows[] = ['nav_item_id' => $navItem->id, 'grantee_type' => 'group', 'grantee_id' => $gid, 'granted_by_user_id' => $grantedBy, 'created_at' => $now, 'updated_at' => $now];
}
if ($rows) UnifiPageGrant::insert($rows);
});
return response()->json(['ok' => true]);
}
}

View File

@@ -10,9 +10,10 @@ use Inertia\Inertia;
class UnifiSettingsController extends Controller
{
public function edit()
public function edit(?string $tab = null)
{
return Inertia::render('Unifi/Settings', [
'activeTab' => $tab,
'controllerUrl' => Setting::get('unifi.controller_url', ''),
'hasApiKey' => (bool) Setting::get('unifi.api_key'),
'site' => Setting::get('unifi.site', 'default'),
@@ -35,6 +36,9 @@ class UnifiSettingsController extends Controller
'ppskSchedulingEnabled' => (bool) Setting::get('unifi.ppsk_scheduling.enabled', false),
'apiEnabled' => (bool) Setting::get('unifi.api.enabled', false),
'apiToken' => Setting::get('unifi.api_token', null),
'guestCardEnabled' => (bool) Setting::get('unifi.guest_card.enabled', false),
'guestCardSsid' => Setting::get('unifi.guest_card.ssid', ''),
'guestCardTitle' => Setting::get('unifi.guest_card.title', ''),
]);
}
@@ -73,6 +77,9 @@ class UnifiSettingsController extends Controller
'rotation_wordlist' => 'nullable|string|max:20000',
'ppsk_scheduling_enabled' => 'boolean',
'api_enabled' => 'boolean',
'guest_card_enabled' => 'boolean',
'guest_card_ssid' => 'nullable|string|max:100',
'guest_card_title' => 'nullable|string|max:100',
]);
Setting::set('unifi.controller_url', rtrim($request->controller_url, '/'));
@@ -100,6 +107,9 @@ class UnifiSettingsController extends Controller
Setting::set('unifi.password_rotation.wordlist', $request->input('rotation_wordlist', ''));
Setting::set('unifi.ppsk_scheduling.enabled', $request->boolean('ppsk_scheduling_enabled') ? '1' : '');
Setting::set('unifi.api.enabled', $request->boolean('api_enabled') ? '1' : '');
Setting::set('unifi.guest_card.enabled', $request->boolean('guest_card_enabled') ? '1' : '');
Setting::set('unifi.guest_card.ssid', trim((string) $request->input('guest_card_ssid', '')));
Setting::set('unifi.guest_card.title', trim((string) $request->input('guest_card_title', '')));
\Illuminate\Support\Facades\Cache::forget('unifi:api_prefix:' . md5(rtrim($request->controller_url, '/')));

View File

@@ -149,8 +149,18 @@ class WifiController extends Controller
$name = $networksById[$nconfId]['name'] ?? null;
}
// Match by unifi_id, or by passphrase for a held embedded record re-appearing
// Match in priority order:
// 1. by current unifi_id (already-synced row)
// 2. by name within this wlan (catches rotation: passphrase
// changed → synthetic id changed → row identity unchanged)
// 3. by passphrase among held rows (legacy fallback for
// cases where name wasn't ingested)
$record = UnifiPpsk::where('unifi_id', $uid)->first()
?? ($name
? UnifiPpsk::where('wlan_id', $wlanId)->where('name', $name)
->orderByRaw("FIELD(state, 'active', 'held')")
->first()
: null)
?? UnifiPpsk::where('wlan_id', $wlanId)
->where('x_passphrase', $pass)
->where('state', 'held')
@@ -174,8 +184,8 @@ class WifiController extends Controller
}
}
// Only mark as held when we have confirmed live IDs —
// never wipe on an empty API response (prevents false-holds on API failures)
// Mark non-matching active rows as held — but ONLY if there's no
// other active row with the same name we just reconnected.
if (! empty($liveIds)) {
UnifiPpsk::where('wlan_id', $wlanId)
->where('state', 'active')
@@ -184,6 +194,47 @@ class WifiController extends Controller
->update(['state' => 'held', 'unifi_id' => null]);
}
// For each active row, salvage any rotate_password / schedule
// settings from the held tombstones with the same name BEFORE
// we prune them. Otherwise a row that had rotate=on loses the
// flag every time a rotation changes its synthetic id.
$activeRows = UnifiPpsk::where('wlan_id', $wlanId)
->where('state', 'active')
->whereNotNull('name')
->get();
foreach ($activeRows as $active) {
$heldWithSettings = UnifiPpsk::where('wlan_id', $wlanId)
->where('state', 'held')
->where('name', $active->name)
->where(fn ($q) => $q
->where('rotate_password', true)
->orWhereNotNull('schedule'))
->orderByDesc('updated_at')
->first();
if (! $heldWithSettings) continue;
$patch = [];
if ($heldWithSettings->rotate_password && ! $active->rotate_password) {
$patch['rotate_password'] = true;
}
if ($heldWithSettings->schedule && ! $active->schedule) {
$patch['schedule'] = $heldWithSettings->schedule;
}
if ($patch) $active->update($patch);
}
// Prune obsolete held rows: any held row whose name matches an
// active row in the same wlan is a stale tombstone — its
// settings have been salvaged above, and its data has been
// superseded by the active one.
$activeNames = $activeRows->pluck('name')->filter()->unique();
if ($activeNames->isNotEmpty()) {
UnifiPpsk::where('wlan_id', $wlanId)
->where('state', 'held')
->whereIn('name', $activeNames)
->delete();
}
$dbRecords = UnifiPpsk::where('wlan_id', $wlanId)
->orderByRaw("FIELD(state, 'active', 'held')")
->orderBy('name')
@@ -293,9 +344,37 @@ class WifiController extends Controller
if (! empty($unifiUpdate)) {
if (str_starts_with($record->unifi_id, 'emb_') && isset($unifiUpdate['x_passphrase'])) {
// Embedded PPSK update path — modify the WLAN's embedded array.
$unifi->updateEmbeddedPpsk($record->wlan_id, $record->x_passphrase, $unifiUpdate['x_passphrase']);
// Synthetic id is derived from the new passphrase.
$data['unifi_id'] = 'emb_' . substr(hash('sha256', $record->wlan_id . ':' . $unifiUpdate['x_passphrase']), 0, 32);
// Match by name (reliable across drift).
$newPass = $unifiUpdate['x_passphrase'];
$unifi->updateEmbeddedPpsk($record->wlan_id, $record->x_passphrase, $newPass, $record->name);
$data['unifi_id'] = 'emb_' . substr(hash('sha256', $record->wlan_id . ':' . $newPass), 0, 32);
// Also update grouped WLAN siblings (user-defined
// SSID groups, falling back to same-name).
foreach ($unifi->getGroupedWlans($record->wlan_id) as $siblingWlanId) {
$sibling = UnifiPpsk::where('wlan_id', $siblingWlanId)
->where('name', $record->name)
->where('state', 'active')
->first();
try {
$unifi->updateEmbeddedPpsk($siblingWlanId, $sibling?->x_passphrase, $newPass, $record->name);
if ($sibling) {
$sibling->update([
'x_passphrase' => $newPass,
'unifi_id' => 'emb_' . substr(hash('sha256', $siblingWlanId . ':' . $newPass), 0, 32),
]);
}
} catch (\Throwable $e) {
// PPSK absent on this band is fine — just
// means it isn't mirrored. Anything else
// gets warning-logged.
$level = str_contains($e->getMessage(), 'not found') ? 'info' : 'warning';
\Illuminate\Support\Facades\Log::log($level, 'unifi.ppsk_sibling_update', [
'sibling_wlan' => $siblingWlanId,
'error' => $e->getMessage(),
]);
}
}
} else {
$unifi->updatePpsk($record->unifi_id, $unifiUpdate);
}

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

@@ -1,58 +0,0 @@
<?php
namespace Dashboard\Unifi\Models;
use App\Models\NavItem;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UnifiPageGrant extends Model
{
protected $table = 'unifi_page_grants';
protected $fillable = [
'nav_item_id',
'grantee_type',
'grantee_id',
'granted_by_user_id',
];
public function navItem(): BelongsTo
{
return $this->belongsTo(NavItem::class);
}
public function grantedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'granted_by_user_id');
}
/**
* True iff $user is allowed to access $navItem under strict allowlist
* semantics:
* * super-admins (the model-level flag) always pass
* * otherwise the user must be a direct grantee, OR be in a group
* that is a grantee
*
* A page with NO grants saved is only visible to super-admins —
* the admin must explicitly authorize everyone else via the
* Access tab.
*/
public static function userCanAccess(User $user, NavItem $navItem): bool
{
if ($user->is_super_admin) return true;
$groupIds = $user->groups()->pluck('groups.id');
return static::where('nav_item_id', $navItem->id)
->where(function ($q) use ($user, $groupIds) {
$q->where(function ($u) use ($user) {
$u->where('grantee_type', 'user')->where('grantee_id', $user->id);
})->orWhere(function ($g) use ($groupIds) {
$g->where('grantee_type', 'group')->whereIn('grantee_id', $groupIds);
});
})
->exists();
}
}

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();
@@ -312,6 +349,122 @@ class UnifiApiClient
return $this->put("/rest/wlanconf/{$wlanId}", $data);
}
/**
* Find every other WLAN that should rotate/update together with this
* one. Authoritative source: the user-defined "SSID groups" setting
* (unifi.ssid_groups) from the WiFi Networks page, which lets the
* operator manually couple WLANs that may have different SSID names.
*
* Falls back to same-SSID-name siblings for installs that haven't
* configured groups yet.
*
* Returns an array of sibling wlan IDs (excludes $wlanId itself).
*/
public function getGroupedWlans(string $wlanId): array
{
$groupsJson = Setting::get('unifi.ssid_groups', '{}');
$groups = json_decode($groupsJson, true);
if (is_array($groups)) {
foreach ($groups as $wlanIds) {
if (! is_array($wlanIds)) continue;
if (in_array($wlanId, $wlanIds, true)) {
return array_values(array_filter($wlanIds, fn ($id) => $id !== $wlanId));
}
}
}
return $this->getWlanSiblings($wlanId);
}
/**
* Verify an embedded PPSK has the expected passphrase right now.
* Used after an update to confirm the change actually applied —
* UniFi sometimes 200s an update that didn't stick (cluster sync
* race, hot-restart in progress, etc.).
*
* Returns ['ok' => true] on a clean match, or
* ['ok' => false, 'reason' => 'fetch_failed'|'not_found'|'mismatch']
* with optional 'error' on fetch failures.
*/
public function verifyEmbeddedPpsk(string $wlanId, string $name, string $expectedPassphrase): array
{
try {
$entries = $this->getPpskEntries($wlanId);
} catch (\Throwable $e) {
return ['ok' => false, 'reason' => 'fetch_failed', 'error' => $e->getMessage()];
}
$networkconfId = $this->findNetworkconfIdByName($name);
foreach ($entries as $e) {
$entryName = $e['name'] ?? $e['label'] ?? $e['username'] ?? $e['privatePskName'] ?? null;
$entryNetId = $e['networkconf_id'] ?? null;
$entryMatches = ($networkconfId !== null && $entryNetId === $networkconfId)
|| ($entryName !== null && $entryName === $name);
if (! $entryMatches) continue;
$entryPass = $e['x_passphrase'] ?? $e['password'] ?? $e['passphrase'] ?? null;
return $entryPass === $expectedPassphrase
? ['ok' => true]
: ['ok' => false, 'reason' => 'mismatch'];
}
return ['ok' => false, 'reason' => 'not_found'];
}
/**
* Look up a networkconf (VLAN/network) by its display name. Embedded
* PPSKs on this controller use networkconf_id as their stable
* identifier — the human "name" the operator sees is actually the
* network's name.
*/
private function findNetworkconfIdByName(string $name): ?string
{
try {
$networks = $this->getNetworkConfs();
} catch (\Throwable) {
return null;
}
foreach ($networks as $n) {
if (($n['name'] ?? null) === $name) {
return $n['_id'] ?? null;
}
}
return null;
}
/**
* Find sibling WLAN configs — same SSID name, different _id. UniFi
* splits a "banded" SSID (band-steering disabled) into one wlanconf
* per band, each with its own _id and its own embedded PPSK array.
* A rotation that updates one band must also update the others, or
* the SSID's two halves drift out of sync.
*
* Returns an array of sibling wlan IDs (excludes $wlanId itself).
* Empty array if the target WLAN is unique or can't be found.
*/
public function getWlanSiblings(string $wlanId): array
{
try {
$all = $this->get('/rest/wlanconf');
} catch (\Throwable) {
return [];
}
$target = null;
foreach ($all as $w) {
if (($w['_id'] ?? null) === $wlanId) { $target = $w; break; }
}
if (! $target || empty($target['name'])) return [];
$siblings = [];
foreach ($all as $w) {
if (($w['_id'] ?? null) === $wlanId) continue;
if (($w['name'] ?? null) === $target['name']) {
$siblings[] = $w['_id'];
}
}
return $siblings;
}
// ── PPSK ─────────────────────────────────────────────────────────────────
/**
@@ -504,7 +657,7 @@ class UnifiApiClient
* no controller-side ID. Only changes the entry's passphrase; name
* isn't separately addressable on embedded PPSKs.
*/
public function updateEmbeddedPpsk(string $wlanId, string $oldPassphrase, string $newPassphrase): array
public function updateEmbeddedPpsk(string $wlanId, ?string $oldPassphrase, string $newPassphrase, ?string $name = null): array
{
$wlanResp = $this->get("/rest/wlanconf/{$wlanId}");
$wlan = $wlanResp[0] ?? $wlanResp;
@@ -514,26 +667,66 @@ class UnifiApiClient
throw new \RuntimeException('WLAN has no embedded PPSKs to update.');
}
$matched = false;
foreach ($entries as &$e) {
$current = $e['x_passphrase'] ?? $e['password'] ?? $e['passphrase'] ?? null;
if ($current === $oldPassphrase) {
// Preserve whichever field name the controller is using.
if (array_key_exists('x_passphrase', $e)) $e['x_passphrase'] = $newPassphrase;
if (array_key_exists('password', $e)) $e['password'] = $newPassphrase;
if (array_key_exists('passphrase', $e)) $e['passphrase'] = $newPassphrase;
// If none of the above existed, default to password (most common on embedded).
if (! isset($e['x_passphrase']) && ! isset($e['password']) && ! isset($e['passphrase'])) {
$e['password'] = $newPassphrase;
}
$matched = true;
break;
// Embedded PPSKs on this controller don't carry a name field —
// the human label ("GUEST", "3DPrinters", …) is the *network's*
// name, and each entry references it via networkconf_id. So when
// the caller passes a name, first resolve it to a networkconf_id
// and match on that. Falls back to entry-level name (other
// controller versions DO put a name on the entry) and finally
// to current passphrase.
$applyUpdate = function (array &$e) use ($newPassphrase) {
if (array_key_exists('x_passphrase', $e)) $e['x_passphrase'] = $newPassphrase;
if (array_key_exists('password', $e)) $e['password'] = $newPassphrase;
if (array_key_exists('passphrase', $e)) $e['passphrase'] = $newPassphrase;
if (! isset($e['x_passphrase']) && ! isset($e['password']) && ! isset($e['passphrase'])) {
$e['password'] = $newPassphrase;
}
};
$networkconfId = ($name !== null && $name !== '') ? $this->findNetworkconfIdByName($name) : null;
$matched = false;
if ($networkconfId !== null) {
foreach ($entries as &$e) {
if (($e['networkconf_id'] ?? null) === $networkconfId) {
$applyUpdate($e);
$matched = true;
break;
}
}
unset($e);
}
if (! $matched && $name !== null && $name !== '') {
foreach ($entries as &$e) {
$entryName = $e['name'] ?? $e['label'] ?? $e['username'] ?? $e['privatePskName'] ?? null;
if ($entryName === $name) {
$applyUpdate($e);
$matched = true;
break;
}
}
unset($e);
}
if (! $matched && $oldPassphrase !== null && $oldPassphrase !== '') {
foreach ($entries as &$e) {
$current = $e['x_passphrase'] ?? $e['password'] ?? $e['passphrase'] ?? null;
if ($current === $oldPassphrase) {
$applyUpdate($e);
$matched = true;
break;
}
}
unset($e);
}
unset($e);
if (! $matched) {
throw new \RuntimeException('Embedded PPSK not found by current passphrase.');
throw new \RuntimeException(
'Embedded PPSK not found' .
($name !== null ? " for network \"{$name}\"" : '') .
' or by current passphrase.'
);
}
// UniFi REST expects the full WLAN object on PUT — send what we

View File

@@ -181,7 +181,11 @@ class WebhookCheckService
$prev = DeviceState::where('device_mac', $mac)->first();
if (! $prev) continue;
// Skip planned reboots — these are intentional, not alerts
// Skip planned reboots — these are intentional, not alerts.
// Two layers: a global suppression window set by RebootAllAps
// (Setting, survives any cache driver), plus the per-MAC
// cache keys for finer granularity.
if ($this->inGlobalRebootSuppression()) continue;
if (Cache::has('unifi:planned_reboot:' . strtolower($mac))) continue;
if ($comingOnline) {
@@ -509,6 +513,8 @@ class WebhookCheckService
private function checkReboot($aps, array $filter): array
{
$alerts = [];
if ($this->inGlobalRebootSuppression()) return $alerts;
foreach ($aps as $ap) {
if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue;
if (Cache::has('unifi:planned_reboot:' . strtolower($ap['mac']))) continue;
@@ -584,6 +590,24 @@ class WebhookCheckService
return self::buildPlatformPayload($url, $message, $fullPayload);
}
/**
* Is a fleet reboot in progress right now? RebootAllAps stamps a
* suppression-until timestamp as a Setting; while that timestamp
* is in the future, we skip all device-offline / device-online /
* unexpected-reboot alerts to avoid flooding webhooks during the
* known maintenance window.
*/
private function inGlobalRebootSuppression(): bool
{
$until = \App\Models\Setting::get('unifi.reboot_suppression_until');
if (! $until) return false;
try {
return \Carbon\Carbon::parse($until)->isFuture();
} catch (\Throwable) {
return false;
}
}
/**
* Public/static helper so the test-webhook endpoint produces the
* same per-platform payload shape that real events do.

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

@@ -2,11 +2,7 @@
namespace Dashboard\Unifi;
use App\Models\DashboardApp;
use App\Models\NavItem;
use Dashboard\Unifi\Models\UnifiPageGrant;
use Illuminate\Routing\Events\RouteMatched;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
class UnifiServiceProvider extends ServiceProvider
@@ -25,36 +21,45 @@ class UnifiServiceProvider extends ServiceProvider
$this->loadRoutesFrom(__DIR__ . '/routes/unifi.php');
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
// Per-page access enforcement for unifi routes. If a unifi page has
// any UnifiPageGrant rows, only super-admins and granted users/
// groups can hit it; otherwise (no grants) it's open per the existing
// permission middleware. Super-admins always bypass.
Event::listen(RouteMatched::class, function (RouteMatched $event) {
$routeName = $event->route->getName();
if (! $routeName || ! str_starts_with($routeName, 'unifi.')) return;
$user = $event->request->user();
if (! $user || $user->is_super_admin) return;
try {
$appId = DashboardApp::where('slug', 'unifi')->value('id');
$item = NavItem::where('route_name', $routeName)
->where('app_id', $appId)
->first();
if (! $item) return;
if (! UnifiPageGrant::userCanAccess($user, $item)) {
// 404 instead of 403 — don't leak that the page
// exists. The Access tab is the only way in.
abort(404);
}
} catch (\Throwable) {
// unifi_page_grants table may not exist yet on a fresh
// install before this snap-in's migrations have run —
// fail open in that narrow window.
}
});
// "/" home card: guest Wi-Fi network + current rotating password +
// QR (re-renders when the password changes). Self-hides until enabled
// and a password has been rotated.
//
// The ONE card in the platform that earned a NEW permission key rather
// than reusing one. It cannot be universal — the payload is a live
// credential, and publishing it to every student and parent is the exact
// hole GuestCardController::data was written to close. And no existing
// unifi key fits: unifi.stats is the network dashboards, unifi.settings is
// the controller connection, and handing the front desk either one to let
// them read a guest password would widen access, not reuse it. So the
// granularity goes in the catalogue (same move as the papercut/inventory
// split), and the key is grantable to non-unifi staff — which is the whole
// point of a home-page card.
//
// Declared in composer.json extra.dashboard.permissions, so the catalogue
// row appears when unifi is installed/updated — NOT seeded to any group.
try {
app(\App\Support\DashboardCardRegistry::class)->register(
'unifi.guest_wifi',
'Guest Wi-Fi',
'UnifiGuestWifi',
fn (\App\Models\User $user) => \Dashboard\Unifi\Http\Controllers\GuestCardController::resolve($user),
'The guest network name, current password, and a join QR code — updates automatically when the password rotates.',
permission: 'unifi.guest_wifi',
// So the permission screens can say "granted, but not switched
// on yet" rather than leaving a grantee staring at a home page
// with nothing new on it.
configured: fn () => \Dashboard\Unifi\Http\Controllers\GuestCardController::configured(),
);
} catch (\Throwable) {
// Older shell without the card registry — skip.
}
// Tell the shell's nav sidebar which unifi nav items the user
// can see: pages whose required_permission the user holds through
// groups, plus pages granted via the Settings → Access tab. The
// shell also folds these grants back into User::can()/allPermissions
// so the route-level permission middleware passes for grantees.
if ($this->app->runningInConsole()) {
$this->commands([
Console\CheckWebhooks::class,
@@ -63,10 +68,33 @@ 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'),
], 'unifi-config');
}
}
/**
* Group + direct permissions only. Deliberately NOT allPermissions():
* the shell folds page-grant permissions back into allPermissions/can,
* so using it here would turn a single granted page into every page
* carrying the same permission.
*/
public static function realPermissionKeysFor(\App\Models\User $user): \Illuminate\Support\Collection
{
if ($user->is_super_admin) {
return \App\Models\Permission::pluck('key');
}
$direct = $user->directPermissions()->get();
return \App\Models\Permission::whereHas('groups', fn ($q) => $q->whereIn('group_id', $user->groups()->pluck('groups.id')))
->pluck('key')
->merge($direct->where('granted', true)->pluck('permission_key'))
->diff($direct->where('granted', false)->pluck('permission_key'))
->unique()
->values();
}
}

View File

@@ -69,19 +69,12 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
// ── Settings ─────────────────────────────────────────────────────────
Route::middleware('permission:unifi.settings')->group(function () {
Route::get('/settings', [UnifiSettingsController::class, 'edit']) ->name('settings');
Route::get('/settings/{tab}', [UnifiSettingsController::class, 'edit'])
->where('tab', 'connection|tasks|logs|access')->name('settings.tab');
Route::post('/settings', [UnifiSettingsController::class, 'update']) ->name('settings.update');
Route::post('/settings/test', [UnifiSettingsController::class, 'testConnection'])->name('settings.test');
Route::post('/settings/sites', [UnifiSettingsController::class, 'fetchSites']) ->name('settings.sites');
// Page Access — super-admin only. Lists unifi pages and lets
// operators assign per-page user/group grants.
Route::middleware('super.admin')->group(function () {
Route::get('/settings/pages-access', [UnifiPagesAccessController::class, 'index']) ->name('settings.pages-access.index');
Route::get('/settings/pages-access/users/search', [UnifiPagesAccessController::class, 'searchUsers'])->name('settings.pages-access.users.search');
Route::get('/settings/pages-access/groups/search', [UnifiPagesAccessController::class, 'searchGroups'])->name('settings.pages-access.groups.search');
Route::put('/settings/pages-access/{navItem}', [UnifiPagesAccessController::class, 'update']) ->name('settings.pages-access.update');
});
// Cron logs — read-only history of scheduled-task runs.
Route::get('/settings/cron-logs', [UnifiCronLogsController::class, 'index'])->name('settings.cron-logs.index');
@@ -99,6 +92,15 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
});
});
// ── Guest Wi-Fi home-card poll. Authorises inside the controller against the
// card's own visibility (unifi.guest_wifi) — it returns a live credential, so
// `auth` alone is not a gate. Deliberately outside the permission groups above
// because it's a home-page widget, not a unifi page: unifi.guest_wifi is
// grantable to front-desk staff without any other network access. ──
Route::middleware(['web', 'auth'])
->get('/app/unifi/guest-card/data', [\Dashboard\Unifi\Http\Controllers\GuestCardController::class, 'data'])
->name('unifi.guest-card.data');
// ── Public API (token-protected) ──────────────────────────────────────────
// External integrations (signage, kiosks) hit these without session auth.
Route::prefix('api/unifi')->name('unifi.api.')->group(function () {
@@ -110,3 +112,21 @@ Route::prefix('api/unifi')->name('unifi.api.')->group(function () {
Route::middleware(['web', 'auth'])
->get('/portal/wifi/callback', [PortalController::class, 'captiveCallback'])
->name('unifi.portal.callback');
/*
* Let a user disconnect their OWN device from the captive-portal denial page.
*
* The admin route for this (unifi.portal.sessions.disconnect) sits behind
* `app.access:unifi` + `permission:unifi.auth`, so a student who is told "you
* already have 3 devices connected, disconnect one" was blocked by middleware
* before reaching the action — the page's `can_disconnect: true` was a promise
* the routing could not keep.
*
* Safe to expose with only `auth`: disconnectSession() already authorises
* owner-or-unifi.auth itself (`abort_if($session->user_id !== auth()->id() && !
* auth()->user()->can('unifi.auth'), 403)`), so a non-owner still gets 403. The
* admin route is left exactly as it was rather than loosened.
*/
Route::middleware(['web', 'auth'])
->post('/portal/wifi/sessions/{session}/disconnect', [PortalController::class, 'disconnectSession'])
->name('unifi.portal.self-disconnect');