Files
central-unifi/src/Console/RotatePasswords.php
jwed 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

176 lines
8.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.
// 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);
$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) {
$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) {
// "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()];
}
}
} 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;
}
}