2 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
4 changed files with 135 additions and 22 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.10.2",
"version": "1.11.0",
"type": "library",
"license": "MIT",
"autoload": {

View File

@@ -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]);

View File

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

View File

@@ -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.'
);
}