37 Commits
v1.5.0 ... prod

Author SHA1 Message Date
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
8769308dfd release: 1.8.0 — rolls up the 1.7.1 patch series
Bundled stable cut for prod. Contents since 1.7.0:

* feat(access): strict allowlist enforcement. A unifi page with NO
  grants is now visible only to super-admins — previously it fell back
  to "open for anyone with the route permission". Matches the new
  dashboard-wide access model.
* feat(access): the Access tab now adds groups by typeahead search,
  mirroring the user-search flow. Only granted groups + super-admin
  groups appear in the matrix; other groups are added on demand.
* fix(access): ungranted users hitting a unifi URL get 404 instead of
  403 so the page doesn't leak its existence.

Breaking note: super-admins continue to see everything. Non-super
users that previously accessed a unifi page via permission alone now
need an explicit grant in the Access tab. Configure grants before
relying on existing permission-based access.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:17:21 -04:00
f5848907f5 feat(access): strict allowlist + add groups by search
* UnifiPageGrant::userCanAccess no longer falls back to "open" when a
  page has no grants saved. Pages now require an explicit grant for
  every non-super-admin user — either a direct user grant or via a
  group they belong to. Matches the new dashboard-wide access model.
* Route enforcement returns 404 (was 403) so ungranted users can't even
  confirm the page exists.
* New /settings/pages-access/groups/search endpoint mirrors the
  user typeahead. Groups are no longer all listed by default — only
  super-admin groups (locked-on) and groups with at least one existing
  grant show up in the matrix. Operators add more via search.

v1.7.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:59:28 -04:00
f953fde2be release: 1.7.0 — rolls up the 1.6.1/1.6.2/1.6.3 patch series
Bundled cut for the stable channel. Contents since 1.6.0:

* fix(webhooks): test endpoint formats payload per platform (Google
  Chat / Slack / Discord / Teams) so the Test URL button actually
  succeeds against those targets instead of getting a 400 back.
* fix(schema): add missing unifi_device_states.consecutive_count
  column the scheduled snapshot capture was failing to insert.
* feat(rotate): persist the active rotated password as
  unifi.password_rotation.last_password whenever a whole-SSID
  rotation succeeds. Surfaced in Settings → Tasks under the wordlist.
* feat(api): new GET /api/unifi/wifi/current-password JSON endpoint
  for external signage / kiosks. Token-protected via
  Authorization: Bearer or ?token= query. 401 / 503 / 404 on missing
  auth, disabled API, or no rotation yet.
* feat(settings): "Expose WiFi password API" checkbox under the
  rotate-passwords block. Off by default. Generate / Regenerate /
  Clear token controls and a copy-paste curl example.

No breaking changes. Drop-in upgrade from 1.6.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:50:44 -04:00
9a37eda302 feat(api): explicit enable toggle for WiFi password endpoint
Previously the API was implicitly active whenever a token existed.
Now there's an explicit unifi.api.enabled setting that gates it:

* WifiApiController returns 503 ("API disabled") when the setting is
  off, even if a valid token is presented. Stops the endpoint from
  silently working if a token is lying around.
* Settings page exposes the toggle under the Rotate-WiFi-Passwords
  block. With it off, the token / URL / curl example are hidden.
* The form submit handles the new api_enabled boolean.

v1.6.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:44:57 -04:00
4b29f55518 feat(rotate): persist current password; add token-protected API
* RotatePasswords now stores the active wordlist entry as
  unifi.password_rotation.last_password whenever a whole-SSID rotation
  succeeds. Per-PPSK rotation continues to store passwords on each
  PPSK row as before.
* Settings → Tasks tab surfaces the current password in bold beneath
  the wordlist textarea so operators can quickly check what's live.
* New JSON endpoint GET /api/unifi/wifi/current-password returns
  {"password": "...", "rotated_at": "..."}. Protected by a token stored
  in unifi.api_token — pass as Authorization: Bearer <token> or
  ?token=<token>. 401 on bad/missing token, 503 if no token is
  configured, 404 if no rotation has happened yet.
* Settings page lets super-admins Generate / Regenerate / Clear the
  token. Generated tokens are 48-char hex from bin2hex(random_bytes(24)).
* The endpoint lives outside the web/auth middleware so external
  signage / kiosks can hit it without a session cookie.

v1.6.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:42:13 -04:00
e8796d443e fix(webhooks): test endpoint formats payload per platform; add missing column
* The Test URL button was POSTing a generic {event, timestamp, data}
  envelope to every endpoint. Google Chat / Slack / Discord / Teams
  reject anything that isn't their specific shape — so a successful
  Laravel request still got a 400 back from the platform, making the
  test look broken. The real webhook events already handle this via
  WebhookCheckService::formatPayloadForPlatform; that helper is now
  exposed as a public static (buildPlatformPayload) and the test
  endpoint uses the same code path, so the test exercises the same
  format real events will.
* unifi_device_states was missing a consecutive_count column the
  WebhookCheckService inserts on every snapshot capture. The scheduler
  was throwing "Unknown column 'consecutive_count'" once a minute.
  Added an idempotent migration.

v1.6.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:34:42 -04:00
24aad5cdc0 release: 1.6.0 — hide Portal page until it's fully implemented
Portal page is removed from the snap-in's nav entry list in composer.json
so it stops appearing in the sidebar. All portal routes, the
PortalController, the Portal.vue page, and the unifi.auth permission
are retained — we'll surface the page again in a later version once the
captive portal flow is fully working.

Minor bump because this is the first release that bundles the recent
batch of features:
* per-page access grants (snap-in-local table, super-admin only)
* cron logs tab with structured per-run history
* PPSK scheduling consistency + drift correction
* settings tabs (Connection / Tasks / Logs / Webhooks / Access)
* webhooks moved under /settings/webhooks + Test URL button
* embedded PPSK update via WLAN config
* page width standardized at max-w-7xl px-4

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:19:06 -04:00
27c1584dc3 fix(ppsk): embedded PPSKs update via WLAN config, not /rest/ppsk
Embedded PPSKs live inside the parent WLAN's private_preshared_keys
array — they have no controller-side _id and the synthetic emb_<hash>
we generate locally isn't a real REST id. Hitting /rest/ppsk/emb_xxx
returns HTTP 400/503, which is what the GUEST PPSK rotation was
failing on at the scheduled 3pm run.

* New UnifiApiClient::updateEmbeddedPpsk($wlanId, $oldPass, $newPass):
  GETs /rest/wlanconf/{wlanId}, finds the matching entry in
  private_preshared_keys by current passphrase, swaps the value while
  preserving whichever field name the controller uses (x_passphrase /
  password / passphrase), and PUTs the whole WLAN object back.
* RotatePasswords detects emb_-prefixed unifi_ids and routes through
  the embedded path. The synthetic id is rederived from the new
  passphrase so the DB row stays addressable.
* WifiController::ppskUpdate (manual modal save) does the same — this
  is why manual edits sometimes appeared to succeed but the controller
  side actually rejected them.

Verified live against the GUEST PPSK on 10.81.0.1.
v1.5.5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:14:45 -04:00
c89adeea97 fix(webhooks): add missing columns; add pre-save URL test endpoint
* The model+validation referenced tracked_clients and templates columns
  but they were never in the unifi_webhook_configs migration. Any save
  attempt that included those keys 500'd with "Unknown column".
  Added an additive migration (idempotent) that adds both as nullable
  json columns.
* New POST /settings/webhooks/test-url endpoint takes a url+secret in
  the body and fires the standard test payload. Lets operators validate
  their endpoint before saving the row — useful when first wiring up
  Google Chat, Slack, etc.

v1.5.4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:59:44 -04:00
8f51be8515 fix(rotate): don't skip when only PPSKs are flagged; move webhooks under /settings
* Password rotation was short-circuiting any run that had no whole-SSID
  wlan_ids configured, even if there were PPSKs with rotate_password=true
  in the database. The PPSK rotation block lived after the early-return,
  so per-PPSK rotation never fired. Now we only skip when there's nothing
  at all to rotate (neither wlan_ids nor PPSK opt-ins).
* Webhook routes moved from /app/network/webhooks to
  /app/network/settings/webhooks so the URL reflects that this is a
  settings tab. Route names unchanged.

v1.5.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:53:48 -04:00
0490a1220b feat(access): only return granted users; add search endpoint
Listing every user in the system on the access page didn't scale —
schools have thousands of user rows. Now:
  - index() only returns users that already have a UnifiPageGrant
    somewhere. Groups stay fully listed (few of them).
  - new searchUsers(q) endpoint returns up to 20 typeahead matches
    against name or email (min 2 chars).

v1.5.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 16:51:00 -04:00
4b73b53dd6 chore(nav): drop Webhooks nav row — moved into Settings tabs
Webhooks now lives as a tab alongside Connection / Tasks / Logs /
Access in the Settings page. The standalone Webhooks page still
exists at /app/network/webhooks but no longer appears in the sidebar.

v1.5.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 16:43:30 -04:00
18 changed files with 977 additions and 215 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.5.0",
"version": "1.13.4",
"type": "library",
"license": "MIT",
"autoload": {
@@ -16,6 +16,8 @@
]
},
"dashboard": {
"tier": "optional",
"stability": "stable",
"nav_folder": {
"label": "Unifi Network",
"icon": "wifi",
@@ -27,15 +29,14 @@
{ "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": "Portal", "route_name": "unifi.portal.settings", "icon": "shield-check", "permission": "unifi.auth", "sort_order": 6 },
{ "label": "Webhooks", "route_name": "unifi.webhooks.index", "icon": "bell-alert", "permission": "unifi.settings", "sort_order": 7 },
{ "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,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('unifi_webhook_configs', function (Blueprint $table) {
if (! Schema::hasColumn('unifi_webhook_configs', 'tracked_clients')) {
$table->json('tracked_clients')->nullable()->after('device_filter');
}
if (! Schema::hasColumn('unifi_webhook_configs', 'templates')) {
$table->json('templates')->nullable()->after('tracked_clients');
}
});
}
public function down(): void
{
Schema::table('unifi_webhook_configs', function (Blueprint $table) {
if (Schema::hasColumn('unifi_webhook_configs', 'templates')) {
$table->dropColumn('templates');
}
if (Schema::hasColumn('unifi_webhook_configs', 'tracked_clients')) {
$table->dropColumn('tracked_clients');
}
});
}
};

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('unifi_device_states', function (Blueprint $table) {
if (! Schema::hasColumn('unifi_device_states', 'consecutive_count')) {
$table->unsignedSmallInteger('consecutive_count')->default(0)->after('in_alert');
}
});
}
public function down(): void
{
Schema::table('unifi_device_states', function (Blueprint $table) {
if (Schema::hasColumn('unifi_device_states', 'consecutive_count')) {
$table->dropColumn('consecutive_count');
}
});
}
};

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

@@ -32,9 +32,16 @@ class RotatePasswords extends Command
$run = UnifiCronRun::record('rotate-passwords', $triggeredBy, null, function () use ($unifi, $force) {
$wlanIdsJson = Setting::get('unifi.password_rotation.wlan_ids', '[]');
$wlanIds = json_decode($wlanIdsJson, true);
if (! is_array($wlanIds)) $wlanIds = [];
if (empty($wlanIds) || ! is_array($wlanIds)) {
return ['status' => 'skipped', 'reason' => 'no SSIDs configured for rotation'];
$ppskQuery = UnifiPpsk::where('rotate_password', true)
->where('state', 'active')
->whereNotNull('unifi_id');
// Skip only if there's nothing at all to rotate — neither
// whole-SSID rotation targets nor per-PPSK rotation opt-ins.
if (empty($wlanIds) && ! $ppskQuery->exists()) {
return ['status' => 'skipped', 'reason' => 'no SSIDs or PPSKs configured for rotation'];
}
$wordlist = Setting::get('unifi.password_rotation.wordlist', '');
@@ -61,17 +68,85 @@ class RotatePasswords extends Command
if ($rotated) {
Setting::set('unifi.password_rotation.last_rotated_at', now()->toIso8601String());
// Persist the active password so it can be displayed in
// the Settings page and exposed via the API endpoint.
Setting::set('unifi.password_rotation.last_password', $password);
$this->info('Rotated password for ' . count($rotated) . ' SSID(s).');
}
$rotatedPpsks = [];
$failedPpsks = [];
foreach (UnifiPpsk::where('rotate_password', true)->where('state', 'active')->whereNotNull('unifi_id')->get() as $ppsk) {
foreach ($ppskQuery->get() as $ppsk) {
$newPass = $passwords[array_rand($passwords)];
try {
$unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]);
$ppsk->update(['x_passphrase' => $newPass]);
if (str_starts_with((string) $ppsk->unifi_id, 'emb_')) {
// 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

@@ -36,7 +36,11 @@ class PortalController extends Controller
'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);
@@ -51,7 +55,11 @@ class PortalController extends Controller
'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>
@@ -104,6 +141,23 @@ class PortalController extends Controller
return response('Missing MAC address', 400);
}
// SECURITY: everything identifying the device is request-supplied, and this
// endpoint WRITES network state (authorizeGuest puts a MAC on the mapped VLAN).
// Before this check any signed-in account — a student, a parent — could call
// GET /portal/wifi/callback?mac=<someone else's MAC>&url=…
// and admit a device they do not own, up to max_devices.
//
// A well-formed MAC is the floor, not a fix: it stops the parameter being used
// to smuggle junk into authorizeGuest and into the PortalSession row, but it
// cannot prove the caller owns the device. Properly binding it needs the AP
// handshake — UniFi can sign the redirect, or we ask the controller whether this
// MAC is CURRENTLY associated to $apMac before admitting it. Both need testing
// against real hardware, so they are deliberately not guessed at here.
$clientMac = str_replace('-', ':', $clientMac);
if (! preg_match('/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/', $clientMac)) {
return response('Malformed MAC address', 400);
}
// Find the VLAN mapping for this user's group/OU
$mapping = $this->resolveMapping($user);
if (! $mapping) {
@@ -161,9 +215,16 @@ class PortalController extends Controller
'expires_at' => now()->addMinutes($minutes),
]);
// 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)

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,82 +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');
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' => User::orderBy('name')->get(['id', 'name', 'email']),
'groups' => Group::orderBy('name')->get(['id', 'name', 'is_super']),
]);
}
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'),
@@ -31,10 +32,29 @@ class UnifiSettingsController extends Controller
'rotationMinute' => (int) Setting::get('unifi.password_rotation.minute', 0),
'rotationWordlist' => Setting::get('unifi.password_rotation.wordlist', ''),
'rotationLastRotatedAt' => Setting::get('unifi.password_rotation.last_rotated_at', null),
'rotationLastPassword' => Setting::get('unifi.password_rotation.last_password', null),
'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', ''),
]);
}
public function regenerateApiToken()
{
$token = bin2hex(random_bytes(24));
Setting::set('unifi.api_token', $token);
return response()->json(['token' => $token]);
}
public function clearApiToken()
{
Setting::set('unifi.api_token', '');
return response()->json(['ok' => true]);
}
public function update(Request $request)
{
$request->validate([
@@ -56,6 +76,10 @@ class UnifiSettingsController extends Controller
'rotation_minute' => 'nullable|integer|min:0|max:59',
'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, '/'));
@@ -82,6 +106,10 @@ class UnifiSettingsController extends Controller
Setting::set('unifi.password_rotation.minute', $request->input('rotation_minute', 0));
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

@@ -68,20 +68,49 @@ class WebhookController extends Controller
public function test(WebhookConfig $webhook)
{
$payload = [
return $this->fireTest($webhook->url, $webhook->secret);
}
/**
* Test an arbitrary URL+secret before the webhook is saved. Lets the
* operator validate their endpoint from the form without first
* committing a row.
*/
public function testUrl(Request $request)
{
$data = $request->validate([
'url' => 'required|url|max:500',
'secret' => 'nullable|string|max:255',
]);
return $this->fireTest($data['url'], $data['secret'] ?? null);
}
private function fireTest(string $url, ?string $secret)
{
$message = '✅ Test webhook from ' . config('app.name') . ' — endpoint is reachable.';
$genericPayload = [
'event' => 'test',
'timestamp' => now()->toIso8601String(),
'data' => ['message' => 'This is a test webhook from ' . config('app.name')],
'message' => $message,
'data' => ['message' => $message],
];
// Shape the payload to match the target platform (Google Chat,
// Slack, Discord, Teams) so the test exercises the same code
// path real events use.
$payload = \Dashboard\Unifi\Services\WebhookCheckService::buildPlatformPayload($url, $message, $genericPayload);
$headers = ['Content-Type' => 'application/json'];
if ($webhook->secret) {
$headers['X-Webhook-Signature'] = hash_hmac('sha256', json_encode($payload), $webhook->secret);
if ($secret) {
$headers['X-Webhook-Signature'] = hash_hmac('sha256', json_encode($payload), $secret);
}
try {
$response = \Illuminate\Support\Facades\Http::withHeaders($headers)->timeout(10)->post($webhook->url, $payload);
return response()->json(['ok' => true, 'status' => $response->status()]);
$response = \Illuminate\Support\Facades\Http::withHeaders($headers)->timeout(10)->post($url, $payload);
return response()->json([
'ok' => $response->successful(),
'status' => $response->status(),
'body' => mb_substr((string) $response->body(), 0, 500),
]);
} catch (\Throwable $e) {
return response()->json(['ok' => false, 'error' => $e->getMessage()], 422);
}

View File

@@ -0,0 +1,44 @@
<?php
namespace Dashboard\Unifi\Http\Controllers;
use App\Models\Setting;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
/**
* Token-protected JSON endpoints for external integrations (signage,
* kiosks, room displays, etc.) that need the current rotating WiFi
* password without going through the dashboard UI.
*/
class WifiApiController extends Controller
{
public function currentPassword(Request $request)
{
if (! Setting::get('unifi.api.enabled')) {
return response()->json(['error' => 'API disabled'], 503);
}
$expected = Setting::get('unifi.api_token');
if (! $expected) {
return response()->json(['error' => 'API token not configured'], 503);
}
$provided = $request->bearerToken() ?: $request->query('token');
if (! $provided || ! hash_equals($expected, $provided)) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$password = Setting::get('unifi.password_rotation.last_password');
if (! $password) {
return response()->json([
'error' => 'No rotated password recorded yet — wait for the next scheduled rotation or run unifi:rotate-passwords --force.',
], 404);
}
return response()->json([
'password' => $password,
'rotated_at' => Setting::get('unifi.password_rotation.last_rotated_at'),
]);
}
}

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')
@@ -291,11 +342,46 @@ class WifiController extends Controller
fn ($v) => $v !== null
);
if (! empty($unifiUpdate)) {
$unifi->updatePpsk($record->unifi_id, $unifiUpdate);
if (str_starts_with($record->unifi_id, 'emb_') && isset($unifiUpdate['x_passphrase'])) {
// Embedded PPSK update path — modify the WLAN's embedded array.
// 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);
}
}
}
$dbUpdate = array_intersect_key($data, array_flip(['name', 'x_passphrase']));
$dbUpdate = array_intersect_key($data, array_flip(['name', 'x_passphrase', 'unifi_id']));
// vlan can be explicitly set to null
if (array_key_exists('vlan', $data)) $dbUpdate['vlan'] = $data['vlan'];
if (! empty($dbUpdate)) $record->update($dbUpdate);

View File

@@ -1,56 +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 this grant model.
* Super-admins always pass.
* If there are NO grants for the page, falls back to "open" (anyone
* who can reach the route can access — same as before grants existed).
*/
public static function userCanAccess(User $user, NavItem $navItem): bool
{
if ($user->is_super_admin) return true;
$hasGrants = static::where('nav_item_id', $navItem->id)->exists();
if (! $hasGrants) 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

@@ -312,6 +312,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 ─────────────────────────────────────────────────────────────────
/**
@@ -496,6 +612,103 @@ class UnifiApiClient
return $this->normalizePpsk(is_array($result) && isset($result[0]) ? $result : [$result]);
}
/**
* Update an embedded PPSK (one that lives inside a WLAN's
* private_preshared_keys array rather than as its own REST resource).
*
* Matching is done by current passphrase since embedded entries have
* 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, ?string $name = null): array
{
$wlanResp = $this->get("/rest/wlanconf/{$wlanId}");
$wlan = $wlanResp[0] ?? $wlanResp;
$entries = $wlan['private_preshared_keys'] ?? [];
if (! is_array($entries) || empty($entries)) {
throw new \RuntimeException('WLAN has no embedded PPSKs to update.');
}
// 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);
}
if (! $matched) {
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
// got back, with the mutated PPSK array.
$payload = $wlan;
$payload['private_preshared_keys'] = $entries;
// Strip internal fields the controller rejects on PUT.
unset($payload['_id'], $payload['site_id']);
$this->put("/rest/wlanconf/{$wlanId}", $payload);
// Return a normalized record so callers can read the new state.
return $this->normalizePpsk([[
'_id' => 'emb_' . substr(hash('sha256', $wlanId . ':' . $newPassphrase), 0, 32),
'wlan_id' => $wlanId,
'x_passphrase' => $newPassphrase,
]]);
}
public function deletePpsk(string $ppskId): void
{
// Try v2 hotspot endpoint first

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;
@@ -581,10 +587,37 @@ class WebhookCheckService
private function formatPayloadForPlatform(string $url, string $message, array $fullPayload): array
{
if (str_contains($url, 'chat.googleapis.com')) return ['text' => $message];
if (str_contains($url, 'hooks.slack.com')) return ['text' => $message];
if (str_contains($url, 'discord.com/api/webhooks')) return ['content' => $message];
if (str_contains($url, 'webhook.office.com') || str_contains($url, 'workflows.office.com')) return ['text' => $message];
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.
*/
public static function buildPlatformPayload(string $url, string $message, array $fullPayload): array
{
if (str_contains($url, 'chat.googleapis.com')) return ['text' => $message];
if (str_contains($url, 'hooks.slack.com')) return ['text' => $message];
if (str_contains($url, 'discord.com/api/webhooks')) return ['content' => $message];
if (str_contains($url, 'webhook.office.com') || str_contains($url, 'workflows.office.com')) return ['text' => $message];
return $fullPayload;
}

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,34 +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)) {
abort(403, 'You do not have access to this page.');
}
} 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,
@@ -67,4 +74,26 @@ class UnifiServiceProvider extends ServiceProvider
], '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

@@ -9,6 +9,7 @@ use Dashboard\Unifi\Http\Controllers\UnifiPagesAccessController;
use Dashboard\Unifi\Http\Controllers\UnifiSettingsController;
use Dashboard\Unifi\Http\Controllers\VlanGroupController;
use Dashboard\Unifi\Http\Controllers\WebhookController;
use Dashboard\Unifi\Http\Controllers\WifiApiController;
use Dashboard\Unifi\Http\Controllers\WifiController;
use Illuminate\Support\Facades\Route;
@@ -68,30 +69,64 @@ 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::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');
// Webhooks
Route::get('/webhooks', [WebhookController::class, 'index']) ->name('webhooks.index');
Route::post('/webhooks', [WebhookController::class, 'store']) ->name('webhooks.store');
Route::put('/webhooks/{webhook}', [WebhookController::class, 'update']) ->name('webhooks.update');
Route::delete('/webhooks/{webhook}', [WebhookController::class, 'destroy'])->name('webhooks.destroy');
Route::post('/webhooks/{webhook}/test', [WebhookController::class, 'test']) ->name('webhooks.test');
// Webhooks — lives under /settings/* so it reads as a settings tab.
Route::get('/settings/webhooks', [WebhookController::class, 'index']) ->name('webhooks.index');
Route::post('/settings/webhooks', [WebhookController::class, 'store']) ->name('webhooks.store');
Route::put('/settings/webhooks/{webhook}', [WebhookController::class, 'update']) ->name('webhooks.update');
Route::delete('/settings/webhooks/{webhook}', [WebhookController::class, 'destroy'])->name('webhooks.destroy');
Route::post('/settings/webhooks/{webhook}/test', [WebhookController::class, 'test']) ->name('webhooks.test');
Route::post('/settings/webhooks/test-url', [WebhookController::class, 'testUrl'])->name('webhooks.test-url');
// API-token management
Route::post('/settings/api-token/regenerate', [UnifiSettingsController::class, 'regenerateApiToken'])->name('settings.api-token.regenerate');
Route::delete('/settings/api-token', [UnifiSettingsController::class, 'clearApiToken']) ->name('settings.api-token.clear');
});
});
// ── 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 () {
Route::get('/wifi/current-password', [WifiApiController::class, 'currentPassword'])
->name('wifi.current-password');
});
// ── Captive portal callback (public — user redirected here by UniFi) ─────
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');