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:
@@ -2,61 +2,68 @@
|
||||
|
||||
namespace Dashboard\Unifi\Console;
|
||||
|
||||
use Dashboard\Unifi\Models\UnifiCronRun;
|
||||
use Dashboard\Unifi\Services\UnifiApiClient;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class RebootAllAps extends Command
|
||||
{
|
||||
protected $signature = 'unifi:reboot-all-aps {--delay=5 : Seconds to wait between each reboot}';
|
||||
protected $signature = 'unifi:reboot-all-aps {--delay=5 : Seconds to wait between each reboot} {--triggered-by=schedule}';
|
||||
protected $description = 'Planned reboot of all access points — suppresses webhook offline/online alerts';
|
||||
|
||||
public function handle(UnifiApiClient $unifi): int
|
||||
{
|
||||
try {
|
||||
$aps = $unifi->getAccessPoints();
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Failed to fetch APs: ' . $e->getMessage());
|
||||
return self::FAILURE;
|
||||
}
|
||||
$run = UnifiCronRun::record(
|
||||
'reboot-all-aps',
|
||||
$this->option('triggered-by') ?: 'schedule',
|
||||
null,
|
||||
function () use ($unifi) {
|
||||
$aps = $unifi->getAccessPoints();
|
||||
|
||||
if (empty($aps)) {
|
||||
$this->warn('No access points found.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
if (empty($aps)) {
|
||||
$this->warn('No access points found.');
|
||||
return ['status' => 'skipped', 'reason' => 'no APs found'];
|
||||
}
|
||||
|
||||
$delay = max(0, (int) $this->option('delay'));
|
||||
$delay = max(0, (int) $this->option('delay'));
|
||||
$rebooted = [];
|
||||
$failed = [];
|
||||
|
||||
// Pre-mark all APs as planned reboots before sending any commands
|
||||
foreach ($aps as $ap) {
|
||||
$mac = strtolower($ap['mac']);
|
||||
Cache::put("unifi:planned_reboot:{$mac}", true, now()->addMinutes(20));
|
||||
$this->line("Marked planned reboot: {$ap['name']} ({$mac})");
|
||||
}
|
||||
foreach ($aps as $ap) {
|
||||
$mac = strtolower($ap['mac']);
|
||||
Cache::put("unifi:planned_reboot:{$mac}", true, now()->addMinutes(20));
|
||||
$this->line("Marked planned reboot: {$ap['name']} ({$mac})");
|
||||
}
|
||||
$this->newLine();
|
||||
|
||||
$this->newLine();
|
||||
$ok = 0;
|
||||
$fail = 0;
|
||||
foreach ($aps as $ap) {
|
||||
$mac = strtolower($ap['mac']);
|
||||
$name = $ap['name'] ?? $mac;
|
||||
try {
|
||||
$unifi->rebootDevice($mac);
|
||||
$this->info("Rebooted: {$name} ({$mac})");
|
||||
$rebooted[] = $name;
|
||||
} catch (\Throwable $e) {
|
||||
$this->error("Failed to reboot {$name}: {$e->getMessage()}");
|
||||
$failed[] = ['name' => $name, 'error' => $e->getMessage()];
|
||||
}
|
||||
|
||||
foreach ($aps as $ap) {
|
||||
$mac = strtolower($ap['mac']);
|
||||
$name = $ap['name'] ?? $mac;
|
||||
try {
|
||||
$unifi->rebootDevice($mac);
|
||||
$this->info("Rebooted: {$name} ({$mac})");
|
||||
$ok++;
|
||||
} catch (\Throwable $e) {
|
||||
$this->error("Failed to reboot {$name}: {$e->getMessage()}");
|
||||
$fail++;
|
||||
if ($delay > 0 && count($rebooted) + count($failed) < count($aps)) {
|
||||
sleep($delay);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => count($failed) === 0 ? 'succeeded' : (count($rebooted) > 0 ? 'partial' : 'failed'),
|
||||
'rebooted' => $rebooted,
|
||||
'failed' => $failed,
|
||||
'total' => count($aps),
|
||||
];
|
||||
}
|
||||
);
|
||||
|
||||
if ($delay > 0 && $ok + $fail < count($aps)) {
|
||||
sleep($delay);
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Done. {$ok} rebooted, {$fail} failed.");
|
||||
return $fail > 0 ? self::FAILURE : self::SUCCESS;
|
||||
$this->info("Done. Status: {$run->status}.");
|
||||
return $run->status === 'failed' ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,65 +3,82 @@
|
||||
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 SyncPpskSchedules extends Command
|
||||
{
|
||||
protected $signature = 'unifi:sync-ppsk-schedules {--force : Run even if PPSK scheduling is disabled}';
|
||||
protected $signature = 'unifi:sync-ppsk-schedules {--force : Run even if PPSK scheduling is disabled} {--triggered-by=schedule}';
|
||||
protected $description = 'Enable or disable PPSKs based on their weekly half-hour schedule, kicking active clients when disabling';
|
||||
|
||||
public function handle(UnifiApiClient $unifi): int
|
||||
{
|
||||
// Always run, even when global ppsk_scheduling is disabled — in
|
||||
// that case the target state for every PPSK is "active" (always
|
||||
// on). That way disabling the global setting actually restores
|
||||
// any held PPSKs to active without operators having to do
|
||||
// anything else, and null-schedule PPSKs always end up active.
|
||||
// Schedules in the DB are preserved regardless of toggle state,
|
||||
// so re-enabling resumes the per-PPSK schedule.
|
||||
$globalEnabled = (bool) Setting::get('unifi.ppsk_scheduling.enabled');
|
||||
|
||||
$tz = \App\Support\Timezone::current();
|
||||
$now = now($tz);
|
||||
$day = $now->dayOfWeek; // 0=Sun … 6=Sat
|
||||
$slot = $now->hour * 2 + ($now->minute >= 30 ? 1 : 0); // 0–47
|
||||
|
||||
$ppsks = UnifiPpsk::all();
|
||||
|
||||
if ($ppsks->isEmpty()) {
|
||||
// Don't bother logging — no work, no audit value.
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Fetch network confs once so we can resolve vlan → networkconf_id on re-enable
|
||||
$networksByVlan = [];
|
||||
try {
|
||||
foreach ($unifi->getNetworkConfs() as $n) {
|
||||
if (isset($n['vlan'])) {
|
||||
$networksByVlan[(int) $n['vlan']] = $n;
|
||||
$triggeredBy = $this->option('triggered-by') ?: 'schedule';
|
||||
|
||||
$run = UnifiCronRun::record('sync-ppsk-schedules', $triggeredBy, null, function () use ($unifi, $ppsks) {
|
||||
$globalEnabled = (bool) Setting::get('unifi.ppsk_scheduling.enabled');
|
||||
$tz = \App\Support\Timezone::current();
|
||||
$now = now($tz);
|
||||
$day = $now->dayOfWeek;
|
||||
$slot = $now->hour * 2 + ($now->minute >= 30 ? 1 : 0);
|
||||
|
||||
$networksByVlan = [];
|
||||
try {
|
||||
foreach ($unifi->getNetworkConfs() as $n) {
|
||||
if (isset($n['vlan'])) {
|
||||
$networksByVlan[(int) $n['vlan']] = $n;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->warn("Could not fetch network configs: {$e->getMessage()}");
|
||||
}
|
||||
|
||||
$enabled = [];
|
||||
$disabled = [];
|
||||
$errors = [];
|
||||
|
||||
foreach ($ppsks as $ppsk) {
|
||||
$shouldBeOn = true;
|
||||
if ($globalEnabled && $ppsk->schedule) {
|
||||
$shouldBeOn = (bool) ($ppsk->schedule[$day * 48 + $slot] ?? true);
|
||||
}
|
||||
|
||||
try {
|
||||
if ($shouldBeOn && $ppsk->state === 'held') {
|
||||
$this->enablePpsk($ppsk, $unifi, $networksByVlan);
|
||||
$enabled[] = $ppsk->name;
|
||||
} elseif (! $shouldBeOn && $ppsk->state === 'active' && $ppsk->unifi_id) {
|
||||
$this->disablePpsk($ppsk, $unifi);
|
||||
$disabled[] = $ppsk->name;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$errors[] = ['ppsk' => $ppsk->name, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->warn("Could not fetch network configs: {$e->getMessage()}");
|
||||
}
|
||||
|
||||
foreach ($ppsks as $ppsk) {
|
||||
// Default to "always on". Only consult the schedule if
|
||||
// global scheduling is enabled AND this PPSK has one.
|
||||
$shouldBeOn = true;
|
||||
if ($globalEnabled && $ppsk->schedule) {
|
||||
$shouldBeOn = (bool) ($ppsk->schedule[$day * 48 + $slot] ?? true);
|
||||
}
|
||||
$hasActions = count($enabled) + count($disabled) > 0;
|
||||
$status = count($errors) > 0
|
||||
? ($hasActions ? 'partial' : 'failed')
|
||||
: ($hasActions ? 'succeeded' : 'skipped');
|
||||
|
||||
if ($shouldBeOn && $ppsk->state === 'held') {
|
||||
$this->enablePpsk($ppsk, $unifi, $networksByVlan);
|
||||
} elseif (! $shouldBeOn && $ppsk->state === 'active' && $ppsk->unifi_id) {
|
||||
$this->disablePpsk($ppsk, $unifi);
|
||||
}
|
||||
}
|
||||
return [
|
||||
'status' => $status,
|
||||
'global_enabled' => $globalEnabled,
|
||||
'enabled_ppsks' => $enabled,
|
||||
'disabled_ppsks' => $disabled,
|
||||
'errors' => $errors,
|
||||
];
|
||||
});
|
||||
|
||||
return self::SUCCESS;
|
||||
return $run->status === 'failed' ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
private function enablePpsk(UnifiPpsk $ppsk, UnifiApiClient $unifi, array $networksByVlan): void
|
||||
|
||||
Reference in New Issue
Block a user