fix(ppsk): embedded PPSKs update via WLAN config, not /rest/ppsk
Embedded PPSKs live inside the parent WLAN's private_preshared_keys
array — they have no controller-side _id and the synthetic emb_<hash>
we generate locally isn't a real REST id. Hitting /rest/ppsk/emb_xxx
returns HTTP 400/503, which is what the GUEST PPSK rotation was
failing on at the scheduled 3pm run.
* New UnifiApiClient::updateEmbeddedPpsk($wlanId, $oldPass, $newPass):
GETs /rest/wlanconf/{wlanId}, finds the matching entry in
private_preshared_keys by current passphrase, swaps the value while
preserving whichever field name the controller uses (x_passphrase /
password / passphrase), and PUTs the whole WLAN object back.
* RotatePasswords detects emb_-prefixed unifi_ids and routes through
the embedded path. The synthetic id is rederived from the new
passphrase so the DB row stays addressable.
* WifiController::ppskUpdate (manual modal save) does the same — this
is why manual edits sometimes appeared to succeed but the controller
side actually rejected them.
Verified live against the GUEST PPSK on 10.81.0.1.
v1.5.5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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()}");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user