From a8d7b34266a92f733bc6785ce1289d72e6064b64 Mon Sep 17 00:00:00 2001 From: jwed Date: Fri, 21 Aug 2026 13:31:26 -0400 Subject: [PATCH] Reconcile Wi-Fi sessions against UniFi authorizations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Necessary because session windows are now measured in months. Any drift between our rows and UniFi's authorizations used to be washed away by short windows and frequent portal visits; now it persists. Three jobs. Expire rows past expires_at, which was historically written and never read, so a finished row kept consuming one of the person's device slots forever. Reconcile both directions: revoke an authorization UniFi holds with no live row of ours — a device on the network nobody can see and nobody's quota is paying for, which is exactly what the old presence-based sweep produced — and re-assert a row we consider live that UniFi has forgotten, so a controller upgrade does not bounce somebody back to the portal for something that was not their doing. Third, record what UniFi thinks each device is. Reported only, nothing acts on it: UniFi infers dev_cat from observed traffic, so it does not exist at the moment of authorization and the reconciler is the first point at which there is anything to record. It only ever fills in — an unfingerprinted client must not blank a type already recorded, or the answer is lost every time the device happens to be quiet. A controller we cannot reach is not evidence that anything should change, so a failed read returns after the local expiry work rather than concluding that every authorization is an orphan. NOT scheduled. It calls unauthorizeGuest against a live controller, and nothing here has been tested against real hardware — scheduling belongs with the supervised verification session, not with the code. --- composer.json | 2 +- src/Console/ReconcilePortal.php | 110 ++++++++++++++++++++++++++++++++ src/Services/UnifiApiClient.php | 19 ++++++ src/UnifiServiceProvider.php | 1 + 4 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 src/Console/ReconcilePortal.php diff --git a/composer.json b/composer.json index ce2dd9e..40bff5c 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "dashboard/unifi", "description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform", - "version": "1.13.4", + "version": "1.13.5", "type": "library", "license": "MIT", "autoload": { diff --git a/src/Console/ReconcilePortal.php b/src/Console/ReconcilePortal.php new file mode 100644 index 0000000..eab1fe7 --- /dev/null +++ b/src/Console/ReconcilePortal.php @@ -0,0 +1,110 @@ +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); + } + } + } +} diff --git a/src/Services/UnifiApiClient.php b/src/Services/UnifiApiClient.php index 3953b4d..c64472d 100644 --- a/src/Services/UnifiApiClient.php +++ b/src/Services/UnifiApiClient.php @@ -304,6 +304,25 @@ class UnifiApiClient return $this->post('/cmd/stamgr', ['cmd' => 'unauthorize-guest', 'mac' => strtolower($mac)]); } + /** + * The MACs UniFi currently holds an active guest authorization for. + * + * Read from /stat/guest, which is the authorization list — deliberately not + * /stat/sta, which is who is associated right now. Those are the two facts + * this whole feature had been conflating. + */ + public function getAuthorizedGuestMacs(): array + { + $rows = $this->get('/stat/guest'); + + return collect($rows) + ->filter(fn ($g) => (int) ($g['end'] ?? 0) > now()->timestamp) + ->pluck('mac') + ->map(fn ($m) => strtolower((string) $m)) + ->values() + ->all(); + } + private function delete(string $path): void { $this->init(); diff --git a/src/UnifiServiceProvider.php b/src/UnifiServiceProvider.php index ef80f42..691b7e4 100644 --- a/src/UnifiServiceProvider.php +++ b/src/UnifiServiceProvider.php @@ -68,6 +68,7 @@ class UnifiServiceProvider extends ServiceProvider Console\RebootAllAps::class, Console\RotatePasswords::class, Console\SyncPpskSchedules::class, + Console\ReconcilePortal::class, ]); $this->publishes([ __DIR__ . '/../config/unifi.php' => config_path('unifi.php'),