From 720e94c54a69da03e4a5f3f3a4a32ecba3a8489e Mon Sep 17 00:00:00 2001 From: jwed Date: Sun, 24 May 2026 20:38:10 -0400 Subject: [PATCH] fix(banded ssid): match embedded PPSK by name first, passphrase fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sibling-update path on prod failed with "Embedded PPSK not found by current passphrase" because the DB-stored x_passphrase on the unedited band was stale — earlier manual edits (pre-1.8.1) only touched one band, leaving the other band's row out of sync. When rotation then tried to use that stale passphrase to find the entry, no match. updateEmbeddedPpsk now takes an optional $name parameter and tries it first. PPSK names within a WLAN are unique, so name-matching survives any passphrase drift caused by historical out-of-band edits. Passphrase matching stays as a fallback for callers that don't have a name (none currently — both rotation and the manual modal pass it). v1.9.1. Co-Authored-By: Claude Opus 4.7 (1M context) --- composer.json | 2 +- src/Console/RotatePasswords.php | 14 +++--- src/Http/Controllers/WifiController.php | 7 ++- src/Services/UnifiApiClient.php | 58 +++++++++++++++++-------- 4 files changed, 51 insertions(+), 30 deletions(-) diff --git a/composer.json b/composer.json index c766499..e170ae0 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.9.0", + "version": "1.9.1", "type": "library", "license": "MIT", "autoload": { diff --git a/src/Console/RotatePasswords.php b/src/Console/RotatePasswords.php index 16ce305..8468b54 100644 --- a/src/Console/RotatePasswords.php +++ b/src/Console/RotatePasswords.php @@ -81,24 +81,22 @@ class RotatePasswords extends Command try { if (str_starts_with((string) $ppsk->unifi_id, 'emb_')) { // Embedded PPSK: update inside the parent WLAN object. - // Synthetic ID is derived from the new passphrase, so update it too. - $unifi->updateEmbeddedPpsk($ppsk->wlan_id, $ppsk->x_passphrase, $newPass); + // Match by name (most reliable) — falls back to + // passphrase if name is missing. + $unifi->updateEmbeddedPpsk($ppsk->wlan_id, $ppsk->x_passphrase, $newPass, $ppsk->name); $newUid = 'emb_' . substr(hash('sha256', $ppsk->wlan_id . ':' . $newPass), 0, 32); - $oldPass = $ppsk->x_passphrase; $ppsk->update(['x_passphrase' => $newPass, 'unifi_id' => $newUid]); // Sibling WLANs (same SSID name on a different band): - // their embedded PPSK with the same name also needs - // to rotate to the same new password so the SSID's - // 2.4/5GHz halves stay in sync. + // rotate the matching-name PPSK in each so the + // SSID's 2.4/5GHz halves stay in sync. foreach ($unifi->getWlanSiblings($ppsk->wlan_id) as $siblingWlanId) { $sibling = UnifiPpsk::where('wlan_id', $siblingWlanId) ->where('name', $ppsk->name) ->where('state', 'active') ->first(); - $siblingOldPass = $sibling?->x_passphrase ?? $oldPass; try { - $unifi->updateEmbeddedPpsk($siblingWlanId, $siblingOldPass, $newPass); + $unifi->updateEmbeddedPpsk($siblingWlanId, $sibling?->x_passphrase, $newPass, $ppsk->name); if ($sibling) { $sibling->update([ 'x_passphrase' => $newPass, diff --git a/src/Http/Controllers/WifiController.php b/src/Http/Controllers/WifiController.php index f4f6142..7ac9f66 100644 --- a/src/Http/Controllers/WifiController.php +++ b/src/Http/Controllers/WifiController.php @@ -293,9 +293,9 @@ class WifiController extends Controller if (! empty($unifiUpdate)) { if (str_starts_with($record->unifi_id, 'emb_') && isset($unifiUpdate['x_passphrase'])) { // Embedded PPSK update path — modify the WLAN's embedded array. + // Match by name (reliable across drift). $newPass = $unifiUpdate['x_passphrase']; - $oldPass = $record->x_passphrase; - $unifi->updateEmbeddedPpsk($record->wlan_id, $oldPass, $newPass); + $unifi->updateEmbeddedPpsk($record->wlan_id, $record->x_passphrase, $newPass, $record->name); $data['unifi_id'] = 'emb_' . substr(hash('sha256', $record->wlan_id . ':' . $newPass), 0, 32); // Also update sibling WLANs (banded SSID — same name @@ -305,9 +305,8 @@ class WifiController extends Controller ->where('name', $record->name) ->where('state', 'active') ->first(); - $siblingOldPass = $sibling?->x_passphrase ?? $oldPass; try { - $unifi->updateEmbeddedPpsk($siblingWlanId, $siblingOldPass, $newPass); + $unifi->updateEmbeddedPpsk($siblingWlanId, $sibling?->x_passphrase, $newPass, $record->name); if ($sibling) { $sibling->update([ 'x_passphrase' => $newPass, diff --git a/src/Services/UnifiApiClient.php b/src/Services/UnifiApiClient.php index 793a3e4..d9c7f7d 100644 --- a/src/Services/UnifiApiClient.php +++ b/src/Services/UnifiApiClient.php @@ -538,7 +538,7 @@ class UnifiApiClient * no controller-side ID. Only changes the entry's passphrase; name * isn't separately addressable on embedded PPSKs. */ - public function updateEmbeddedPpsk(string $wlanId, string $oldPassphrase, string $newPassphrase): array + public function updateEmbeddedPpsk(string $wlanId, ?string $oldPassphrase, string $newPassphrase, ?string $name = null): array { $wlanResp = $this->get("/rest/wlanconf/{$wlanId}"); $wlan = $wlanResp[0] ?? $wlanResp; @@ -548,26 +548,50 @@ class UnifiApiClient throw new \RuntimeException('WLAN has no embedded PPSKs to update.'); } - $matched = false; - foreach ($entries as &$e) { - $current = $e['x_passphrase'] ?? $e['password'] ?? $e['passphrase'] ?? null; - if ($current === $oldPassphrase) { - // Preserve whichever field name the controller is using. - if (array_key_exists('x_passphrase', $e)) $e['x_passphrase'] = $newPassphrase; - if (array_key_exists('password', $e)) $e['password'] = $newPassphrase; - if (array_key_exists('passphrase', $e)) $e['passphrase'] = $newPassphrase; - // If none of the above existed, default to password (most common on embedded). - if (! isset($e['x_passphrase']) && ! isset($e['password']) && ! isset($e['passphrase'])) { - $e['password'] = $newPassphrase; - } - $matched = true; - break; + // Match in this order — most reliable first: + // 1. by PPSK name (if provided) — survives passphrase drift + // caused by manual edits or previous out-of-sync rotations. + // 2. by current passphrase (legacy) + $applyUpdate = function (array &$e) use ($newPassphrase) { + if (array_key_exists('x_passphrase', $e)) $e['x_passphrase'] = $newPassphrase; + if (array_key_exists('password', $e)) $e['password'] = $newPassphrase; + if (array_key_exists('passphrase', $e)) $e['passphrase'] = $newPassphrase; + if (! isset($e['x_passphrase']) && ! isset($e['password']) && ! isset($e['passphrase'])) { + $e['password'] = $newPassphrase; } + }; + + $matched = false; + if ($name !== null && $name !== '') { + foreach ($entries as &$e) { + $entryName = $e['name'] ?? $e['label'] ?? $e['username'] ?? $e['privatePskName'] ?? null; + if ($entryName === $name) { + $applyUpdate($e); + $matched = true; + break; + } + } + unset($e); + } + + if (! $matched && $oldPassphrase !== null && $oldPassphrase !== '') { + foreach ($entries as &$e) { + $current = $e['x_passphrase'] ?? $e['password'] ?? $e['passphrase'] ?? null; + if ($current === $oldPassphrase) { + $applyUpdate($e); + $matched = true; + break; + } + } + unset($e); } - unset($e); if (! $matched) { - throw new \RuntimeException('Embedded PPSK not found by current passphrase.'); + throw new \RuntimeException( + 'Embedded PPSK not found' . + ($name !== null ? " by name \"{$name}\"" : '') . + ' or by current passphrase.' + ); } // UniFi REST expects the full WLAN object on PUT — send what we