4 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
4 changed files with 201 additions and 21 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.0",
"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,10 +104,37 @@ class RotatePasswords extends Command
]);
}
} 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()}");
$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

@@ -149,8 +149,18 @@ class WifiController extends Controller
$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()
?? ($name
? UnifiPpsk::where('wlan_id', $wlanId)->where('name', $name)
->orderByRaw("FIELD(state, 'active', 'held')")
->first()
: null)
?? UnifiPpsk::where('wlan_id', $wlanId)
->where('x_passphrase', $pass)
->where('state', 'held')
@@ -174,8 +184,8 @@ class WifiController extends Controller
}
}
// Only mark as held when we have confirmed live IDs —
// never wipe on an empty API response (prevents false-holds on API failures)
// Mark non-matching active rows as held — but ONLY if there's no
// other active row with the same name we just reconnected.
if (! empty($liveIds)) {
UnifiPpsk::where('wlan_id', $wlanId)
->where('state', 'active')
@@ -184,6 +194,47 @@ class WifiController extends Controller
->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)
->orderByRaw("FIELD(state, 'active', 'held')")
->orderBy('name')
@@ -298,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')
@@ -314,7 +365,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

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