diff --git a/composer.json b/composer.json index cc047da..8efed61 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.8.0", + "version": "1.8.1", "type": "library", "license": "MIT", "autoload": { diff --git a/src/Console/RotatePasswords.php b/src/Console/RotatePasswords.php index f4a1425..16ce305 100644 --- a/src/Console/RotatePasswords.php +++ b/src/Console/RotatePasswords.php @@ -84,12 +84,44 @@ class RotatePasswords extends Command // Synthetic ID is derived from the new passphrase, so update it too. $unifi->updateEmbeddedPpsk($ppsk->wlan_id, $ppsk->x_passphrase, $newPass); $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. + 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); + if ($sibling) { + $sibling->update([ + 'x_passphrase' => $newPass, + 'unifi_id' => 'emb_' . substr(hash('sha256', $siblingWlanId . ':' . $newPass), 0, 32), + ]); + } + } catch (\Throwable $e) { + $this->error("Sibling rotate failed for wlan {$siblingWlanId}: {$e->getMessage()}"); + $failedPpsks[] = ['name' => $ppsk->name . ' (sibling wlan ' . $siblingWlanId . ')', 'error' => $e->getMessage()]; + } + } } else { $unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]); $ppsk->update(['x_passphrase' => $newPass]); } $rotatedPpsks[] = $ppsk->name; + + // Save the active password every time a rotation + // succeeds — covers PPSK-only rotation setups where + // there's no whole-SSID rotation. Last successful + // password wins if multiple PPSKs rotate in one run. + Setting::set('unifi.password_rotation.last_password', $newPass); + Setting::set('unifi.password_rotation.last_rotated_at', now()->toIso8601String()); } catch (\Throwable $e) { $this->error("Failed to rotate PPSK \"{$ppsk->name}\": {$e->getMessage()}"); $failedPpsks[] = ['name' => $ppsk->name, 'error' => $e->getMessage()]; diff --git a/src/Http/Controllers/WifiController.php b/src/Http/Controllers/WifiController.php index afa8946..f4f6142 100644 --- a/src/Http/Controllers/WifiController.php +++ b/src/Http/Controllers/WifiController.php @@ -293,9 +293,34 @@ 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. - $unifi->updateEmbeddedPpsk($record->wlan_id, $record->x_passphrase, $unifiUpdate['x_passphrase']); - // Synthetic id is derived from the new passphrase. - $data['unifi_id'] = 'emb_' . substr(hash('sha256', $record->wlan_id . ':' . $unifiUpdate['x_passphrase']), 0, 32); + $newPass = $unifiUpdate['x_passphrase']; + $oldPass = $record->x_passphrase; + $unifi->updateEmbeddedPpsk($record->wlan_id, $oldPass, $newPass); + $data['unifi_id'] = 'emb_' . substr(hash('sha256', $record->wlan_id . ':' . $newPass), 0, 32); + + // Also update sibling WLANs (banded SSID — same name + // on 2.4 and 5GHz are separate wlanconf rows). + foreach ($unifi->getWlanSiblings($record->wlan_id) as $siblingWlanId) { + $sibling = UnifiPpsk::where('wlan_id', $siblingWlanId) + ->where('name', $record->name) + ->where('state', 'active') + ->first(); + $siblingOldPass = $sibling?->x_passphrase ?? $oldPass; + try { + $unifi->updateEmbeddedPpsk($siblingWlanId, $siblingOldPass, $newPass); + if ($sibling) { + $sibling->update([ + 'x_passphrase' => $newPass, + 'unifi_id' => 'emb_' . substr(hash('sha256', $siblingWlanId . ':' . $newPass), 0, 32), + ]); + } + } catch (\Throwable $e) { + \Illuminate\Support\Facades\Log::warning('unifi.ppsk_sibling_update_failed', [ + 'sibling_wlan' => $siblingWlanId, + 'error' => $e->getMessage(), + ]); + } + } } else { $unifi->updatePpsk($record->unifi_id, $unifiUpdate); } diff --git a/src/Services/UnifiApiClient.php b/src/Services/UnifiApiClient.php index 85c8f0b..793a3e4 100644 --- a/src/Services/UnifiApiClient.php +++ b/src/Services/UnifiApiClient.php @@ -312,6 +312,40 @@ class UnifiApiClient return $this->put("/rest/wlanconf/{$wlanId}", $data); } + /** + * Find sibling WLAN configs — same SSID name, different _id. UniFi + * splits a "banded" SSID (band-steering disabled) into one wlanconf + * per band, each with its own _id and its own embedded PPSK array. + * A rotation that updates one band must also update the others, or + * the SSID's two halves drift out of sync. + * + * Returns an array of sibling wlan IDs (excludes $wlanId itself). + * Empty array if the target WLAN is unique or can't be found. + */ + public function getWlanSiblings(string $wlanId): array + { + try { + $all = $this->get('/rest/wlanconf'); + } catch (\Throwable) { + return []; + } + + $target = null; + foreach ($all as $w) { + if (($w['_id'] ?? null) === $wlanId) { $target = $w; break; } + } + if (! $target || empty($target['name'])) return []; + + $siblings = []; + foreach ($all as $w) { + if (($w['_id'] ?? null) === $wlanId) continue; + if (($w['name'] ?? null) === $target['name']) { + $siblings[] = $w['_id']; + } + } + return $siblings; + } + // ── PPSK ───────────────────────────────────────────────────────────────── /**