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'),