7 Commits

Author SHA1 Message Date
dd4e0ca564 release: 1.11.0 — rolls up the 1.10.1/1.10.2/1.10.3/1.10.4 patches
Bundled stable cut for prod. Contents since 1.10.0:

* fix(banded ssid): treat "PPSK not on this band" as a quiet
  info-level skip rather than a failure (1.10.1).

* fix(ppsk sync): the WiFi modal's ingest sync now matches by NAME
  within a wlan before falling back to held-by-passphrase, and
  salvages rotate_password / schedule from held tombstones into the
  active row before pruning them. Prevents the modal from
  accumulating phantom "held" duplicates after every rotation and
  keeps the rotate flag on the row that's actually live (1.10.2).

* feat(grouped wifi): PPSK updates (both rotation and the manual
  modal edit) now follow user-defined SSID groups from the WiFi
  Networks page first, falling back to same-SSID-name detection.
  Lets the operator pair WLANs whose SSIDs have different names
  (e.g. "VCS Guest" and "VCS Guest 5G") (1.10.3).

* fix(name resolution): on this controller, embedded PPSKs don't
  carry a name field — the human "GUEST" label is the *network's*
  name and entries reference it via networkconf_id. updateEmbeddedPpsk
  and verifyEmbeddedPpsk now resolve name → networkconf_id and match
  on that, with entry-name and current-passphrase as fallbacks for
  other controller variants (1.10.4).

* feat(verify): after every rotation, each affected WLAN is
  re-fetched and the new passphrase is checked at the named network.
  Anything that didn't actually propagate (mismatch, fetch failure)
  shows up as a failed PPSK in the cron run details (1.10.4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 21:01:22 -04:00
f533208b37 feat(grouped wifi): route updates through user-defined SSID groups + verify
User-defined SSID groups (configured on the WiFi Networks page and
stored in unifi.ssid_groups) now drive PPSK sibling propagation. The
previous same-SSID-name detection missed cases where two grouped
WLANs have *different* names — e.g. "VCS Guest" on 2.4 and "VCS
Guest 5G" on 5GHz manually grouped by the operator. Falls back to
same-name siblings when no group is configured.

Match-by-name fix: embedded PPSKs on this controller don't carry a
name field — the human "GUEST" label is the *network's* name, with
the entry referenced via networkconf_id. updateEmbeddedPpsk and
verifyEmbeddedPpsk now resolve name → networkconf_id first and match
on that, with entry-name and current-passphrase as fallbacks for
other controller variants.

After every rotation we re-fetch each affected WLAN and verify the
new passphrase is actually present on the named network. Failures
("mismatch" or "fetch_failed" on the primary, anything other than
"not_found" on a sibling) surface in the cron run details as failed
PPSKs so the operator sees what didn't propagate.

v1.10.4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:58:10 -04:00
bb74edf4c1 fix(ppsk sync): match by name + salvage settings, prune dup tombstones
Every rotation changes an embedded PPSK's synthetic id (it's derived
from sha256(wlan_id : passphrase)). The ingest sync matched only by
unifi_id, so after rotation the row's id was "new" — the sync created
a fresh active row and marked the previous one held. Over multiple
rotations this accumulated: each rotation left a held tombstone, and
the rotate_password / schedule flags were stuck on the original
tombstone instead of transferring to the new active row.

Dev's GUEST PPSK had 3 rows after a few rotations: two held (with
rotate_password=true on the first), one active with rotate=false.
Future rotations would silently skip that PPSK because the active row
no longer had the rotate flag set.

Fix in three layers, all in WifiController::ppskIndex:

1. Match priority extended: unifi_id → name within wlan → held by
   passphrase. The name match means a passphrase change just updates
   the existing row in place. No more new-row creation per rotation.

2. Salvage step before pruning: for each active row, scan held
   tombstones with the same name and copy over rotate_password and
   schedule. Operator's rotation opt-in survives history.

3. Prune step: held rows with the same name as an active row in the
   same wlan are now hard-deleted (their settings were just salvaged,
   their data is stale). Keeps the WiFi modal clean instead of
   accumulating phantoms.

v1.10.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:49:26 -04:00
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 241 additions and 40 deletions

View File

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

View File

@@ -80,25 +80,23 @@ class RotatePasswords extends Command
$newPass = $passwords[array_rand($passwords)]; $newPass = $passwords[array_rand($passwords)];
try { try {
if (str_starts_with((string) $ppsk->unifi_id, 'emb_')) { if (str_starts_with((string) $ppsk->unifi_id, 'emb_')) {
// Embedded PPSK: update inside the parent WLAN object. // Embedded PPSK: update inside the parent WLAN object,
// Synthetic ID is derived from the new passphrase, so update it too. // matched by name (synthetic id changes with the
$unifi->updateEmbeddedPpsk($ppsk->wlan_id, $ppsk->x_passphrase, $newPass); // 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); $newUid = 'emb_' . substr(hash('sha256', $ppsk->wlan_id . ':' . $newPass), 0, 32);
$oldPass = $ppsk->x_passphrase;
$ppsk->update(['x_passphrase' => $newPass, 'unifi_id' => $newUid]); $ppsk->update(['x_passphrase' => $newPass, 'unifi_id' => $newUid]);
// Sibling WLANs (same SSID name on a different band): // Update every grouped sibling (user-defined SSID
// their embedded PPSK with the same name also needs // groups take precedence; same-name fallback for
// to rotate to the same new password so the SSID's // installs that haven't grouped manually).
// 2.4/5GHz halves stay in sync. foreach ($unifi->getGroupedWlans($ppsk->wlan_id) as $siblingWlanId) {
foreach ($unifi->getWlanSiblings($ppsk->wlan_id) as $siblingWlanId) {
$sibling = UnifiPpsk::where('wlan_id', $siblingWlanId) $sibling = UnifiPpsk::where('wlan_id', $siblingWlanId)
->where('name', $ppsk->name) ->where('name', $ppsk->name)
->where('state', 'active') ->where('state', 'active')
->first(); ->first();
$siblingOldPass = $sibling?->x_passphrase ?? $oldPass;
try { try {
$unifi->updateEmbeddedPpsk($siblingWlanId, $siblingOldPass, $newPass); $unifi->updateEmbeddedPpsk($siblingWlanId, $sibling?->x_passphrase, $newPass, $ppsk->name);
if ($sibling) { if ($sibling) {
$sibling->update([ $sibling->update([
'x_passphrase' => $newPass, 'x_passphrase' => $newPass,
@@ -106,10 +104,37 @@ class RotatePasswords extends Command
]); ]);
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
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()}"); $this->error("Sibling rotate failed for wlan {$siblingWlanId}: {$e->getMessage()}");
$failedPpsks[] = ['name' => $ppsk->name . ' (sibling wlan ' . $siblingWlanId . ')', 'error' => $e->getMessage()]; $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 { } else {
$unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]); $unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]);
$ppsk->update(['x_passphrase' => $newPass]); $ppsk->update(['x_passphrase' => $newPass]);

View File

@@ -149,8 +149,18 @@ class WifiController extends Controller
$name = $networksById[$nconfId]['name'] ?? null; $name = $networksById[$nconfId]['name'] ?? null;
} }
// Match by unifi_id, or by passphrase for a held embedded record re-appearing // Match in priority order:
// 1. by current unifi_id (already-synced row)
// 2. by name within this wlan (catches rotation: passphrase
// changed → synthetic id changed → row identity unchanged)
// 3. by passphrase among held rows (legacy fallback for
// cases where name wasn't ingested)
$record = UnifiPpsk::where('unifi_id', $uid)->first() $record = UnifiPpsk::where('unifi_id', $uid)->first()
?? ($name
? UnifiPpsk::where('wlan_id', $wlanId)->where('name', $name)
->orderByRaw("FIELD(state, 'active', 'held')")
->first()
: null)
?? UnifiPpsk::where('wlan_id', $wlanId) ?? UnifiPpsk::where('wlan_id', $wlanId)
->where('x_passphrase', $pass) ->where('x_passphrase', $pass)
->where('state', 'held') ->where('state', 'held')
@@ -174,8 +184,8 @@ class WifiController extends Controller
} }
} }
// Only mark as held when we have confirmed live IDs — // Mark non-matching active rows as held — but ONLY if there's no
// never wipe on an empty API response (prevents false-holds on API failures) // other active row with the same name we just reconnected.
if (! empty($liveIds)) { if (! empty($liveIds)) {
UnifiPpsk::where('wlan_id', $wlanId) UnifiPpsk::where('wlan_id', $wlanId)
->where('state', 'active') ->where('state', 'active')
@@ -184,6 +194,47 @@ class WifiController extends Controller
->update(['state' => 'held', 'unifi_id' => null]); ->update(['state' => 'held', 'unifi_id' => null]);
} }
// For each active row, salvage any rotate_password / schedule
// settings from the held tombstones with the same name BEFORE
// we prune them. Otherwise a row that had rotate=on loses the
// flag every time a rotation changes its synthetic id.
$activeRows = UnifiPpsk::where('wlan_id', $wlanId)
->where('state', 'active')
->whereNotNull('name')
->get();
foreach ($activeRows as $active) {
$heldWithSettings = UnifiPpsk::where('wlan_id', $wlanId)
->where('state', 'held')
->where('name', $active->name)
->where(fn ($q) => $q
->where('rotate_password', true)
->orWhereNotNull('schedule'))
->orderByDesc('updated_at')
->first();
if (! $heldWithSettings) continue;
$patch = [];
if ($heldWithSettings->rotate_password && ! $active->rotate_password) {
$patch['rotate_password'] = true;
}
if ($heldWithSettings->schedule && ! $active->schedule) {
$patch['schedule'] = $heldWithSettings->schedule;
}
if ($patch) $active->update($patch);
}
// Prune obsolete held rows: any held row whose name matches an
// active row in the same wlan is a stale tombstone — its
// settings have been salvaged above, and its data has been
// superseded by the active one.
$activeNames = $activeRows->pluck('name')->filter()->unique();
if ($activeNames->isNotEmpty()) {
UnifiPpsk::where('wlan_id', $wlanId)
->where('state', 'held')
->whereIn('name', $activeNames)
->delete();
}
$dbRecords = UnifiPpsk::where('wlan_id', $wlanId) $dbRecords = UnifiPpsk::where('wlan_id', $wlanId)
->orderByRaw("FIELD(state, 'active', 'held')") ->orderByRaw("FIELD(state, 'active', 'held')")
->orderBy('name') ->orderBy('name')
@@ -293,21 +344,20 @@ class WifiController extends Controller
if (! empty($unifiUpdate)) { if (! empty($unifiUpdate)) {
if (str_starts_with($record->unifi_id, 'emb_') && isset($unifiUpdate['x_passphrase'])) { if (str_starts_with($record->unifi_id, 'emb_') && isset($unifiUpdate['x_passphrase'])) {
// Embedded PPSK update path — modify the WLAN's embedded array. // Embedded PPSK update path — modify the WLAN's embedded array.
// Match by name (reliable across drift).
$newPass = $unifiUpdate['x_passphrase']; $newPass = $unifiUpdate['x_passphrase'];
$oldPass = $record->x_passphrase; $unifi->updateEmbeddedPpsk($record->wlan_id, $record->x_passphrase, $newPass, $record->name);
$unifi->updateEmbeddedPpsk($record->wlan_id, $oldPass, $newPass);
$data['unifi_id'] = 'emb_' . substr(hash('sha256', $record->wlan_id . ':' . $newPass), 0, 32); $data['unifi_id'] = 'emb_' . substr(hash('sha256', $record->wlan_id . ':' . $newPass), 0, 32);
// Also update sibling WLANs (banded SSID — same name // Also update grouped WLAN siblings (user-defined
// on 2.4 and 5GHz are separate wlanconf rows). // SSID groups, falling back to same-name).
foreach ($unifi->getWlanSiblings($record->wlan_id) as $siblingWlanId) { foreach ($unifi->getGroupedWlans($record->wlan_id) as $siblingWlanId) {
$sibling = UnifiPpsk::where('wlan_id', $siblingWlanId) $sibling = UnifiPpsk::where('wlan_id', $siblingWlanId)
->where('name', $record->name) ->where('name', $record->name)
->where('state', 'active') ->where('state', 'active')
->first(); ->first();
$siblingOldPass = $sibling?->x_passphrase ?? $oldPass;
try { try {
$unifi->updateEmbeddedPpsk($siblingWlanId, $siblingOldPass, $newPass); $unifi->updateEmbeddedPpsk($siblingWlanId, $sibling?->x_passphrase, $newPass, $record->name);
if ($sibling) { if ($sibling) {
$sibling->update([ $sibling->update([
'x_passphrase' => $newPass, 'x_passphrase' => $newPass,
@@ -315,7 +365,11 @@ class WifiController extends Controller
]); ]);
} }
} catch (\Throwable $e) { } 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, 'sibling_wlan' => $siblingWlanId,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);

View File

@@ -312,6 +312,88 @@ class UnifiApiClient
return $this->put("/rest/wlanconf/{$wlanId}", $data); 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 * Find sibling WLAN configs — same SSID name, different _id. UniFi
* splits a "banded" SSID (band-steering disabled) into one wlanconf * splits a "banded" SSID (band-steering disabled) into one wlanconf
@@ -538,7 +620,7 @@ class UnifiApiClient
* no controller-side ID. Only changes the entry's passphrase; name * no controller-side ID. Only changes the entry's passphrase; name
* isn't separately addressable on embedded PPSKs. * 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}"); $wlanResp = $this->get("/rest/wlanconf/{$wlanId}");
$wlan = $wlanResp[0] ?? $wlanResp; $wlan = $wlanResp[0] ?? $wlanResp;
@@ -548,26 +630,66 @@ class UnifiApiClient
throw new \RuntimeException('WLAN has no embedded PPSKs to update.'); throw new \RuntimeException('WLAN has no embedded PPSKs to update.');
} }
$matched = false; // Embedded PPSKs on this controller don't carry a name field —
foreach ($entries as &$e) { // the human label ("GUEST", "3DPrinters", …) is the *network's*
$current = $e['x_passphrase'] ?? $e['password'] ?? $e['passphrase'] ?? null; // name, and each entry references it via networkconf_id. So when
if ($current === $oldPassphrase) { // the caller passes a name, first resolve it to a networkconf_id
// Preserve whichever field name the controller is using. // 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('x_passphrase', $e)) $e['x_passphrase'] = $newPassphrase;
if (array_key_exists('password', $e)) $e['password'] = $newPassphrase; if (array_key_exists('password', $e)) $e['password'] = $newPassphrase;
if (array_key_exists('passphrase', $e)) $e['passphrase'] = $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'])) { if (! isset($e['x_passphrase']) && ! isset($e['password']) && ! isset($e['passphrase'])) {
$e['password'] = $newPassphrase; $e['password'] = $newPassphrase;
} }
};
$networkconfId = ($name !== null && $name !== '') ? $this->findNetworkconfIdByName($name) : null;
$matched = false;
if ($networkconfId !== null) {
foreach ($entries as &$e) {
if (($e['networkconf_id'] ?? null) === $networkconfId) {
$applyUpdate($e);
$matched = true; $matched = true;
break; break;
} }
} }
unset($e); unset($e);
}
if (! $matched && $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);
}
if (! $matched) { if (! $matched) {
throw new \RuntimeException('Embedded PPSK not found by current passphrase.'); throw new \RuntimeException(
'Embedded PPSK not found' .
($name !== null ? " for network \"{$name}\"" : '') .
' or by current passphrase.'
);
} }
// UniFi REST expects the full WLAN object on PUT — send what we // UniFi REST expects the full WLAN object on PUT — send what we