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>
191 lines
9.4 KiB
PHP
191 lines
9.4 KiB
PHP
<?php
|
|
|
|
namespace Dashboard\Unifi\Console;
|
|
|
|
use App\Models\Setting;
|
|
use Dashboard\Unifi\Models\UnifiCronRun;
|
|
use Dashboard\Unifi\Models\UnifiPpsk;
|
|
use Dashboard\Unifi\Services\UnifiApiClient;
|
|
use Illuminate\Console\Command;
|
|
|
|
class RotatePasswords extends Command
|
|
{
|
|
protected $signature = 'unifi:rotate-passwords {--force : Run regardless of schedule} {--triggered-by=schedule}';
|
|
protected $description = 'Rotate WiFi passwords for SSIDs configured with a wordlist schedule';
|
|
|
|
public function handle(UnifiApiClient $unifi): int
|
|
{
|
|
if (! Setting::get('unifi.password_rotation.enabled')) {
|
|
// Don't log anything — the scheduler runs this every minute
|
|
// and we'd flood the logs with "rotation disabled" rows.
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
if (! $this->option('force') && ! $this->isDue()) {
|
|
// Same reasoning — only log when we actually do something.
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$force = $this->option('force');
|
|
$triggeredBy = $this->option('triggered-by') ?: 'schedule';
|
|
|
|
$run = UnifiCronRun::record('rotate-passwords', $triggeredBy, null, function () use ($unifi, $force) {
|
|
$wlanIdsJson = Setting::get('unifi.password_rotation.wlan_ids', '[]');
|
|
$wlanIds = json_decode($wlanIdsJson, true);
|
|
if (! is_array($wlanIds)) $wlanIds = [];
|
|
|
|
$ppskQuery = UnifiPpsk::where('rotate_password', true)
|
|
->where('state', 'active')
|
|
->whereNotNull('unifi_id');
|
|
|
|
// Skip only if there's nothing at all to rotate — neither
|
|
// whole-SSID rotation targets nor per-PPSK rotation opt-ins.
|
|
if (empty($wlanIds) && ! $ppskQuery->exists()) {
|
|
return ['status' => 'skipped', 'reason' => 'no SSIDs or PPSKs configured for rotation'];
|
|
}
|
|
|
|
$wordlist = Setting::get('unifi.password_rotation.wordlist', '');
|
|
$passwords = array_values(array_filter(array_map('trim', explode("\n", $wordlist))));
|
|
|
|
if (empty($passwords)) {
|
|
$this->warn('Password rotation: no passwords in wordlist — skipped.');
|
|
return ['status' => 'skipped', 'reason' => 'empty wordlist'];
|
|
}
|
|
|
|
$password = $passwords[array_rand($passwords)];
|
|
$rotated = [];
|
|
$failedWlans = [];
|
|
|
|
foreach ($wlanIds as $wlanId) {
|
|
try {
|
|
$unifi->updateWlan($wlanId, ['x_passphrase' => $password]);
|
|
$rotated[] = $wlanId;
|
|
} catch (\Throwable $e) {
|
|
$this->error("Failed to rotate wlan {$wlanId}: {$e->getMessage()}");
|
|
$failedWlans[] = ['wlan_id' => $wlanId, 'error' => $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
if ($rotated) {
|
|
Setting::set('unifi.password_rotation.last_rotated_at', now()->toIso8601String());
|
|
// Persist the active password so it can be displayed in
|
|
// the Settings page and exposed via the API endpoint.
|
|
Setting::set('unifi.password_rotation.last_password', $password);
|
|
$this->info('Rotated password for ' . count($rotated) . ' SSID(s).');
|
|
}
|
|
|
|
$rotatedPpsks = [];
|
|
$failedPpsks = [];
|
|
foreach ($ppskQuery->get() as $ppsk) {
|
|
$newPass = $passwords[array_rand($passwords)];
|
|
try {
|
|
if (str_starts_with((string) $ppsk->unifi_id, 'emb_')) {
|
|
// 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]);
|
|
|
|
// 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')
|
|
->first();
|
|
try {
|
|
$unifi->updateEmbeddedPpsk($siblingWlanId, $sibling?->x_passphrase, $newPass, $ppsk->name);
|
|
if ($sibling) {
|
|
$sibling->update([
|
|
'x_passphrase' => $newPass,
|
|
'unifi_id' => 'emb_' . substr(hash('sha256', $siblingWlanId . ':' . $newPass), 0, 32),
|
|
]);
|
|
}
|
|
} 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]);
|
|
}
|
|
$rotatedPpsks[] = $ppsk->name;
|
|
|
|
// Save the active password every time a rotation
|
|
// succeeds — covers PPSK-only rotation setups where
|
|
// there's no whole-SSID rotation. Last successful
|
|
// password wins if multiple PPSKs rotate in one run.
|
|
Setting::set('unifi.password_rotation.last_password', $newPass);
|
|
Setting::set('unifi.password_rotation.last_rotated_at', now()->toIso8601String());
|
|
} catch (\Throwable $e) {
|
|
$this->error("Failed to rotate PPSK \"{$ppsk->name}\": {$e->getMessage()}");
|
|
$failedPpsks[] = ['name' => $ppsk->name, 'error' => $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
$hasFailures = count($failedWlans) + count($failedPpsks) > 0;
|
|
$hasSuccess = count($rotated) + count($rotatedPpsks) > 0;
|
|
|
|
return [
|
|
'status' => $hasFailures ? ($hasSuccess ? 'partial' : 'failed') : 'succeeded',
|
|
'rotated_wlans' => $rotated,
|
|
'failed_wlans' => $failedWlans,
|
|
'rotated_ppsks' => $rotatedPpsks,
|
|
'failed_ppsks' => $failedPpsks,
|
|
];
|
|
});
|
|
|
|
return $run->status === 'failed' ? self::FAILURE : self::SUCCESS;
|
|
}
|
|
|
|
private function isDue(): bool
|
|
{
|
|
$frequency = Setting::get('unifi.password_rotation.frequency', 'weekly');
|
|
$hour = (int) Setting::get('unifi.password_rotation.hour', 2);
|
|
$minute = (int) Setting::get('unifi.password_rotation.minute', 0);
|
|
$dow = (int) Setting::get('unifi.password_rotation.day_of_week', 0);
|
|
$tz = \App\Support\Timezone::current();
|
|
$now = now($tz);
|
|
|
|
if ($now->hour !== $hour || $now->minute !== $minute) {
|
|
return false;
|
|
}
|
|
|
|
if ($frequency === 'weekly' && $now->dayOfWeek !== $dow) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|