Compare commits
37 Commits
v1.7.1
...
33e465898c
| Author | SHA1 | Date | |
|---|---|---|---|
| 33e465898c | |||
| a8d7b34266 | |||
| 030fac3961 | |||
| b59b5bf6be | |||
| 4322d07b40 | |||
| ccd6fc49b2 | |||
| 655d8dd50c | |||
| a74818bdaf | |||
| dbebb6c964 | |||
| c745aa8b2c | |||
| 79a8360714 | |||
| 6287054a2a | |||
| b0287f1b10 | |||
| 186c1eaf2b | |||
| c93f73ce02 | |||
| cac47b74bd | |||
| 7c6bf99201 | |||
| bc3f415647 | |||
| 9cbc8e682a | |||
| f4631d1da0 | |||
|
|
9413cf9ab9 | ||
| 070001ffff | |||
| 71807c8815 | |||
| 462a1a3611 | |||
| ee27bee716 | |||
| 429cd44ac5 | |||
| 274f210337 | |||
| c0f12ce931 | |||
| dd4e0ca564 | |||
| f533208b37 | |||
| bb74edf4c1 | |||
| e5cc075938 | |||
| 4ec4a293c0 | |||
| 720e94c54a | |||
| 2be17c70db | |||
| 31686a35d5 | |||
| 8769308dfd |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dashboard/unifi",
|
||||
"description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform",
|
||||
"version": "1.7.1",
|
||||
"version": "1.13.5",
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
@@ -16,6 +16,8 @@
|
||||
]
|
||||
},
|
||||
"dashboard": {
|
||||
"tier": "optional",
|
||||
"stability": "stable",
|
||||
"nav_folder": {
|
||||
"label": "Unifi Network",
|
||||
"icon": "wifi",
|
||||
@@ -33,7 +35,8 @@
|
||||
{ "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." }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Whether the device presented a private (randomized) address.
|
||||
*
|
||||
* Nullable, because rows written before this existed genuinely do not know —
|
||||
* defaulting them to false would assert something untrue about every historical
|
||||
* session.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('unifi_portal_sessions', function (Blueprint $table) {
|
||||
$table->boolean('is_randomized')->nullable()->after('mac_address');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('unifi_portal_sessions', function (Blueprint $table) {
|
||||
$table->dropColumn('is_randomized');
|
||||
});
|
||||
}
|
||||
};
|
||||
1415
docs/superpowers/plans/2026-08-21-byod-wifi-access-model.md
Normal file
1415
docs/superpowers/plans/2026-08-21-byod-wifi-access-model.md
Normal file
File diff suppressed because it is too large
Load Diff
322
docs/superpowers/reviews/2026-08-21-plan1-review.md
Normal file
322
docs/superpowers/reviews/2026-08-21-plan1-review.md
Normal file
@@ -0,0 +1,322 @@
|
||||
# Plan 1 review findings — BYOD Wi-Fi access model
|
||||
|
||||
**Date:** 2026-08-21
|
||||
**Reviewed:** the six commits implementing
|
||||
`docs/superpowers/plans/2026-08-21-byod-wifi-access-model.md`
|
||||
(`central-unifi` `a74818b`..`a8d7b34`, `dashboard-shell` `f3de4aa`..HEAD)
|
||||
**Verdict:** CHANGES NEEDED. Nothing needs redesigning; the list below needs fixing.
|
||||
**Suite at time of review:** 48 passed in `tests/Feature/Unifi/`; full suite
|
||||
6293 passed / 10 failed, all in the unrelated pre-existing `Logs\ScriptRunsTest`.
|
||||
|
||||
Recorded here because these findings outlived the session that produced them and
|
||||
every one of them is about code that is already committed.
|
||||
|
||||
---
|
||||
|
||||
## 0. The request IP is trusted, and the app has no opinion of its own
|
||||
|
||||
`dashboard-shell/bootstrap/app.php:14` — `$middleware->trustProxies(at: '*')`.
|
||||
Laravel routes `'*'` to `setTrustedProxyIpAddressesToTheCallingIp()`, so **the
|
||||
immediate peer is unconditionally trusted as a proxy** and its `X-Forwarded-For`
|
||||
becomes `$request->ip()`. `docker/nginx/default.conf` sets no `real_ip_header` or
|
||||
`X-Forwarded-For` of its own, so a client-supplied header reaches PHP verbatim.
|
||||
|
||||
`DeviceResolver::resolve()` reads `$request->ip()` and derives the MAC from it, so
|
||||
if a client can deliver a forged header the resolver identifies a device of the
|
||||
attacker's choosing. The AP check does not help: `ap` is also a request parameter,
|
||||
so an attacker forges both.
|
||||
|
||||
### What is NOT established
|
||||
|
||||
An earlier session demonstrated forgery by sending `X-Forwarded-For` **directly to
|
||||
nginx on `:80`**. That path is not available to a real client: the Wi-Fi VLAN is
|
||||
firewalled off from the server and can reach only Traefik. So that demonstration
|
||||
does **not** show a live exploit, and it should not be cited as one.
|
||||
|
||||
### What IS established: the legitimate value arrives correctly
|
||||
|
||||
Confirmed by the operator on 2026-08-21 from the production Logged-in Users list,
|
||||
served through Traefik: sessions show **real per-client addresses** —
|
||||
`10.22.5.254` and `10.22.5.154` for on-site staff, and a public address for an
|
||||
off-site phone. So `TrustProxies` plus Traefik does yield the true client IP, and
|
||||
UniFi can map an on-site `10.x` address to a MAC.
|
||||
|
||||
That settles two things:
|
||||
|
||||
- **The IP → MAC approach is viable.** This was the operator's original question and
|
||||
the answer is yes, with production evidence.
|
||||
- **Spec §E had the release-gate risk backwards.** It called "whether TrustProxies
|
||||
yields the true client IP" the thing that gates release, fearing a fail-closed
|
||||
outage where nobody can get online. That fear is unfounded — it demonstrably
|
||||
works. Remove it as a blocker.
|
||||
|
||||
Note the off-site public address: a portal request only ever originates on-site, so
|
||||
a request whose IP is public will correctly find no UniFi client and refuse. That is
|
||||
the right behaviour, not a gap.
|
||||
|
||||
### The open question, which is narrower than it first appeared
|
||||
|
||||
**A legitimate value arriving correctly and a forged value being rejected are
|
||||
different properties.** The evidence above establishes the first. It does not
|
||||
establish the second, because nothing in that list would look wrong if Traefik were
|
||||
also passing through client-supplied headers.
|
||||
|
||||
So one question remains, and it is a one-line check
|
||||
|
||||
Whether a client can deliver a forged header depends entirely on Traefik's
|
||||
`forwardedHeaders` for the entry point, on the Traefik host (162.223.102.234 —
|
||||
a separate LXC, not reachable from the dev box):
|
||||
|
||||
- `forwardedHeaders.insecure: true`, or `trustedIPs` covering client ranges
|
||||
→ client headers are passed through → **the resolver's guarantee is void**
|
||||
- unset (Traefik's default) → Traefik replaces client-supplied forwarded headers
|
||||
→ the design works as intended
|
||||
|
||||
**Treat this as the release gate.** Narrowing `trustProxies` from `*` to the
|
||||
Traefik network is worth doing either way as defence in depth: as written, the
|
||||
application delegates the entire question to whoever happens to be talking to it.
|
||||
|
||||
### The consequence that was missed, if it is forgeable
|
||||
|
||||
Not a quota bypass — quotas key on `user_id`, so an attacker cannot exceed their
|
||||
own limit. It is worse in a different way. `updateOrCreate` writes a row keyed
|
||||
`(attacker_user_id, victim_mac)`, making the attacker the legitimate **owner** of a
|
||||
session pointing at somebody else's device. `disconnectSession` then passes its
|
||||
owner check and runs `kickClient` + `unauthorizeGuest` on the victim's MAC.
|
||||
|
||||
The spec declared `disconnectSession` "already correct and not being changed". That
|
||||
was true of the function and false of its inputs — the one component held out of
|
||||
scope as safe is what turns this into an eviction primitive. Worth remembering as a
|
||||
general lesson about scoping something out on the strength of its internals.
|
||||
|
||||
Also note the three distinct refusal reasons (`no_client` / `ap_mismatch` / success)
|
||||
form an oracle: iterating candidate IPs enumerates the live client table with MAC
|
||||
and AP. They were added for operability and double as reconnaissance.
|
||||
|
||||
**Nothing in the six commits is defective on this point.** `DeviceResolver` is a
|
||||
correct implementation of "derive the MAC from the source IP" sitting on a platform
|
||||
assumption. Spec §E named this exact dependency as the release gate and got the
|
||||
direction backwards — it worried `TrustProxies` would be too strict (a visible
|
||||
outage) and never considered it being too permissive (silent fail-open). The
|
||||
blind-spot note in `b59b5bf` reasons carefully and still only considers the strict
|
||||
direction.
|
||||
|
||||
**Exposure is wider than this feature.** Every consumer of `$request->ip()` in the
|
||||
shell inherits it — session client IP, the Logged-in Users list, anything keyed on
|
||||
IP. File against `dashboard-shell`, not `central-unifi`.
|
||||
|
||||
### What would not help
|
||||
|
||||
Assessed and rejected, all reducing to trusting the same value:
|
||||
|
||||
- **Re-checking the client's reported IP** — circular; the record was *selected* by
|
||||
that IP, so the check cannot fail.
|
||||
- **Requiring the client to be unauthorized** — breaks the renewal path and with it
|
||||
the spec's headline promise, while still leaving first-time connectors targetable.
|
||||
- **Association recency** — narrows harvesting to a race the attacker wins by
|
||||
polling, and wrongly refuses a device revisiting after a long window expires.
|
||||
|
||||
The durable fix is to stop making the request IP load-bearing: have the controller
|
||||
vouch for the MAC (a signed redirect / HMAC over `mac, ap, timestamp`). That
|
||||
survives any proxy misconfiguration. Whether this controller supports it is a
|
||||
question for the hardware session — **add it as a fourth item to spec §E**, ranked
|
||||
above the health line in Plan 2.
|
||||
|
||||
---
|
||||
|
||||
## 1. The reconciler would revoke every guest authorization on the whole site
|
||||
|
||||
`src/Console/ReconcilePortal.php` — `array_diff($authorized, $live)` then
|
||||
`unauthorizeGuest` on each, where `$authorized` comes from `getAuthorizedGuestMacs()`
|
||||
reading **site-wide** `/stat/guest`. Nothing scopes it to authorizations this portal
|
||||
created.
|
||||
|
||||
A second guest SSID, UniFi's own voucher portal, front-desk visitors, contractors,
|
||||
a gym rental, or anything an admin authorized by hand: all revoked on the first
|
||||
scheduled run, and again every run. Inversely, a device an admin deliberately
|
||||
un-authorized in the UniFi UI gets re-authorized — the dashboard silently
|
||||
overriding the network admin, forever.
|
||||
|
||||
Fix: only revoke a MAC that appears in `unifi_portal_sessions` at all, or filter by
|
||||
the portal's SSID/VLAN. **This is why "not scheduled" is currently load-bearing
|
||||
rather than merely cautious.**
|
||||
|
||||
Unverifiable without hardware, and worth adding to §E: what window does
|
||||
`GET /stat/guest` return with no `within`, and does the controller rewrite `end`
|
||||
when an authorization is revoked? If not, job 3 silently stops re-asserting.
|
||||
|
||||
## 2. The pre-DHCP retry cannot succeed, and costs a worker 1.5s per failure
|
||||
|
||||
`DeviceResolver` retries three times, but `findClientByIp()` → `getActiveClients()`
|
||||
is `Cache::remember('unifi:clients', 30s)`. All three attempts read one snapshot.
|
||||
|
||||
Worse than a no-op: the first attempt **warms** the cache with a snapshot lacking
|
||||
the connecting device, so "try again" stays refused for the rest of the TTL, for
|
||||
that pupil and everyone else. Meanwhile each failure holds a PHP worker for 1.5s of
|
||||
`usleep` — the exact cost the comment claims to bound, with none of the benefit. A
|
||||
class of 25 arriving together is 25 workers asleep on one stale array.
|
||||
|
||||
`UnifiApiClient`'s own comment justifies the cache on the grounds that "the portal
|
||||
calls this once per connection attempt". The resolver calls it three times.
|
||||
|
||||
Fix: `Cache::forget('unifi:clients')` between retries, or an uncached read path.
|
||||
|
||||
## 3a. Device type should come from the User-Agent, not from UniFi
|
||||
|
||||
Raised by the operator on 2026-08-21, from the same production screenshot: the
|
||||
Logged-in Users list already labels sessions "Safari · iPhone" and
|
||||
"Chrome · macOS", and does it well.
|
||||
|
||||
That is parsed from the **User-Agent**, by a hand-rolled private method
|
||||
`ActiveSessionController::device(string $ua)` — no library involved. Its OS table
|
||||
already distinguishes `iPhone`, `iPad`, `Android` from `Windows`, `macOS`,
|
||||
`Linux`, `CrOS`, which is precisely the phone-versus-laptop distinction the
|
||||
no-phones-for-students policy needs.
|
||||
|
||||
**This is a better source than UniFi's fingerprint for this purpose**, and it
|
||||
removes a dependency the spec could never verify:
|
||||
|
||||
- **Available at authorization time.** A portal request comes *from the device*, so
|
||||
its User-Agent is the device's own. UniFi's `dev_cat` is inferred from observed
|
||||
traffic and does not exist yet at that moment — the reason §C had to push capture
|
||||
into the reconciler at all.
|
||||
- **Known to be populated.** `unifi_client_snapshots` has zero rows on dev, so
|
||||
whether `dev_cat` is ever filled in this environment was never established. The
|
||||
User-Agent is always present.
|
||||
- **It fixes finding 3 with a value that is actually there.** Replacing the three
|
||||
always-NULL `$request->input(...)` writes with the parsed label gives the denial
|
||||
page real device names, which is the single case that list exists for.
|
||||
|
||||
Recommendation: extract `device()` into a shared support class (e.g.
|
||||
`App\Support\UserAgent::describe()`) and have both the Logged-in Users controller
|
||||
and the portal call it. Small refactor, one owner for the parsing, no duplication.
|
||||
|
||||
**The spoofing caveat stands and does not weaken this.** A mobile browser's
|
||||
"Request Desktop Site" toggle changes the User-Agent, so this is sound for
|
||||
*reporting* and unsound for *enforcement* — which is exactly decision 6
|
||||
(report-only). Keep UniFi's `dev_cat` capture in the reconciler as a corroborating
|
||||
second opinion: it is not client-controlled, so a disagreement between the two is
|
||||
itself a useful signal if enforcement is ever wanted.
|
||||
|
||||
## 3. Every portal visit blanks the fingerprint the reconciler filled in
|
||||
|
||||
`PortalController` writes `device_hostname`, `device_os` and `device_type` from
|
||||
`$request->input('hostname'/'os'/'dev_cat')`. UniFi's redirect sends `id`, `ap`,
|
||||
`t`, `url`, `ssid` — the controller's own comment says so — so all three are
|
||||
written NULL on every authorization.
|
||||
|
||||
The exact mirror of the bug `captureFingerprints()` goes out of its way to prevent.
|
||||
Consequence: the denial page's device list always reads "Unknown device", so a pupil
|
||||
at their limit chooses between two unlabelled MACs — the single case that list
|
||||
exists for. Plan 2's Wi-Fi Devices page inherits it.
|
||||
|
||||
`$device->client` already carries UniFi's `hostname`/`os_name`/`dev_cat` and is
|
||||
never read. Use it. These are also the last request-supplied values written to a
|
||||
session, so `?hostname=Principal's Laptop` is shown to admins as fact.
|
||||
|
||||
## 4. Windows slide, contradicting the spec
|
||||
|
||||
`expires_at => now()->addMinutes($minutes)` on the renewal branch grants a fresh
|
||||
full window on every visit. With `session_minutes` set to a term, anyone who
|
||||
bookmarks the callback never ages out — which is what §C's fixed-window rule exists
|
||||
to prevent. Either preserve `expires_at` on renewal, or amend the spec and say why
|
||||
in a comment; today there is no comment where a reader would ask.
|
||||
|
||||
## 5. Two routes to a wrong quota
|
||||
|
||||
No unique index on `(user_id, mac_address)` (confirmed: only PRIMARY,
|
||||
`(user_id,is_active)`, `(mac_address)`) and no lock around read-then-authorize.
|
||||
|
||||
- **Over quota:** two devices of a 1-device pupil hit the callback concurrently,
|
||||
both read zero live sessions, both are authorized. The reconciler cannot correct
|
||||
it — both rows are live and both authorized.
|
||||
- **Phantom slots:** two concurrent renewals of the same MAC each miss the other and
|
||||
insert twice. One phone, two live rows, permanently. The reconciler's
|
||||
`keyBy(mac)` silently collapses the pair so neither direction sees it, and
|
||||
self-disconnect deactivates only the row that was clicked.
|
||||
|
||||
Fix: unique index plus `lockForUpdate` in a transaction. Consider a reconciler step
|
||||
trimming a user's live rows to their quota, newest first — nothing currently
|
||||
enforces the quota against our own rows.
|
||||
|
||||
## 6. Denial copy contradicts itself on the commonest path
|
||||
|
||||
Controller says "Still connecting — wait a moment and try again"; `PortalDenied.vue`
|
||||
then prints "Please contact the school office or IT for help getting online". Two
|
||||
instructions in one view, on the most frequent denial. `resolveFailure` is passed
|
||||
and never declared as a prop.
|
||||
|
||||
---
|
||||
|
||||
## Tests that pass for the wrong reason
|
||||
|
||||
1. **The headline quota-leak regression test** asserts `component('Unifi/PortalDenied')`
|
||||
and a live count — but four branches render that component. It passes with no
|
||||
role and no mapping at all, via "No WiFi access configured". Fix: assert the
|
||||
`reason` string.
|
||||
2. **The proxy-outage test** sets `X-Forwarded-For` and `REMOTE_ADDR` to the same
|
||||
value, so its condition holds by construction; mutating the production
|
||||
condition to `($forwarded)` alone still passes. (Note: under
|
||||
`trustProxies(at: '*')`, `$ip === $raw` only holds when no XFF was sent, so
|
||||
`proxy_untrusted` is unreachable in this environment — a consequence of #0.)
|
||||
3. **The reconciler re-assert test** constrains neither MAC nor minutes. A sign
|
||||
error in `now()->diffInMinutes($expires_at, false)` would clamp every
|
||||
re-assertion to one minute and the test would not notice — and Carbon 3.13.1
|
||||
changed that method's return type and default `$absolute`.
|
||||
4. **The `sort_order` precedence test** inserts the low-`sort_order` row first, so
|
||||
replacing `orderBy('sort_order')` with `all()` still passes. It also never
|
||||
asserts the user holds `parent`.
|
||||
|
||||
Untested: the unreachable-controller guard — the fail-safe between a network blip
|
||||
and mass revocation. Worth a test now, and more so once #1 is fixed.
|
||||
|
||||
---
|
||||
|
||||
## Smaller notes
|
||||
|
||||
- `PortalDeniedTest`'s docblock explains why a device "must look CONNECTED, or the
|
||||
controller treats the session as stale, frees the slot and authorises" —
|
||||
documenting behaviour this work **deleted**. Most confidently-worded comment in
|
||||
the suite and now false. The dead `isClientConnected` stub goes with it.
|
||||
- `ResolvedDevice::$client` is never read (see #3). `$macParameterDisagreed` is
|
||||
asserted in a test and consumed nowhere — surface it or drop it and keep the log.
|
||||
- The disagreement test asserts the flag, not that `Log::warning` fired, which is
|
||||
what §B actually asks for.
|
||||
- Unused `use ...DB;` in two test files, copied from the plan.
|
||||
- Three resolver tests burn 1.5s of real `usleep` each; injecting the pause would
|
||||
pay for itself.
|
||||
- `UnifiApiClient::isClientConnected()` is now referenced nowhere in `src/`.
|
||||
Correct per spec (display-only, Plan 2) — say so in a comment or the next
|
||||
dead-code sweep deletes it.
|
||||
|
||||
## Release sequencing — not a finding, but it changes the plan
|
||||
|
||||
`Pages/Unifi/Portal.vue` still offers "Default (catch-all)", which now 422s, and
|
||||
offers **no `role` option**. Wi-Fi quotas are therefore *unconfigurable* today:
|
||||
the reachable match types are `ou`/`group`/`email_domain`. Spec §F step 3
|
||||
("configure `role` → student / staff") cannot be carried out until Plan 2, which
|
||||
makes **Plan 2 a hard prerequisite for release rather than a follow-up**.
|
||||
|
||||
---
|
||||
|
||||
## What is good and should not be disturbed
|
||||
|
||||
- **`scopeLive`** is the right shape in the right place: the model owns the
|
||||
definition of a consumed slot, a NULL expiry is not live, and the *why* is in the
|
||||
docblock. Leak 2 is fixed at the query level and does not depend on the
|
||||
unscheduled reconciler. The most valuable thing in the six commits, and untouched
|
||||
by #0.
|
||||
- **`?mac=` really is dead.** Verified end to end: with a supplied MAC present,
|
||||
`authorizeGuest` receives the derived one and no other row is written. No
|
||||
fallback anywhere; an absent `?ap=` fails closed as `ap_mismatch` rather than
|
||||
skipping the check. The fail-closed discipline is genuine and thorough — it is
|
||||
the input that betrays it, not the logic.
|
||||
- **`DeviceResolver` split out of the controller** — four named refusal reasons, no
|
||||
quota knowledge, no Inertia. When the platform is fixed this is the right object
|
||||
in the right place, and where an eventual HMAC check belongs.
|
||||
- **`captureFingerprints()`' fill-only rule** and its reasoning. #3 is the
|
||||
controller violating this rule, not this code being wrong.
|
||||
- **The Mockery declaration-order docblock** in the reconciler test will save the
|
||||
next person an hour.
|
||||
- **Removing `default` while `unifi_vlan_mappings` was empty**, and recording in
|
||||
code why doing it later would be worse.
|
||||
@@ -0,0 +1,302 @@
|
||||
# Registered guest / BYOD Wi-Fi — hardening the captive portal
|
||||
|
||||
**Date:** 2026-08-21
|
||||
**Repos:** `central-unifi` (most of it), `dashboard-shell` (tests, one Vue page)
|
||||
**Status:** design approved, no implementation plan yet
|
||||
|
||||
---
|
||||
|
||||
## Why this document is short on new features
|
||||
|
||||
Nearly everything asked for already exists in `central-unifi` v1.13.4 and has for
|
||||
some time. The request was "design registered guest Wi-Fi"; the finding is that
|
||||
the feature is largely built, partly broken in ways that only show up under real
|
||||
use, and **currently unreachable**. So this is a hardening spec, not a greenfield
|
||||
one.
|
||||
|
||||
### What already works
|
||||
|
||||
| Requirement | Where it lives |
|
||||
|---|---|
|
||||
| Captive portal + Google auth | `PortalController::captiveCallback`, `/portal/wifi/callback`; the shell's OAuth has already authenticated the user by then |
|
||||
| Per-cohort device limits | `unifi_vlan_mappings.max_devices` |
|
||||
| Per-cohort VLAN | `unifi_vlan_mappings.vlan_id` |
|
||||
| Session length | `unifi_vlan_mappings.session_minutes` (validated 1..525600) |
|
||||
| Self-service eviction | `Unifi/PortalDenied` page lists the user's devices with `can_disconnect`, plus `unifi.portal.self-disconnect` |
|
||||
| Admin eviction | `unifi.portal.sessions.disconnect` behind `app.access:unifi` + `permission:unifi.auth` |
|
||||
| Device fingerprint capture | `CaptureSnapshots` already writes `dev_cat` and `os_name` per MAC into `unifi_client_snapshots` |
|
||||
|
||||
Eviction authorisation is already correct and is **not** being changed:
|
||||
`disconnectSession()` does `kickClient` + `unauthorizeGuest` and authorises
|
||||
owner-or-`unifi.auth` inside the action, so a student can free their own slot and
|
||||
nobody else's.
|
||||
|
||||
### The answer to "must users re-do the portal every time?"
|
||||
|
||||
No, and the mechanism is already wired. `authorizeGuest($mac, $minutes)` sends
|
||||
UniFi `cmd: authorize-guest` with a **minutes** value taken from
|
||||
`session_minutes`. UniFi holds that MAC as authorized for the whole window and
|
||||
does not redirect it again. Set the window to a term and a student signs in once
|
||||
a term.
|
||||
|
||||
The portal is not the obstacle. **MAC randomization is** — see decision 2.
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
| # | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| 1 | Harden and finish the existing portal rather than redesign | 80% exists and the schema shape is right; the defects are in accounting and trust, not structure |
|
||||
| 2 | Detect a randomized MAC and coach the user; keep manual eviction | Manual choice is the stated requirement and already built; randomization is detectable, so the confusing case can be explained rather than hidden |
|
||||
| 3 | Derive the MAC from the request's source IP and verify AP association; ignore `?mac=` | Closes the ownership hole by construction — the caller never gets to name a MAC |
|
||||
| 4 | Add `role` as a `match_type` | Quotas were asked for per role; roles are the platform's own authority and need no external sync |
|
||||
| 5 | One Wi-Fi Devices page grouped by person; no profile card | Matches the requirement exactly; keeps `/admin/users` as the one directory |
|
||||
| 6 | Device type is **reported, not enforced**, for now | Whether `dev_cat` is populated in this environment is unverified; enforcing on unvalidated data can lock out the wrong people |
|
||||
| 7 | **Explicit allow-list only** — no `default` mapping | No Wi-Fi unless an admin has added that role and set an amount. A `default` row silently grants everyone, which is the opposite of the requirement |
|
||||
| 8 | Roles are chosen from a dropdown; session length entered in days | A free-text role slug that matches nothing fails silently — it presents as "this student has no Wi-Fi" with no error anywhere to explain it |
|
||||
|
||||
---
|
||||
|
||||
## A. What consumes a device slot
|
||||
|
||||
### The defect
|
||||
|
||||
A slot is currently freed when `isClientConnected($mac)` is false. That conflates
|
||||
**connected right now** with **authorized**, and they are different facts. Two
|
||||
leaks follow, in opposite directions, and both get worse the longer
|
||||
`session_minutes` is — which is exactly the direction this design pushes it.
|
||||
|
||||
**Leak 1 — offline treated as unauthorized.** A phone leaves the building. Its
|
||||
session row is marked inactive, freeing a slot, while UniFi still holds the MAC
|
||||
authorized for the rest of its window. The user authorizes a second device. Two
|
||||
devices are now on the network against a one-device quota, and one of them is an
|
||||
orphan nothing tracks.
|
||||
|
||||
**Leak 2 — `expires_at` is written and never read.** No scheduled job expires
|
||||
sessions. A row stays `is_active` long after UniFi stopped honouring it, so the
|
||||
user is refused by slots that do not exist.
|
||||
|
||||
They compound: the same student can be simultaneously over-provisioned on the
|
||||
network and locked out of the portal.
|
||||
|
||||
### The model
|
||||
|
||||
- A slot is consumed by a **live authorization**: `is_active AND expires_at > now()`.
|
||||
Presence on the network is irrelevant to the count.
|
||||
- A slot is freed by exactly three things: eviction (user or admin), expiry, or the
|
||||
reconciler finding UniFi no longer holds it.
|
||||
- `isClientConnected()` is **no longer an eviction trigger**. It becomes a display
|
||||
value only — "connected" / "away" on the admin page.
|
||||
- Quota resolution gains `role`. Precedence stays `sort_order`, so a mapping for
|
||||
`staff` above one for `parent` gives a staff-parent 4 devices.
|
||||
|
||||
---
|
||||
|
||||
## B. Identifying the device
|
||||
|
||||
### Flow
|
||||
|
||||
1. UniFi redirects the device to `/portal/wifi/callback?ap=<ap_mac>&ssid=…&url=…`.
|
||||
2. Google auth via the shell — unchanged.
|
||||
3. Resolve the device **server-side**: match the request's client IP against
|
||||
UniFi's live client table (`getActiveClients()`, on `ip`) to obtain the real
|
||||
MAC, its `ap_mac`, and its authorization state.
|
||||
4. Require that the resolved client's `ap_mac` equals the `ap` in the redirect —
|
||||
the device must be on the AP that sent it here.
|
||||
5. `?mac=` is **ignored**: not validated, not used. It is read for one purpose —
|
||||
if present and disagreeing with the derived MAC, log it. That disagreement is
|
||||
somebody attempting to authorize a device they do not own, and today nothing
|
||||
would notice.
|
||||
6. Check the locally-administered bit on the **derived** MAC. Record it on the
|
||||
session; it is what triggers the coaching copy on the denial page.
|
||||
|
||||
This replaces the hole the original author documented and declined to guess at:
|
||||
every identifying value was request-supplied, so any signed-in account could
|
||||
authorize somebody else's MAC up to `max_devices`.
|
||||
|
||||
### Failure modes, deliberately closed
|
||||
|
||||
- **IP matches no client** → refuse.
|
||||
- **IP resolves to the proxy** → refuse, and report it distinctly (see below).
|
||||
- **AP mismatch** → refuse.
|
||||
- **Client not in the table yet** (associated, pre-DHCP) → retry twice at ~750ms,
|
||||
then show "still connecting, try again" rather than a hard error. Bounded on
|
||||
purpose: this runs inside a web request, and a longer wait would hold a worker
|
||||
per connecting device at exactly the moment a class of them arrives at once.
|
||||
|
||||
### The operational risk this creates
|
||||
|
||||
Failing closed means it can fail closed **for everyone**. If `TrustProxies` is
|
||||
not correct for Traefik, every request resolves to the proxy, no client ever
|
||||
matches, and nobody can get on the Wi-Fi — a worse outage than the vulnerability
|
||||
being fixed.
|
||||
|
||||
Mitigation: the resolver distinguishes "no client matched this IP" from "this IP
|
||||
is the proxy", and the second is surfaced as a health line on the Wi-Fi Devices
|
||||
page. The failure becomes one glance to diagnose instead of a mystery. This is
|
||||
also the single most important thing to verify against the live controller.
|
||||
|
||||
**Known blind spot in that detection** (found during implementation, 2026-08-21).
|
||||
`proxy_untrusted` is inferred from `X-Forwarded-For` being present while
|
||||
`$request->ip()` still equals the raw `REMOTE_ADDR` — that is, the header arrived
|
||||
but `TrustProxies` did not apply it. It therefore does NOT catch a Traefik
|
||||
misconfiguration that sends no forwarded header at all: that degrades to
|
||||
`no_client`, which reads as "your device is not on the network" rather than "this
|
||||
is broken, tell IT".
|
||||
|
||||
So `proxy_untrusted` is a useful signal but **not a sufficient outage alarm**. The
|
||||
hardware validation step must confirm the true client IP actually arrives, rather
|
||||
than confirming that the misconfiguration alarm is quiet — a quiet alarm is
|
||||
exactly what the blind spot produces. Plan 2's health line should read the
|
||||
resolved IP itself and say what it is, not merely report a reason code.
|
||||
|
||||
### MAC randomization
|
||||
|
||||
Normally stable per SSID, so it does not break the design. When it does rotate,
|
||||
the device looks new: it consumes a slot and the user is asked to evict what
|
||||
appears to be the same phone. With `max_devices: 1` this is the likeliest support
|
||||
call.
|
||||
|
||||
Handling: when the derived MAC is randomized **and** the user is at limit, the
|
||||
denial page leads with an explanation — the device is using a private Wi-Fi
|
||||
address, which is why it looks new — followed by the iOS and Android steps to
|
||||
turn it off for this network, then the normal eviction list. Detection is the
|
||||
locally-administered bit; no guessing.
|
||||
|
||||
---
|
||||
|
||||
## C. Persistence, and the reconciler
|
||||
|
||||
`session_minutes` is the lever for "no portal on every connection". Default to a
|
||||
term (~120 days) rather than a year, so lost or handed-down devices age out.
|
||||
|
||||
A scheduled reconciler (following `CaptureSnapshots` and the `unifi_cron_runs`
|
||||
pattern) does three jobs:
|
||||
|
||||
1. **Expire** rows past `expires_at` — fixes leak 2.
|
||||
2. **Reconcile both directions** — revoke UniFi authorizations with no live row
|
||||
(the orphans leak 1 produced), and expire rows UniFi no longer holds.
|
||||
3. **Capture device type** onto the session from the client record — the
|
||||
report-only fingerprint data for decision 6.
|
||||
|
||||
Job 2 also heals controller-side loss: if UniFi forgets an authorization while our
|
||||
row is still live and in-window (upgrade, restore), the reconciler re-authorizes
|
||||
it. The user is not bounced to the portal for something that was not their doing.
|
||||
|
||||
Windows are **fixed, not sliding** — a device's authorization ends when it ends,
|
||||
so "one term" means one term.
|
||||
|
||||
---
|
||||
|
||||
## C2. Settings: who gets Wi-Fi, and how much
|
||||
|
||||
Access is an **explicit allow-list**. A person gets Wi-Fi only if one of their
|
||||
roles has been added here with an amount. No role added means no Wi-Fi, and that
|
||||
is the intended answer rather than a gap to be filled by a catch-all.
|
||||
|
||||
Consequences, stated so nobody re-adds the hole later:
|
||||
|
||||
- **`default` is removed from the available match types.** A `default` row matches
|
||||
everybody, so its existence makes the allow-list advisory. `resolveMapping()`
|
||||
already returns null when nothing matches, and null already produces the
|
||||
"No WiFi access configured for your account." denial — so deny-by-default is
|
||||
the behaviour once the catch-all cannot be created.
|
||||
- Safe to remove now: **there are zero rows in `unifi_vlan_mappings` in any
|
||||
environment**, so nothing is being revoked from anyone. Doing this later, after
|
||||
a school had leaned on a catch-all, would cut off every unmapped person at once.
|
||||
- `email_domain`, `group` and `ou` stay available — they are narrow matches, not
|
||||
catch-alls, and removing working options is not part of this.
|
||||
|
||||
### The settings screen
|
||||
|
||||
Extends the existing Portal settings page rather than adding another one. Per row:
|
||||
|
||||
| Field | Control | Why |
|
||||
|---|---|---|
|
||||
| Role | **dropdown of real seeded roles** | Free text is the defect. `match_value` is a string compared exactly, so `Student` or `studnet` matches nobody and the failure surfaces as a pupil with no Wi-Fi and nothing logged. The dropdown makes an unmatchable value impossible to enter. |
|
||||
| Devices | number, 1..50 | The quota. Column is already bounded. |
|
||||
| Session length | **days, with presets** (a week / a month / a term / a year) | `session_minutes` stays the stored unit, but a term is 172,800 minutes and nobody should type that. Same reasoning as presenting cron schedules as presets rather than raw strings. |
|
||||
| VLAN | existing select | **Still required** — the network needs to know where to put the device, and this was not in the original request, so it is called out rather than quietly dropped. |
|
||||
| Order | existing `sort_order` | Precedence for multi-role people: staff above parent gives a staff-parent 4. |
|
||||
|
||||
Roles already in the system and available to add: staff, student, parent,
|
||||
oncall-staff, coach, alumni, applicant, board-member, contact, homeschool,
|
||||
homeschool-parent, homeschool-student, open-house, tuition-payee, forms-payer.
|
||||
|
||||
## D. The Wi-Fi Devices page
|
||||
|
||||
New page under Unifi, gated on the existing `unifi.auth`, grouped by person.
|
||||
|
||||
- **Person row:** name, role, quota usage ("staff · 2 of 4").
|
||||
- **Device rows:** hostname, device type (reported), MAC in `font-mono` as a
|
||||
technical id, a "private address" marker when randomized, AP, SSID, authorized
|
||||
at, expires at, and connected/away — display only.
|
||||
- **Per-device disconnect** as a `.btn-icon-danger` row action behind the shared
|
||||
confirm modal. Never a native `confirm()`.
|
||||
- Search, plus a filter for over-quota people.
|
||||
- The portal health line from section B.
|
||||
|
||||
Follows the house table and empty-state conventions in the shell's `CLAUDE.md`.
|
||||
|
||||
---
|
||||
|
||||
## E. Testing
|
||||
|
||||
### Covered here, with `Http::fake()` for the controller
|
||||
|
||||
- Both quota leaks, one regression test each: an offline device keeps its slot; an
|
||||
expired row stops consuming one.
|
||||
- `role` matching, and `sort_order` precedence including the staff-parent case.
|
||||
- The reconciler in both directions, including the re-authorize-on-controller-loss
|
||||
path.
|
||||
- Randomized-MAC bit detection, both values.
|
||||
- Resolver fail-closed paths: no client match, IP resolves to the proxy, AP
|
||||
mismatch, client not yet in the table.
|
||||
- That `?mac=` is ignored, and that a disagreement is logged.
|
||||
- Authorization-not-connectivity: a slot survives the device going away.
|
||||
|
||||
Plus a browser pass over the denial page (including the randomized-MAC copy),
|
||||
self-service eviction, and the admin page — UI behaviour changes, so the
|
||||
before-push browser rule applies.
|
||||
|
||||
### NOT provable without the live controller
|
||||
|
||||
- The actual parameters UniFi's redirect sends.
|
||||
- Whether `dev_cat` is populated for these clients — `unifi_client_snapshots` has
|
||||
zero rows on dev, so there is no evidence either way.
|
||||
- Whether `TrustProxies` yields the true client IP through Traefik.
|
||||
|
||||
These need a supervised session against real hardware, in the same spirit as the
|
||||
Bosch cutover. The third is the one that gates release: it is the difference
|
||||
between a closed hole and nobody getting online.
|
||||
|
||||
---
|
||||
|
||||
## F. Rollout
|
||||
|
||||
1. **Register the app.** Every route here 404s today, because `unifi` has no
|
||||
`dashboard_apps` row and `app.access:unifi` aborts 404 when the lookup misses.
|
||||
Nothing in this spec is reachable until that is resolved. Note `central-unifi`
|
||||
declares `stability: stable`, so unlike On-Call this is a gap rather than a
|
||||
decision — **and it is still an open question, not settled by this document.**
|
||||
2. Verify the three hardware-dependent items above against the live controller.
|
||||
3. Configure mappings: `role` → student (1 device) and staff (4 devices), with
|
||||
`sort_order` set so multi-role people land deliberately.
|
||||
4. Pilot with staff, then students.
|
||||
|
||||
---
|
||||
|
||||
## Explicitly out of scope
|
||||
|
||||
- **Enforcing** a no-phones rule. Decision 6 is report-only until the fingerprint
|
||||
data is shown to be trustworthy. When it is, enforcement belongs in the
|
||||
reconciler (authorize, then sweep), because UniFi's fingerprint is derived from
|
||||
observed traffic and is not available at the moment of authorization.
|
||||
- MAC OUI as a device-type signal. A randomized MAC has no real OUI, so this
|
||||
would fail on phones — its intended target — while working on the school
|
||||
laptops that should not be blocked.
|
||||
- Blocking on the portal's User-Agent. Defeated by one "Request Desktop Site" tap.
|
||||
- Tethering. A blocked phone can share a laptop's connection and vice versa; no
|
||||
design here addresses that, and none is proposed.
|
||||
- Any change to `disconnectSession`, which is already correct.
|
||||
110
src/Console/ReconcilePortal.php
Normal file
110
src/Console/ReconcilePortal.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace Dashboard\Unifi\Console;
|
||||
|
||||
use Dashboard\Unifi\Models\PortalSession;
|
||||
use Dashboard\Unifi\Services\UnifiApiClient;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Keep our rows and UniFi's authorizations in step.
|
||||
*
|
||||
* Necessary because session windows are now measured in months: any drift that
|
||||
* used to be washed away by short sessions and frequent portal visits now
|
||||
* persists. Three jobs, and the middle one is the one that matters most.
|
||||
*/
|
||||
class ReconcilePortal extends Command
|
||||
{
|
||||
protected $signature = 'unifi:reconcile-portal';
|
||||
protected $description = 'Expire finished Wi-Fi sessions and reconcile them against UniFi authorizations';
|
||||
|
||||
public function handle(UnifiApiClient $unifi): int
|
||||
{
|
||||
// 1. Expiry. expires_at used to be written and never read, so a finished
|
||||
// row kept consuming a slot indefinitely.
|
||||
$expired = PortalSession::where('is_active', true)
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<=', now())
|
||||
->update(['is_active' => false]);
|
||||
|
||||
if ($expired) {
|
||||
$this->info("Expired {$expired} finished session(s).");
|
||||
}
|
||||
|
||||
try {
|
||||
$authorized = $unifi->getAuthorizedGuestMacs();
|
||||
} catch (\Throwable $e) {
|
||||
// A controller we cannot reach is not evidence that anything should
|
||||
// change. Expiry above is local and already done.
|
||||
$this->error('Could not read UniFi authorizations: ' . $e->getMessage());
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$live = PortalSession::live()->get()->keyBy(fn ($s) => strtolower($s->mac_address));
|
||||
|
||||
// 2. Orphans: authorized on the network with no live row of ours. These
|
||||
// are what the old presence-based sweep produced — a device nobody
|
||||
// can see in the UI and nobody's quota is paying for.
|
||||
foreach (array_diff($authorized, $live->keys()->all()) as $orphan) {
|
||||
$unifi->unauthorizeGuest($orphan);
|
||||
$this->warn("Revoked orphan authorization {$orphan}.");
|
||||
}
|
||||
|
||||
// 3. The other direction: we consider it live but UniFi has forgotten it
|
||||
// (upgrade, restore). Re-assert rather than making the person sign in
|
||||
// again for something that was not their doing.
|
||||
foreach ($live->keys()->diff($authorized) as $mac) {
|
||||
$session = $live->get($mac);
|
||||
$minutes = max(1, (int) now()->diffInMinutes($session->expires_at, false));
|
||||
$unifi->authorizeGuest($mac, $minutes);
|
||||
$this->warn("Re-asserted authorization for {$mac} ({$minutes}m remaining).");
|
||||
}
|
||||
|
||||
$this->captureFingerprints($unifi, $live);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record what UniFi thinks each device IS. Reported only — nothing acts on
|
||||
* it, per the decision that enforcing on unvalidated fingerprint data would
|
||||
* lock out the wrong people.
|
||||
*
|
||||
* This is the earliest point the data exists: UniFi infers dev_cat from
|
||||
* observed traffic, so it is absent at the moment of authorization.
|
||||
*
|
||||
* @param \Illuminate\Support\Collection $live keyed by lower-case MAC
|
||||
*/
|
||||
private function captureFingerprints(UnifiApiClient $unifi, $live): void
|
||||
{
|
||||
try {
|
||||
$clients = collect($unifi->getActiveClients())
|
||||
->keyBy(fn ($c) => strtolower((string) ($c['mac'] ?? '')));
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Could not read clients for fingerprints: ' . $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($live as $mac => $session) {
|
||||
$client = $clients->get($mac);
|
||||
if (! $client) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only ever fill in. An unfingerprinted client must not blank a type
|
||||
// we already recorded — that would lose the answer every time the
|
||||
// device happened to be quiet.
|
||||
$updates = array_filter([
|
||||
'device_type' => $client['dev_cat'] ?? null,
|
||||
'device_os' => $client['os_name'] ?? null,
|
||||
'device_hostname' => $client['hostname'] ?? null,
|
||||
], fn ($v) => $v !== null && $v !== '');
|
||||
|
||||
if ($updates) {
|
||||
$session->update($updates);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,16 +80,73 @@ class RotatePasswords extends Command
|
||||
$newPass = $passwords[array_rand($passwords)];
|
||||
try {
|
||||
if (str_starts_with((string) $ppsk->unifi_id, 'emb_')) {
|
||||
// Embedded PPSK: update inside the parent WLAN object.
|
||||
// Synthetic ID is derived from the new passphrase, so update it too.
|
||||
$unifi->updateEmbeddedPpsk($ppsk->wlan_id, $ppsk->x_passphrase, $newPass);
|
||||
// Embedded PPSK: update inside the parent WLAN object,
|
||||
// matched by name (synthetic id changes with the
|
||||
// passphrase, so it's not a stable matcher).
|
||||
$unifi->updateEmbeddedPpsk($ppsk->wlan_id, $ppsk->x_passphrase, $newPass, $ppsk->name);
|
||||
$newUid = 'emb_' . substr(hash('sha256', $ppsk->wlan_id . ':' . $newPass), 0, 32);
|
||||
$ppsk->update(['x_passphrase' => $newPass, 'unifi_id' => $newUid]);
|
||||
|
||||
// Update every grouped sibling (user-defined SSID
|
||||
// groups take precedence; same-name fallback for
|
||||
// installs that haven't grouped manually).
|
||||
foreach ($unifi->getGroupedWlans($ppsk->wlan_id) as $siblingWlanId) {
|
||||
$sibling = UnifiPpsk::where('wlan_id', $siblingWlanId)
|
||||
->where('name', $ppsk->name)
|
||||
->where('state', 'active')
|
||||
->first();
|
||||
try {
|
||||
$unifi->updateEmbeddedPpsk($siblingWlanId, $sibling?->x_passphrase, $newPass, $ppsk->name);
|
||||
if ($sibling) {
|
||||
$sibling->update([
|
||||
'x_passphrase' => $newPass,
|
||||
'unifi_id' => 'emb_' . substr(hash('sha256', $siblingWlanId . ':' . $newPass), 0, 32),
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
if (str_contains($e->getMessage(), 'not found')) {
|
||||
\Illuminate\Support\Facades\Log::info('unifi.ppsk_sibling_skipped', [
|
||||
'sibling_wlan' => $siblingWlanId,
|
||||
'ppsk_name' => $ppsk->name,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
$this->error("Sibling rotate failed for wlan {$siblingWlanId}: {$e->getMessage()}");
|
||||
$failedPpsks[] = ['name' => $ppsk->name . ' (sibling wlan ' . $siblingWlanId . ')', 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
// Verify that the new passphrase actually applied
|
||||
// on every grouped WLAN. UniFi can 200 an update
|
||||
// that doesn't stick (cluster sync race, etc).
|
||||
// Anything we expected to rotate that didn't is a
|
||||
// failure — surface it in the cron log.
|
||||
$allWlanIds = array_merge([$ppsk->wlan_id], $unifi->getGroupedWlans($ppsk->wlan_id));
|
||||
foreach ($allWlanIds as $checkWlanId) {
|
||||
$result = $unifi->verifyEmbeddedPpsk($checkWlanId, $ppsk->name, $newPass);
|
||||
if ($result['ok']) continue;
|
||||
|
||||
// 'not_found' on a sibling = PPSK isn't on that band — ignore
|
||||
// (consistent with the skip in the update loop).
|
||||
if ($result['reason'] === 'not_found' && $checkWlanId !== $ppsk->wlan_id) continue;
|
||||
|
||||
$failedPpsks[] = [
|
||||
'name' => $ppsk->name . ' (verify wlan ' . $checkWlanId . ')',
|
||||
'error' => 'verification ' . $result['reason'] . ($result['error'] ?? null ? ': ' . $result['error'] : ''),
|
||||
];
|
||||
}
|
||||
} else {
|
||||
$unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]);
|
||||
$ppsk->update(['x_passphrase' => $newPass]);
|
||||
}
|
||||
$rotatedPpsks[] = $ppsk->name;
|
||||
|
||||
// Save the active password every time a rotation
|
||||
// succeeds — covers PPSK-only rotation setups where
|
||||
// there's no whole-SSID rotation. Last successful
|
||||
// password wins if multiple PPSKs rotate in one run.
|
||||
Setting::set('unifi.password_rotation.last_password', $newPass);
|
||||
Setting::set('unifi.password_rotation.last_rotated_at', now()->toIso8601String());
|
||||
} catch (\Throwable $e) {
|
||||
$this->error("Failed to rotate PPSK \"{$ppsk->name}\": {$e->getMessage()}");
|
||||
$failedPpsks[] = ['name' => $ppsk->name, 'error' => $e->getMessage()];
|
||||
|
||||
124
src/Http/Controllers/GuestCardController.php
Normal file
124
src/Http/Controllers/GuestCardController.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -32,11 +32,15 @@ class PortalController extends Controller
|
||||
{
|
||||
$data = $request->validate([
|
||||
'group_name' => 'required|string|max:255',
|
||||
'match_type' => 'required|in:ou,group,email_domain,default',
|
||||
'match_type' => 'required|in:ou,group,email_domain,role',
|
||||
'match_value' => 'required|string|max:255',
|
||||
'vlan_id' => 'required|integer|min:1|max:4094',
|
||||
'max_devices' => 'required|integer|min:1|max:50',
|
||||
'session_minutes' => 'required|integer|min:1',
|
||||
// Bounded by the column that stores it: unifi_vlan_mappings.session_minutes
|
||||
// is unsignedInteger, so an unbounded value reached the INSERT and raised
|
||||
// SQLSTATE 22003 — a 500 where a validation error belongs (central-unifi#5).
|
||||
// 525600 minutes is a year, far more session than anyone will ever grant.
|
||||
'session_minutes' => 'required|integer|min:1|max:525600',
|
||||
]);
|
||||
$data['sort_order'] = VlanMapping::max('sort_order') + 1;
|
||||
VlanMapping::create($data);
|
||||
@@ -47,11 +51,15 @@ class PortalController extends Controller
|
||||
{
|
||||
$data = $request->validate([
|
||||
'group_name' => 'required|string|max:255',
|
||||
'match_type' => 'required|in:ou,group,email_domain,default',
|
||||
'match_type' => 'required|in:ou,group,email_domain,role',
|
||||
'match_value' => 'required|string|max:255',
|
||||
'vlan_id' => 'required|integer|min:1|max:4094',
|
||||
'max_devices' => 'required|integer|min:1|max:50',
|
||||
'session_minutes' => 'required|integer|min:1',
|
||||
// Bounded by the column that stores it: unifi_vlan_mappings.session_minutes
|
||||
// is unsignedInteger, so an unbounded value reached the INSERT and raised
|
||||
// SQLSTATE 22003 — a 500 where a validation error belongs (central-unifi#5).
|
||||
// 525600 minutes is a year, far more session than anyone will ever grant.
|
||||
'session_minutes' => 'required|integer|min:1|max:525600',
|
||||
]);
|
||||
$mapping->update($data);
|
||||
return back()->with('success', 'Mapping updated.');
|
||||
@@ -87,6 +95,35 @@ class PortalController extends Controller
|
||||
|
||||
// ── Captive portal callback (called by UniFi external portal redirect) ───
|
||||
|
||||
/**
|
||||
* A redirect target that cannot be turned into a phishing or script payload.
|
||||
*
|
||||
* Accepts only absolute http/https URLs and site-relative paths. Rejects
|
||||
* javascript:/data:/vbscript: schemes, and protocol-relative "//host" values which
|
||||
* redirect() would happily send off-site. Anything rejected falls back to the
|
||||
* dashboard rather than erroring — the guest is already online at this point, so a
|
||||
* bad `url` parameter should not look like a failed login.
|
||||
*/
|
||||
private function safeRedirectTarget(?string $url): string
|
||||
{
|
||||
$url = trim((string) $url);
|
||||
|
||||
if ($url === '' || str_starts_with($url, '//')) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
// Site-relative is always fine.
|
||||
if (str_starts_with($url, '/')) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
|
||||
|
||||
return in_array($scheme, ['http', 'https'], true) && parse_url($url, PHP_URL_HOST)
|
||||
? $url
|
||||
: '/';
|
||||
}
|
||||
|
||||
public function captiveCallback(Request $request, UnifiApiClient $unifi)
|
||||
{
|
||||
// UniFi redirects guest to this URL with ?id=<ap_mac>&ap=<ap_mac>&t=<timestamp>&url=<original_url>&ssid=<ssid>
|
||||
@@ -96,42 +133,55 @@ class PortalController extends Controller
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$clientMac = strtolower($request->input('mac', $request->input('id', '')));
|
||||
$ssid = $request->input('ssid', '');
|
||||
$apMac = $request->input('ap', '');
|
||||
$apMac = (string) $request->input('ap', '');
|
||||
$ssid = (string) $request->input('ssid', '');
|
||||
|
||||
if (! $clientMac) {
|
||||
return response('Missing MAC address', 400);
|
||||
$device = app(\Dashboard\Unifi\Services\DeviceResolver::class)->resolve($request, $apMac);
|
||||
|
||||
if (! $device->ok) {
|
||||
return Inertia::render('Unifi/PortalDenied', [
|
||||
'reason' => match ($device->reason) {
|
||||
'ap_mismatch' => 'That device is not connected to this access point. Reconnect and try again.',
|
||||
'proxy_untrusted' => 'Wi-Fi sign-in is misconfigured and cannot identify your device. Please tell IT.',
|
||||
default => 'Still connecting — wait a moment and try again.',
|
||||
},
|
||||
'resolveFailure' => $device->reason,
|
||||
]);
|
||||
}
|
||||
|
||||
$clientMac = $device->mac;
|
||||
$isRandomized = $device->isRandomized;
|
||||
|
||||
// Find the VLAN mapping for this user's group/OU
|
||||
$mapping = $this->resolveMapping($user);
|
||||
if (! $mapping) {
|
||||
return Inertia::render('Unifi/PortalDenied', ['reason' => 'No WiFi access configured for your account.']);
|
||||
}
|
||||
|
||||
// Check device limit
|
||||
$activeSessions = PortalSession::where('user_id', $user->id)
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
/*
|
||||
* Presence is NOT the question — a live authorization is.
|
||||
*
|
||||
* This used to walk the user's sessions and mark any device UniFi did not
|
||||
* currently see as stale, freeing its slot. But UniFi holds an
|
||||
* authorization for its whole window regardless of whether the device is
|
||||
* in the building, so a phone that went home had its slot freed while
|
||||
* still being authorized: the owner then authorized a second device and
|
||||
* one of the two was an orphan nothing tracked. The longer
|
||||
* session_minutes is — and it is now meant to be a term — the more of
|
||||
* those accumulate.
|
||||
*
|
||||
* Slots are now freed by eviction, by expiry, or by ReconcilePortal.
|
||||
*/
|
||||
$live = PortalSession::live()->where('user_id', $user->id)->get();
|
||||
|
||||
foreach ($activeSessions as $session) {
|
||||
if ($session->mac_address === $clientMac) {
|
||||
// Same device reconnecting — refresh
|
||||
$session->update(['is_active' => false]);
|
||||
continue;
|
||||
}
|
||||
// Check if other device is still actually connected
|
||||
if (! $unifi->isClientConnected($session->mac_address)) {
|
||||
$session->update(['is_active' => false]); // stale session
|
||||
}
|
||||
}
|
||||
// The same device coming back is a renewal, not a new device: it keeps
|
||||
// its slot and its row, and simply gets a fresh window.
|
||||
$existing = $live->firstWhere('mac_address', $clientMac);
|
||||
|
||||
$activeCount = PortalSession::where('user_id', $user->id)->where('is_active', true)->count();
|
||||
if ($activeCount >= $mapping->max_devices) {
|
||||
if (! $existing && $live->count() >= $mapping->max_devices) {
|
||||
return Inertia::render('Unifi/PortalDenied', [
|
||||
'reason' => "You already have {$activeCount} device(s) connected (limit: {$mapping->max_devices}).",
|
||||
'sessions' => $activeSessions->map(fn ($s) => [
|
||||
'reason' => "You already have {$live->count()} device(s) connected (limit: {$mapping->max_devices}).",
|
||||
'sessions' => $live->map(fn ($s) => [
|
||||
'id' => $s->id,
|
||||
'mac' => $s->mac_address,
|
||||
'hostname' => $s->device_hostname,
|
||||
@@ -146,10 +196,9 @@ class PortalController extends Controller
|
||||
$minutes = $mapping->session_minutes;
|
||||
$unifi->authorizeGuest($clientMac, $minutes);
|
||||
|
||||
// Record the session
|
||||
PortalSession::create([
|
||||
'user_id' => $user->id,
|
||||
'mac_address' => $clientMac,
|
||||
PortalSession::updateOrCreate(
|
||||
['user_id' => $user->id, 'mac_address' => $clientMac],
|
||||
[
|
||||
'device_hostname' => $request->input('hostname'),
|
||||
'device_os' => $request->input('os'),
|
||||
'device_type' => $request->input('dev_cat'),
|
||||
@@ -159,11 +208,20 @@ class PortalController extends Controller
|
||||
'is_active' => true,
|
||||
'authorized_at' => now(),
|
||||
'expires_at' => now()->addMinutes($minutes),
|
||||
]);
|
||||
'is_randomized' => $isRandomized,
|
||||
]
|
||||
);
|
||||
|
||||
// Redirect to the original URL the user was trying to reach
|
||||
$redirectUrl = $request->input('url', '/');
|
||||
return redirect($redirectUrl);
|
||||
// Redirect to the original URL the user was trying to reach.
|
||||
//
|
||||
// SECURITY: this was `redirect($request->input('url', '/'))` — an unvalidated
|
||||
// open redirect on an authenticated route, i.e. a phishing primitive wearing
|
||||
// this school's domain. The whole point of a captive portal is to send someone
|
||||
// to an ARBITRARY external site they were already trying to reach, so an
|
||||
// allowlist is not available; what is available is refusing anything that is
|
||||
// not a plain http(s) URL, which blocks javascript:, data: and protocol-relative
|
||||
// //evil.example payloads.
|
||||
return redirect($this->safeRedirectTarget($request->input('url')));
|
||||
}
|
||||
|
||||
public function disconnectSession(Request $request, PortalSession $session, UnifiApiClient $unifi)
|
||||
@@ -193,7 +251,16 @@ class PortalController extends Controller
|
||||
'email_domain' => str_ends_with($user->email ?? '', '@' . $mapping->match_value),
|
||||
'group' => $user->groups?->contains('name', $mapping->match_value) ?? false,
|
||||
'ou' => str_contains($user->ou ?? '', $mapping->match_value),
|
||||
'default' => true,
|
||||
/*
|
||||
* Roles are the platform's own authority and need no external
|
||||
* sync to be correct, which is why quotas key off them.
|
||||
*
|
||||
* `default` is deliberately absent. It matched everybody, so its
|
||||
* existence made this an advisory list rather than an allow-list.
|
||||
* Returning null here is already the deny path: captiveCallback
|
||||
* renders "No WiFi access configured for your account."
|
||||
*/
|
||||
'role' => $user->hasRole($mapping->match_value),
|
||||
default => false,
|
||||
};
|
||||
if ($matched) return $mapping;
|
||||
|
||||
@@ -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' => [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Dashboard\Unifi\Http\Controllers;
|
||||
|
||||
use App\Models\DashboardApp;
|
||||
use App\Models\Group;
|
||||
use App\Models\NavItem;
|
||||
use App\Models\User;
|
||||
use Dashboard\Unifi\Models\UnifiPageGrant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Super-admin-only endpoints for managing per-page access on unifi
|
||||
* pages. Pages here = nav_items where app_id = unifi's DashboardApp row.
|
||||
*/
|
||||
class UnifiPagesAccessController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$app = DashboardApp::where('slug', 'unifi')->first();
|
||||
if (! $app) {
|
||||
return response()->json(['pages' => [], 'users' => [], 'groups' => []]);
|
||||
}
|
||||
|
||||
$pages = NavItem::where('app_id', $app->id)
|
||||
->where('is_folder', false)
|
||||
->whereNotNull('route_name')
|
||||
->orderBy('sort_order')
|
||||
->get(['id', 'label', 'route_name']);
|
||||
|
||||
$grants = UnifiPageGrant::whereIn('nav_item_id', $pages->pluck('id'))
|
||||
->get()
|
||||
->groupBy('nav_item_id');
|
||||
|
||||
// Only return users that ALREADY have grants. The full users list
|
||||
// can be enormous (thousands of rows); the operator adds more via
|
||||
// the searchUsers endpoint as needed.
|
||||
$grantedUserIds = $grants->flatten(1)->where('grantee_type', 'user')->pluck('grantee_id')->unique();
|
||||
$users = User::whereIn('id', $grantedUserIds)->orderBy('name')->get(['id', 'name', 'email']);
|
||||
|
||||
// Groups: always include super-admin groups (locked-on across all
|
||||
// pages) plus any group with at least one grant. Other groups are
|
||||
// added via searchGroups.
|
||||
$grantedGroupIds = $grants->flatten(1)->where('grantee_type', 'group')->pluck('grantee_id')->unique();
|
||||
$groups = Group::where(function ($q) use ($grantedGroupIds) {
|
||||
$q->where('is_super', true)
|
||||
->orWhereIn('id', $grantedGroupIds);
|
||||
})->orderBy('name')->get(['id', 'name', 'is_super']);
|
||||
|
||||
return response()->json([
|
||||
'pages' => $pages->map(fn ($p) => [
|
||||
'id' => $p->id,
|
||||
'label' => $p->label,
|
||||
'route_name' => $p->route_name,
|
||||
'user_ids' => $grants->get($p->id, collect())->where('grantee_type', 'user')->pluck('grantee_id')->all(),
|
||||
'group_ids' => $grants->get($p->id, collect())->where('grantee_type', 'group')->pluck('grantee_id')->all(),
|
||||
])->values(),
|
||||
'users' => $users,
|
||||
'groups' => $groups,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Typeahead-style search for users to add to the access matrix.
|
||||
* Returns up to 20 matches against name or email. Empty query returns
|
||||
* an empty array — caller must enter at least 2 chars.
|
||||
*/
|
||||
public function searchUsers(Request $request)
|
||||
{
|
||||
$q = trim((string) $request->query('q', ''));
|
||||
if (strlen($q) < 2) {
|
||||
return response()->json(['users' => []]);
|
||||
}
|
||||
|
||||
$users = User::where(function ($w) use ($q) {
|
||||
$w->where('name', 'like', '%' . $q . '%')
|
||||
->orWhere('email', 'like', '%' . $q . '%');
|
||||
})
|
||||
->orderBy('name')
|
||||
->limit(20)
|
||||
->get(['id', 'name', 'email']);
|
||||
|
||||
return response()->json(['users' => $users]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Typeahead-style search for groups to add to the access matrix.
|
||||
* Excludes super-admin groups (they're already in the matrix and
|
||||
* locked-on across every page).
|
||||
*/
|
||||
public function searchGroups(Request $request)
|
||||
{
|
||||
$q = trim((string) $request->query('q', ''));
|
||||
if (strlen($q) < 2) {
|
||||
return response()->json(['groups' => []]);
|
||||
}
|
||||
|
||||
$groups = Group::where('name', 'like', '%' . $q . '%')
|
||||
->where(function ($w) { $w->where('is_super', false)->orWhereNull('is_super'); })
|
||||
->orderBy('name')
|
||||
->limit(20)
|
||||
->get(['id', 'name', 'is_super']);
|
||||
|
||||
return response()->json(['groups' => $groups]);
|
||||
}
|
||||
|
||||
public function update(Request $request, NavItem $navItem)
|
||||
{
|
||||
$app = DashboardApp::where('slug', 'unifi')->first();
|
||||
if (! $app || $navItem->app_id !== $app->id) {
|
||||
return response()->json(['error' => 'Not a unifi page.'], 422);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'user_ids' => 'present|array',
|
||||
'user_ids.*' => 'integer|exists:users,id',
|
||||
'group_ids' => 'present|array',
|
||||
'group_ids.*' => 'integer|exists:groups,id',
|
||||
]);
|
||||
|
||||
$grantedBy = $request->user()?->id;
|
||||
|
||||
DB::transaction(function () use ($navItem, $data, $grantedBy) {
|
||||
UnifiPageGrant::where('nav_item_id', $navItem->id)->delete();
|
||||
|
||||
$rows = [];
|
||||
$now = now();
|
||||
foreach ($data['user_ids'] as $uid) {
|
||||
$rows[] = ['nav_item_id' => $navItem->id, 'grantee_type' => 'user', 'grantee_id' => $uid, 'granted_by_user_id' => $grantedBy, 'created_at' => $now, 'updated_at' => $now];
|
||||
}
|
||||
foreach ($data['group_ids'] as $gid) {
|
||||
$rows[] = ['nav_item_id' => $navItem->id, 'grantee_type' => 'group', 'grantee_id' => $gid, 'granted_by_user_id' => $grantedBy, 'created_at' => $now, 'updated_at' => $now];
|
||||
}
|
||||
if ($rows) UnifiPageGrant::insert($rows);
|
||||
});
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,10 @@ use Inertia\Inertia;
|
||||
|
||||
class UnifiSettingsController extends Controller
|
||||
{
|
||||
public function edit()
|
||||
public function edit(?string $tab = null)
|
||||
{
|
||||
return Inertia::render('Unifi/Settings', [
|
||||
'activeTab' => $tab,
|
||||
'controllerUrl' => Setting::get('unifi.controller_url', ''),
|
||||
'hasApiKey' => (bool) Setting::get('unifi.api_key'),
|
||||
'site' => Setting::get('unifi.site', 'default'),
|
||||
@@ -35,6 +36,9 @@ class UnifiSettingsController extends Controller
|
||||
'ppskSchedulingEnabled' => (bool) Setting::get('unifi.ppsk_scheduling.enabled', false),
|
||||
'apiEnabled' => (bool) Setting::get('unifi.api.enabled', false),
|
||||
'apiToken' => Setting::get('unifi.api_token', null),
|
||||
'guestCardEnabled' => (bool) Setting::get('unifi.guest_card.enabled', false),
|
||||
'guestCardSsid' => Setting::get('unifi.guest_card.ssid', ''),
|
||||
'guestCardTitle' => Setting::get('unifi.guest_card.title', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -73,6 +77,9 @@ class UnifiSettingsController extends Controller
|
||||
'rotation_wordlist' => 'nullable|string|max:20000',
|
||||
'ppsk_scheduling_enabled' => 'boolean',
|
||||
'api_enabled' => 'boolean',
|
||||
'guest_card_enabled' => 'boolean',
|
||||
'guest_card_ssid' => 'nullable|string|max:100',
|
||||
'guest_card_title' => 'nullable|string|max:100',
|
||||
]);
|
||||
|
||||
Setting::set('unifi.controller_url', rtrim($request->controller_url, '/'));
|
||||
@@ -100,6 +107,9 @@ class UnifiSettingsController extends Controller
|
||||
Setting::set('unifi.password_rotation.wordlist', $request->input('rotation_wordlist', ''));
|
||||
Setting::set('unifi.ppsk_scheduling.enabled', $request->boolean('ppsk_scheduling_enabled') ? '1' : '');
|
||||
Setting::set('unifi.api.enabled', $request->boolean('api_enabled') ? '1' : '');
|
||||
Setting::set('unifi.guest_card.enabled', $request->boolean('guest_card_enabled') ? '1' : '');
|
||||
Setting::set('unifi.guest_card.ssid', trim((string) $request->input('guest_card_ssid', '')));
|
||||
Setting::set('unifi.guest_card.title', trim((string) $request->input('guest_card_title', '')));
|
||||
|
||||
\Illuminate\Support\Facades\Cache::forget('unifi:api_prefix:' . md5(rtrim($request->controller_url, '/')));
|
||||
|
||||
|
||||
@@ -149,8 +149,18 @@ class WifiController extends Controller
|
||||
$name = $networksById[$nconfId]['name'] ?? null;
|
||||
}
|
||||
|
||||
// Match by unifi_id, or by passphrase for a held embedded record re-appearing
|
||||
// Match in priority order:
|
||||
// 1. by current unifi_id (already-synced row)
|
||||
// 2. by name within this wlan (catches rotation: passphrase
|
||||
// changed → synthetic id changed → row identity unchanged)
|
||||
// 3. by passphrase among held rows (legacy fallback for
|
||||
// cases where name wasn't ingested)
|
||||
$record = UnifiPpsk::where('unifi_id', $uid)->first()
|
||||
?? ($name
|
||||
? UnifiPpsk::where('wlan_id', $wlanId)->where('name', $name)
|
||||
->orderByRaw("FIELD(state, 'active', 'held')")
|
||||
->first()
|
||||
: null)
|
||||
?? UnifiPpsk::where('wlan_id', $wlanId)
|
||||
->where('x_passphrase', $pass)
|
||||
->where('state', 'held')
|
||||
@@ -174,8 +184,8 @@ class WifiController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// Only mark as held when we have confirmed live IDs —
|
||||
// never wipe on an empty API response (prevents false-holds on API failures)
|
||||
// Mark non-matching active rows as held — but ONLY if there's no
|
||||
// other active row with the same name we just reconnected.
|
||||
if (! empty($liveIds)) {
|
||||
UnifiPpsk::where('wlan_id', $wlanId)
|
||||
->where('state', 'active')
|
||||
@@ -184,6 +194,47 @@ class WifiController extends Controller
|
||||
->update(['state' => 'held', 'unifi_id' => null]);
|
||||
}
|
||||
|
||||
// For each active row, salvage any rotate_password / schedule
|
||||
// settings from the held tombstones with the same name BEFORE
|
||||
// we prune them. Otherwise a row that had rotate=on loses the
|
||||
// flag every time a rotation changes its synthetic id.
|
||||
$activeRows = UnifiPpsk::where('wlan_id', $wlanId)
|
||||
->where('state', 'active')
|
||||
->whereNotNull('name')
|
||||
->get();
|
||||
foreach ($activeRows as $active) {
|
||||
$heldWithSettings = UnifiPpsk::where('wlan_id', $wlanId)
|
||||
->where('state', 'held')
|
||||
->where('name', $active->name)
|
||||
->where(fn ($q) => $q
|
||||
->where('rotate_password', true)
|
||||
->orWhereNotNull('schedule'))
|
||||
->orderByDesc('updated_at')
|
||||
->first();
|
||||
if (! $heldWithSettings) continue;
|
||||
|
||||
$patch = [];
|
||||
if ($heldWithSettings->rotate_password && ! $active->rotate_password) {
|
||||
$patch['rotate_password'] = true;
|
||||
}
|
||||
if ($heldWithSettings->schedule && ! $active->schedule) {
|
||||
$patch['schedule'] = $heldWithSettings->schedule;
|
||||
}
|
||||
if ($patch) $active->update($patch);
|
||||
}
|
||||
|
||||
// Prune obsolete held rows: any held row whose name matches an
|
||||
// active row in the same wlan is a stale tombstone — its
|
||||
// settings have been salvaged above, and its data has been
|
||||
// superseded by the active one.
|
||||
$activeNames = $activeRows->pluck('name')->filter()->unique();
|
||||
if ($activeNames->isNotEmpty()) {
|
||||
UnifiPpsk::where('wlan_id', $wlanId)
|
||||
->where('state', 'held')
|
||||
->whereIn('name', $activeNames)
|
||||
->delete();
|
||||
}
|
||||
|
||||
$dbRecords = UnifiPpsk::where('wlan_id', $wlanId)
|
||||
->orderByRaw("FIELD(state, 'active', 'held')")
|
||||
->orderBy('name')
|
||||
@@ -293,9 +344,37 @@ class WifiController extends Controller
|
||||
if (! empty($unifiUpdate)) {
|
||||
if (str_starts_with($record->unifi_id, 'emb_') && isset($unifiUpdate['x_passphrase'])) {
|
||||
// Embedded PPSK update path — modify the WLAN's embedded array.
|
||||
$unifi->updateEmbeddedPpsk($record->wlan_id, $record->x_passphrase, $unifiUpdate['x_passphrase']);
|
||||
// Synthetic id is derived from the new passphrase.
|
||||
$data['unifi_id'] = 'emb_' . substr(hash('sha256', $record->wlan_id . ':' . $unifiUpdate['x_passphrase']), 0, 32);
|
||||
// Match by name (reliable across drift).
|
||||
$newPass = $unifiUpdate['x_passphrase'];
|
||||
$unifi->updateEmbeddedPpsk($record->wlan_id, $record->x_passphrase, $newPass, $record->name);
|
||||
$data['unifi_id'] = 'emb_' . substr(hash('sha256', $record->wlan_id . ':' . $newPass), 0, 32);
|
||||
|
||||
// Also update grouped WLAN siblings (user-defined
|
||||
// SSID groups, falling back to same-name).
|
||||
foreach ($unifi->getGroupedWlans($record->wlan_id) as $siblingWlanId) {
|
||||
$sibling = UnifiPpsk::where('wlan_id', $siblingWlanId)
|
||||
->where('name', $record->name)
|
||||
->where('state', 'active')
|
||||
->first();
|
||||
try {
|
||||
$unifi->updateEmbeddedPpsk($siblingWlanId, $sibling?->x_passphrase, $newPass, $record->name);
|
||||
if ($sibling) {
|
||||
$sibling->update([
|
||||
'x_passphrase' => $newPass,
|
||||
'unifi_id' => 'emb_' . substr(hash('sha256', $siblingWlanId . ':' . $newPass), 0, 32),
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// PPSK absent on this band is fine — just
|
||||
// means it isn't mirrored. Anything else
|
||||
// gets warning-logged.
|
||||
$level = str_contains($e->getMessage(), 'not found') ? 'info' : 'warning';
|
||||
\Illuminate\Support\Facades\Log::log($level, 'unifi.ppsk_sibling_update', [
|
||||
'sibling_wlan' => $siblingWlanId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$unifi->updatePpsk($record->unifi_id, $unifiUpdate);
|
||||
}
|
||||
|
||||
@@ -11,11 +11,13 @@ class PortalSession extends Model
|
||||
protected $fillable = [
|
||||
'user_id', 'mac_address', 'device_hostname', 'device_os', 'device_type',
|
||||
'ssid', 'vlan_id', 'ap_mac', 'is_active', 'authorized_at', 'expires_at',
|
||||
'is_randomized',
|
||||
];
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'authorized_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'is_randomized' => 'boolean',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
@@ -23,6 +25,23 @@ class PortalSession extends Model
|
||||
return $this->belongsTo(\App\Models\User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows that consume a device slot.
|
||||
*
|
||||
* Presence on the network is deliberately NOT part of this. UniFi holds an
|
||||
* authorization for its full window whether or not the device is in the
|
||||
* building, so a phone that went home still owns its slot — freeing it here
|
||||
* is what let one person hold two authorizations against a one-device quota.
|
||||
*
|
||||
* A NULL expires_at is not live: it predates this being enforced.
|
||||
*/
|
||||
public function scopeLive($query)
|
||||
{
|
||||
return $query->where('is_active', true)
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '>', now());
|
||||
}
|
||||
|
||||
public function setMacAddressAttribute($value)
|
||||
{
|
||||
$this->attributes['mac_address'] = strtolower($value);
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Dashboard\Unifi\Models;
|
||||
|
||||
use App\Models\NavItem;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class UnifiPageGrant extends Model
|
||||
{
|
||||
protected $table = 'unifi_page_grants';
|
||||
|
||||
protected $fillable = [
|
||||
'nav_item_id',
|
||||
'grantee_type',
|
||||
'grantee_id',
|
||||
'granted_by_user_id',
|
||||
];
|
||||
|
||||
public function navItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(NavItem::class);
|
||||
}
|
||||
|
||||
public function grantedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'granted_by_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff $user is allowed to access $navItem under strict allowlist
|
||||
* semantics:
|
||||
* * super-admins (the model-level flag) always pass
|
||||
* * otherwise the user must be a direct grantee, OR be in a group
|
||||
* that is a grantee
|
||||
*
|
||||
* A page with NO grants saved is only visible to super-admins —
|
||||
* the admin must explicitly authorize everyone else via the
|
||||
* Access tab.
|
||||
*/
|
||||
public static function userCanAccess(User $user, NavItem $navItem): bool
|
||||
{
|
||||
if ($user->is_super_admin) return true;
|
||||
|
||||
$groupIds = $user->groups()->pluck('groups.id');
|
||||
|
||||
return static::where('nav_item_id', $navItem->id)
|
||||
->where(function ($q) use ($user, $groupIds) {
|
||||
$q->where(function ($u) use ($user) {
|
||||
$u->where('grantee_type', 'user')->where('grantee_id', $user->id);
|
||||
})->orWhere(function ($g) use ($groupIds) {
|
||||
$g->where('grantee_type', 'group')->whereIn('grantee_id', $groupIds);
|
||||
});
|
||||
})
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
104
src/Services/DeviceResolver.php
Normal file
104
src/Services/DeviceResolver.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace Dashboard\Unifi\Services;
|
||||
|
||||
use Dashboard\Unifi\Support\MacAddress;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/** The outcome of trying to identify the device making a portal request. */
|
||||
class ResolvedDevice
|
||||
{
|
||||
public function __construct(
|
||||
public bool $ok,
|
||||
public ?string $mac = null,
|
||||
public ?string $reason = null,
|
||||
public bool $isRandomized = false,
|
||||
public bool $macParameterDisagreed = false,
|
||||
public array $client = [],
|
||||
) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify the device that made a captive-portal request, or refuse.
|
||||
*
|
||||
* The `?mac=` parameter is NOT trusted. It is read only so a disagreement with
|
||||
* the derived value can be logged — that disagreement is somebody trying to
|
||||
* authorize a device they do not own, and before this nothing anywhere noticed.
|
||||
*
|
||||
* Every path refuses rather than guessing. The cost of that is real: if
|
||||
* TrustProxies is misconfigured, `proxy_untrusted` is returned for everyone and
|
||||
* nobody gets online, which is why it is a distinct reason the portal page can
|
||||
* report instead of a generic miss.
|
||||
*/
|
||||
class DeviceResolver
|
||||
{
|
||||
public function __construct(private UnifiApiClient $unifi) {}
|
||||
|
||||
/** Attempts, and the pause between them, for a device that has just associated. */
|
||||
private const ATTEMPTS = 3;
|
||||
private const PAUSE_US = 750_000;
|
||||
|
||||
public function resolve(Request $request, string $apMac): ResolvedDevice
|
||||
{
|
||||
$ip = $request->ip();
|
||||
|
||||
/*
|
||||
* A device that has only just associated may have no IP yet, so UniFi's
|
||||
* client table does not know it. Retry briefly rather than telling a
|
||||
* pupil their device is not connected when it is about to be.
|
||||
*
|
||||
* Bounded on purpose: this runs inside a web request, and a longer wait
|
||||
* would hold a worker per connecting device at exactly the moment a
|
||||
* class of them arrives at once.
|
||||
*/
|
||||
$client = null;
|
||||
for ($attempt = 1; $attempt <= self::ATTEMPTS; $attempt++) {
|
||||
$client = $this->unifi->findClientByIp($ip);
|
||||
if ($client) {
|
||||
break;
|
||||
}
|
||||
if ($attempt < self::ATTEMPTS) {
|
||||
usleep(self::PAUSE_US);
|
||||
}
|
||||
}
|
||||
|
||||
if (! $client) {
|
||||
// A forwarded header present while the request IP is still the raw
|
||||
// socket address means TrustProxies is not applying, so every lookup
|
||||
// here is doomed for the same reason. Say which failure this is.
|
||||
$forwarded = $request->headers->get('X-Forwarded-For');
|
||||
$raw = $request->server('REMOTE_ADDR');
|
||||
$reason = ($forwarded && $ip === $raw) ? 'proxy_untrusted' : 'no_client';
|
||||
|
||||
return new ResolvedDevice(ok: false, reason: $reason);
|
||||
}
|
||||
|
||||
$mac = MacAddress::normalise($client['mac'] ?? null);
|
||||
if (! $mac) {
|
||||
return new ResolvedDevice(ok: false, reason: 'no_client');
|
||||
}
|
||||
|
||||
$clientAp = MacAddress::normalise($client['ap_mac'] ?? null);
|
||||
if (! $clientAp || $clientAp !== MacAddress::normalise($apMac)) {
|
||||
return new ResolvedDevice(ok: false, reason: 'ap_mismatch');
|
||||
}
|
||||
|
||||
$supplied = MacAddress::normalise($request->input('mac'));
|
||||
$disagreed = $supplied !== null && $supplied !== $mac;
|
||||
|
||||
if ($disagreed) {
|
||||
Log::warning('unifi.portal: supplied MAC disagreed with the derived one', [
|
||||
'derived' => $mac, 'supplied' => $supplied, 'ip' => $ip, 'ap' => $apMac,
|
||||
]);
|
||||
}
|
||||
|
||||
return new ResolvedDevice(
|
||||
ok: true,
|
||||
mac: $mac,
|
||||
isRandomized: MacAddress::isRandomized($mac),
|
||||
macParameterDisagreed: $disagreed,
|
||||
client: $client,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -253,6 +253,24 @@ class UnifiApiClient
|
||||
return ! empty($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* The client currently holding this IP, or null.
|
||||
*
|
||||
* Reads the cached client list rather than querying per IP: the portal calls
|
||||
* this once per connection attempt, and a class arriving at once would
|
||||
* otherwise be one controller round trip each.
|
||||
*/
|
||||
public function findClientByIp(string $ip): ?array
|
||||
{
|
||||
foreach ($this->getActiveClients() as $client) {
|
||||
if (($client['ip'] ?? null) === $ip) {
|
||||
return $client;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function kickClient(string $mac): array
|
||||
{
|
||||
Cache::forget('unifi:clients');
|
||||
@@ -286,6 +304,25 @@ class UnifiApiClient
|
||||
return $this->post('/cmd/stamgr', ['cmd' => 'unauthorize-guest', 'mac' => strtolower($mac)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The MACs UniFi currently holds an active guest authorization for.
|
||||
*
|
||||
* Read from /stat/guest, which is the authorization list — deliberately not
|
||||
* /stat/sta, which is who is associated right now. Those are the two facts
|
||||
* this whole feature had been conflating.
|
||||
*/
|
||||
public function getAuthorizedGuestMacs(): array
|
||||
{
|
||||
$rows = $this->get('/stat/guest');
|
||||
|
||||
return collect($rows)
|
||||
->filter(fn ($g) => (int) ($g['end'] ?? 0) > now()->timestamp)
|
||||
->pluck('mac')
|
||||
->map(fn ($m) => strtolower((string) $m))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function delete(string $path): void
|
||||
{
|
||||
$this->init();
|
||||
@@ -312,6 +349,122 @@ class UnifiApiClient
|
||||
return $this->put("/rest/wlanconf/{$wlanId}", $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every other WLAN that should rotate/update together with this
|
||||
* one. Authoritative source: the user-defined "SSID groups" setting
|
||||
* (unifi.ssid_groups) from the WiFi Networks page, which lets the
|
||||
* operator manually couple WLANs that may have different SSID names.
|
||||
*
|
||||
* Falls back to same-SSID-name siblings for installs that haven't
|
||||
* configured groups yet.
|
||||
*
|
||||
* Returns an array of sibling wlan IDs (excludes $wlanId itself).
|
||||
*/
|
||||
public function getGroupedWlans(string $wlanId): array
|
||||
{
|
||||
$groupsJson = Setting::get('unifi.ssid_groups', '{}');
|
||||
$groups = json_decode($groupsJson, true);
|
||||
if (is_array($groups)) {
|
||||
foreach ($groups as $wlanIds) {
|
||||
if (! is_array($wlanIds)) continue;
|
||||
if (in_array($wlanId, $wlanIds, true)) {
|
||||
return array_values(array_filter($wlanIds, fn ($id) => $id !== $wlanId));
|
||||
}
|
||||
}
|
||||
}
|
||||
return $this->getWlanSiblings($wlanId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an embedded PPSK has the expected passphrase right now.
|
||||
* Used after an update to confirm the change actually applied —
|
||||
* UniFi sometimes 200s an update that didn't stick (cluster sync
|
||||
* race, hot-restart in progress, etc.).
|
||||
*
|
||||
* Returns ['ok' => true] on a clean match, or
|
||||
* ['ok' => false, 'reason' => 'fetch_failed'|'not_found'|'mismatch']
|
||||
* with optional 'error' on fetch failures.
|
||||
*/
|
||||
public function verifyEmbeddedPpsk(string $wlanId, string $name, string $expectedPassphrase): array
|
||||
{
|
||||
try {
|
||||
$entries = $this->getPpskEntries($wlanId);
|
||||
} catch (\Throwable $e) {
|
||||
return ['ok' => false, 'reason' => 'fetch_failed', 'error' => $e->getMessage()];
|
||||
}
|
||||
|
||||
$networkconfId = $this->findNetworkconfIdByName($name);
|
||||
|
||||
foreach ($entries as $e) {
|
||||
$entryName = $e['name'] ?? $e['label'] ?? $e['username'] ?? $e['privatePskName'] ?? null;
|
||||
$entryNetId = $e['networkconf_id'] ?? null;
|
||||
$entryMatches = ($networkconfId !== null && $entryNetId === $networkconfId)
|
||||
|| ($entryName !== null && $entryName === $name);
|
||||
if (! $entryMatches) continue;
|
||||
|
||||
$entryPass = $e['x_passphrase'] ?? $e['password'] ?? $e['passphrase'] ?? null;
|
||||
return $entryPass === $expectedPassphrase
|
||||
? ['ok' => true]
|
||||
: ['ok' => false, 'reason' => 'mismatch'];
|
||||
}
|
||||
return ['ok' => false, 'reason' => 'not_found'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a networkconf (VLAN/network) by its display name. Embedded
|
||||
* PPSKs on this controller use networkconf_id as their stable
|
||||
* identifier — the human "name" the operator sees is actually the
|
||||
* network's name.
|
||||
*/
|
||||
private function findNetworkconfIdByName(string $name): ?string
|
||||
{
|
||||
try {
|
||||
$networks = $this->getNetworkConfs();
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
foreach ($networks as $n) {
|
||||
if (($n['name'] ?? null) === $name) {
|
||||
return $n['_id'] ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find sibling WLAN configs — same SSID name, different _id. UniFi
|
||||
* splits a "banded" SSID (band-steering disabled) into one wlanconf
|
||||
* per band, each with its own _id and its own embedded PPSK array.
|
||||
* A rotation that updates one band must also update the others, or
|
||||
* the SSID's two halves drift out of sync.
|
||||
*
|
||||
* Returns an array of sibling wlan IDs (excludes $wlanId itself).
|
||||
* Empty array if the target WLAN is unique or can't be found.
|
||||
*/
|
||||
public function getWlanSiblings(string $wlanId): array
|
||||
{
|
||||
try {
|
||||
$all = $this->get('/rest/wlanconf');
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$target = null;
|
||||
foreach ($all as $w) {
|
||||
if (($w['_id'] ?? null) === $wlanId) { $target = $w; break; }
|
||||
}
|
||||
if (! $target || empty($target['name'])) return [];
|
||||
|
||||
$siblings = [];
|
||||
foreach ($all as $w) {
|
||||
if (($w['_id'] ?? null) === $wlanId) continue;
|
||||
if (($w['name'] ?? null) === $target['name']) {
|
||||
$siblings[] = $w['_id'];
|
||||
}
|
||||
}
|
||||
return $siblings;
|
||||
}
|
||||
|
||||
// ── PPSK ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -504,7 +657,7 @@ class UnifiApiClient
|
||||
* no controller-side ID. Only changes the entry's passphrase; name
|
||||
* isn't separately addressable on embedded PPSKs.
|
||||
*/
|
||||
public function updateEmbeddedPpsk(string $wlanId, string $oldPassphrase, string $newPassphrase): array
|
||||
public function updateEmbeddedPpsk(string $wlanId, ?string $oldPassphrase, string $newPassphrase, ?string $name = null): array
|
||||
{
|
||||
$wlanResp = $this->get("/rest/wlanconf/{$wlanId}");
|
||||
$wlan = $wlanResp[0] ?? $wlanResp;
|
||||
@@ -514,26 +667,66 @@ class UnifiApiClient
|
||||
throw new \RuntimeException('WLAN has no embedded PPSKs to update.');
|
||||
}
|
||||
|
||||
$matched = false;
|
||||
foreach ($entries as &$e) {
|
||||
$current = $e['x_passphrase'] ?? $e['password'] ?? $e['passphrase'] ?? null;
|
||||
if ($current === $oldPassphrase) {
|
||||
// Preserve whichever field name the controller is using.
|
||||
// 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 none of the above existed, default to password (most common on embedded).
|
||||
if (! isset($e['x_passphrase']) && ! isset($e['password']) && ! isset($e['passphrase'])) {
|
||||
$e['password'] = $newPassphrase;
|
||||
}
|
||||
};
|
||||
|
||||
$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 by current passphrase.');
|
||||
throw new \RuntimeException(
|
||||
'Embedded PPSK not found' .
|
||||
($name !== null ? " for network \"{$name}\"" : '') .
|
||||
' or by current passphrase.'
|
||||
);
|
||||
}
|
||||
|
||||
// UniFi REST expects the full WLAN object on PUT — send what we
|
||||
|
||||
@@ -181,7 +181,11 @@ class WebhookCheckService
|
||||
$prev = DeviceState::where('device_mac', $mac)->first();
|
||||
if (! $prev) continue;
|
||||
|
||||
// Skip planned reboots — these are intentional, not alerts
|
||||
// Skip planned reboots — these are intentional, not alerts.
|
||||
// Two layers: a global suppression window set by RebootAllAps
|
||||
// (Setting, survives any cache driver), plus the per-MAC
|
||||
// cache keys for finer granularity.
|
||||
if ($this->inGlobalRebootSuppression()) continue;
|
||||
if (Cache::has('unifi:planned_reboot:' . strtolower($mac))) continue;
|
||||
|
||||
if ($comingOnline) {
|
||||
@@ -509,6 +513,8 @@ class WebhookCheckService
|
||||
private function checkReboot($aps, array $filter): array
|
||||
{
|
||||
$alerts = [];
|
||||
if ($this->inGlobalRebootSuppression()) return $alerts;
|
||||
|
||||
foreach ($aps as $ap) {
|
||||
if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue;
|
||||
if (Cache::has('unifi:planned_reboot:' . strtolower($ap['mac']))) continue;
|
||||
@@ -584,6 +590,24 @@ class WebhookCheckService
|
||||
return self::buildPlatformPayload($url, $message, $fullPayload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is a fleet reboot in progress right now? RebootAllAps stamps a
|
||||
* suppression-until timestamp as a Setting; while that timestamp
|
||||
* is in the future, we skip all device-offline / device-online /
|
||||
* unexpected-reboot alerts to avoid flooding webhooks during the
|
||||
* known maintenance window.
|
||||
*/
|
||||
private function inGlobalRebootSuppression(): bool
|
||||
{
|
||||
$until = \App\Models\Setting::get('unifi.reboot_suppression_until');
|
||||
if (! $until) return false;
|
||||
try {
|
||||
return \Carbon\Carbon::parse($until)->isFuture();
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public/static helper so the test-webhook endpoint produces the
|
||||
* same per-platform payload shape that real events do.
|
||||
|
||||
31
src/Support/MacAddress.php
Normal file
31
src/Support/MacAddress.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Dashboard\Unifi\Support;
|
||||
|
||||
/**
|
||||
* Pure MAC helpers. No dependencies, so they are cheap to test and safe to call
|
||||
* from anywhere.
|
||||
*/
|
||||
class MacAddress
|
||||
{
|
||||
/** Lower-case, colon-separated, or null if it is not a MAC at all. */
|
||||
public static function normalise(?string $mac): ?string
|
||||
{
|
||||
$mac = strtolower(str_replace('-', ':', trim((string) $mac)));
|
||||
|
||||
return preg_match('/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/', $mac) ? $mac : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this a private (randomized) address rather than a real manufacturer one?
|
||||
*
|
||||
* The locally-administered bit is the second-least-significant bit of the
|
||||
* first octet. iOS "Private Wi-Fi Address" and Android per-network
|
||||
* randomization both set it. This is why OUI is useless for identifying
|
||||
* device types: a randomized MAC has no real manufacturer prefix.
|
||||
*/
|
||||
public static function isRandomized(string $mac): bool
|
||||
{
|
||||
return (bool) (hexdec(substr($mac, 0, 2)) & 0b10);
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,7 @@
|
||||
|
||||
namespace Dashboard\Unifi;
|
||||
|
||||
use App\Models\DashboardApp;
|
||||
use App\Models\NavItem;
|
||||
use Dashboard\Unifi\Models\UnifiPageGrant;
|
||||
use Illuminate\Routing\Events\RouteMatched;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class UnifiServiceProvider extends ServiceProvider
|
||||
@@ -25,36 +21,45 @@ class UnifiServiceProvider extends ServiceProvider
|
||||
$this->loadRoutesFrom(__DIR__ . '/routes/unifi.php');
|
||||
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
|
||||
|
||||
// Per-page access enforcement for unifi routes. If a unifi page has
|
||||
// any UnifiPageGrant rows, only super-admins and granted users/
|
||||
// groups can hit it; otherwise (no grants) it's open per the existing
|
||||
// permission middleware. Super-admins always bypass.
|
||||
Event::listen(RouteMatched::class, function (RouteMatched $event) {
|
||||
$routeName = $event->route->getName();
|
||||
if (! $routeName || ! str_starts_with($routeName, 'unifi.')) return;
|
||||
|
||||
$user = $event->request->user();
|
||||
if (! $user || $user->is_super_admin) return;
|
||||
|
||||
// "/" 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 {
|
||||
$appId = DashboardApp::where('slug', 'unifi')->value('id');
|
||||
$item = NavItem::where('route_name', $routeName)
|
||||
->where('app_id', $appId)
|
||||
->first();
|
||||
if (! $item) return;
|
||||
|
||||
if (! UnifiPageGrant::userCanAccess($user, $item)) {
|
||||
// 404 instead of 403 — don't leak that the page
|
||||
// exists. The Access tab is the only way in.
|
||||
abort(404);
|
||||
}
|
||||
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) {
|
||||
// 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.
|
||||
// Older shell without the card registry — skip.
|
||||
}
|
||||
});
|
||||
|
||||
// Tell the shell's nav sidebar which unifi nav items the user
|
||||
// can see: pages whose required_permission the user holds through
|
||||
// groups, plus pages granted via the Settings → Access tab. The
|
||||
// shell also folds these grants back into User::can()/allPermissions
|
||||
// so the route-level permission middleware passes for grantees.
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->commands([
|
||||
Console\CheckWebhooks::class,
|
||||
@@ -63,10 +68,33 @@ class UnifiServiceProvider extends ServiceProvider
|
||||
Console\RebootAllAps::class,
|
||||
Console\RotatePasswords::class,
|
||||
Console\SyncPpskSchedules::class,
|
||||
Console\ReconcilePortal::class,
|
||||
]);
|
||||
$this->publishes([
|
||||
__DIR__ . '/../config/unifi.php' => config_path('unifi.php'),
|
||||
], 'unifi-config');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Group + direct permissions only. Deliberately NOT allPermissions():
|
||||
* the shell folds page-grant permissions back into allPermissions/can,
|
||||
* so using it here would turn a single granted page into every page
|
||||
* carrying the same permission.
|
||||
*/
|
||||
public static function realPermissionKeysFor(\App\Models\User $user): \Illuminate\Support\Collection
|
||||
{
|
||||
if ($user->is_super_admin) {
|
||||
return \App\Models\Permission::pluck('key');
|
||||
}
|
||||
|
||||
$direct = $user->directPermissions()->get();
|
||||
|
||||
return \App\Models\Permission::whereHas('groups', fn ($q) => $q->whereIn('group_id', $user->groups()->pluck('groups.id')))
|
||||
->pluck('key')
|
||||
->merge($direct->where('granted', true)->pluck('permission_key'))
|
||||
->diff($direct->where('granted', false)->pluck('permission_key'))
|
||||
->unique()
|
||||
->values();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,19 +69,12 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
|
||||
// ── Settings ─────────────────────────────────────────────────────────
|
||||
Route::middleware('permission:unifi.settings')->group(function () {
|
||||
Route::get('/settings', [UnifiSettingsController::class, 'edit']) ->name('settings');
|
||||
Route::get('/settings/{tab}', [UnifiSettingsController::class, 'edit'])
|
||||
->where('tab', 'connection|tasks|logs|access')->name('settings.tab');
|
||||
Route::post('/settings', [UnifiSettingsController::class, 'update']) ->name('settings.update');
|
||||
Route::post('/settings/test', [UnifiSettingsController::class, 'testConnection'])->name('settings.test');
|
||||
Route::post('/settings/sites', [UnifiSettingsController::class, 'fetchSites']) ->name('settings.sites');
|
||||
|
||||
// Page Access — super-admin only. Lists unifi pages and lets
|
||||
// operators assign per-page user/group grants.
|
||||
Route::middleware('super.admin')->group(function () {
|
||||
Route::get('/settings/pages-access', [UnifiPagesAccessController::class, 'index']) ->name('settings.pages-access.index');
|
||||
Route::get('/settings/pages-access/users/search', [UnifiPagesAccessController::class, 'searchUsers'])->name('settings.pages-access.users.search');
|
||||
Route::get('/settings/pages-access/groups/search', [UnifiPagesAccessController::class, 'searchGroups'])->name('settings.pages-access.groups.search');
|
||||
Route::put('/settings/pages-access/{navItem}', [UnifiPagesAccessController::class, 'update']) ->name('settings.pages-access.update');
|
||||
});
|
||||
|
||||
// Cron logs — read-only history of scheduled-task runs.
|
||||
Route::get('/settings/cron-logs', [UnifiCronLogsController::class, 'index'])->name('settings.cron-logs.index');
|
||||
|
||||
@@ -99,6 +92,15 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
|
||||
});
|
||||
});
|
||||
|
||||
// ── Guest Wi-Fi home-card poll. Authorises inside the controller against the
|
||||
// card's own visibility (unifi.guest_wifi) — it returns a live credential, so
|
||||
// `auth` alone is not a gate. Deliberately outside the permission groups above
|
||||
// because it's a home-page widget, not a unifi page: unifi.guest_wifi is
|
||||
// grantable to front-desk staff without any other network access. ──
|
||||
Route::middleware(['web', 'auth'])
|
||||
->get('/app/unifi/guest-card/data', [\Dashboard\Unifi\Http\Controllers\GuestCardController::class, 'data'])
|
||||
->name('unifi.guest-card.data');
|
||||
|
||||
// ── Public API (token-protected) ──────────────────────────────────────────
|
||||
// External integrations (signage, kiosks) hit these without session auth.
|
||||
Route::prefix('api/unifi')->name('unifi.api.')->group(function () {
|
||||
@@ -110,3 +112,21 @@ Route::prefix('api/unifi')->name('unifi.api.')->group(function () {
|
||||
Route::middleware(['web', 'auth'])
|
||||
->get('/portal/wifi/callback', [PortalController::class, 'captiveCallback'])
|
||||
->name('unifi.portal.callback');
|
||||
|
||||
/*
|
||||
* Let a user disconnect their OWN device from the captive-portal denial page.
|
||||
*
|
||||
* The admin route for this (unifi.portal.sessions.disconnect) sits behind
|
||||
* `app.access:unifi` + `permission:unifi.auth`, so a student who is told "you
|
||||
* already have 3 devices connected, disconnect one" was blocked by middleware
|
||||
* before reaching the action — the page's `can_disconnect: true` was a promise
|
||||
* the routing could not keep.
|
||||
*
|
||||
* Safe to expose with only `auth`: disconnectSession() already authorises
|
||||
* owner-or-unifi.auth itself (`abort_if($session->user_id !== auth()->id() && !
|
||||
* auth()->user()->can('unifi.auth'), 403)`), so a non-owner still gets 403. The
|
||||
* admin route is left exactly as it was rather than loosened.
|
||||
*/
|
||||
Route::middleware(['web', 'auth'])
|
||||
->post('/portal/wifi/sessions/{session}/disconnect', [PortalController::class, 'disconnectSession'])
|
||||
->name('unifi.portal.self-disconnect');
|
||||
|
||||
Reference in New Issue
Block a user