From bb74edf4c10efd0c5591923d992ebc0ba01464fe Mon Sep 17 00:00:00 2001 From: jwed Date: Sun, 24 May 2026 20:49:26 -0400 Subject: [PATCH] fix(ppsk sync): match by name + salvage settings, prune dup tombstones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every rotation changes an embedded PPSK's synthetic id (it's derived from sha256(wlan_id : passphrase)). The ingest sync matched only by unifi_id, so after rotation the row's id was "new" — the sync created a fresh active row and marked the previous one held. Over multiple rotations this accumulated: each rotation left a held tombstone, and the rotate_password / schedule flags were stuck on the original tombstone instead of transferring to the new active row. Dev's GUEST PPSK had 3 rows after a few rotations: two held (with rotate_password=true on the first), one active with rotate=false. Future rotations would silently skip that PPSK because the active row no longer had the rotate flag set. Fix in three layers, all in WifiController::ppskIndex: 1. Match priority extended: unifi_id → name within wlan → held by passphrase. The name match means a passphrase change just updates the existing row in place. No more new-row creation per rotation. 2. Salvage step before pruning: for each active row, scan held tombstones with the same name and copy over rotate_password and schedule. Operator's rotation opt-in survives history. 3. Prune step: held rows with the same name as an active row in the same wlan are now hard-deleted (their settings were just salvaged, their data is stale). Keeps the WiFi modal clean instead of accumulating phantoms. v1.10.2. Co-Authored-By: Claude Opus 4.7 (1M context) --- composer.json | 2 +- src/Http/Controllers/WifiController.php | 57 +++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index e3f26d7..f01253b 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.10.1", + "version": "1.10.2", "type": "library", "license": "MIT", "autoload": { diff --git a/src/Http/Controllers/WifiController.php b/src/Http/Controllers/WifiController.php index fdd0beb..f326777 100644 --- a/src/Http/Controllers/WifiController.php +++ b/src/Http/Controllers/WifiController.php @@ -149,8 +149,18 @@ class WifiController extends Controller $name = $networksById[$nconfId]['name'] ?? null; } - // Match by unifi_id, or by passphrase for a held embedded record re-appearing + // Match in priority order: + // 1. by current unifi_id (already-synced row) + // 2. by name within this wlan (catches rotation: passphrase + // changed → synthetic id changed → row identity unchanged) + // 3. by passphrase among held rows (legacy fallback for + // cases where name wasn't ingested) $record = UnifiPpsk::where('unifi_id', $uid)->first() + ?? ($name + ? UnifiPpsk::where('wlan_id', $wlanId)->where('name', $name) + ->orderByRaw("FIELD(state, 'active', 'held')") + ->first() + : null) ?? UnifiPpsk::where('wlan_id', $wlanId) ->where('x_passphrase', $pass) ->where('state', 'held') @@ -174,8 +184,8 @@ class WifiController extends Controller } } - // Only mark as held when we have confirmed live IDs — - // never wipe on an empty API response (prevents false-holds on API failures) + // Mark non-matching active rows as held — but ONLY if there's no + // other active row with the same name we just reconnected. if (! empty($liveIds)) { UnifiPpsk::where('wlan_id', $wlanId) ->where('state', 'active') @@ -184,6 +194,47 @@ class WifiController extends Controller ->update(['state' => 'held', 'unifi_id' => null]); } + // For each active row, salvage any rotate_password / schedule + // settings from the held tombstones with the same name BEFORE + // we prune them. Otherwise a row that had rotate=on loses the + // flag every time a rotation changes its synthetic id. + $activeRows = UnifiPpsk::where('wlan_id', $wlanId) + ->where('state', 'active') + ->whereNotNull('name') + ->get(); + foreach ($activeRows as $active) { + $heldWithSettings = UnifiPpsk::where('wlan_id', $wlanId) + ->where('state', 'held') + ->where('name', $active->name) + ->where(fn ($q) => $q + ->where('rotate_password', true) + ->orWhereNotNull('schedule')) + ->orderByDesc('updated_at') + ->first(); + if (! $heldWithSettings) continue; + + $patch = []; + if ($heldWithSettings->rotate_password && ! $active->rotate_password) { + $patch['rotate_password'] = true; + } + if ($heldWithSettings->schedule && ! $active->schedule) { + $patch['schedule'] = $heldWithSettings->schedule; + } + if ($patch) $active->update($patch); + } + + // Prune obsolete held rows: any held row whose name matches an + // active row in the same wlan is a stale tombstone — its + // settings have been salvaged above, and its data has been + // superseded by the active one. + $activeNames = $activeRows->pluck('name')->filter()->unique(); + if ($activeNames->isNotEmpty()) { + UnifiPpsk::where('wlan_id', $wlanId) + ->where('state', 'held') + ->whereIn('name', $activeNames) + ->delete(); + } + $dbRecords = UnifiPpsk::where('wlan_id', $wlanId) ->orderByRaw("FIELD(state, 'active', 'held')") ->orderBy('name')