diff --git a/composer.json b/composer.json index 5aa4bd8..228aac4 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "dashboard/unifi", "description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform", - "version": "1.4.0", + "version": "1.5.0", "type": "library", "license": "MIT", "autoload": { diff --git a/database/migrations/2026_05_24_000001_create_unifi_cron_runs_table.php b/database/migrations/2026_05_24_000001_create_unifi_cron_runs_table.php new file mode 100644 index 0000000..a55c804 --- /dev/null +++ b/database/migrations/2026_05_24_000001_create_unifi_cron_runs_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('command', 64)->index(); // 'reboot-all-aps' | 'rotate-passwords' | 'sync-ppsk-schedules' + $table->enum('triggered_by', ['schedule', 'manual']); + $table->foreignId('triggered_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('started_at')->index(); + $table->timestamp('finished_at')->nullable(); + $table->string('status', 16); // 'running' | 'succeeded' | 'partial' | 'failed' | 'skipped' + $table->longText('details')->nullable(); // JSON: counts, per-item actions, error summary + }); + } + + public function down(): void + { + Schema::dropIfExists('unifi_cron_runs'); + } +}; diff --git a/src/Console/RebootAllAps.php b/src/Console/RebootAllAps.php index 1e476dc..e6af1c3 100644 --- a/src/Console/RebootAllAps.php +++ b/src/Console/RebootAllAps.php @@ -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; } } diff --git a/src/Console/RotatePasswords.php b/src/Console/RotatePasswords.php index e9b75d7..a168d50 100644 --- a/src/Console/RotatePasswords.php +++ b/src/Console/RotatePasswords.php @@ -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 diff --git a/src/Console/SyncPpskSchedules.php b/src/Console/SyncPpskSchedules.php index c97ff03..6e18575 100644 --- a/src/Console/SyncPpskSchedules.php +++ b/src/Console/SyncPpskSchedules.php @@ -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 diff --git a/src/Http/Controllers/UnifiCronLogsController.php b/src/Http/Controllers/UnifiCronLogsController.php new file mode 100644 index 0000000..089f87f --- /dev/null +++ b/src/Http/Controllers/UnifiCronLogsController.php @@ -0,0 +1,43 @@ +only(['command', 'status']); + + $runs = UnifiCronRun::query() + ->with('triggeredByUser:id,name,email') + ->when($filters['command'] ?? null, fn ($q, $c) => $q->where('command', $c)) + ->when($filters['status'] ?? null, fn ($q, $s) => $q->where('status', $s)) + ->orderByDesc('started_at') + ->limit(200) + ->get(); + + return response()->json([ + 'runs' => $runs->map(fn ($r) => [ + 'id' => $r->id, + 'command' => $r->command, + 'triggered_by' => $r->triggered_by, + 'triggered_user' => $r->triggeredByUser ? [ + 'id' => $r->triggeredByUser->id, + 'name' => $r->triggeredByUser->name, + 'email' => $r->triggeredByUser->email, + ] : null, + 'started_at' => $r->started_at?->toIso8601String(), + 'finished_at' => $r->finished_at?->toIso8601String(), + 'duration_ms' => $r->finished_at && $r->started_at + ? (int) $r->finished_at->diffInMilliseconds($r->started_at) + : null, + 'status' => $r->status, + 'details' => $r->details, + ])->values(), + ]); + } +} diff --git a/src/Models/UnifiCronRun.php b/src/Models/UnifiCronRun.php new file mode 100644 index 0000000..10a583e --- /dev/null +++ b/src/Models/UnifiCronRun.php @@ -0,0 +1,79 @@ + 'datetime', + 'finished_at' => 'datetime', + 'details' => 'array', + ]; + + public function triggeredByUser() + { + return $this->belongsTo(\App\Models\User::class, 'triggered_by_user_id'); + } + + /** + * Wraps a unit of cron work, recording start/finish/status and any + * exception. Returns whatever the work returns; the resulting + * UnifiCronRun row is returned via the $run reference param. + */ + public static function record(string $command, string $triggeredBy, ?int $userId, callable $work): self + { + $run = static::create([ + 'command' => $command, + 'triggered_by' => $triggeredBy, + 'triggered_by_user_id' => $userId, + 'started_at' => now(), + 'status' => 'running', + ]); + + try { + $details = $work($run); + + // Caller can return a status string ("skipped", "partial", + // etc.) by sticking it under the 'status' key in details. + // Default = succeeded. + $status = is_array($details) && isset($details['status']) + ? $details['status'] + : 'succeeded'; + + $run->update([ + 'finished_at' => now(), + 'status' => $status, + 'details' => is_array($details) ? array_diff_key($details, ['status' => null]) : null, + ]); + } catch (\Throwable $e) { + $run->update([ + 'finished_at' => now(), + 'status' => 'failed', + 'details' => [ + 'error' => $e->getMessage(), + 'class' => $e::class, + 'file' => $e->getFile() . ':' . $e->getLine(), + ], + ]); + throw $e; + } + + return $run->refresh(); + } +} diff --git a/src/routes/unifi.php b/src/routes/unifi.php index b649e67..8ca4e35 100644 --- a/src/routes/unifi.php +++ b/src/routes/unifi.php @@ -4,6 +4,7 @@ use Dashboard\Unifi\Http\Controllers\ClientController; use Dashboard\Unifi\Http\Controllers\DeviceController; use Dashboard\Unifi\Http\Controllers\PortalController; use Dashboard\Unifi\Http\Controllers\StatsController; +use Dashboard\Unifi\Http\Controllers\UnifiCronLogsController; use Dashboard\Unifi\Http\Controllers\UnifiPagesAccessController; use Dashboard\Unifi\Http\Controllers\UnifiSettingsController; use Dashboard\Unifi\Http\Controllers\VlanGroupController; @@ -78,6 +79,9 @@ Route::middleware(['web', 'auth', 'app.access:unifi']) Route::put('/settings/pages-access/{navItem}', [UnifiPagesAccessController::class, 'update']) ->name('settings.pages-access.update'); }); + // Cron logs — read-only history of scheduled-task runs. + Route::get('/settings/cron-logs', [UnifiCronLogsController::class, 'index'])->name('settings.cron-logs.index'); + // Webhooks Route::get('/webhooks', [WebhookController::class, 'index']) ->name('webhooks.index'); Route::post('/webhooks', [WebhookController::class, 'store']) ->name('webhooks.store');