feat(logs): structured cron run history + read endpoint

Adds unifi_cron_runs table (one row per scheduled-task execution) and
UnifiCronRun::record() wrapper that captures start/finish/status and
exceptions. The three scheduled commands now write through it:

  - reboot-all-aps    → rebooted/failed AP names per run
  - rotate-passwords  → rotated SSIDs + PPSKs, failures (when actually
                        rotating; the "is it due" early-return is silent
                        so we don't flood the log with no-op rows every
                        minute)
  - sync-ppsk-schedules → enabled/disabled PPSKs (silent when there's
                          no work)

UnifiCronLogsController returns the most-recent 200 runs as JSON,
filterable by command + status. Behind permission:unifi.settings; no
super-admin required — read-only history is fine for any operator
who can see settings.

v1.5.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 16:05:36 -04:00
parent a33f2885ff
commit 75943fbe2b
8 changed files with 325 additions and 125 deletions

View File

@@ -3,76 +3,94 @@
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;
use Illuminate\Support\Carbon;
class RotatePasswords extends Command
{
protected $signature = 'unifi:rotate-passwords {--force : Run regardless of schedule}';
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')) {
return self::SUCCESS;
}
$wlanIdsJson = Setting::get('unifi.password_rotation.wlan_ids', '[]');
$wlanIds = json_decode($wlanIdsJson, true);
if (empty($wlanIds) || ! is_array($wlanIds)) {
return self::SUCCESS;
}
$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.');
// 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;
}
$password = $passwords[array_rand($passwords)];
$rotated = 0;
$force = $this->option('force');
$triggeredBy = $this->option('triggered-by') ?: 'schedule';
foreach ($wlanIds as $wlanId) {
try {
$unifi->updateWlan($wlanId, ['x_passphrase' => $password]);
$rotated++;
} catch (\Throwable $e) {
$this->error("Failed to rotate wlan {$wlanId}: {$e->getMessage()}");
$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 (empty($wlanIds) || ! is_array($wlanIds)) {
return ['status' => 'skipped', 'reason' => 'no SSIDs configured for rotation'];
}
}
if ($rotated > 0) {
Setting::set('unifi.password_rotation.last_rotated_at', now()->toIso8601String());
$this->info("Rotated password for {$rotated} SSID(s).");
}
$wordlist = Setting::get('unifi.password_rotation.wordlist', '');
$passwords = array_values(array_filter(array_map('trim', explode("\n", $wordlist))));
// ── Rotate PPSK passwords ────────────────────────────────────────────
$rotatedPpsks = 0;
foreach (UnifiPpsk::where('rotate_password', true)->where('state', 'active')->whereNotNull('unifi_id')->get() as $ppsk) {
// Each PPSK gets its own independently-chosen password from the wordlist
$newPass = $passwords[array_rand($passwords)];
try {
$unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]);
$ppsk->update(['x_passphrase' => $newPass]);
$rotatedPpsks++;
} catch (\Throwable $e) {
$this->error("Failed to rotate PPSK \"{$ppsk->name}\": {$e->getMessage()}");
if (empty($passwords)) {
$this->warn('Password rotation: no passwords in wordlist — skipped.');
return ['status' => 'skipped', 'reason' => 'empty wordlist'];
}
}
if ($rotatedPpsks > 0) {
$this->info("Rotated password for {$rotatedPpsks} PPSK(s).");
}
return self::SUCCESS;
$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());
$this->info('Rotated password for ' . count($rotated) . ' SSID(s).');
}
$rotatedPpsks = [];
$failedPpsks = [];
foreach (UnifiPpsk::where('rotate_password', true)->where('state', 'active')->whereNotNull('unifi_id')->get() as $ppsk) {
$newPass = $passwords[array_rand($passwords)];
try {
$unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]);
$ppsk->update(['x_passphrase' => $newPass]);
$rotatedPpsks[] = $ppsk->name;
} 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