Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f621e3973 | |||
| 868f5edeed | |||
| 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 | |||
| f5848907f5 | |||
| f953fde2be | |||
| 9a37eda302 | |||
| 4b29f55518 | |||
| e8796d443e | |||
| 24aad5cdc0 |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "dashboard/unifi",
|
"name": "dashboard/unifi",
|
||||||
"description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform",
|
"description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform",
|
||||||
"version": "1.5.5",
|
"version": "1.13.5",
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"autoload": {
|
"autoload": {
|
||||||
@@ -16,6 +16,8 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
|
"tier": "optional",
|
||||||
|
"stability": "stable",
|
||||||
"nav_folder": {
|
"nav_folder": {
|
||||||
"label": "Unifi Network",
|
"label": "Unifi Network",
|
||||||
"icon": "wifi",
|
"icon": "wifi",
|
||||||
@@ -27,14 +29,15 @@
|
|||||||
{ "label": "Devices", "route_name": "unifi.devices", "icon": "cpu-chip", "permission": "unifi.stats", "sort_order": 3 },
|
{ "label": "Devices", "route_name": "unifi.devices", "icon": "cpu-chip", "permission": "unifi.stats", "sort_order": 3 },
|
||||||
{ "label": "Clients", "route_name": "unifi.clients", "icon": "users", "permission": "unifi.stats", "sort_order": 4 },
|
{ "label": "Clients", "route_name": "unifi.clients", "icon": "users", "permission": "unifi.stats", "sort_order": 4 },
|
||||||
{ "label": "WiFi Networks", "route_name": "unifi.wifi", "icon": "wifi", "permission": "unifi.manage", "sort_order": 5 },
|
{ "label": "WiFi Networks", "route_name": "unifi.wifi", "icon": "wifi", "permission": "unifi.manage", "sort_order": 5 },
|
||||||
{ "label": "Portal", "route_name": "unifi.portal.settings", "icon": "shield-check", "permission": "unifi.auth", "sort_order": 6 },
|
{ "label": "Captive Portal", "route_name": "unifi.portal.settings", "icon": "shield-check", "permission": "unifi.auth", "sort_order": 6 },
|
||||||
{ "label": "Settings", "route_name": "unifi.settings", "icon": "cog-6-tooth", "permission": "unifi.settings", "sort_order": 99 }
|
{ "label": "Settings", "route_name": "unifi.settings", "icon": "cog-6-tooth", "permission": "unifi.settings", "sort_order": 99 }
|
||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
{ "key": "unifi.stats", "label": "View Network Stats", "description": "View WiFi dashboards, AP stats, and client lists" },
|
{ "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.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.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,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('unifi_device_states', function (Blueprint $table) {
|
||||||
|
if (! Schema::hasColumn('unifi_device_states', 'consecutive_count')) {
|
||||||
|
$table->unsignedSmallInteger('consecutive_count')->default(0)->after('in_alert');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('unifi_device_states', function (Blueprint $table) {
|
||||||
|
if (Schema::hasColumn('unifi_device_states', 'consecutive_count')) {
|
||||||
|
$table->dropColumn('consecutive_count');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
# Captive portal splash designer
|
||||||
|
|
||||||
|
**Date:** 2026-08-21
|
||||||
|
**Repos:** `central-unifi` (routes, controller, model, migration), `dashboard-shell` (editor page, Blade splash view, tests)
|
||||||
|
**Status:** design approved, no implementation plan yet
|
||||||
|
**Sequencing:** queues behind the six open findings in
|
||||||
|
`docs/superpowers/reviews/2026-08-21-plan1-review.md`. Nothing here depends on
|
||||||
|
them, but they touch the same controller.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
Let an operator lay out what a pupil sees at the captive portal, with the device's
|
||||||
|
own facts available as variables — IP, MAC, browser, machine type — rather than the
|
||||||
|
four hardcoded strings the portal has today.
|
||||||
|
|
||||||
|
## What already exists and is being reused
|
||||||
|
|
||||||
|
| Need | Existing thing |
|
||||||
|
|---|---|
|
||||||
|
| `{token}` substitution + `{if}/{else}` conditionals | `App\Support\TemplateRender::apply()` — already used by signatures, NoE PDFs, payment receipts |
|
||||||
|
| Value escaping | the same method's `$escapeValues` parameter; signatures already pass it |
|
||||||
|
| Grouped variable sidebar | `TemplateRender`'s helper that turns a token→label map into grouped, alphabetised lists for a template editor |
|
||||||
|
| Live preview | the JS mirror `resources/js/utils/template.js` (`applyTemplate`) |
|
||||||
|
| Visual, source-free editing | the shared EmailComposer's anatomy (visual-only, no HTML source) |
|
||||||
|
| Device type from a User-Agent | `ActiveSessionController::device()` — already yields "Safari · iPhone", and its OS table separates iPhone/iPad/Android from the desktop platforms |
|
||||||
|
|
||||||
|
So this is mostly assembly. The genuinely new parts are a public Blade route, a
|
||||||
|
templates table, and the editor screen.
|
||||||
|
|
||||||
|
**Prerequisite:** `ActiveSessionController::device()` is a private method on a
|
||||||
|
controller. Extract it to `App\Support\UserAgent::describe()` and have the Logged-in
|
||||||
|
Users page and the portal both call it. This is also finding 3a of the Plan 1
|
||||||
|
review, which wants the same extraction for a different reason.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| # | Decision | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | The designer covers **four** templates: splash, success, over-limit, refused | The after-login pages are where a pupil is actually stuck, and they can use quota facts the splash cannot |
|
||||||
|
| 2 | **Visual editor inside a fixed frame** | The sign-in button must not be removable; a bad edit would brick the portal for everyone at once |
|
||||||
|
| 3 | Device variables everywhere; person and quota variables on after-login pages only | Pre-login there is no signed-in user, so they would render blank |
|
||||||
|
| 4 | The splash is a **plain server-rendered Blade page**, not Inertia | The captive-network mini-browser is a stripped-down WebView; SPA bootstrapping is unreliable there |
|
||||||
|
| 5 | **Escape every substituted value; no `rawKeys`** | `{browser}` and `{device_type}` derive from a client-controlled header, on a public page |
|
||||||
|
| 6 | **No HTML sanitiser** on the operator's body | No library present, and a homemade allowlist is worse than none. Mitigated by a source-free editor and the `unifi.manage` gate. Residual risk stated below |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. The four templates
|
||||||
|
|
||||||
|
| Template key | Rendered by | Auth | Variables |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `splash` | new public route, Blade | none | device only |
|
||||||
|
| `success` | `captiveCallback`, after authorizing | auth | device + person/quota |
|
||||||
|
| `over_limit` | `captiveCallback`, quota branch | auth | device + person/quota + the device list |
|
||||||
|
| `refused` | `captiveCallback`, no-mapping or resolver refusal | auth | device only — there may be no mapping to quote |
|
||||||
|
|
||||||
|
**The splash needs a new public route.** `captiveCallback` sits behind `['web','auth']`
|
||||||
|
today, so an unauthenticated hit does `redirect()->route('login')` and the pupil
|
||||||
|
lands on the ordinary shell login. That is why there is no splash at all now.
|
||||||
|
|
||||||
|
Flow: UniFi redirects → public splash (device facts, "Sign in with Google") →
|
||||||
|
Google → `captiveCallback` → success / over-limit / refused.
|
||||||
|
|
||||||
|
The three after-login pages stay Inertia: by then the pupil has been through Google
|
||||||
|
in a real browser.
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
A `unifi_portal_templates` table, one row per key: `key`, `body`, `updated_at`.
|
||||||
|
Not `Setting`, because these are long HTML bodies and a table gives a per-template
|
||||||
|
`updated_at` and a clean per-template reset to default.
|
||||||
|
|
||||||
|
### Variables
|
||||||
|
|
||||||
|
Device — available on all four:
|
||||||
|
|
||||||
|
| Token | Source | Trustworthy? |
|
||||||
|
|---|---|---|
|
||||||
|
| `{ip}` | `$request->ip()` via Traefik | Yes in practice — production shows real per-client addresses |
|
||||||
|
| `{mac}` | derived from the IP via UniFi's client table | Yes — server-derived |
|
||||||
|
| `{is_randomized}` | locally-administered bit on the derived MAC | Yes — server-derived |
|
||||||
|
| `{browser}` | `App\Support\UserAgent::describe()` | **No — client-controlled** |
|
||||||
|
| `{device_type}` | same | **No — client-controlled** |
|
||||||
|
| `{ssid}`, `{ap_name}` | the redirect / the client record | Yes |
|
||||||
|
|
||||||
|
Person and quota — after-login pages only: `{name}`, `{role}`, `{devices_used}`,
|
||||||
|
`{device_limit}`, `{expires_on}`.
|
||||||
|
|
||||||
|
On the splash template the person and quota tokens are **shown disabled with the
|
||||||
|
reason**, not hidden and not silently rendering blank. Hiding them invites the
|
||||||
|
question "why can't I show their name"; blanking them ships a broken page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. The editor
|
||||||
|
|
||||||
|
A new tab on the existing Unifi → Portal settings page, gated on `unifi.manage` —
|
||||||
|
the key that already guards portal settings.
|
||||||
|
|
||||||
|
- **Template switcher** across the four keys. One screen, not four.
|
||||||
|
- **Fixed frame.** Logo slot, editable content region, sign-in button, footer. The
|
||||||
|
frame and the button are our markup and are not part of the editable body, so no
|
||||||
|
edit can remove the way in.
|
||||||
|
- **Variable picker** beside the editor, grouped Device / Person & Quota, built from
|
||||||
|
`TemplateRender`'s grouping helper.
|
||||||
|
- **Live preview** through the JS mirror, fed sample values. The token set is
|
||||||
|
defined once, server-side, and handed to the page — the memory on this engine is
|
||||||
|
explicit that the JS mirror and the PHP engine must change together, so a single
|
||||||
|
source for the token list is the point.
|
||||||
|
- **Images must be local.** The picker offers uploaded images only, and pasting an
|
||||||
|
external URL warns. See section C — a remote asset cannot load on the splash.
|
||||||
|
- **Reset to default** per template.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C. Rendering safety
|
||||||
|
|
||||||
|
### Escape everything
|
||||||
|
|
||||||
|
`TemplateRender::apply($body, $values, escapeValues: true)` with **no `rawKeys`**.
|
||||||
|
`{browser}` and `{device_type}` come from the User-Agent, so a crafted header would
|
||||||
|
otherwise be reflected XSS on an unauthenticated page. `{ip}` and `{mac}` are
|
||||||
|
server-derived and escaped anyway, because there is no reason not to. Nothing needs
|
||||||
|
to bypass escaping: with a fixed frame the sign-in button is markup, not a token.
|
||||||
|
|
||||||
|
The engine defaults `escapeValues` to false, which is correct for its email and PDF
|
||||||
|
callers. This is therefore an explicit decision at the call site, and the reason
|
||||||
|
belongs in a comment there.
|
||||||
|
|
||||||
|
### No sanitiser on the operator's body, and what that leaves
|
||||||
|
|
||||||
|
There is no HTML sanitising library in the project and no allowlist helper. Writing
|
||||||
|
one is rejected: a homemade allowlist is leaky in ways nobody audits, which is worse
|
||||||
|
than not having one.
|
||||||
|
|
||||||
|
Mitigations: the editor is visual-only and offers no source view or script
|
||||||
|
insertion, and the body is editable only by `unifi.manage` holders.
|
||||||
|
|
||||||
|
**Residual risk, stated plainly:** an admin holding `unifi.manage` can put arbitrary
|
||||||
|
HTML on a public page. That is a trusted-insider scenario rather than an escalation —
|
||||||
|
the same admin already controls the portal, the VLAN mappings and the device quotas.
|
||||||
|
But it is a real difference from signatures, which use the same engine and render
|
||||||
|
into email rather than onto the open internet. If the trust boundary ever changes —
|
||||||
|
say portal editing is delegated to a non-admin role — this decision must be revisited
|
||||||
|
first.
|
||||||
|
|
||||||
|
### The constraint most likely to break a rollout
|
||||||
|
|
||||||
|
**The splash is served before the device has internet access.** That is what a
|
||||||
|
captive portal is. Any external asset — a CDN, Google Fonts, a remote logo — silently
|
||||||
|
fails to load, and the pupil gets an unstyled page at the worst possible moment.
|
||||||
|
|
||||||
|
Therefore: all CSS and images local, no external fonts, and UniFi's pre-auth walled
|
||||||
|
garden must allow this host. The likeliest way this breaks in practice is a
|
||||||
|
well-meaning operator pasting a remote logo URL into the editor, which is why
|
||||||
|
section B restricts images rather than merely advising.
|
||||||
|
|
||||||
|
### Fallback
|
||||||
|
|
||||||
|
If a template row is missing, empty, or throws while rendering, render a built-in
|
||||||
|
default body. A blank or broken template must never leave a pupil with no way in —
|
||||||
|
the same reasoning as the fixed frame, applied to content instead of structure.
|
||||||
|
|
||||||
|
### Headers
|
||||||
|
|
||||||
|
The splash carries a device's MAC and IP, so: `Cache-Control: no-store` and
|
||||||
|
`noindex`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D. Testing
|
||||||
|
|
||||||
|
Covered here:
|
||||||
|
|
||||||
|
- Escaping: a hostile User-Agent (`<script>` and an `onerror=` attribute) renders
|
||||||
|
escaped in the splash body.
|
||||||
|
- The public splash route renders with **no** authentication.
|
||||||
|
- Person and quota tokens on the splash render as their disabled placeholder rather
|
||||||
|
than leaking a blank or a previous user's value.
|
||||||
|
- A missing template row, and an empty body, each fall back to the default.
|
||||||
|
- The after-login pages render quota values — `devices_used`, `device_limit`,
|
||||||
|
`expires_on`.
|
||||||
|
- **Rendered splash HTML references no external hosts.** This is the regression most
|
||||||
|
likely to creep back in, and it is invisible on a developer machine that has
|
||||||
|
internet.
|
||||||
|
- `UserAgent::describe()` against real iOS, Android, macOS and Windows strings, plus
|
||||||
|
an empty one.
|
||||||
|
|
||||||
|
Browser pass on the splash in a plain, non-Inertia context.
|
||||||
|
|
||||||
|
**Not provable here** — same hardware list as the access-model spec: whether UniFi's
|
||||||
|
walled garden lets this host through pre-auth, and how the real Captive Network
|
||||||
|
Assistant behaves with the page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- **Drag-and-drop or free positioning.** A captive portal is a single narrow column
|
||||||
|
on a phone; absolute positioning is actively wrong for it.
|
||||||
|
- **Conditionals on `{device_type}` as a decision.** The engine supports
|
||||||
|
`{if device_type == 'iPhone'}` and the picker will not encourage it: it puts a
|
||||||
|
spoofable value into a decision rather than a display, which contradicts the
|
||||||
|
report-only stance agreed for device type.
|
||||||
|
- **Enforcement of any kind.** This designer changes what a pupil is told, never
|
||||||
|
what they are allowed.
|
||||||
|
- **Per-role or per-SSID template variants.** One template per key. Add variants
|
||||||
|
when somebody asks for them.
|
||||||
|
- **A block builder.** Considered and rejected: it invents a pattern this codebase
|
||||||
|
has no precedent for, and the layout freedom it buys is limited on a phone.
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,9 @@ class RotatePasswords extends Command
|
|||||||
|
|
||||||
if ($rotated) {
|
if ($rotated) {
|
||||||
Setting::set('unifi.password_rotation.last_rotated_at', now()->toIso8601String());
|
Setting::set('unifi.password_rotation.last_rotated_at', now()->toIso8601String());
|
||||||
|
// Persist the active password so it can be displayed in
|
||||||
|
// the Settings page and exposed via the API endpoint.
|
||||||
|
Setting::set('unifi.password_rotation.last_password', $password);
|
||||||
$this->info('Rotated password for ' . count($rotated) . ' SSID(s).');
|
$this->info('Rotated password for ' . count($rotated) . ' SSID(s).');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,16 +80,73 @@ class RotatePasswords extends Command
|
|||||||
$newPass = $passwords[array_rand($passwords)];
|
$newPass = $passwords[array_rand($passwords)];
|
||||||
try {
|
try {
|
||||||
if (str_starts_with((string) $ppsk->unifi_id, 'emb_')) {
|
if (str_starts_with((string) $ppsk->unifi_id, 'emb_')) {
|
||||||
// Embedded PPSK: update inside the parent WLAN object.
|
// Embedded PPSK: update inside the parent WLAN object,
|
||||||
// Synthetic ID is derived from the new passphrase, so update it too.
|
// matched by name (synthetic id changes with the
|
||||||
$unifi->updateEmbeddedPpsk($ppsk->wlan_id, $ppsk->x_passphrase, $newPass);
|
// 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);
|
$newUid = 'emb_' . substr(hash('sha256', $ppsk->wlan_id . ':' . $newPass), 0, 32);
|
||||||
$ppsk->update(['x_passphrase' => $newPass, 'unifi_id' => $newUid]);
|
$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 {
|
} else {
|
||||||
$unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]);
|
$unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]);
|
||||||
$ppsk->update(['x_passphrase' => $newPass]);
|
$ppsk->update(['x_passphrase' => $newPass]);
|
||||||
}
|
}
|
||||||
$rotatedPpsks[] = $ppsk->name;
|
$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) {
|
} catch (\Throwable $e) {
|
||||||
$this->error("Failed to rotate PPSK \"{$ppsk->name}\": {$e->getMessage()}");
|
$this->error("Failed to rotate PPSK \"{$ppsk->name}\": {$e->getMessage()}");
|
||||||
$failedPpsks[] = ['name' => $ppsk->name, 'error' => $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([
|
$data = $request->validate([
|
||||||
'group_name' => 'required|string|max:255',
|
'group_name' => 'required|string|max:255',
|
||||||
'match_type' => 'required|in:ou,group,email_domain,default',
|
'match_type' => 'required|in:ou,group,email_domain,role',
|
||||||
'match_value' => 'required|string|max:255',
|
'match_value' => 'required|string|max:255',
|
||||||
'vlan_id' => 'required|integer|min:1|max:4094',
|
'vlan_id' => 'required|integer|min:1|max:4094',
|
||||||
'max_devices' => 'required|integer|min:1|max:50',
|
'max_devices' => 'required|integer|min:1|max:50',
|
||||||
'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;
|
$data['sort_order'] = VlanMapping::max('sort_order') + 1;
|
||||||
VlanMapping::create($data);
|
VlanMapping::create($data);
|
||||||
@@ -47,11 +51,15 @@ class PortalController extends Controller
|
|||||||
{
|
{
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'group_name' => 'required|string|max:255',
|
'group_name' => 'required|string|max:255',
|
||||||
'match_type' => 'required|in:ou,group,email_domain,default',
|
'match_type' => 'required|in:ou,group,email_domain,role',
|
||||||
'match_value' => 'required|string|max:255',
|
'match_value' => 'required|string|max:255',
|
||||||
'vlan_id' => 'required|integer|min:1|max:4094',
|
'vlan_id' => 'required|integer|min:1|max:4094',
|
||||||
'max_devices' => 'required|integer|min:1|max:50',
|
'max_devices' => 'required|integer|min:1|max:50',
|
||||||
'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);
|
$mapping->update($data);
|
||||||
return back()->with('success', 'Mapping updated.');
|
return back()->with('success', 'Mapping updated.');
|
||||||
@@ -87,6 +95,35 @@ class PortalController extends Controller
|
|||||||
|
|
||||||
// ── Captive portal callback (called by UniFi external portal redirect) ───
|
// ── 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)
|
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>
|
// 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');
|
return redirect()->route('login');
|
||||||
}
|
}
|
||||||
|
|
||||||
$clientMac = strtolower($request->input('mac', $request->input('id', '')));
|
$apMac = (string) $request->input('ap', '');
|
||||||
$ssid = $request->input('ssid', '');
|
$ssid = (string) $request->input('ssid', '');
|
||||||
$apMac = $request->input('ap', '');
|
|
||||||
|
|
||||||
if (! $clientMac) {
|
$device = app(\Dashboard\Unifi\Services\DeviceResolver::class)->resolve($request, $apMac);
|
||||||
return response('Missing MAC address', 400);
|
|
||||||
|
if (! $device->ok) {
|
||||||
|
return Inertia::render('Unifi/PortalDenied', [
|
||||||
|
'reason' => match ($device->reason) {
|
||||||
|
'ap_mismatch' => 'That device is not connected to this access point. Reconnect and try again.',
|
||||||
|
'proxy_untrusted' => 'Wi-Fi sign-in is misconfigured and cannot identify your device. Please tell IT.',
|
||||||
|
default => 'Still connecting — wait a moment and try again.',
|
||||||
|
},
|
||||||
|
'resolveFailure' => $device->reason,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$clientMac = $device->mac;
|
||||||
|
$isRandomized = $device->isRandomized;
|
||||||
|
|
||||||
// Find the VLAN mapping for this user's group/OU
|
// Find the VLAN mapping for this user's group/OU
|
||||||
$mapping = $this->resolveMapping($user);
|
$mapping = $this->resolveMapping($user);
|
||||||
if (! $mapping) {
|
if (! $mapping) {
|
||||||
return Inertia::render('Unifi/PortalDenied', ['reason' => 'No WiFi access configured for your account.']);
|
return Inertia::render('Unifi/PortalDenied', ['reason' => 'No WiFi access configured for your account.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check device limit
|
/*
|
||||||
$activeSessions = PortalSession::where('user_id', $user->id)
|
* Presence is NOT the question — a live authorization is.
|
||||||
->where('is_active', true)
|
*
|
||||||
->get();
|
* This used to walk the user's sessions and mark any device UniFi did not
|
||||||
|
* currently see as stale, freeing its slot. But UniFi holds an
|
||||||
|
* authorization for its whole window regardless of whether the device is
|
||||||
|
* in the building, so a phone that went home had its slot freed while
|
||||||
|
* still being authorized: the owner then authorized a second device and
|
||||||
|
* one of the two was an orphan nothing tracked. The longer
|
||||||
|
* session_minutes is — and it is now meant to be a term — the more of
|
||||||
|
* those accumulate.
|
||||||
|
*
|
||||||
|
* Slots are now freed by eviction, by expiry, or by ReconcilePortal.
|
||||||
|
*/
|
||||||
|
$live = PortalSession::live()->where('user_id', $user->id)->get();
|
||||||
|
|
||||||
foreach ($activeSessions as $session) {
|
// The same device coming back is a renewal, not a new device: it keeps
|
||||||
if ($session->mac_address === $clientMac) {
|
// its slot and its row, and simply gets a fresh window.
|
||||||
// Same device reconnecting — refresh
|
$existing = $live->firstWhere('mac_address', $clientMac);
|
||||||
$session->update(['is_active' => false]);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Check if other device is still actually connected
|
|
||||||
if (! $unifi->isClientConnected($session->mac_address)) {
|
|
||||||
$session->update(['is_active' => false]); // stale session
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$activeCount = PortalSession::where('user_id', $user->id)->where('is_active', true)->count();
|
if (! $existing && $live->count() >= $mapping->max_devices) {
|
||||||
if ($activeCount >= $mapping->max_devices) {
|
|
||||||
return Inertia::render('Unifi/PortalDenied', [
|
return Inertia::render('Unifi/PortalDenied', [
|
||||||
'reason' => "You already have {$activeCount} device(s) connected (limit: {$mapping->max_devices}).",
|
'reason' => "You already have {$live->count()} device(s) connected (limit: {$mapping->max_devices}).",
|
||||||
'sessions' => $activeSessions->map(fn ($s) => [
|
'sessions' => $live->map(fn ($s) => [
|
||||||
'id' => $s->id,
|
'id' => $s->id,
|
||||||
'mac' => $s->mac_address,
|
'mac' => $s->mac_address,
|
||||||
'hostname' => $s->device_hostname,
|
'hostname' => $s->device_hostname,
|
||||||
@@ -146,10 +196,9 @@ class PortalController extends Controller
|
|||||||
$minutes = $mapping->session_minutes;
|
$minutes = $mapping->session_minutes;
|
||||||
$unifi->authorizeGuest($clientMac, $minutes);
|
$unifi->authorizeGuest($clientMac, $minutes);
|
||||||
|
|
||||||
// Record the session
|
PortalSession::updateOrCreate(
|
||||||
PortalSession::create([
|
['user_id' => $user->id, 'mac_address' => $clientMac],
|
||||||
'user_id' => $user->id,
|
[
|
||||||
'mac_address' => $clientMac,
|
|
||||||
'device_hostname' => $request->input('hostname'),
|
'device_hostname' => $request->input('hostname'),
|
||||||
'device_os' => $request->input('os'),
|
'device_os' => $request->input('os'),
|
||||||
'device_type' => $request->input('dev_cat'),
|
'device_type' => $request->input('dev_cat'),
|
||||||
@@ -159,11 +208,20 @@ class PortalController extends Controller
|
|||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
'authorized_at' => now(),
|
'authorized_at' => now(),
|
||||||
'expires_at' => now()->addMinutes($minutes),
|
'expires_at' => now()->addMinutes($minutes),
|
||||||
]);
|
'is_randomized' => $isRandomized,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
// Redirect to the original URL the user was trying to reach
|
// Redirect to the original URL the user was trying to reach.
|
||||||
$redirectUrl = $request->input('url', '/');
|
//
|
||||||
return redirect($redirectUrl);
|
// 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)
|
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),
|
'email_domain' => str_ends_with($user->email ?? '', '@' . $mapping->match_value),
|
||||||
'group' => $user->groups?->contains('name', $mapping->match_value) ?? false,
|
'group' => $user->groups?->contains('name', $mapping->match_value) ?? false,
|
||||||
'ou' => str_contains($user->ou ?? '', $mapping->match_value),
|
'ou' => str_contains($user->ou ?? '', $mapping->match_value),
|
||||||
'default' => true,
|
/*
|
||||||
|
* Roles are the platform's own authority and need no external
|
||||||
|
* sync to be correct, which is why quotas key off them.
|
||||||
|
*
|
||||||
|
* `default` is deliberately absent. It matched everybody, so its
|
||||||
|
* existence made this an advisory list rather than an allow-list.
|
||||||
|
* Returning null here is already the deny path: captiveCallback
|
||||||
|
* renders "No WiFi access configured for your account."
|
||||||
|
*/
|
||||||
|
'role' => $user->hasRole($mapping->match_value),
|
||||||
default => false,
|
default => false,
|
||||||
};
|
};
|
||||||
if ($matched) return $mapping;
|
if ($matched) return $mapping;
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class StatsController extends Controller
|
|||||||
if ($wanIp && str_starts_with($wanIp, '127.')) $wanIp = $gw['connect_request_ip'] ?? null;
|
if ($wanIp && str_starts_with($wanIp, '127.')) $wanIp = $gw['connect_request_ip'] ?? null;
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
'available' => true,
|
||||||
'status' => $wan['status'] ?? 'unknown',
|
'status' => $wan['status'] ?? 'unknown',
|
||||||
'tx_rate' => $wan['tx_bytes-r'] ?? 0,
|
'tx_rate' => $wan['tx_bytes-r'] ?? 0,
|
||||||
'rx_rate' => $wan['rx_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,
|
'latency' => $wan['latency'] ?? $gw['wan1']['latency'] ?? null,
|
||||||
]);
|
]);
|
||||||
} catch (\Throwable $e) {
|
} 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();
|
->values();
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
'available' => true,
|
||||||
'labels' => $series['labels'],
|
'labels' => $series['labels'],
|
||||||
'traffic_rx' => $rx,
|
'traffic_rx' => $rx,
|
||||||
'traffic_tx' => $tx,
|
'traffic_tx' => $tx,
|
||||||
]);
|
]);
|
||||||
} catch (\Throwable $e) {
|
} 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,111 +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']);
|
|
||||||
|
|
||||||
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' => Group::orderBy('name')->get(['id', 'name', 'is_super']),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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]);
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
class UnifiSettingsController extends Controller
|
||||||
{
|
{
|
||||||
public function edit()
|
public function edit(?string $tab = null)
|
||||||
{
|
{
|
||||||
return Inertia::render('Unifi/Settings', [
|
return Inertia::render('Unifi/Settings', [
|
||||||
|
'activeTab' => $tab,
|
||||||
'controllerUrl' => Setting::get('unifi.controller_url', ''),
|
'controllerUrl' => Setting::get('unifi.controller_url', ''),
|
||||||
'hasApiKey' => (bool) Setting::get('unifi.api_key'),
|
'hasApiKey' => (bool) Setting::get('unifi.api_key'),
|
||||||
'site' => Setting::get('unifi.site', 'default'),
|
'site' => Setting::get('unifi.site', 'default'),
|
||||||
@@ -31,10 +32,29 @@ class UnifiSettingsController extends Controller
|
|||||||
'rotationMinute' => (int) Setting::get('unifi.password_rotation.minute', 0),
|
'rotationMinute' => (int) Setting::get('unifi.password_rotation.minute', 0),
|
||||||
'rotationWordlist' => Setting::get('unifi.password_rotation.wordlist', ''),
|
'rotationWordlist' => Setting::get('unifi.password_rotation.wordlist', ''),
|
||||||
'rotationLastRotatedAt' => Setting::get('unifi.password_rotation.last_rotated_at', null),
|
'rotationLastRotatedAt' => Setting::get('unifi.password_rotation.last_rotated_at', null),
|
||||||
|
'rotationLastPassword' => Setting::get('unifi.password_rotation.last_password', null),
|
||||||
'ppskSchedulingEnabled' => (bool) Setting::get('unifi.ppsk_scheduling.enabled', false),
|
'ppskSchedulingEnabled' => (bool) Setting::get('unifi.ppsk_scheduling.enabled', false),
|
||||||
|
'apiEnabled' => (bool) Setting::get('unifi.api.enabled', false),
|
||||||
|
'apiToken' => Setting::get('unifi.api_token', null),
|
||||||
|
'guestCardEnabled' => (bool) Setting::get('unifi.guest_card.enabled', false),
|
||||||
|
'guestCardSsid' => Setting::get('unifi.guest_card.ssid', ''),
|
||||||
|
'guestCardTitle' => Setting::get('unifi.guest_card.title', ''),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function regenerateApiToken()
|
||||||
|
{
|
||||||
|
$token = bin2hex(random_bytes(24));
|
||||||
|
Setting::set('unifi.api_token', $token);
|
||||||
|
return response()->json(['token' => $token]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearApiToken()
|
||||||
|
{
|
||||||
|
Setting::set('unifi.api_token', '');
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
public function update(Request $request)
|
public function update(Request $request)
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
@@ -56,6 +76,10 @@ class UnifiSettingsController extends Controller
|
|||||||
'rotation_minute' => 'nullable|integer|min:0|max:59',
|
'rotation_minute' => 'nullable|integer|min:0|max:59',
|
||||||
'rotation_wordlist' => 'nullable|string|max:20000',
|
'rotation_wordlist' => 'nullable|string|max:20000',
|
||||||
'ppsk_scheduling_enabled' => 'boolean',
|
'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, '/'));
|
Setting::set('unifi.controller_url', rtrim($request->controller_url, '/'));
|
||||||
@@ -82,6 +106,10 @@ class UnifiSettingsController extends Controller
|
|||||||
Setting::set('unifi.password_rotation.minute', $request->input('rotation_minute', 0));
|
Setting::set('unifi.password_rotation.minute', $request->input('rotation_minute', 0));
|
||||||
Setting::set('unifi.password_rotation.wordlist', $request->input('rotation_wordlist', ''));
|
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.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, '/')));
|
\Illuminate\Support\Facades\Cache::forget('unifi:api_prefix:' . md5(rtrim($request->controller_url, '/')));
|
||||||
|
|
||||||
|
|||||||
@@ -87,11 +87,17 @@ class WebhookController extends Controller
|
|||||||
|
|
||||||
private function fireTest(string $url, ?string $secret)
|
private function fireTest(string $url, ?string $secret)
|
||||||
{
|
{
|
||||||
$payload = [
|
$message = '✅ Test webhook from ' . config('app.name') . ' — endpoint is reachable.';
|
||||||
|
$genericPayload = [
|
||||||
'event' => 'test',
|
'event' => 'test',
|
||||||
'timestamp' => now()->toIso8601String(),
|
'timestamp' => now()->toIso8601String(),
|
||||||
'data' => ['message' => 'This is a test webhook from ' . config('app.name')],
|
'message' => $message,
|
||||||
|
'data' => ['message' => $message],
|
||||||
];
|
];
|
||||||
|
// Shape the payload to match the target platform (Google Chat,
|
||||||
|
// Slack, Discord, Teams) so the test exercises the same code
|
||||||
|
// path real events use.
|
||||||
|
$payload = \Dashboard\Unifi\Services\WebhookCheckService::buildPlatformPayload($url, $message, $genericPayload);
|
||||||
|
|
||||||
$headers = ['Content-Type' => 'application/json'];
|
$headers = ['Content-Type' => 'application/json'];
|
||||||
if ($secret) {
|
if ($secret) {
|
||||||
|
|||||||
44
src/Http/Controllers/WifiApiController.php
Normal file
44
src/Http/Controllers/WifiApiController.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Dashboard\Unifi\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Routing\Controller;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token-protected JSON endpoints for external integrations (signage,
|
||||||
|
* kiosks, room displays, etc.) that need the current rotating WiFi
|
||||||
|
* password without going through the dashboard UI.
|
||||||
|
*/
|
||||||
|
class WifiApiController extends Controller
|
||||||
|
{
|
||||||
|
public function currentPassword(Request $request)
|
||||||
|
{
|
||||||
|
if (! Setting::get('unifi.api.enabled')) {
|
||||||
|
return response()->json(['error' => 'API disabled'], 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
$expected = Setting::get('unifi.api_token');
|
||||||
|
if (! $expected) {
|
||||||
|
return response()->json(['error' => 'API token not configured'], 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
$provided = $request->bearerToken() ?: $request->query('token');
|
||||||
|
if (! $provided || ! hash_equals($expected, $provided)) {
|
||||||
|
return response()->json(['error' => 'Unauthorized'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$password = Setting::get('unifi.password_rotation.last_password');
|
||||||
|
if (! $password) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'No rotated password recorded yet — wait for the next scheduled rotation or run unifi:rotate-passwords --force.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'password' => $password,
|
||||||
|
'rotated_at' => Setting::get('unifi.password_rotation.last_rotated_at'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -149,8 +149,18 @@ class WifiController extends Controller
|
|||||||
$name = $networksById[$nconfId]['name'] ?? null;
|
$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()
|
$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)
|
?? UnifiPpsk::where('wlan_id', $wlanId)
|
||||||
->where('x_passphrase', $pass)
|
->where('x_passphrase', $pass)
|
||||||
->where('state', 'held')
|
->where('state', 'held')
|
||||||
@@ -174,8 +184,8 @@ class WifiController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only mark as held when we have confirmed live IDs —
|
// Mark non-matching active rows as held — but ONLY if there's no
|
||||||
// never wipe on an empty API response (prevents false-holds on API failures)
|
// other active row with the same name we just reconnected.
|
||||||
if (! empty($liveIds)) {
|
if (! empty($liveIds)) {
|
||||||
UnifiPpsk::where('wlan_id', $wlanId)
|
UnifiPpsk::where('wlan_id', $wlanId)
|
||||||
->where('state', 'active')
|
->where('state', 'active')
|
||||||
@@ -184,6 +194,47 @@ class WifiController extends Controller
|
|||||||
->update(['state' => 'held', 'unifi_id' => null]);
|
->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)
|
$dbRecords = UnifiPpsk::where('wlan_id', $wlanId)
|
||||||
->orderByRaw("FIELD(state, 'active', 'held')")
|
->orderByRaw("FIELD(state, 'active', 'held')")
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
@@ -293,9 +344,37 @@ class WifiController extends Controller
|
|||||||
if (! empty($unifiUpdate)) {
|
if (! empty($unifiUpdate)) {
|
||||||
if (str_starts_with($record->unifi_id, 'emb_') && isset($unifiUpdate['x_passphrase'])) {
|
if (str_starts_with($record->unifi_id, 'emb_') && isset($unifiUpdate['x_passphrase'])) {
|
||||||
// Embedded PPSK update path — modify the WLAN's embedded array.
|
// Embedded PPSK update path — modify the WLAN's embedded array.
|
||||||
$unifi->updateEmbeddedPpsk($record->wlan_id, $record->x_passphrase, $unifiUpdate['x_passphrase']);
|
// Match by name (reliable across drift).
|
||||||
// Synthetic id is derived from the new passphrase.
|
$newPass = $unifiUpdate['x_passphrase'];
|
||||||
$data['unifi_id'] = 'emb_' . substr(hash('sha256', $record->wlan_id . ':' . $unifiUpdate['x_passphrase']), 0, 32);
|
$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 {
|
} else {
|
||||||
$unifi->updatePpsk($record->unifi_id, $unifiUpdate);
|
$unifi->updatePpsk($record->unifi_id, $unifiUpdate);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ class PortalSession extends Model
|
|||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'user_id', 'mac_address', 'device_hostname', 'device_os', 'device_type',
|
'user_id', 'mac_address', 'device_hostname', 'device_os', 'device_type',
|
||||||
'ssid', 'vlan_id', 'ap_mac', 'is_active', 'authorized_at', 'expires_at',
|
'ssid', 'vlan_id', 'ap_mac', 'is_active', 'authorized_at', 'expires_at',
|
||||||
|
'is_randomized',
|
||||||
];
|
];
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
'authorized_at' => 'datetime',
|
'authorized_at' => 'datetime',
|
||||||
'expires_at' => 'datetime',
|
'expires_at' => 'datetime',
|
||||||
|
'is_randomized' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function user(): BelongsTo
|
public function user(): BelongsTo
|
||||||
@@ -23,6 +25,23 @@ class PortalSession extends Model
|
|||||||
return $this->belongsTo(\App\Models\User::class);
|
return $this->belongsTo(\App\Models\User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rows that consume a device slot.
|
||||||
|
*
|
||||||
|
* Presence on the network is deliberately NOT part of this. UniFi holds an
|
||||||
|
* authorization for its full window whether or not the device is in the
|
||||||
|
* building, so a phone that went home still owns its slot — freeing it here
|
||||||
|
* is what let one person hold two authorizations against a one-device quota.
|
||||||
|
*
|
||||||
|
* A NULL expires_at is not live: it predates this being enforced.
|
||||||
|
*/
|
||||||
|
public function scopeLive($query)
|
||||||
|
{
|
||||||
|
return $query->where('is_active', true)
|
||||||
|
->whereNotNull('expires_at')
|
||||||
|
->where('expires_at', '>', now());
|
||||||
|
}
|
||||||
|
|
||||||
public function setMacAddressAttribute($value)
|
public function setMacAddressAttribute($value)
|
||||||
{
|
{
|
||||||
$this->attributes['mac_address'] = strtolower($value);
|
$this->attributes['mac_address'] = strtolower($value);
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Dashboard\Unifi\Models;
|
|
||||||
|
|
||||||
use App\Models\NavItem;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
class UnifiPageGrant extends Model
|
|
||||||
{
|
|
||||||
protected $table = 'unifi_page_grants';
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'nav_item_id',
|
|
||||||
'grantee_type',
|
|
||||||
'grantee_id',
|
|
||||||
'granted_by_user_id',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function navItem(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(NavItem::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function grantedBy(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class, 'granted_by_user_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True iff $user is allowed to access $navItem under this grant model.
|
|
||||||
* Super-admins always pass.
|
|
||||||
* If there are NO grants for the page, falls back to "open" (anyone
|
|
||||||
* who can reach the route can access — same as before grants existed).
|
|
||||||
*/
|
|
||||||
public static function userCanAccess(User $user, NavItem $navItem): bool
|
|
||||||
{
|
|
||||||
if ($user->is_super_admin) return true;
|
|
||||||
|
|
||||||
$hasGrants = static::where('nav_item_id', $navItem->id)->exists();
|
|
||||||
if (! $hasGrants) return true;
|
|
||||||
|
|
||||||
$groupIds = $user->groups()->pluck('groups.id');
|
|
||||||
|
|
||||||
return static::where('nav_item_id', $navItem->id)
|
|
||||||
->where(function ($q) use ($user, $groupIds) {
|
|
||||||
$q->where(function ($u) use ($user) {
|
|
||||||
$u->where('grantee_type', 'user')->where('grantee_id', $user->id);
|
|
||||||
})->orWhere(function ($g) use ($groupIds) {
|
|
||||||
$g->where('grantee_type', 'group')->whereIn('grantee_id', $groupIds);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
->exists();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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);
|
return ! empty($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The client currently holding this IP, or null.
|
||||||
|
*
|
||||||
|
* Reads the cached client list rather than querying per IP: the portal calls
|
||||||
|
* this once per connection attempt, and a class arriving at once would
|
||||||
|
* otherwise be one controller round trip each.
|
||||||
|
*/
|
||||||
|
public function findClientByIp(string $ip): ?array
|
||||||
|
{
|
||||||
|
foreach ($this->getActiveClients() as $client) {
|
||||||
|
if (($client['ip'] ?? null) === $ip) {
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
public function kickClient(string $mac): array
|
public function kickClient(string $mac): array
|
||||||
{
|
{
|
||||||
Cache::forget('unifi:clients');
|
Cache::forget('unifi:clients');
|
||||||
@@ -286,6 +304,25 @@ class UnifiApiClient
|
|||||||
return $this->post('/cmd/stamgr', ['cmd' => 'unauthorize-guest', 'mac' => strtolower($mac)]);
|
return $this->post('/cmd/stamgr', ['cmd' => 'unauthorize-guest', 'mac' => strtolower($mac)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The MACs UniFi currently holds an active guest authorization for.
|
||||||
|
*
|
||||||
|
* Read from /stat/guest, which is the authorization list — deliberately not
|
||||||
|
* /stat/sta, which is who is associated right now. Those are the two facts
|
||||||
|
* this whole feature had been conflating.
|
||||||
|
*/
|
||||||
|
public function getAuthorizedGuestMacs(): array
|
||||||
|
{
|
||||||
|
$rows = $this->get('/stat/guest');
|
||||||
|
|
||||||
|
return collect($rows)
|
||||||
|
->filter(fn ($g) => (int) ($g['end'] ?? 0) > now()->timestamp)
|
||||||
|
->pluck('mac')
|
||||||
|
->map(fn ($m) => strtolower((string) $m))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
private function delete(string $path): void
|
private function delete(string $path): void
|
||||||
{
|
{
|
||||||
$this->init();
|
$this->init();
|
||||||
@@ -312,6 +349,122 @@ class UnifiApiClient
|
|||||||
return $this->put("/rest/wlanconf/{$wlanId}", $data);
|
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 ─────────────────────────────────────────────────────────────────
|
// ── PPSK ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -504,7 +657,7 @@ class UnifiApiClient
|
|||||||
* no controller-side ID. Only changes the entry's passphrase; name
|
* no controller-side ID. Only changes the entry's passphrase; name
|
||||||
* isn't separately addressable on embedded PPSKs.
|
* 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}");
|
$wlanResp = $this->get("/rest/wlanconf/{$wlanId}");
|
||||||
$wlan = $wlanResp[0] ?? $wlanResp;
|
$wlan = $wlanResp[0] ?? $wlanResp;
|
||||||
@@ -514,26 +667,66 @@ class UnifiApiClient
|
|||||||
throw new \RuntimeException('WLAN has no embedded PPSKs to update.');
|
throw new \RuntimeException('WLAN has no embedded PPSKs to update.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$matched = false;
|
// Embedded PPSKs on this controller don't carry a name field —
|
||||||
foreach ($entries as &$e) {
|
// the human label ("GUEST", "3DPrinters", …) is the *network's*
|
||||||
$current = $e['x_passphrase'] ?? $e['password'] ?? $e['passphrase'] ?? null;
|
// name, and each entry references it via networkconf_id. So when
|
||||||
if ($current === $oldPassphrase) {
|
// the caller passes a name, first resolve it to a networkconf_id
|
||||||
// Preserve whichever field name the controller is using.
|
// 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('x_passphrase', $e)) $e['x_passphrase'] = $newPassphrase;
|
||||||
if (array_key_exists('password', $e)) $e['password'] = $newPassphrase;
|
if (array_key_exists('password', $e)) $e['password'] = $newPassphrase;
|
||||||
if (array_key_exists('passphrase', $e)) $e['passphrase'] = $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'])) {
|
if (! isset($e['x_passphrase']) && ! isset($e['password']) && ! isset($e['passphrase'])) {
|
||||||
$e['password'] = $newPassphrase;
|
$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;
|
$matched = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
unset($e);
|
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) {
|
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
|
// 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();
|
$prev = DeviceState::where('device_mac', $mac)->first();
|
||||||
if (! $prev) continue;
|
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 (Cache::has('unifi:planned_reboot:' . strtolower($mac))) continue;
|
||||||
|
|
||||||
if ($comingOnline) {
|
if ($comingOnline) {
|
||||||
@@ -509,6 +513,8 @@ class WebhookCheckService
|
|||||||
private function checkReboot($aps, array $filter): array
|
private function checkReboot($aps, array $filter): array
|
||||||
{
|
{
|
||||||
$alerts = [];
|
$alerts = [];
|
||||||
|
if ($this->inGlobalRebootSuppression()) return $alerts;
|
||||||
|
|
||||||
foreach ($aps as $ap) {
|
foreach ($aps as $ap) {
|
||||||
if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue;
|
if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue;
|
||||||
if (Cache::has('unifi:planned_reboot:' . strtolower($ap['mac']))) continue;
|
if (Cache::has('unifi:planned_reboot:' . strtolower($ap['mac']))) continue;
|
||||||
@@ -580,6 +586,33 @@ class WebhookCheckService
|
|||||||
}
|
}
|
||||||
|
|
||||||
private function formatPayloadForPlatform(string $url, string $message, array $fullPayload): array
|
private function formatPayloadForPlatform(string $url, string $message, array $fullPayload): array
|
||||||
|
{
|
||||||
|
return self::buildPlatformPayload($url, $message, $fullPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is a fleet reboot in progress right now? RebootAllAps stamps a
|
||||||
|
* suppression-until timestamp as a Setting; while that timestamp
|
||||||
|
* is in the future, we skip all device-offline / device-online /
|
||||||
|
* unexpected-reboot alerts to avoid flooding webhooks during the
|
||||||
|
* known maintenance window.
|
||||||
|
*/
|
||||||
|
private function inGlobalRebootSuppression(): bool
|
||||||
|
{
|
||||||
|
$until = \App\Models\Setting::get('unifi.reboot_suppression_until');
|
||||||
|
if (! $until) return false;
|
||||||
|
try {
|
||||||
|
return \Carbon\Carbon::parse($until)->isFuture();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public/static helper so the test-webhook endpoint produces the
|
||||||
|
* same per-platform payload shape that real events do.
|
||||||
|
*/
|
||||||
|
public static function buildPlatformPayload(string $url, string $message, array $fullPayload): array
|
||||||
{
|
{
|
||||||
if (str_contains($url, 'chat.googleapis.com')) return ['text' => $message];
|
if (str_contains($url, 'chat.googleapis.com')) return ['text' => $message];
|
||||||
if (str_contains($url, 'hooks.slack.com')) return ['text' => $message];
|
if (str_contains($url, 'hooks.slack.com')) return ['text' => $message];
|
||||||
|
|||||||
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;
|
namespace Dashboard\Unifi;
|
||||||
|
|
||||||
use App\Models\DashboardApp;
|
|
||||||
use App\Models\NavItem;
|
use App\Models\NavItem;
|
||||||
use Dashboard\Unifi\Models\UnifiPageGrant;
|
|
||||||
use Illuminate\Routing\Events\RouteMatched;
|
|
||||||
use Illuminate\Support\Facades\Event;
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class UnifiServiceProvider extends ServiceProvider
|
class UnifiServiceProvider extends ServiceProvider
|
||||||
@@ -25,34 +21,45 @@ class UnifiServiceProvider extends ServiceProvider
|
|||||||
$this->loadRoutesFrom(__DIR__ . '/routes/unifi.php');
|
$this->loadRoutesFrom(__DIR__ . '/routes/unifi.php');
|
||||||
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
|
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
|
||||||
|
|
||||||
// Per-page access enforcement for unifi routes. If a unifi page has
|
// "/" home card: guest Wi-Fi network + current rotating password +
|
||||||
// any UnifiPageGrant rows, only super-admins and granted users/
|
// QR (re-renders when the password changes). Self-hides until enabled
|
||||||
// groups can hit it; otherwise (no grants) it's open per the existing
|
// and a password has been rotated.
|
||||||
// permission middleware. Super-admins always bypass.
|
//
|
||||||
Event::listen(RouteMatched::class, function (RouteMatched $event) {
|
// The ONE card in the platform that earned a NEW permission key rather
|
||||||
$routeName = $event->route->getName();
|
// than reusing one. It cannot be universal — the payload is a live
|
||||||
if (! $routeName || ! str_starts_with($routeName, 'unifi.')) return;
|
// credential, and publishing it to every student and parent is the exact
|
||||||
|
// hole GuestCardController::data was written to close. And no existing
|
||||||
$user = $event->request->user();
|
// unifi key fits: unifi.stats is the network dashboards, unifi.settings is
|
||||||
if (! $user || $user->is_super_admin) return;
|
// 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 {
|
try {
|
||||||
$appId = DashboardApp::where('slug', 'unifi')->value('id');
|
app(\App\Support\DashboardCardRegistry::class)->register(
|
||||||
$item = NavItem::where('route_name', $routeName)
|
'unifi.guest_wifi',
|
||||||
->where('app_id', $appId)
|
'Guest Wi-Fi',
|
||||||
->first();
|
'UnifiGuestWifi',
|
||||||
if (! $item) return;
|
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.',
|
||||||
if (! UnifiPageGrant::userCanAccess($user, $item)) {
|
permission: 'unifi.guest_wifi',
|
||||||
abort(403, 'You do not have access to this page.');
|
// 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) {
|
} catch (\Throwable) {
|
||||||
// unifi_page_grants table may not exist yet on a fresh
|
// Older shell without the card registry — skip.
|
||||||
// install before this snap-in's migrations have run —
|
|
||||||
// fail open in that narrow window.
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
|
// 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()) {
|
if ($this->app->runningInConsole()) {
|
||||||
$this->commands([
|
$this->commands([
|
||||||
Console\CheckWebhooks::class,
|
Console\CheckWebhooks::class,
|
||||||
@@ -61,10 +68,33 @@ class UnifiServiceProvider extends ServiceProvider
|
|||||||
Console\RebootAllAps::class,
|
Console\RebootAllAps::class,
|
||||||
Console\RotatePasswords::class,
|
Console\RotatePasswords::class,
|
||||||
Console\SyncPpskSchedules::class,
|
Console\SyncPpskSchedules::class,
|
||||||
|
Console\ReconcilePortal::class,
|
||||||
]);
|
]);
|
||||||
$this->publishes([
|
$this->publishes([
|
||||||
__DIR__ . '/../config/unifi.php' => config_path('unifi.php'),
|
__DIR__ . '/../config/unifi.php' => config_path('unifi.php'),
|
||||||
], 'unifi-config');
|
], '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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use Dashboard\Unifi\Http\Controllers\UnifiPagesAccessController;
|
|||||||
use Dashboard\Unifi\Http\Controllers\UnifiSettingsController;
|
use Dashboard\Unifi\Http\Controllers\UnifiSettingsController;
|
||||||
use Dashboard\Unifi\Http\Controllers\VlanGroupController;
|
use Dashboard\Unifi\Http\Controllers\VlanGroupController;
|
||||||
use Dashboard\Unifi\Http\Controllers\WebhookController;
|
use Dashboard\Unifi\Http\Controllers\WebhookController;
|
||||||
|
use Dashboard\Unifi\Http\Controllers\WifiApiController;
|
||||||
use Dashboard\Unifi\Http\Controllers\WifiController;
|
use Dashboard\Unifi\Http\Controllers\WifiController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
@@ -68,18 +69,12 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
|
|||||||
// ── Settings ─────────────────────────────────────────────────────────
|
// ── Settings ─────────────────────────────────────────────────────────
|
||||||
Route::middleware('permission:unifi.settings')->group(function () {
|
Route::middleware('permission:unifi.settings')->group(function () {
|
||||||
Route::get('/settings', [UnifiSettingsController::class, 'edit']) ->name('settings');
|
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', [UnifiSettingsController::class, 'update']) ->name('settings.update');
|
||||||
Route::post('/settings/test', [UnifiSettingsController::class, 'testConnection'])->name('settings.test');
|
Route::post('/settings/test', [UnifiSettingsController::class, 'testConnection'])->name('settings.test');
|
||||||
Route::post('/settings/sites', [UnifiSettingsController::class, 'fetchSites']) ->name('settings.sites');
|
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::put('/settings/pages-access/{navItem}', [UnifiPagesAccessController::class, 'update']) ->name('settings.pages-access.update');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cron logs — read-only history of scheduled-task runs.
|
// Cron logs — read-only history of scheduled-task runs.
|
||||||
Route::get('/settings/cron-logs', [UnifiCronLogsController::class, 'index'])->name('settings.cron-logs.index');
|
Route::get('/settings/cron-logs', [UnifiCronLogsController::class, 'index'])->name('settings.cron-logs.index');
|
||||||
|
|
||||||
@@ -90,10 +85,48 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
|
|||||||
Route::delete('/settings/webhooks/{webhook}', [WebhookController::class, 'destroy'])->name('webhooks.destroy');
|
Route::delete('/settings/webhooks/{webhook}', [WebhookController::class, 'destroy'])->name('webhooks.destroy');
|
||||||
Route::post('/settings/webhooks/{webhook}/test', [WebhookController::class, 'test']) ->name('webhooks.test');
|
Route::post('/settings/webhooks/{webhook}/test', [WebhookController::class, 'test']) ->name('webhooks.test');
|
||||||
Route::post('/settings/webhooks/test-url', [WebhookController::class, 'testUrl'])->name('webhooks.test-url');
|
Route::post('/settings/webhooks/test-url', [WebhookController::class, 'testUrl'])->name('webhooks.test-url');
|
||||||
|
|
||||||
|
// API-token management
|
||||||
|
Route::post('/settings/api-token/regenerate', [UnifiSettingsController::class, 'regenerateApiToken'])->name('settings.api-token.regenerate');
|
||||||
|
Route::delete('/settings/api-token', [UnifiSettingsController::class, 'clearApiToken']) ->name('settings.api-token.clear');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Guest Wi-Fi home-card poll. Authorises inside the controller against the
|
||||||
|
// card's own visibility (unifi.guest_wifi) — it returns a live credential, so
|
||||||
|
// `auth` alone is not a gate. Deliberately outside the permission groups above
|
||||||
|
// because it's a home-page widget, not a unifi page: unifi.guest_wifi is
|
||||||
|
// grantable to front-desk staff without any other network access. ──
|
||||||
|
Route::middleware(['web', 'auth'])
|
||||||
|
->get('/app/unifi/guest-card/data', [\Dashboard\Unifi\Http\Controllers\GuestCardController::class, 'data'])
|
||||||
|
->name('unifi.guest-card.data');
|
||||||
|
|
||||||
|
// ── Public API (token-protected) ──────────────────────────────────────────
|
||||||
|
// External integrations (signage, kiosks) hit these without session auth.
|
||||||
|
Route::prefix('api/unifi')->name('unifi.api.')->group(function () {
|
||||||
|
Route::get('/wifi/current-password', [WifiApiController::class, 'currentPassword'])
|
||||||
|
->name('wifi.current-password');
|
||||||
|
});
|
||||||
|
|
||||||
// ── Captive portal callback (public — user redirected here by UniFi) ─────
|
// ── Captive portal callback (public — user redirected here by UniFi) ─────
|
||||||
Route::middleware(['web', 'auth'])
|
Route::middleware(['web', 'auth'])
|
||||||
->get('/portal/wifi/callback', [PortalController::class, 'captiveCallback'])
|
->get('/portal/wifi/callback', [PortalController::class, 'captiveCallback'])
|
||||||
->name('unifi.portal.callback');
|
->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