Compare commits
8 Commits
dbebb6c964
...
33e465898c
| Author | SHA1 | Date | |
|---|---|---|---|
| 33e465898c | |||
| a8d7b34266 | |||
| 030fac3961 | |||
| b59b5bf6be | |||
| 4322d07b40 | |||
| ccd6fc49b2 | |||
| 655d8dd50c | |||
| a74818bdaf |
@@ -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.13.4",
|
"version": "1.13.5",
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"autoload": {
|
"autoload": {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
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.
|
||||||
@@ -137,6 +137,20 @@ 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
|
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.
|
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
|
### MAC randomization
|
||||||
|
|
||||||
Normally stable per SSID, so it does not break the design. When it does rotate,
|
Normally stable per SSID, so it does not break the design. When it does rotate,
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ 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',
|
||||||
@@ -51,7 +51,7 @@ 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',
|
||||||
@@ -133,30 +133,24 @@ 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,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// SECURITY: everything identifying the device is request-supplied, and this
|
$clientMac = $device->mac;
|
||||||
// endpoint WRITES network state (authorizeGuest puts a MAC on the mapped VLAN).
|
$isRandomized = $device->isRandomized;
|
||||||
// Before this check any signed-in account — a student, a parent — could call
|
|
||||||
// GET /portal/wifi/callback?mac=<someone else's MAC>&url=…
|
|
||||||
// and admit a device they do not own, up to max_devices.
|
|
||||||
//
|
|
||||||
// A well-formed MAC is the floor, not a fix: it stops the parameter being used
|
|
||||||
// to smuggle junk into authorizeGuest and into the PortalSession row, but it
|
|
||||||
// cannot prove the caller owns the device. Properly binding it needs the AP
|
|
||||||
// handshake — UniFi can sign the redirect, or we ask the controller whether this
|
|
||||||
// MAC is CURRENTLY associated to $apMac before admitting it. Both need testing
|
|
||||||
// against real hardware, so they are deliberately not guessed at here.
|
|
||||||
$clientMac = str_replace('-', ':', $clientMac);
|
|
||||||
if (! preg_match('/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/', $clientMac)) {
|
|
||||||
return response('Malformed MAC address', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the VLAN mapping for this user's group/OU
|
// Find the VLAN mapping for this user's group/OU
|
||||||
$mapping = $this->resolveMapping($user);
|
$mapping = $this->resolveMapping($user);
|
||||||
@@ -164,28 +158,30 @@ class PortalController extends Controller
|
|||||||
return Inertia::render('Unifi/PortalDenied', ['reason' => 'No WiFi access configured for your account.']);
|
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,
|
||||||
@@ -200,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'),
|
||||||
@@ -213,7 +208,9 @@ 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.
|
||||||
//
|
//
|
||||||
@@ -254,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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
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();
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,7 @@ 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'),
|
||||||
|
|||||||
Reference in New Issue
Block a user