diff --git a/composer.json b/composer.json index 983a9dd..550da13 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.5.4", + "version": "1.5.5", "type": "library", "license": "MIT", "autoload": { diff --git a/src/Console/RotatePasswords.php b/src/Console/RotatePasswords.php index f3f9c14..f552f37 100644 --- a/src/Console/RotatePasswords.php +++ b/src/Console/RotatePasswords.php @@ -76,8 +76,16 @@ class RotatePasswords extends Command foreach ($ppskQuery->get() as $ppsk) { $newPass = $passwords[array_rand($passwords)]; try { - $unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]); - $ppsk->update(['x_passphrase' => $newPass]); + 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); + $newUid = 'emb_' . substr(hash('sha256', $ppsk->wlan_id . ':' . $newPass), 0, 32); + $ppsk->update(['x_passphrase' => $newPass, 'unifi_id' => $newUid]); + } else { + $unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]); + $ppsk->update(['x_passphrase' => $newPass]); + } $rotatedPpsks[] = $ppsk->name; } catch (\Throwable $e) { $this->error("Failed to rotate PPSK \"{$ppsk->name}\": {$e->getMessage()}"); diff --git a/src/Http/Controllers/WifiController.php b/src/Http/Controllers/WifiController.php index 2ee005e..afa8946 100644 --- a/src/Http/Controllers/WifiController.php +++ b/src/Http/Controllers/WifiController.php @@ -291,11 +291,18 @@ class WifiController extends Controller fn ($v) => $v !== null ); if (! empty($unifiUpdate)) { - $unifi->updatePpsk($record->unifi_id, $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); + } else { + $unifi->updatePpsk($record->unifi_id, $unifiUpdate); + } } } - $dbUpdate = array_intersect_key($data, array_flip(['name', 'x_passphrase'])); + $dbUpdate = array_intersect_key($data, array_flip(['name', 'x_passphrase', 'unifi_id'])); // vlan can be explicitly set to null if (array_key_exists('vlan', $data)) $dbUpdate['vlan'] = $data['vlan']; if (! empty($dbUpdate)) $record->update($dbUpdate); diff --git a/src/Services/UnifiApiClient.php b/src/Services/UnifiApiClient.php index 766e42f..85c8f0b 100644 --- a/src/Services/UnifiApiClient.php +++ b/src/Services/UnifiApiClient.php @@ -496,6 +496,63 @@ class UnifiApiClient return $this->normalizePpsk(is_array($result) && isset($result[0]) ? $result : [$result]); } + /** + * Update an embedded PPSK (one that lives inside a WLAN's + * private_preshared_keys array rather than as its own REST resource). + * + * Matching is done by current passphrase since embedded entries have + * 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 + { + $wlanResp = $this->get("/rest/wlanconf/{$wlanId}"); + $wlan = $wlanResp[0] ?? $wlanResp; + $entries = $wlan['private_preshared_keys'] ?? []; + + if (! is_array($entries) || empty($entries)) { + 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; + } + } + unset($e); + + if (! $matched) { + throw new \RuntimeException('Embedded PPSK not found by current passphrase.'); + } + + // UniFi REST expects the full WLAN object on PUT — send what we + // got back, with the mutated PPSK array. + $payload = $wlan; + $payload['private_preshared_keys'] = $entries; + // Strip internal fields the controller rejects on PUT. + unset($payload['_id'], $payload['site_id']); + + $this->put("/rest/wlanconf/{$wlanId}", $payload); + + // Return a normalized record so callers can read the new state. + return $this->normalizePpsk([[ + '_id' => 'emb_' . substr(hash('sha256', $wlanId . ':' . $newPassphrase), 0, 32), + 'wlan_id' => $wlanId, + 'x_passphrase' => $newPassphrase, + ]]); + } + public function deletePpsk(string $ppskId): void { // Try v2 hotspot endpoint first