From 6806ed31fad1f83c9e18d438b3b39604b4009e5c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:28:51 +0200 Subject: [PATCH] feat(patches): save check results and run checks in a queued job - server_settings gets patch_check_result (JSON), patch_check_error, patch_checked_at, and patch_check_frequency. - The patches page shows the saved result and "Last checked" on load. "Check for updates" queues ServerPatchCheckJob (manual) instead of running SSH in the Livewire request. The job saves the result and broadcasts ServerPatchCheckFinished on the server team channel; the page reloads the saved result (with a 10 s poll as a fallback). - A finished package update (callEventOnFinish) queues a new check on the server side, so the page no longer depends on the browser. ServerPackageUpdated is removed. - The check frequency is a server setting (cron or preset, at most once per hour). Empty keeps the staggered weekly default. - The failure notification is sent only when the state changes from ok to error, not on every check. - Page access needs viewSecurity and every action needs update on the loaded server; a member gets 403. Updates accept only a package from the saved result. - Server transfer: the frequency moves, the last result stays on the source (runtime). Co-Authored-By: Claude Opus 5.5 --- app/Actions/Server/CheckUpdates.php | 2 +- app/Actions/Server/UpdatePackage.php | 5 +- app/Events/ServerPackageUpdated.php | 14 - app/Events/ServerPackagesUpdateFinished.php | 18 ++ app/Events/ServerPatchCheckFinished.php | 32 +++ app/Jobs/ServerManagerJob.php | 12 +- app/Jobs/ServerPatchCheckJob.php | 83 ++++-- .../CheckPatchesAfterPackageUpdate.php | 20 ++ app/Livewire/Server/Security/Patches.php | 228 ++++++++------- app/Models/ServerSetting.php | 6 + .../ServerTransfer/ServerTransferCoverage.php | 1 + .../ServerTransfer/ServerTransferExporter.php | 4 + .../ServerTransfer/ServerTransferImporter.php | 1 + ...d_patch_check_to_server_settings_table.php | 25 ++ docs/server-transfer-full-coverage.md | 1 + .../server/security/patches.blade.php | 214 ++++++++------ tests/Feature/Server/ServerPatchesTest.php | 265 ++++++++++++++++++ tests/Feature/TeamBroadcastEventsTest.php | 2 - .../ServerTransferExporterImporterTest.php | 25 ++ 19 files changed, 711 insertions(+), 247 deletions(-) delete mode 100644 app/Events/ServerPackageUpdated.php create mode 100644 app/Events/ServerPackagesUpdateFinished.php create mode 100644 app/Events/ServerPatchCheckFinished.php create mode 100644 app/Listeners/CheckPatchesAfterPackageUpdate.php create mode 100644 database/migrations/2026_10_19_082209_add_patch_check_to_server_settings_table.php create mode 100644 tests/Feature/Server/ServerPatchesTest.php diff --git a/app/Actions/Server/CheckUpdates.php b/app/Actions/Server/CheckUpdates.php index 2d4cbdef29..a50af94ddb 100644 --- a/app/Actions/Server/CheckUpdates.php +++ b/app/Actions/Server/CheckUpdates.php @@ -146,12 +146,12 @@ class CheckUpdates ]; } } catch (\Throwable $e) { + Log::warning('Server patch check failed', ['server_uuid' => $server->uuid, 'error' => $e->getMessage()]); return [ 'osId' => $osId, 'package_manager' => $packageManager, 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), ]; } } diff --git a/app/Actions/Server/UpdatePackage.php b/app/Actions/Server/UpdatePackage.php index c87513502a..7b6b6116d3 100644 --- a/app/Actions/Server/UpdatePackage.php +++ b/app/Actions/Server/UpdatePackage.php @@ -54,7 +54,10 @@ class UpdatePackage ]; } - return remote_process($commands, $server); + return remote_process($commands, $server, callEventOnFinish: 'ServerPackagesUpdateFinished', callEventData: [ + 'server_uuid' => $server->uuid, + 'packages' => $all ? [] : [$package], + ]); } catch (\Exception $e) { return [ 'error' => $e->getMessage(), diff --git a/app/Events/ServerPackageUpdated.php b/app/Events/ServerPackageUpdated.php deleted file mode 100644 index 88c8c52bb7..0000000000 --- a/app/Events/ServerPackageUpdated.php +++ /dev/null @@ -1,14 +0,0 @@ -} $data + */ + public function __construct(public readonly array $data) {} +} diff --git a/app/Events/ServerPatchCheckFinished.php b/app/Events/ServerPatchCheckFinished.php new file mode 100644 index 0000000000..c9d8cb269e --- /dev/null +++ b/app/Events/ServerPatchCheckFinished.php @@ -0,0 +1,32 @@ +teamId = $teamId; + } + + public function broadcastAs(): string + { + return 'ServerPatchCheckFinished'; + } + + /** + * @return array{serverUuid: string|null} + */ + public function broadcastWith(): array + { + return ['serverUuid' => $this->serverUuid]; + } +} diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php index ac1c551982..e1ab5f94ee 100644 --- a/app/Jobs/ServerManagerJob.php +++ b/app/Jobs/ServerManagerJob.php @@ -205,7 +205,7 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue } } - // Dispatch ServerPatchCheckJob if due (weekly, staggered per server on Sunday). + // Dispatch ServerPatchCheckJob if due (by default weekly, staggered per server on Sunday). // The "-v2" keys start fresh: v4.3.23 stored its old run times under the unversioned keys, which // would make every server run these checks at once on the first run after the upgrade. $shouldRunPatchCheck = shouldRunCronNow(self::patchCheckCron($server), $serverTimezone, "server-patch-check-v2:{$server->id}", $this->executionTime); @@ -229,12 +229,16 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue } /** - * Weekly patch check at a stable per-server time on Sunday between 04:00 and 23:59. - * - * The early hours are skipped because most daylight saving transitions happen there. + * The patch check frequency of the server. The default is weekly, at a stable per-server time on Sunday + * between 04:00 and 23:59. The early hours are skipped because most daylight saving transitions happen there. */ public static function patchCheckCron(Server $server): string { + $frequency = data_get($server->settings, 'patch_check_frequency'); + if (filled($frequency)) { + return VALID_CRON_STRINGS[$frequency] ?? $frequency; + } + $slot = $server->id % (20 * 60); return sprintf('%d %d * * 0', $slot % 60, 4 + intdiv($slot, 60)); diff --git a/app/Jobs/ServerPatchCheckJob.php b/app/Jobs/ServerPatchCheckJob.php index a383a962a3..f35881efa9 100644 --- a/app/Jobs/ServerPatchCheckJob.php +++ b/app/Jobs/ServerPatchCheckJob.php @@ -3,6 +3,7 @@ namespace App\Jobs; use App\Actions\Server\CheckUpdates; +use App\Events\ServerPatchCheckFinished; use App\Models\Server; use App\Notifications\Server\ServerPatchCheck; use Illuminate\Bus\Queueable; @@ -12,6 +13,8 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; class ServerPatchCheckJob implements ShouldBeEncrypted, ShouldQueue @@ -20,51 +23,93 @@ class ServerPatchCheckJob implements ShouldBeEncrypted, ShouldQueue public $timeout = 600; + public $tries = 1; + + /** + * @param bool $manual A check that a user started from the patches page. It saves the result also for a + * server that is not functional, and it sends no "updates available" notification. + */ + public function __construct(public Server $server, public bool $manual = false) + { + $this->onQueue(maintenance_queue()); + } + + public static function runningCacheKey(Server $server): string + { + return "server-patch-check-running:{$server->uuid}"; + } + + /** + * Queues a manual check and marks it as running, so the patches page shows the progress after a reload. + */ + public static function dispatchManual(Server $server): void + { + Cache::put(self::runningCacheKey($server), true, now()->addMinutes(15)); + self::dispatch($server, true); + } + public function middleware(): array { return [(new WithoutOverlapping('server-patch-check-'.$this->server->uuid))->expireAfter(600)->dontRelease()]; } - public function __construct(public Server $server) - { - $this->onQueue(maintenance_queue()); - } - public function handle(): void { try { - if ($this->server->isFunctional() === false) { - return; - } - $team = data_get($this->server, 'team'); if (! $team) { return; } - // Check for updates - $patchData = CheckUpdates::run($this->server); + if ($this->server->isFunctional() === false) { + if ($this->manual) { + $this->saveResult(['error' => 'Server is not reachable or not ready.']); + } - if (isset($patchData['error'])) { - $team->notify(new ServerPatchCheck($this->server, $patchData)); - - return; // Skip if there's an error checking for updates + return; } - $totalUpdates = $patchData['total_updates'] ?? 0; + $previousError = $this->server->settings->patch_check_error; + $patchData = CheckUpdates::run($this->server); + $this->saveResult($patchData); - // Only send notification if there are updates available - if ($totalUpdates > 0) { + if (isset($patchData['error'])) { + // Notify only when the state changes from ok to error, not on every check of a broken server. + if (blank($previousError)) { + $team->notify(new ServerPatchCheck($this->server, $patchData)); + } + + return; + } + + if (! $this->manual && ($patchData['total_updates'] ?? 0) > 0) { $team->notify(new ServerPatchCheck($this->server, $patchData)); } } catch (\Throwable $e) { - // Log error but don't fail the job Log::error('ServerPatchCheckJob failed: '.$e->getMessage(), [ 'server_id' => $this->server->id, 'server_name' => $this->server->name, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); + $this->saveResult(['error' => $e->getMessage()]); + } finally { + Cache::forget(self::runningCacheKey($this->server)); + ServerPatchCheckFinished::dispatch($this->server->team_id, $this->server->uuid); } } + + /** + * @param array $patchData + */ + private function saveResult(array $patchData): void + { + $error = $patchData['error'] ?? null; + + $this->server->settings->forceFill([ + 'patch_check_result' => $error === null ? $patchData : Arr::only($patchData, ['osId', 'package_manager']), + 'patch_check_error' => $error === null ? null : (string) $error, + 'patch_checked_at' => now(), + ])->saveQuietly(); + } } diff --git a/app/Listeners/CheckPatchesAfterPackageUpdate.php b/app/Listeners/CheckPatchesAfterPackageUpdate.php new file mode 100644 index 0000000000..492f9f5463 --- /dev/null +++ b/app/Listeners/CheckPatchesAfterPackageUpdate.php @@ -0,0 +1,20 @@ +where('uuid', $event->data['server_uuid'] ?? null)->first(); + if ($server === null) { + return; + } + + ServerPatchCheckJob::dispatchManual($server); + } +} diff --git a/app/Livewire/Server/Security/Patches.php b/app/Livewire/Server/Security/Patches.php index 2a2b82a3f1..dd5a37070e 100644 --- a/app/Livewire/Server/Security/Patches.php +++ b/app/Livewire/Server/Security/Patches.php @@ -2,13 +2,15 @@ namespace App\Livewire\Server\Security; -use App\Actions\Server\CheckUpdates; use App\Actions\Server\UpdatePackage; -use App\Events\ServerPackageUpdated; +use App\Jobs\ServerPatchCheckJob; use App\Models\Server; use App\Notifications\Server\ServerPatchCheck; use App\Traits\ListensToTeamChannel; +use Cron\CronExpression; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\Cache; +use Livewire\Attributes\Locked; use Livewire\Component; class Patches extends Component @@ -16,58 +18,62 @@ class Patches extends Component use AuthorizesRequests; use ListensToTeamChannel; - public array $parameters; - public Server $server; - public ?int $totalUpdates = null; - - public ?array $updates = null; + /** + * The saved result of the last check (see ServerPatchCheckJob). + */ + #[Locked] + public ?array $result = null; + #[Locked] public ?string $error = null; - public ?string $osId = null; + #[Locked] + public bool $checking = false; - public ?string $packageManager = null; + public string $patchCheckFrequency = ''; public function getListeners() { return $this->teamChannelListeners([ - 'ServerPackageUpdated' => 'checkForUpdatesDispatch', + 'ServerPatchCheckFinished' => 'patchCheckFinished', ]); } - public function mount() + public function mount(string $server_uuid) { - $this->parameters = get_route_parameters(); - $this->server = Server::ownedByCurrentTeam()->whereUuid($this->parameters['server_uuid'])->firstOrFail(); - try { - $this->authorize('viewSecurity', $this->server); - } catch (\Throwable $e) { - return handleError($e, $this); - } + $this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); + $this->authorize('viewSecurity', $this->server); + $this->loadResult(); + $this->patchCheckFrequency = (string) $this->server->settings->patch_check_frequency; } - public function checkForUpdatesDispatch() + public function loadResult(): void { - $this->totalUpdates = null; - $this->updates = null; - $this->error = null; - $this->osId = null; - $this->packageManager = null; - $this->dispatch('checkForUpdatesDispatch'); + $this->server->settings->refresh(); + $this->result = $this->server->settings->patch_check_result; + $this->error = $this->server->settings->patch_check_error; + $this->checking = Cache::has(ServerPatchCheckJob::runningCacheKey($this->server)); + } + + public function patchCheckFinished(array $event = []): void + { + $serverUuid = data_get($event, 'serverUuid'); + if ($serverUuid !== null && $serverUuid !== $this->server->uuid) { + return; + } + $this->loadResult(); } public function checkForUpdates() { - $job = CheckUpdates::run($this->server); - if (isset($job['error'])) { - $this->error = data_get($job, 'error', 'Something went wrong.'); - } else { - $this->totalUpdates = data_get($job, 'total_updates', 0); - $this->updates = data_get($job, 'updates', []); - $this->osId = data_get($job, 'osId', null); - $this->packageManager = data_get($job, 'package_manager', null); + try { + $this->authorize('update', $this->server); + ServerPatchCheckJob::dispatchManual($this->server); + $this->checking = true; + } catch (\Throwable $e) { + return handleError($e, $this); } } @@ -75,45 +81,85 @@ class Patches extends Component { try { $this->authorize('update', $this->server); - if (! $this->packageManager || ! $this->osId) { - $this->dispatch('error', message: 'Run "Check for updates" first.'); - - return; - } - - $activity = UpdatePackage::run( - server: $this->server, - packageManager: $this->packageManager, - osId: $this->osId, - all: true - ); - if (is_array($activity)) { - $this->dispatch('error', message: $activity['error'] ?? 'Something went wrong.'); - - return; - } - $this->dispatch('activityMonitor', $activity->id, ServerPackageUpdated::class); + $this->runUpdate(all: true); } catch (\Throwable $e) { return handleError($e, $this); } } - public function updatePackage($package) + public function updatePackage(string $package) { try { $this->authorize('update', $this->server); - $activity = UpdatePackage::run(server: $this->server, packageManager: $this->packageManager, osId: $this->osId, package: $package); - if (is_array($activity)) { - $this->dispatch('error', message: $activity['error'] ?? 'Something went wrong.'); + $savedPackages = collect(data_get($this->server->settings->patch_check_result, 'updates', []))->pluck('package'); + if (! $savedPackages->contains($package)) { + $this->dispatch('error', message: 'This package has no update in the last check.'); return; } - $this->dispatch('activityMonitor', $activity->id, ServerPackageUpdated::class); - } catch (\Exception $e) { - $this->dispatch('error', message: $e->getMessage()); + $this->runUpdate(package: $package); + } catch (\Throwable $e) { + return handleError($e, $this); } } + public function savePatchCheckFrequency() + { + try { + $this->authorize('update', $this->server); + $frequency = trim($this->patchCheckFrequency); + if ($frequency !== '' && ! self::isValidPatchCheckFrequency($frequency)) { + $this->patchCheckFrequency = (string) $this->server->settings->patch_check_frequency; + throw new \Exception('Invalid check frequency. Use a cron expression or a preset (daily, weekly, monthly) that runs at most once per hour.'); + } + $this->server->settings->patch_check_frequency = $frequency === '' ? null : $frequency; + $this->server->settings->save(); + $this->patchCheckFrequency = $frequency; + $this->dispatch('success', 'Check frequency saved.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + /** + * A valid cron expression or preset that runs at most once per hour, because every check runs the package manager. + */ + public static function isValidPatchCheckFrequency(string $frequency): bool + { + if (! validate_cron_expression($frequency)) { + return false; + } + $runs = (new CronExpression(VALID_CRON_STRINGS[$frequency] ?? $frequency))->getMultipleRunDates(5); + for ($i = 1; $i < count($runs); $i++) { + if ($runs[$i]->getTimestamp() - $runs[$i - 1]->getTimestamp() < 3600) { + return false; + } + } + + return true; + } + + private function runUpdate(bool $all = false, ?string $package = null): void + { + $savedResult = $this->server->settings->patch_check_result; + $packageManager = data_get($savedResult, 'package_manager'); + $osId = data_get($savedResult, 'osId'); + if (! $packageManager || ! $osId) { + $this->dispatch('error', message: 'Run "Check for updates" first.'); + + return; + } + + $activity = UpdatePackage::run(server: $this->server, osId: $osId, package: $package, packageManager: $packageManager, all: $all); + if (is_array($activity)) { + $this->dispatch('error', message: $activity['error'] ?? 'Something went wrong.'); + + return; + } + $this->dispatch('startupdate'); + $this->dispatch('activityMonitor', $activity->id); + } + public function sendTestEmail() { if (! isDev()) { @@ -123,11 +169,7 @@ class Patches extends Component } try { - // Get current patch data or create test data if none exists - $testPatchData = $this->createTestPatchData(); - - // Send test notification - $this->server->team->notify(new ServerPatchCheck($this->server, $testPatchData)); + $this->server->team->notify(new ServerPatchCheck($this->server, $this->createTestPatchData())); $this->dispatch('success', 'Test email sent successfully! Check your email inbox.'); } catch (\Exception $e) { @@ -137,63 +179,19 @@ class Patches extends Component private function createTestPatchData(): array { - // If we have real patch data, use it - if (isset($this->updates) && is_array($this->updates) && count($this->updates) > 0) { - return [ - 'total_updates' => $this->totalUpdates, - 'updates' => $this->updates, - 'osId' => $this->osId, - 'package_manager' => $this->packageManager, - ]; + if (count(data_get($this->result, 'updates', [])) > 0) { + return $this->result; } - // Otherwise create realistic test data return [ - 'total_updates' => 8, + 'total_updates' => 3, 'updates' => [ - [ - 'package' => 'docker-ce', - 'current_version' => '24.0.7-1', - 'new_version' => '25.0.1-1', - ], - [ - 'package' => 'nginx', - 'current_version' => '1.20.2-1', - 'new_version' => '1.22.1-1', - ], - [ - 'package' => 'kernel-generic', - 'current_version' => '5.15.0-89', - 'new_version' => '5.15.0-91', - ], - [ - 'package' => 'openssh-server', - 'current_version' => '8.9p1-3', - 'new_version' => '9.0p1-1', - ], - [ - 'package' => 'curl', - 'current_version' => '7.81.0-1', - 'new_version' => '7.85.0-1', - ], - [ - 'package' => 'git', - 'current_version' => '2.34.1-1', - 'new_version' => '2.39.1-1', - ], - [ - 'package' => 'python3', - 'current_version' => '3.10.6-1', - 'new_version' => '3.11.0-1', - ], - [ - 'package' => 'htop', - 'current_version' => '3.2.1-1', - 'new_version' => '3.2.2-1', - ], + ['package' => 'docker-ce', 'current_version' => '24.0.7-1', 'new_version' => '25.0.1-1', 'architecture' => 'amd64', 'repository' => 'stable'], + ['package' => 'openssh-server', 'current_version' => '8.9p1-3', 'new_version' => '9.0p1-1', 'architecture' => 'amd64', 'repository' => 'noble-security'], + ['package' => 'curl', 'current_version' => '7.81.0-1', 'new_version' => '7.85.0-1', 'architecture' => 'amd64', 'repository' => 'noble-updates'], ], - 'osId' => $this->osId ?? 'ubuntu', - 'package_manager' => $this->packageManager ?? 'apt', + 'osId' => 'ubuntu', + 'package_manager' => 'apt', ]; } diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index f279f978ef..5519e1effa 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -153,6 +153,10 @@ class ServerSetting extends Model 'docker_version_checked_at', 'compose_version', 'compose_version_checked_at', + 'patch_check_frequency', + 'patch_check_result', + 'patch_check_error', + 'patch_checked_at', ]; /** @@ -190,6 +194,8 @@ class ServerSetting extends Model 'compose_version_checked_at' => 'datetime', 'backup_compression_cpu_percentage' => 'integer', 'server_disk_usage_notification_interval_hours' => 'integer', + 'patch_check_result' => 'array', + 'patch_checked_at' => 'datetime', ]; /** diff --git a/app/Services/ServerTransfer/ServerTransferCoverage.php b/app/Services/ServerTransfer/ServerTransferCoverage.php index 168ffae7c0..94200d4169 100644 --- a/app/Services/ServerTransfer/ServerTransferCoverage.php +++ b/app/Services/ServerTransfer/ServerTransferCoverage.php @@ -191,6 +191,7 @@ final class ServerTransferCoverage 'sentinel_token' => 'The target makes a new Sentinel token when it claims the server.', 'sentinel_custom_url' => 'The target sets its own URL when it claims the server.', 'cloudflare_tunnel_integration_token_id' => 'Exported as cloudflare_tunnel_integration_token_uuid. '.self::LINKED, + 'patch_check_result' => self::RUNTIME, 'patch_check_error' => self::RUNTIME, 'patch_checked_at' => self::RUNTIME, ], PrivateKey::class => ['team_id' => self::TEAM], StandaloneDocker::class => ['server_id' => self::LINKED], diff --git a/app/Services/ServerTransfer/ServerTransferExporter.php b/app/Services/ServerTransfer/ServerTransferExporter.php index 66895c4209..c80309cb9d 100644 --- a/app/Services/ServerTransfer/ServerTransferExporter.php +++ b/app/Services/ServerTransfer/ServerTransferExporter.php @@ -63,6 +63,10 @@ class ServerTransferExporter 'is_sentinel_debug_enabled', // Exported as cloudflare_tunnel_integration_token_uuid. 'cloudflare_tunnel_integration_token_id', + // The last patch check of the source; the target runs its own. + 'patch_check_result', + 'patch_check_error', + 'patch_checked_at', ]; /** diff --git a/app/Services/ServerTransfer/ServerTransferImporter.php b/app/Services/ServerTransfer/ServerTransferImporter.php index 053d043d0d..d183f4d76b 100644 --- a/app/Services/ServerTransfer/ServerTransferImporter.php +++ b/app/Services/ServerTransfer/ServerTransferImporter.php @@ -698,6 +698,7 @@ class ServerTransferImporter // A foreign key of the source instance; older bundles still contain it. 'cloudflare_tunnel_integration_token_id', 'cloudflare_tunnel_integration_token_uuid', + 'patch_check_result', 'patch_check_error', 'patch_checked_at', ]) ->all(); $server->settings->fill($safe); diff --git a/database/migrations/2026_10_19_082209_add_patch_check_to_server_settings_table.php b/database/migrations/2026_10_19_082209_add_patch_check_to_server_settings_table.php new file mode 100644 index 0000000000..4b6f0bd79a --- /dev/null +++ b/database/migrations/2026_10_19_082209_add_patch_check_to_server_settings_table.php @@ -0,0 +1,25 @@ +string('patch_check_frequency')->nullable(); + $table->json('patch_check_result')->nullable(); + $table->text('patch_check_error')->nullable(); + $table->timestamp('patch_checked_at')->nullable(); + }); + } + + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn(['patch_check_frequency', 'patch_check_result', 'patch_check_error', 'patch_checked_at']); + }); + } +}; diff --git a/docs/server-transfer-full-coverage.md b/docs/server-transfer-full-coverage.md index af6636b8f0..daabfaaec5 100644 --- a/docs/server-transfer-full-coverage.md +++ b/docs/server-transfer-full-coverage.md @@ -32,6 +32,7 @@ When you add a model or a setting to the transfer, add it to this file too. | ➕ | Server timezone | `Europe/Budapest` | | ➕ | Docker cleanup | force cleanup on, frequency `0 4 * * *`, threshold 70, delete unused volumes on, delete unused networks on | | ➕ | Disk usage notification | threshold 70, check frequency `*/30 * * * *`, interval 12 h | +| ➕ | Security > Patches | check frequency `daily` (the last check result is not moved; the target shows "never" until its first check) | | ➕ | Terminal | terminal access off | | ➕ | Image retention | "disable application image retention" on | | ➕ | Labels / Traefik | generate exact labels on, Traefik certificate resolver `custom` | diff --git a/resources/views/livewire/server/security/patches.blade.php b/resources/views/livewire/server/security/patches.blade.php index a094b8167a..3669a3a981 100644 --- a/resources/views/livewire/server/security/patches.blade.php +++ b/resources/views/livewire/server/security/patches.blade.php @@ -14,6 +14,14 @@ + @php + $updates = data_get($result, 'updates', []); + $totalUpdates = count($updates); + $packageManager = data_get($result, 'package_manager'); + $checkedAt = $server->settings->patch_checked_at; + $fullUpgradeOnly = in_array($packageManager, \App\Actions\Server\CheckUpdates::FULL_UPGRADE_ONLY, true); + @endphp +
@@ -28,122 +36,146 @@ Send test email @endif - - - Check for updates - + @can('update', $server) + + + Check for updates + + @endcan +
+ + Last checked: + @if ($checkedAt) + {{ $checkedAt->diffForHumans() }} + @else + never + @endif + + @if ($checkedAt && ! $error) + @if ($totalUpdates === 0) + + @else + + @endif + @endif +
+ - Automated package discovery currently supports apk, apt, dnf, pacman, and zypper. Weekly status + Automated package discovery currently supports apk, apt, dnf, pacman, and zypper. Status notifications can be managed from notification settings. -
- -
- - Inspecting installed packages… -
+ @if ($checking) +
+ +
+ + Inspecting installed packages… +
+
+
+ @elseif ($error) + + + {{ $error }} + -
- -
- @if ($error) - - - {{ $error }} - - - @elseif ($totalUpdates === 0) - - - - @elseif (isset($updates) && count($updates) > 0) - + @elseif ($checkedAt && $totalUpdates === 0) + + + + @elseif ($totalUpdates > 0) + + @can('update', $server) - + @endcan -
-
- Package - New version - Action -
- @foreach ($updates as $update) -
-
- @if (data_get_str($update, 'package')->contains('docker') || data_get_str($update, 'package')->contains('kernel')) - - @endif - - {{ data_get($update, 'package') }} - -
-
-

- {{ data_get($update, 'new_version') }} + @if ($fullUpgradeOnly) +

+ + Arch Linux does not support partial upgrades, so packages cannot be updated one by one. + +
+ @endif + +
+
+ Package + New version + Action +
+ @foreach ($updates as $update) +
+
+ + {{ data_get($update, 'package') }} + +
+
+

+ {{ data_get($update, 'new_version') }} +

+ @if (data_get($update, 'current_version') && data_get($update, 'current_version') !== 'unknown') +

+ Current: {{ data_get($update, 'current_version') }}

- @if ($packageManager !== 'dnf' && data_get($update, 'current_version')) -

- Current: {{ data_get($update, 'current_version') }} -

- @endif -
-
- @unless (in_array($packageManager, \App\Actions\Server\CheckUpdates::FULL_UPGRADE_ONLY, true)) + @endif +
+
+ @if (! $fullUpgradeOnly) + @can('update', $server) + wire:click="updatePackage({{ Js::from(data_get($update, 'package')) }})"> Update - @endunless -
+ @endcan + @endif
- @endforeach -
- {{ count($updates) }} {{ Str::plural('package update', count($updates)) }}
+ @endforeach +
+ {{ $totalUpdates }} {{ Str::plural('package update', $totalUpdates) }}
- - @endif -
+
+ + @endif + +
+ + + @can('update', $server) + Save + @endcan + + + +
- - @script - - @endscript
diff --git a/tests/Feature/Server/ServerPatchesTest.php b/tests/Feature/Server/ServerPatchesTest.php new file mode 100644 index 0000000000..21a8cf77ba --- /dev/null +++ b/tests/Feature/Server/ServerPatchesTest.php @@ -0,0 +1,265 @@ +withoutVite(); + InstanceSettings::forceCreate(['id' => 0]); + + $this->team = Team::factory()->create(); + $this->admin = User::factory()->create(); + $this->team->members()->attach($this->admin, ['role' => 'admin']); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, + ]); + $this->server->settings->forceFill(['is_reachable' => true, 'is_usable' => true])->saveQuietly(); +}); + +function patchesTestResult(int $updates = 1): array +{ + return [ + 'osId' => 'ubuntu', + 'package_manager' => 'apt', + 'total_updates' => $updates, + 'updates' => array_slice([ + ['package' => 'openssl', 'current_version' => '3.0.13-0ubuntu3.1', 'new_version' => '3.0.13-0ubuntu3.4', 'architecture' => 'amd64', 'repository' => 'noble-updates,noble-security'], + ['package' => 'curl', 'current_version' => '8.5.0-2ubuntu10.1', 'new_version' => '8.5.0-2ubuntu10.6', 'architecture' => 'amd64', 'repository' => 'noble-updates'], + ], 0, $updates), + ]; +} + +function runPatchesTestJob(Server $server, bool $manual = false): void +{ + (new ServerPatchCheckJob($server->fresh(), $manual))->handle(); +} + +describe('patch check job', function () { + beforeEach(function () { + Notification::fake(); + Event::fake([ServerPatchCheckFinished::class]); + $this->team->emailNotificationSettings()->update(['use_instance_email_settings' => true, 'server_patch_email_notifications' => true]); + }); + + it('saves the result, clears the running flag, and broadcasts to the server team', function () { + CheckUpdates::shouldRun()->andReturn(patchesTestResult(2)); + Cache::put(ServerPatchCheckJob::runningCacheKey($this->server), true); + + runPatchesTestJob($this->server, manual: true); + + $settings = $this->server->settings->fresh(); + expect($settings->patch_check_result['total_updates'])->toBe(2) + ->and($settings->patch_check_result['updates'][0]['package'])->toBe('openssl') + ->and($settings->patch_check_error)->toBeNull() + ->and($settings->patch_checked_at)->not->toBeNull() + ->and(Cache::has(ServerPatchCheckJob::runningCacheKey($this->server)))->toBeFalse(); + Event::assertDispatched(ServerPatchCheckFinished::class, fn ($event) => $event->teamId === $this->team->id && $event->serverUuid === $this->server->uuid); + Notification::assertNothingSent(); + }); + + it('notifies about available updates on a scheduled check', function () { + CheckUpdates::shouldRun()->andReturn(patchesTestResult()); + + runPatchesTestJob($this->server); + + Notification::assertSentTo($this->team, ServerPatchCheck::class); + }); + + it('sends the failure notification only when the check changes from ok to error', function () { + CheckUpdates::shouldRun()->andReturn( + ['osId' => 'ubuntu', 'package_manager' => 'apt', 'error' => 'apt is locked'], + ['osId' => 'ubuntu', 'package_manager' => 'apt', 'error' => 'apt is locked'], + patchesTestResult(0), + ['osId' => 'ubuntu', 'package_manager' => 'apt', 'error' => 'apt is locked again'], + ); + + runPatchesTestJob($this->server); + runPatchesTestJob($this->server); + expect($this->server->settings->fresh()->patch_check_error)->toBe('apt is locked'); + Notification::assertSentToTimes($this->team, ServerPatchCheck::class, 1); + + runPatchesTestJob($this->server); + expect($this->server->settings->fresh()->patch_check_error)->toBeNull(); + + runPatchesTestJob($this->server); + Notification::assertSentToTimes($this->team, ServerPatchCheck::class, 2); + }); + + it('saves an error for a manual check of a server that is not functional', function () { + $this->server->settings->forceFill(['is_reachable' => false])->saveQuietly(); + CheckUpdates::shouldRun()->never(); + + runPatchesTestJob($this->server, manual: true); + + expect($this->server->settings->fresh()->patch_check_error)->toBe('Server is not reachable or not ready.'); + Event::assertDispatched(ServerPatchCheckFinished::class); + }); +}); + +describe('check frequency', function () { + it('uses the server frequency instead of the weekly default', function () { + $default = ServerManagerJob::patchCheckCron($this->server); + expect($default)->toEndWith('* * 0'); + + $this->server->settings->forceFill(['patch_check_frequency' => 'daily'])->saveQuietly(); + expect(ServerManagerJob::patchCheckCron($this->server->fresh()))->toBe('0 0 * * *'); + + $this->server->settings->forceFill(['patch_check_frequency' => '30 3 * * 1'])->saveQuietly(); + expect(ServerManagerJob::patchCheckCron($this->server->fresh()))->toBe('30 3 * * 1'); + }); + + it('accepts presets and cron expressions that run at most once per hour', function (string $frequency, bool $valid) { + expect(Patches::isValidPatchCheckFrequency($frequency))->toBe($valid); + })->with([ + ['daily', true], + ['hourly', true], + ['0 3 * * *', true], + ['every_minute', false], + ['*/30 * * * *', false], + ['not a cron', false], + ]); + + it('saves a valid frequency and rejects an invalid one', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]) + ->set('patchCheckFrequency', 'daily') + ->call('savePatchCheckFrequency') + ->assertDispatched('success') + ->set('patchCheckFrequency', '* * * * *') + ->call('savePatchCheckFrequency') + ->assertDispatched('error'); + + expect($this->server->settings->fresh()->patch_check_frequency)->toBe('daily'); + }); +}); + +describe('patches page', function () { + beforeEach(function () { + Queue::fake(); + }); + + it('shows the saved result of the last check', function () { + $this->server->settings->forceFill(['patch_check_result' => patchesTestResult(2), 'patch_checked_at' => now()->subHour()])->saveQuietly(); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]) + ->assertSee('1 hour ago') + ->assertSee('openssl') + ->assertSee('8.5.0-2ubuntu10.6'); + }); + + it('queues the check for an admin and shows the progress', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]) + ->call('checkForUpdates') + ->assertSet('checking', true) + ->assertSee('Inspecting installed packages'); + + Queue::assertPushed(ServerPatchCheckJob::class, fn ($job) => $job->manual && $job->server->is($this->server)); + }); + + it('refreshes from the saved result when the check finishes', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + $component = Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])->call('checkForUpdates'); + + $this->server->settings->forceFill(['patch_check_result' => patchesTestResult(), 'patch_checked_at' => now()])->saveQuietly(); + Cache::forget(ServerPatchCheckJob::runningCacheKey($this->server)); + + $component->call('patchCheckFinished', ['serverUuid' => $this->server->uuid]) + ->assertSet('checking', false) + ->assertSee('openssl'); + }); + + it('updates only a package from the saved result', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + $this->server->settings->forceFill(['patch_check_result' => patchesTestResult(), 'patch_checked_at' => now()])->saveQuietly(); + + Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]) + ->call('updatePackage', 'curl; reboot') + ->assertDispatched('error', message: 'This package has no update in the last check.'); + }); +}); + +describe('patches authorization', function () { + beforeEach(function () { + Queue::fake(); + $this->member = User::factory()->create(); + $this->team->members()->attach($this->member, ['role' => 'member']); + }); + + it('does not let a member open the page', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])->assertForbidden(); + }); + + it('does not show a server of another team', function () { + $otherTeam = Team::factory()->create(); + $this->admin->teams()->attach($otherTeam, ['role' => 'owner']); + $this->actingAs($this->admin); + session(['currentTeam' => $otherTeam]); + + expect(fn () => Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]))->toThrow(ModelNotFoundException::class); + }); + + it('uses the role in the server team after the session switches to a team the user owns', function () { + $otherTeam = Team::factory()->create(); + $this->member->teams()->attach($otherTeam, ['role' => 'owner']); + $this->member->teams()->updateExistingPivot($this->team->id, ['role' => 'admin']); + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]); + + // The user is now only a member of the server team, but owns the session team. + $this->member->teams()->updateExistingPivot($this->team->id, ['role' => 'member']); + $this->member->unsetRelation('teams'); + session(['currentTeam' => $otherTeam]); + + $component->call('checkForUpdates')->assertDispatched('error'); + $component->set('patchCheckFrequency', 'daily')->call('savePatchCheckFrequency')->assertDispatched('error'); + + Queue::assertNothingPushed(); + expect($this->server->settings->fresh()->patch_check_frequency)->toBeNull(); + }); +}); + +it('runs a new check when a package update finishes', function () { + Queue::fake(); + + ServerPackagesUpdateFinished::dispatch(['server_uuid' => $this->server->uuid, 'packages' => ['curl']]); + + Queue::assertPushed(ServerPatchCheckJob::class, fn ($job) => $job->manual && $job->server->is($this->server)); + expect(Cache::has(ServerPatchCheckJob::runningCacheKey($this->server)))->toBeTrue(); +}); diff --git a/tests/Feature/TeamBroadcastEventsTest.php b/tests/Feature/TeamBroadcastEventsTest.php index c41abb7c99..f07a521501 100644 --- a/tests/Feature/TeamBroadcastEventsTest.php +++ b/tests/Feature/TeamBroadcastEventsTest.php @@ -11,7 +11,6 @@ use App\Events\ProxyStatusChangedUI; use App\Events\ScheduledTaskDone; use App\Events\SentinelRestarted; use App\Events\SentinelSynchronized; -use App\Events\ServerPackageUpdated; use App\Events\ServerValidated; use App\Events\ServiceChecked; use App\Events\ServiceStatusChanged; @@ -54,7 +53,6 @@ dataset('team broadcast events', [ FileStorageChanged::class, ProxyStatusChangedUI::class, ScheduledTaskDone::class, - ServerPackageUpdated::class, ServerValidated::class, ServiceChecked::class, ServiceStatusChanged::class, diff --git a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php index 26eb2f9aad..7970238a1b 100644 --- a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php +++ b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php @@ -390,6 +390,31 @@ test('the Cloudflare tunnel token moves with the server by uuid and the raw toke ->and($imported->token)->toBe('cf-tunnel-token'); }); +test('the patch check frequency moves with the server and the last patch check result stays on the source', function () { + $this->server->settings->forceFill([ + 'patch_check_frequency' => 'daily', + 'patch_check_result' => ['osId' => 'ubuntu', 'package_manager' => 'apt', 'total_updates' => 0, 'updates' => []], + 'patch_check_error' => 'old error', + 'patch_checked_at' => now(), + ])->saveQuietly(); + + $bundle = $this->exporter->export($this->server); + expect(data_get($bundle, 'server.settings'))->not->toHaveKeys(['patch_check_result', 'patch_check_error', 'patch_checked_at']); + + $bundle['server']['settings']['patch_check_result'] = ['osId' => 'tampered']; + $this->service->forceDelete(); + $this->application->forceDelete(); + $this->database->forceDelete(); + $this->server->forceDelete(); + + $result = $this->importer->import($bundle, teamId: $this->team->id); + + $settings = Server::where('uuid', $result['server_uuid'])->first()->settings; + expect($settings->patch_check_frequency)->toBe('daily') + ->and($settings->patch_check_result)->toBeNull() + ->and($settings->patch_checked_at)->toBeNull(); +}); + test('a transfer back replaces the archive of the same team in the import transaction', function () { $bundle = $this->exporter->export($this->server); $bundle['server']['name'] = 'changed-on-the-other-instance';