Reconcile Wi-Fi sessions against UniFi authorizations

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.
This commit is contained in:
2026-08-21 13:31:26 -04:00
parent 030fac3961
commit a8d7b34266
4 changed files with 131 additions and 1 deletions

View File

@@ -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": {

View 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);
}
}
}
}

View File

@@ -304,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();

View File

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