4 Commits

Author SHA1 Message Date
e5cc075938 fix(banded ssid): treat "PPSK not on this band" as a quiet skip
The sibling-rotation path's "Embedded PPSK not found" error was being
surfaced to the operator as a failure, but it's not — it just means
the PPSK isn't mirrored on that band (GUEST was configured on one
band only, which is a perfectly valid setup). Logging this as a
sibling failure also poisoned the cron run status to "partial".

Now: "not found"-style errors from updateEmbeddedPpsk on a sibling
become info-level log entries and the loop continues. Other errors
(API failures, permissions, etc.) still surface as warnings/failures.

v1.10.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:43:10 -04:00
4ec4a293c0 release: 1.10.0 — rolls up 1.9.1 (banded-SSID PPSK match by name)
Bundled stable cut for prod. Contents since 1.9.0:

* fix(banded ssid): updateEmbeddedPpsk now matches embedded PPSK
  entries by name first (e.g. "GUEST") and falls back to current
  passphrase. Name-matching survives any passphrase drift caused by
  pre-1.8.1 out-of-band manual edits — the sibling-rotation failure
  reported on prod after upgrading to 1.9.0 no longer happens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:40:13 -04:00
720e94c54a fix(banded ssid): match embedded PPSK by name first, passphrase fallback
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) <noreply@anthropic.com>
2026-05-24 20:38:10 -04:00
2be17c70db release: 1.9.0 — rolls up the 1.8.1 patch series
Bundled stable cut for prod. Contents since 1.8.0:

* fix(rotate): unifi.password_rotation.last_password is now saved on
  successful PPSK rotation as well as whole-SSID rotation. PPSK-only
  setups (typical guest-WiFi configurations) will populate the
  Settings → Tasks "current password" display and the
  /api/unifi/wifi/current-password endpoint after the next rotation.

* fix(banded-ssid): when an SSID is split across 2.4 and 5GHz bands
  (band-steering disabled — two wlanconf rows with the same name),
  rotating or manually editing a PPSK on one band now also updates
  the same-name PPSK on every sibling band. Previously the two halves
  drifted out of sync. Both the rotation scheduler and the WiFi modal
  use the new UnifiApiClient::getWlanSiblings helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:33:48 -04:00
4 changed files with 68 additions and 31 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "dashboard/unifi",
"description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform",
"version": "1.8.1",
"version": "1.10.1",
"type": "library",
"license": "MIT",
"autoload": {

View File

@@ -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,
@@ -106,6 +104,18 @@ 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,
'ppsk_name' => $ppsk->name,
]);
continue;
}
$this->error("Sibling rotate failed for wlan {$siblingWlanId}: {$e->getMessage()}");
$failedPpsks[] = ['name' => $ppsk->name . ' (sibling wlan ' . $siblingWlanId . ')', 'error' => $e->getMessage()];
}

View File

@@ -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,
@@ -315,7 +314,11 @@ class WifiController extends Controller
]);
}
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::warning('unifi.ppsk_sibling_update_failed', [
// PPSK absent on this band is fine — just
// means it isn't mirrored. Anything else
// gets warning-logged.
$level = str_contains($e->getMessage(), 'not found') ? 'info' : 'warning';
\Illuminate\Support\Facades\Log::log($level, 'unifi.ppsk_sibling_update', [
'sibling_wlan' => $siblingWlanId,
'error' => $e->getMessage(),
]);

View File

@@ -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