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:
@@ -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": {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user