diff --git a/composer.json b/composer.json index f01253b..b4693e2 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.2", + "version": "1.10.4", "type": "library", "license": "MIT", "autoload": { diff --git a/src/Console/RotatePasswords.php b/src/Console/RotatePasswords.php index 4f39516..e8fbabc 100644 --- a/src/Console/RotatePasswords.php +++ b/src/Console/RotatePasswords.php @@ -80,17 +80,17 @@ class RotatePasswords extends Command $newPass = $passwords[array_rand($passwords)]; try { if (str_starts_with((string) $ppsk->unifi_id, 'emb_')) { - // Embedded PPSK: update inside the parent WLAN object. - // Match by name (most reliable) — falls back to - // passphrase if name is missing. + // Embedded PPSK: update inside the parent WLAN object, + // matched by name (synthetic id changes with the + // passphrase, so it's not a stable matcher). $unifi->updateEmbeddedPpsk($ppsk->wlan_id, $ppsk->x_passphrase, $newPass, $ppsk->name); $newUid = 'emb_' . substr(hash('sha256', $ppsk->wlan_id . ':' . $newPass), 0, 32); $ppsk->update(['x_passphrase' => $newPass, 'unifi_id' => $newUid]); - // Sibling WLANs (same SSID name on a different band): - // 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) { + // Update every grouped sibling (user-defined SSID + // groups take precedence; same-name fallback for + // installs that haven't grouped manually). + foreach ($unifi->getGroupedWlans($ppsk->wlan_id) as $siblingWlanId) { $sibling = UnifiPpsk::where('wlan_id', $siblingWlanId) ->where('name', $ppsk->name) ->where('state', 'active') @@ -104,11 +104,6 @@ class RotatePasswords extends Command ]); } } catch (\Throwable $e) { - // "Not found" in a sibling just means the - // PPSK isn't mirrored on that band — totally - // normal if GUEST was only configured on one - // band. Skip quietly; don't poison the - // run status. if (str_contains($e->getMessage(), 'not found')) { \Illuminate\Support\Facades\Log::info('unifi.ppsk_sibling_skipped', [ 'sibling_wlan' => $siblingWlanId, @@ -120,6 +115,26 @@ class RotatePasswords extends Command $failedPpsks[] = ['name' => $ppsk->name . ' (sibling wlan ' . $siblingWlanId . ')', 'error' => $e->getMessage()]; } } + + // Verify that the new passphrase actually applied + // on every grouped WLAN. UniFi can 200 an update + // that doesn't stick (cluster sync race, etc). + // Anything we expected to rotate that didn't is a + // failure — surface it in the cron log. + $allWlanIds = array_merge([$ppsk->wlan_id], $unifi->getGroupedWlans($ppsk->wlan_id)); + foreach ($allWlanIds as $checkWlanId) { + $result = $unifi->verifyEmbeddedPpsk($checkWlanId, $ppsk->name, $newPass); + if ($result['ok']) continue; + + // 'not_found' on a sibling = PPSK isn't on that band — ignore + // (consistent with the skip in the update loop). + if ($result['reason'] === 'not_found' && $checkWlanId !== $ppsk->wlan_id) continue; + + $failedPpsks[] = [ + 'name' => $ppsk->name . ' (verify wlan ' . $checkWlanId . ')', + 'error' => 'verification ' . $result['reason'] . ($result['error'] ?? null ? ': ' . $result['error'] : ''), + ]; + } } else { $unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]); $ppsk->update(['x_passphrase' => $newPass]); diff --git a/src/Http/Controllers/WifiController.php b/src/Http/Controllers/WifiController.php index f326777..5dd1ff9 100644 --- a/src/Http/Controllers/WifiController.php +++ b/src/Http/Controllers/WifiController.php @@ -349,9 +349,9 @@ class WifiController extends Controller $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 - // on 2.4 and 5GHz are separate wlanconf rows). - foreach ($unifi->getWlanSiblings($record->wlan_id) as $siblingWlanId) { + // Also update grouped WLAN siblings (user-defined + // SSID groups, falling back to same-name). + foreach ($unifi->getGroupedWlans($record->wlan_id) as $siblingWlanId) { $sibling = UnifiPpsk::where('wlan_id', $siblingWlanId) ->where('name', $record->name) ->where('state', 'active') diff --git a/src/Services/UnifiApiClient.php b/src/Services/UnifiApiClient.php index d9c7f7d..fe965dc 100644 --- a/src/Services/UnifiApiClient.php +++ b/src/Services/UnifiApiClient.php @@ -312,6 +312,88 @@ class UnifiApiClient return $this->put("/rest/wlanconf/{$wlanId}", $data); } + /** + * Find every other WLAN that should rotate/update together with this + * one. Authoritative source: the user-defined "SSID groups" setting + * (unifi.ssid_groups) from the WiFi Networks page, which lets the + * operator manually couple WLANs that may have different SSID names. + * + * Falls back to same-SSID-name siblings for installs that haven't + * configured groups yet. + * + * Returns an array of sibling wlan IDs (excludes $wlanId itself). + */ + public function getGroupedWlans(string $wlanId): array + { + $groupsJson = Setting::get('unifi.ssid_groups', '{}'); + $groups = json_decode($groupsJson, true); + if (is_array($groups)) { + foreach ($groups as $wlanIds) { + if (! is_array($wlanIds)) continue; + if (in_array($wlanId, $wlanIds, true)) { + return array_values(array_filter($wlanIds, fn ($id) => $id !== $wlanId)); + } + } + } + return $this->getWlanSiblings($wlanId); + } + + /** + * Verify an embedded PPSK has the expected passphrase right now. + * Used after an update to confirm the change actually applied — + * UniFi sometimes 200s an update that didn't stick (cluster sync + * race, hot-restart in progress, etc.). + * + * Returns ['ok' => true] on a clean match, or + * ['ok' => false, 'reason' => 'fetch_failed'|'not_found'|'mismatch'] + * with optional 'error' on fetch failures. + */ + public function verifyEmbeddedPpsk(string $wlanId, string $name, string $expectedPassphrase): array + { + try { + $entries = $this->getPpskEntries($wlanId); + } catch (\Throwable $e) { + return ['ok' => false, 'reason' => 'fetch_failed', 'error' => $e->getMessage()]; + } + + $networkconfId = $this->findNetworkconfIdByName($name); + + foreach ($entries as $e) { + $entryName = $e['name'] ?? $e['label'] ?? $e['username'] ?? $e['privatePskName'] ?? null; + $entryNetId = $e['networkconf_id'] ?? null; + $entryMatches = ($networkconfId !== null && $entryNetId === $networkconfId) + || ($entryName !== null && $entryName === $name); + if (! $entryMatches) continue; + + $entryPass = $e['x_passphrase'] ?? $e['password'] ?? $e['passphrase'] ?? null; + return $entryPass === $expectedPassphrase + ? ['ok' => true] + : ['ok' => false, 'reason' => 'mismatch']; + } + return ['ok' => false, 'reason' => 'not_found']; + } + + /** + * Look up a networkconf (VLAN/network) by its display name. Embedded + * PPSKs on this controller use networkconf_id as their stable + * identifier — the human "name" the operator sees is actually the + * network's name. + */ + private function findNetworkconfIdByName(string $name): ?string + { + try { + $networks = $this->getNetworkConfs(); + } catch (\Throwable) { + return null; + } + foreach ($networks as $n) { + if (($n['name'] ?? null) === $name) { + return $n['_id'] ?? null; + } + } + return null; + } + /** * Find sibling WLAN configs — same SSID name, different _id. UniFi * splits a "banded" SSID (band-steering disabled) into one wlanconf @@ -548,10 +630,13 @@ class UnifiApiClient throw new \RuntimeException('WLAN has no embedded PPSKs to update.'); } - // 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) + // Embedded PPSKs on this controller don't carry a name field — + // the human label ("GUEST", "3DPrinters", …) is the *network's* + // name, and each entry references it via networkconf_id. So when + // the caller passes a name, first resolve it to a networkconf_id + // and match on that. Falls back to entry-level name (other + // controller versions DO put a name on the entry) and finally + // to current passphrase. $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; @@ -561,8 +646,21 @@ class UnifiApiClient } }; + $networkconfId = ($name !== null && $name !== '') ? $this->findNetworkconfIdByName($name) : null; + $matched = false; - if ($name !== null && $name !== '') { + if ($networkconfId !== null) { + foreach ($entries as &$e) { + if (($e['networkconf_id'] ?? null) === $networkconfId) { + $applyUpdate($e); + $matched = true; + break; + } + } + unset($e); + } + + if (! $matched && $name !== null && $name !== '') { foreach ($entries as &$e) { $entryName = $e['name'] ?? $e['label'] ?? $e['username'] ?? $e['privatePskName'] ?? null; if ($entryName === $name) { @@ -589,7 +687,7 @@ class UnifiApiClient if (! $matched) { throw new \RuntimeException( 'Embedded PPSK not found' . - ($name !== null ? " by name \"{$name}\"" : '') . + ($name !== null ? " for network \"{$name}\"" : '') . ' or by current passphrase.' ); }