From 3da85e2c07b72b7caa5740a1b4401decbd997781 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:39:51 +0200 Subject: [PATCH] feat(patches): mark security updates, reboot state, and risky packages - Security updates: apt marks a package from a "-security" suite (Debian 12 now reports oldstable-security). dnf reads `dnf updateinfo list --security` (dnf5 keeps updateinfo as an alias of advisory). zypper uses `zypper -x patch --dry-run --category security`, because `zypper -x list-patches` ignores --category and has no package names. pacman and apk cannot tell, so they show no security count. https://dnf5.readthedocs.io/en/latest/commands/advisory.8.html https://github.com/openSUSE/zypper/blob/master/doc/zypper.8.txt - Reboot required: /var/run/reboot-required, `needrestart -b` (KSTA 2 or 3), `dnf needs-restarting -r` (matched by text, because a missing plugin also exits 1), and `zypper needs-rebooting` (exit 102). https://github.com/liske/needrestart/blob/master/README.batch.md https://dnf-plugins-core.readthedocs.io/en/latest/needs_restarting.html - An explicit risk list per package manager (Docker engine, kernels, libc/glibc/musl, systemd, dbus, openssh) replaces the "name contains docker/kernel" guess on the page and in every notification channel. - The page shows security and reboot badges, a security count, and the risk of each package. "Reboot server" (with confirmation, update policy on the loaded server) schedules the reboot with systemd-run and checks the server again five minutes later. Archived (transferred) servers are never touched. Co-Authored-By: Claude Opus 5.5 --- app/Actions/Server/CheckUpdates.php | 209 +++++++++++++++--- app/Actions/Server/RebootServer.php | 38 ++++ app/Jobs/ServerPatchCheckJob.php | 2 +- app/Livewire/Server/Security/Patches.php | 24 ++ app/Notifications/Server/ServerPatchCheck.php | 48 ++-- .../views/emails/server-patches.blade.php | 15 +- .../server/security/patches.blade.php | 41 +++- tests/Feature/Server/ServerPatchesTest.php | 71 ++++++ .../Server/ServerPatchCommandsTest.php | 112 ++++++++++ 9 files changed, 489 insertions(+), 71 deletions(-) create mode 100644 app/Actions/Server/RebootServer.php diff --git a/app/Actions/Server/CheckUpdates.php b/app/Actions/Server/CheckUpdates.php index a50af94ddb..d0586f2d16 100644 --- a/app/Actions/Server/CheckUpdates.php +++ b/app/Actions/Server/CheckUpdates.php @@ -35,6 +35,74 @@ class CheckUpdates */ public const PACMAN_CHECK_COMMAND = "sh -c 'if command -v checkupdates >/dev/null 2>&1 && command -v fakeroot >/dev/null 2>&1; then checkupdates; rc=\$?; if [ \$rc -eq 2 ]; then exit 0; fi; exit \$rc; fi; db=\$(mktemp -d) || exit 1; chmod 755 \"\$db\"; ln -s /var/lib/pacman/local \"\$db/local\"; if ! LANG=C pacman -Sy --dbpath \"\$db\" --logfile /dev/null >/dev/null; then rm -rf \"\$db\"; exit 1; fi; pacman -Qu --dbpath \"\$db\" 2>/dev/null; rm -rf \"\$db\"; exit 0'"; + /** + * Security advisories of the available updates. dnf5 still accepts `updateinfo` as an alias of `advisory`. + * + * @see https://dnf5.readthedocs.io/en/latest/commands/advisory.8.html + */ + public const DNF_SECURITY_COMMAND = "sh -c 'LANG=C dnf updateinfo list --security -q 2>/dev/null; exit 0'"; + + /** + * Packages that the security patches would upgrade. `zypper -x list-patches` ignores --category, and it does + * not contain package names, so a dry run of `zypper patch` is used. + * + * @see https://github.com/openSUSE/zypper/blob/master/doc/zypper.8.txt + */ + public const ZYPPER_SECURITY_COMMAND = "sh -c 'LANG=C zypper -n -x patch --dry-run --category security 2>/dev/null; exit 0'"; + + /** + * Prints REBOOT=yes|no|unknown. Debian/Ubuntu write /var/run/reboot-required; needrestart batch mode reports the + * kernel status (KSTA 2 and 3 mean a newer kernel is installed); dnf needs-restarting and zypper needs-rebooting + * (exit code 102) cover the RHEL and SUSE families. Text is matched because a missing dnf plugin also exits 1. + * + * @see https://github.com/liske/needrestart/blob/master/README.batch.md + * @see https://dnf-plugins-core.readthedocs.io/en/latest/needs_restarting.html + */ + public const REBOOT_CHECK_COMMAND = <<<'SH' +sh -c 'if [ -f /var/run/reboot-required ]; then echo REBOOT=yes; exit 0; fi; if command -v needrestart >/dev/null 2>&1; then k=$(needrestart -b 2>/dev/null | sed -n "s/^NEEDRESTART-KSTA: *//p"); case "$k" in 2|3) echo REBOOT=yes; exit 0;; 1) echo REBOOT=no; exit 0;; esac; fi; if command -v dnf >/dev/null 2>&1; then o=$(LANG=C dnf needs-restarting -r 2>/dev/null); case "$o" in *"Reboot is required"*) echo REBOOT=yes; exit 0;; *"should not be necessary"*) echo REBOOT=no; exit 0;; esac; fi; if command -v zypper >/dev/null 2>&1; then zypper -q needs-rebooting >/dev/null 2>&1; rc=$?; if [ $rc -eq 102 ]; then echo REBOOT=yes; exit 0; fi; if [ $rc -eq 0 ]; then echo REBOOT=no; exit 0; fi; fi; if [ -d /var/lib/dpkg ]; then echo REBOOT=no; exit 0; fi; echo REBOOT=unknown' +SH; + + /** + * Packages whose update can interrupt the server, per package manager. `*` matches like a shell glob. + * docker: restarts the Docker daemon and with it the running containers. kernel: needs a reboot. + * system: core libraries and services that are restarted or need a reboot to apply fully. + * + * @var array>> + */ + public const RISKY_PACKAGES = [ + 'apt' => [ + 'docker' => ['docker-ce', 'docker-ce-cli', 'docker-ce-rootless-extras', 'docker-buildx-plugin', 'docker-compose-plugin', 'containerd.io', 'containerd', 'runc', 'docker.io'], + 'kernel' => ['linux-image-*', 'linux-modules-*', 'linux-generic*', 'linux-virtual*', 'linux-aws*', 'linux-azure*', 'linux-gcp*', 'linux-oracle*', 'linux-kvm*', 'linux-raspi*'], + 'system' => ['libc6', 'libc-bin', 'systemd', 'systemd-sysv', 'libsystemd0', 'udev', 'dbus', 'openssh-server', 'openssh-sftp-server'], + ], + 'dnf' => [ + 'docker' => ['docker-ce', 'docker-ce-cli', 'docker-ce-rootless-extras', 'docker-buildx-plugin', 'docker-compose-plugin', 'containerd.io', 'containerd', 'runc', 'moby-engine', 'docker'], + 'kernel' => ['kernel', 'kernel-core', 'kernel-modules', 'kernel-modules-core', 'kernel-modules-extra', 'kernel-uek', 'kernel-uek-core', 'kernel-uek-modules'], + 'system' => ['glibc', 'glibc-common', 'glibc-langpack-*', 'systemd', 'systemd-libs', 'systemd-udev', 'dbus', 'dbus-broker', 'openssh-server'], + ], + 'zypper' => [ + 'docker' => ['docker', 'docker-ce', 'containerd', 'runc', 'docker-compose', 'docker-buildx'], + 'kernel' => ['kernel-default', 'kernel-default-base', 'kernel-azure', 'kernel-kvmsmall'], + 'system' => ['glibc', 'systemd', 'libsystemd0', 'udev', 'dbus-1', 'openssh', 'openssh-server'], + ], + 'pacman' => [ + 'docker' => ['docker', 'containerd', 'runc', 'docker-compose', 'docker-buildx'], + 'kernel' => ['linux', 'linux-lts', 'linux-zen', 'linux-hardened'], + 'system' => ['glibc', 'systemd', 'systemd-libs', 'dbus', 'openssh'], + ], + 'apk' => [ + 'docker' => ['docker', 'docker-engine', 'docker-cli', 'docker-openrc', 'docker-cli-compose', 'docker-cli-buildx', 'containerd', 'runc'], + 'kernel' => ['linux-lts', 'linux-virt', 'linux-edge'], + 'system' => ['musl', 'openrc', 'openssh', 'openssh-server'], + ], + ]; + + public const RISK_LABELS = [ + 'docker' => 'Restarts Docker and the running containers', + 'kernel' => 'Needs a reboot to apply', + 'system' => 'Restarts core system services', + ]; + public function handle(Server $server) { $osId = 'unknown'; @@ -97,47 +165,29 @@ class CheckUpdates default => null }; + $securityPackages = null; switch ($packageManager) { case 'zypper': - $output = instant_remote_process(['LANG=C zypper -tx list-updates'], $server); - $out = $this->parseZypperOutput($output); - $out['osId'] = $osId; - $out['package_manager'] = $packageManager; - - return $out; + $out = $this->parseZypperOutput(instant_remote_process(['LANG=C zypper -tx list-updates'], $server)); + $securityPackages = $this->parseZypperSecurityPackages((string) instant_remote_process([self::ZYPPER_SECURITY_COMMAND], $server, false)); + break; case 'dnf': $output = instant_remote_process([self::DNF_UPGRADES_COMMAND], $server); $installed = instant_remote_process([self::RPM_INSTALLED_COMMAND], $server); $out = $this->parseDnfOutput($output, $installed); - $out['osId'] = $osId; - $out['package_manager'] = $packageManager; - - return $out; + $securityPackages = $this->parseDnfSecurityPackages((string) instant_remote_process([self::DNF_SECURITY_COMMAND], $server, false)); + break; case 'apt': instant_remote_process(['apt-get update -qq'], $server); - $output = instant_remote_process(['LANG=C apt list --upgradable 2>/dev/null'], $server); - - $out = $this->parseAptOutput($output); - $out['osId'] = $osId; - $out['package_manager'] = $packageManager; - - return $out; + $out = $this->parseAptOutput(instant_remote_process(['LANG=C apt list --upgradable 2>/dev/null'], $server)); + break; case 'pacman': - $output = instant_remote_process([self::PACMAN_CHECK_COMMAND], $server); - $out = $this->parsePacmanOutput($output); - $out['osId'] = $osId; - $out['package_manager'] = $packageManager; - - return $out; + $out = $this->parsePacmanOutput(instant_remote_process([self::PACMAN_CHECK_COMMAND], $server)); + break; case 'apk': instant_remote_process(['apk update -q'], $server); - $output = instant_remote_process(['LANG=C apk list --upgradable 2>/dev/null'], $server); - - $out = $this->parseApkOutput($output); - $out['osId'] = $osId; - $out['package_manager'] = $packageManager; - - return $out; + $out = $this->parseApkOutput(instant_remote_process(['LANG=C apk list --upgradable 2>/dev/null'], $server)); + break; default: return [ 'osId' => $osId, @@ -145,6 +195,13 @@ class CheckUpdates 'package_manager' => $packageManager, ]; } + + $out = $this->addUpdateDetails($out, $packageManager, $securityPackages); + $out['reboot_required'] = $this->parseRebootStatus((string) instant_remote_process([self::REBOOT_CHECK_COMMAND], $server, false)); + $out['osId'] = $osId; + $out['package_manager'] = $packageManager; + + return $out; } catch (\Throwable $e) { Log::warning('Server patch check failed', ['server_uuid' => $server->uuid, 'error' => $e->getMessage()]); @@ -156,6 +213,98 @@ class CheckUpdates } } + /** + * Adds the security flag and the risk to each update, and the number of security updates. + * The security count is null when the package manager cannot tell (pacman, apk) or the advisory query failed. + * + * @param list|null $securityPackages Package names with a security advisory (dnf, zypper). + */ + public function addUpdateDetails(array $out, string $packageManager, ?array $securityPackages = null): array + { + $knowsSecurity = $packageManager === 'apt' || $securityPackages !== null; + + $out['updates'] = array_map(function (array $update) use ($packageManager, $securityPackages) { + $update['security'] = match ($packageManager) { + 'apt' => collect(explode(',', (string) ($update['repository'] ?? '')))->contains(fn (string $suite) => str_ends_with($suite, '-security')), + default => in_array($update['package'], $securityPackages ?? [], true), + }; + $update['risk'] = self::riskFor($packageManager, $update['package']); + + return $update; + }, $out['updates'] ?? []); + $out['security_updates'] = $knowsSecurity ? count(array_filter($out['updates'], fn (array $update) => $update['security'])) : null; + + return $out; + } + + /** + * @return 'docker'|'kernel'|'system'|null + */ + public static function riskFor(?string $packageManager, string $package): ?string + { + foreach (self::RISKY_PACKAGES[$packageManager] ?? [] as $risk => $patterns) { + foreach ($patterns as $pattern) { + if (fnmatch($pattern, $package)) { + return $risk; + } + } + } + + return null; + } + + /** + * Package names from `dnf updateinfo list --security` (dnf4: "RLSA-2024:1234 Important/Sec. openssl-1:3.0.7-27.el9.x86_64", + * dnf5: "FEDORA-2024-1 security Critical curl-8.6.0-1.fc40.x86_64 2024-01-01 00:00:00"). + * + * @return list + */ + public function parseDnfSecurityPackages(string $output): array + { + $packages = []; + foreach (preg_split('/\s+/', $output) as $token) { + // name-[epoch:]version-release.arch: the name is everything before the last two dashes. + if (preg_match('/^(.+)-[^-\/]+-[^-\/]+\.[A-Za-z0-9_]+$/', $token, $matches)) { + $packages[] = $matches[1]; + } + } + + return array_values(array_unique($packages)); + } + + /** + * Package names that `zypper -x patch --dry-run --category security` would upgrade. + * + * @return list + */ + public function parseZypperSecurityPackages(string $output): array + { + if (! preg_match_all('#(.*?)#s', $output, $blocks)) { + return []; + } + + $packages = []; + foreach ($blocks[1] as $block) { + preg_match_all('#]*>#', $block, $solvables); + foreach ($solvables[0] as $solvable) { + if (preg_match('#\bkind="package"#', $solvable) && preg_match('#\bname="([^"]+)"#', $solvable, $name)) { + $packages[] = $name[1]; + } + } + } + + return array_values(array_unique($packages)); + } + + public function parseRebootStatus(string $output): ?bool + { + return match (true) { + str_contains($output, 'REBOOT=yes') => true, + str_contains($output, 'REBOOT=no') => false, + default => null, + }; + } + public function parseZypperOutput(string $output): array { $updates = []; diff --git a/app/Actions/Server/RebootServer.php b/app/Actions/Server/RebootServer.php new file mode 100644 index 0000000000..0ca6a25f6b --- /dev/null +++ b/app/Actions/Server/RebootServer.php @@ -0,0 +1,38 @@ +/dev/null 2>&1; then systemd-run --on-active=5 --timer-property=AccuracySec=1s systemctl reboot >/dev/null 2>&1; else nohup sh -c "sleep 5; reboot" >/dev/null 2>&1 isTransferredAway()) { + throw new \RuntimeException('This server is managed by another Coolify instance.'); + } + if (! $server->isFunctional()) { + throw new \RuntimeException('Server is not reachable or not ready.'); + } + + $output = instant_remote_process([self::COMMAND], $server); + if (! str_contains((string) $output, 'REBOOT_SCHEDULED')) { + throw new \RuntimeException('The reboot could not be started.'); + } + + // Check again when the server is back, so the "Reboot required" state is cleared. + ServerPatchCheckJob::dispatch($server, true)->delay(now()->addMinutes(5)); + } +} diff --git a/app/Jobs/ServerPatchCheckJob.php b/app/Jobs/ServerPatchCheckJob.php index f35881efa9..3e135f721b 100644 --- a/app/Jobs/ServerPatchCheckJob.php +++ b/app/Jobs/ServerPatchCheckJob.php @@ -57,7 +57,7 @@ class ServerPatchCheckJob implements ShouldBeEncrypted, ShouldQueue { try { $team = data_get($this->server, 'team'); - if (! $team) { + if (! $team || $this->server->isTransferredAway()) { return; } diff --git a/app/Livewire/Server/Security/Patches.php b/app/Livewire/Server/Security/Patches.php index dd5a37070e..ea1c1f5625 100644 --- a/app/Livewire/Server/Security/Patches.php +++ b/app/Livewire/Server/Security/Patches.php @@ -2,6 +2,7 @@ namespace App\Livewire\Server\Security; +use App\Actions\Server\RebootServer; use App\Actions\Server\UpdatePackage; use App\Jobs\ServerPatchCheckJob; use App\Models\Server; @@ -70,6 +71,7 @@ class Patches extends Component { try { $this->authorize('update', $this->server); + $this->ensureManagedHere(); ServerPatchCheckJob::dispatchManual($this->server); $this->checking = true; } catch (\Throwable $e) { @@ -103,6 +105,17 @@ class Patches extends Component } } + public function rebootServer() + { + try { + $this->authorize('update', $this->server); + RebootServer::run($this->server); + $this->dispatch('success', 'Reboot started. The server is back in a few minutes, and Coolify then checks it again.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function savePatchCheckFrequency() { try { @@ -139,8 +152,19 @@ class Patches extends Component return true; } + /** + * A transferred server is an archive: another instance manages the host. + */ + private function ensureManagedHere(): void + { + if ($this->server->isTransferredAway()) { + throw new \RuntimeException('This server is managed by another Coolify instance.'); + } + } + private function runUpdate(bool $all = false, ?string $package = null): void { + $this->ensureManagedHere(); $savedResult = $this->server->settings->patch_check_result; $packageManager = data_get($savedResult, 'package_manager'); $osId = data_get($savedResult, 'osId'); diff --git a/app/Notifications/Server/ServerPatchCheck.php b/app/Notifications/Server/ServerPatchCheck.php index ba6cd49829..84b3028502 100644 --- a/app/Notifications/Server/ServerPatchCheck.php +++ b/app/Notifications/Server/ServerPatchCheck.php @@ -8,6 +8,7 @@ use App\Notifications\Dto\DiscordMessage; use App\Notifications\Dto\PushoverMessage; use App\Notifications\Dto\SlackMessage; use Illuminate\Notifications\Messages\MailMessage; +use Illuminate\Support\Collection; class ServerPatchCheck extends CustomEmailNotification { @@ -48,6 +49,8 @@ class ServerPatchCheck extends CustomEmailNotification 'name' => $this->server->name, 'total_updates' => $totalUpdates, 'updates' => $this->patchData['updates'] ?? [], + 'security_updates' => $this->patchData['security_updates'] ?? null, + 'reboot_required' => $this->patchData['reboot_required'] ?? null, 'osId' => $this->patchData['osId'] ?? 'unknown', 'package_manager' => $this->patchData['package_manager'] ?? 'unknown', 'server_url' => $this->serverUrl, @@ -101,12 +104,7 @@ class ServerPatchCheck extends CustomEmailNotification } // Check for critical packages - $criticalPackages = collect($updates)->filter(function ($update) { - return str_contains(strtolower($update['package']), 'docker') || - str_contains(strtolower($update['package']), 'kernel') || - str_contains(strtolower($update['package']), 'openssh') || - str_contains(strtolower($update['package']), 'ssl'); - }); + $criticalPackages = $this->criticalPackages(); if ($criticalPackages->count() > 0) { $description .= "\n **Critical packages detected** ({$criticalPackages->count()} packages may require restarts)"; @@ -169,12 +167,7 @@ class ServerPatchCheck extends CustomEmailNotification } // Check for critical packages - $criticalPackages = collect($updates)->filter(function ($update) { - return str_contains(strtolower($update['package']), 'docker') || - str_contains(strtolower($update['package']), 'kernel') || - str_contains(strtolower($update['package']), 'openssh') || - str_contains(strtolower($update['package']), 'ssl'); - }); + $criticalPackages = $this->criticalPackages(); if ($criticalPackages->count() > 0) { $message .= "\n⚠️ Critical packages detected: {$criticalPackages->count()} packages may require restarts\n"; @@ -247,12 +240,7 @@ class ServerPatchCheck extends CustomEmailNotification } // Check for critical packages - $criticalPackages = collect($updates)->filter(function ($update) { - return str_contains(strtolower($update['package']), 'docker') || - str_contains(strtolower($update['package']), 'kernel') || - str_contains(strtolower($update['package']), 'openssh') || - str_contains(strtolower($update['package']), 'ssl'); - }); + $criticalPackages = $this->criticalPackages(); if ($criticalPackages->count() > 0) { $message .= "\nCritical packages detected: {$criticalPackages->count()} may require restarts"; @@ -316,12 +304,7 @@ class ServerPatchCheck extends CustomEmailNotification } // Check for critical packages - $criticalPackages = collect($updates)->filter(function ($update) { - return str_contains(strtolower($update['package']), 'docker') || - str_contains(strtolower($update['package']), 'kernel') || - str_contains(strtolower($update['package']), 'openssh') || - str_contains(strtolower($update['package']), 'ssl'); - }); + $criticalPackages = $this->criticalPackages(); if ($criticalPackages->count() > 0) { $description .= "\n:warning: *Critical packages detected:* {$criticalPackages->count()} packages may require restarts\n"; @@ -364,12 +347,7 @@ class ServerPatchCheck extends CustomEmailNotification $updates = $this->patchData['updates'] ?? []; // Check for critical packages - $criticalPackages = collect($updates)->filter(function ($update) { - return str_contains(strtolower($update['package']), 'docker') || - str_contains(strtolower($update['package']), 'kernel') || - str_contains(strtolower($update['package']), 'openssh') || - str_contains(strtolower($update['package']), 'ssl'); - }); + $criticalPackages = $this->criticalPackages(); return [ 'success' => false, @@ -382,7 +360,17 @@ class ServerPatchCheck extends CustomEmailNotification 'package_manager' => $this->patchData['package_manager'] ?? 'unknown', 'updates' => $updates, 'critical_packages_count' => $criticalPackages->count(), + 'security_updates' => $this->patchData['security_updates'] ?? null, + 'reboot_required' => $this->patchData['reboot_required'] ?? null, 'url' => $this->serverUrl, ]; } + + /** + * Updates that can restart Docker or core services, or need a reboot (see CheckUpdates::RISKY_PACKAGES). + */ + private function criticalPackages(): Collection + { + return collect($this->patchData['updates'] ?? [])->filter(fn (array $update) => filled($update['risk'] ?? null))->values(); + } } diff --git a/resources/views/emails/server-patches.blade.php b/resources/views/emails/server-patches.blade.php index 37644eaa2e..21e455e6d8 100644 --- a/resources/views/emails/server-patches.blade.php +++ b/resources/views/emails/server-patches.blade.php @@ -6,13 +6,19 @@ - Operating System: {{ ucfirst($osId) }} - Package Manager: {{ $package_manager }} - Total Updates: {{ $total_updates }} +@if (isset($security_updates)) +- Security Updates: {{ $security_updates }} +@endif +@if ($reboot_required ?? false) +- A reboot is required to apply installed updates. +@endif ## Available Updates @if ($total_updates > 0) @foreach ($updates as $update) -Package: {{ $update['package'] }} ({{ $update['architecture'] }}), from version {{ $update['current_version'] }} to {{ $update['new_version'] }} at repository {{ $update['repository'] ?? 'Unknown' }} +Package: {{ $update['package'] }}{{ ($update['security'] ?? false) ? ' [security]' : '' }} ({{ $update['architecture'] ?? 'unknown' }}), from version {{ $update['current_version'] }} to {{ $update['new_version'] }} at repository {{ $update['repository'] ?? 'Unknown' }} @endforeach ## Security Considerations @@ -21,12 +27,7 @@ Some of these updates may include important security patches. We recommend revie ### Critical packages that may require container/server/service restarts: @php -$criticalPackages = collect($updates)->filter(function ($update) { - return str_contains(strtolower($update['package']), 'docker') || - str_contains(strtolower($update['package']), 'kernel') || - str_contains(strtolower($update['package']), 'openssh') || - str_contains(strtolower($update['package']), 'ssl'); - }); +$criticalPackages = collect($updates)->filter(fn ($update) => filled($update['risk'] ?? null)); @endphp @if ($criticalPackages->count() > 0) diff --git a/resources/views/livewire/server/security/patches.blade.php b/resources/views/livewire/server/security/patches.blade.php index 3669a3a981..77d0e6f8c5 100644 --- a/resources/views/livewire/server/security/patches.blade.php +++ b/resources/views/livewire/server/security/patches.blade.php @@ -15,11 +15,17 @@ @php - $updates = data_get($result, 'updates', []); + $updates = collect(data_get($result, 'updates', [])) + ->sortBy(fn ($update) => [data_get($update, 'security') ? 0 : 1, filled(data_get($update, 'risk')) ? 0 : 1, data_get($update, 'package')]) + ->values() + ->all(); $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); + $securityUpdates = data_get($result, 'security_updates'); + $rebootRequired = data_get($result, 'reboot_required') === true; + $riskyUpdates = collect($updates)->filter(fn ($update) => filled(data_get($update, 'risk')))->count(); @endphp
@endif @can('update', $server) + @if ($rebootRequired) + + @endif Check for updates @@ -59,6 +77,12 @@ @else @endif + @if ($securityUpdates > 0) + + @endif + @if ($rebootRequired) + + @endif @endif
@@ -95,14 +119,16 @@ @elseif ($totalUpdates > 0) @can('update', $server)
+ @if (filled(data_get($update, 'risk'))) + + + {{ \App\Actions\Server\CheckUpdates::RISK_LABELS[data_get($update, 'risk')] ?? '' }} + + @endif {{ data_get($update, 'package') }} + @if (data_get($update, 'security')) + + @endif

diff --git a/tests/Feature/Server/ServerPatchesTest.php b/tests/Feature/Server/ServerPatchesTest.php index 21a8cf77ba..6f2282e529 100644 --- a/tests/Feature/Server/ServerPatchesTest.php +++ b/tests/Feature/Server/ServerPatchesTest.php @@ -1,6 +1,7 @@ $job->manual && $job->server->is($this->server)); expect(Cache::has(ServerPatchCheckJob::runningCacheKey($this->server)))->toBeTrue(); }); + +describe('security, reboot, and risk', function () { + beforeEach(function () { + Queue::fake(); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + }); + + it('shows security updates, risky packages, and the reboot action', function () { + $result = (new CheckUpdates)->addUpdateDetails([ + 'osId' => 'ubuntu', 'package_manager' => 'apt', 'total_updates' => 2, + 'updates' => [ + ['package' => 'curl', 'new_version' => '8.5.0-2ubuntu10.6', 'repository' => 'noble-updates'], + ['package' => 'docker-ce', 'new_version' => '5:28.0.0-1', 'repository' => 'noble-security'], + ], + ], 'apt'); + $result['reboot_required'] = true; + $this->server->settings->forceFill(['patch_check_result' => $result, 'patch_checked_at' => now()])->saveQuietly(); + + Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]) + ->assertSee('1 security update') + ->assertSee('Reboot required') + ->assertSee('Reboot server') + ->assertSee(CheckUpdates::RISK_LABELS['docker']) + ->assertSeeInOrder(['docker-ce', 'curl']); + }); + + it('reboots the server for an admin and checks it again later', function () { + RebootServer::shouldRun()->once()->with(Mockery::on(fn ($server) => $server->is($this->server))); + + Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]) + ->call('rebootServer') + ->assertDispatched('success'); + }); + + it('does not let a member of the server team reboot after the session switches to a team the user owns', function () { + $user = User::factory()->create(); + $this->team->members()->attach($user, ['role' => 'admin']); + $otherTeam = Team::factory()->create(); + $user->teams()->attach($otherTeam, ['role' => 'owner']); + $this->actingAs($user); + $component = Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]); + + $user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']); + $user->unsetRelation('teams'); + session(['currentTeam' => $otherTeam]); + RebootServer::shouldRun()->never(); + + $component->call('rebootServer')->assertDispatched('error'); + }); + + it('does not reboot a server that another instance manages', function () { + $this->server->forceFill(['server_metadata' => ['transfer' => ['status' => 'transferred']]])->saveQuietly(); + + expect(fn () => RebootServer::run($this->server->fresh()))->toThrow(RuntimeException::class, 'managed by another Coolify instance'); + }); + + it('marks the risky packages in the notification', function () { + $result = (new CheckUpdates)->addUpdateDetails(['updates' => [ + ['package' => 'linux-image-6.8.0-45-generic', 'repository' => 'noble-security', 'current_version' => '1', 'new_version' => '2', 'architecture' => 'amd64'], + ['package' => 'curl', 'repository' => 'noble-updates', 'current_version' => '1', 'new_version' => '2', 'architecture' => 'amd64'], + ]], 'apt'); + + $payload = (new ServerPatchCheck($this->server, [...$result, 'total_updates' => 2, 'reboot_required' => true]))->toWebhook(); + + expect($payload['critical_packages_count'])->toBe(1) + ->and($payload['security_updates'])->toBe(1) + ->and($payload['reboot_required'])->toBeTrue(); + }); +}); diff --git a/tests/Unit/Actions/Server/ServerPatchCommandsTest.php b/tests/Unit/Actions/Server/ServerPatchCommandsTest.php index 9705b1eec0..1d9c777eeb 100644 --- a/tests/Unit/Actions/Server/ServerPatchCommandsTest.php +++ b/tests/Unit/Actions/Server/ServerPatchCommandsTest.php @@ -98,3 +98,115 @@ it('parses dnf5 repoquery output', function () { it('updates a dnf package by its name', function () { expect(UpdatePackage::commands('dnf', "'python3.11'"))->toBe(["dnf upgrade -y 'python3.11'"]); }); + +it('marks apt updates from a security suite', function () { + // Ubuntu 24.04 and Debian 12 (now oldstable-security) `apt list --upgradable` lines. + $output = <<<'OUTPUT' +Listing... Done +libsystemd0/noble-security 255.4-1ubuntu8.18 amd64 [upgradable from: 255.4-1ubuntu8.17] +openssl/noble-updates,noble-security 3.0.13-0ubuntu3.4 amd64 [upgradable from: 3.0.13-0ubuntu3.1] +perl-base/oldstable-security 5.36.0-7+deb12u4 amd64 [upgradable from: 5.36.0-7+deb12u3] +curl/noble-updates 8.5.0-2ubuntu10.6 amd64 [upgradable from: 8.5.0-2ubuntu10.1] +OUTPUT; + $action = new CheckUpdates; + + $result = $action->addUpdateDetails($action->parseAptOutput($output), 'apt'); + + expect(collect($result['updates'])->pluck('security', 'package')->all())->toBe([ + 'libsystemd0' => true, + 'openssl' => true, + 'perl-base' => true, + 'curl' => false, + ])->and($result['security_updates'])->toBe(3) + ->and($result['updates'][0]['risk'])->toBe('system'); +}); + +it('reads the security packages of dnf4 and dnf5 advisories', function () { + // Rocky Linux 9 (dnf4) and Fedora 42 (dnf5) `dnf updateinfo list --security`. + $dnf4 = <<<'OUTPUT' +RLSA-2026:42736 Important/Sec. acl-2.4.0-1.el9_8.x86_64 +RLSA-2023:0340 Moderate/Sec. bash-5.1.8-6.el9_1.x86_64 +RLSA-2025:23343 Moderate/Sec. binutils-gold-2.35.2-67.el9_7.1.x86_64 +OUTPUT; + $dnf5 = <<<'OUTPUT' +Name Type Severity Package Issued +FEDORA-2026-11d7d4d8f3 security Moderate vim-data-2:9.2.390-1.fc42.noarch 2026-05-07 01:26:58 +FEDORA-2026-7af660d639 security Moderate openssl-libs-1:3.2.6-4.fc42.x86_64 2026-05-08 01:24:13 +OUTPUT; + $action = new CheckUpdates; + + expect($action->parseDnfSecurityPackages($dnf4))->toBe(['acl', 'bash', 'binutils-gold']) + ->and($action->parseDnfSecurityPackages($dnf5))->toBe(['vim-data', 'openssl-libs']); + + $result = $action->addUpdateDetails(['updates' => [ + ['package' => 'openssl-libs', 'repository' => 'updates'], + ['package' => 'kernel-core', 'repository' => 'updates'], + ]], 'dnf', $action->parseDnfSecurityPackages($dnf5)); + + expect($result['security_updates'])->toBe(1) + ->and($result['updates'][0]['security'])->toBeTrue() + ->and($result['updates'][1])->toMatchArray(['security' => false, 'risk' => 'kernel']); +}); + +it('reads the packages that zypper security patches upgrade', function () { + $output = <<<'XML' + + +Loading repository data... + + + + + + + + + + +XML; + + expect((new CheckUpdates)->parseZypperSecurityPackages($output))->toBe(['openssh', 'libopenssl3']) + ->and((new CheckUpdates)->parseZypperSecurityPackages('Nothing to do.'))->toBe([]); +}); + +it('does not report a security count when the package manager cannot tell', function () { + $result = (new CheckUpdates)->addUpdateDetails(['updates' => [['package' => 'linux', 'repository' => 'unknown']]], 'pacman'); + + expect($result['security_updates'])->toBeNull() + ->and($result['updates'][0])->toMatchArray(['security' => false, 'risk' => 'kernel']); +}); + +it('classifies risky packages per package manager', function (string $packageManager, string $package, ?string $risk) { + expect(CheckUpdates::riskFor($packageManager, $package))->toBe($risk); +})->with([ + ['apt', 'docker-ce', 'docker'], + ['apt', 'containerd.io', 'docker'], + ['apt', 'linux-image-6.8.0-45-generic', 'kernel'], + ['apt', 'libc6', 'system'], + ['apt', 'openssh-server', 'system'], + ['apt', 'docker-compose', null], + ['apt', 'linux-firmware', null], + ['apt', 'curl', null], + ['dnf', 'kernel-core', 'kernel'], + ['dnf', 'glibc', 'system'], + ['dnf', 'moby-engine', 'docker'], + ['zypper', 'kernel-default', 'kernel'], + ['pacman', 'linux-lts', 'kernel'], + ['pacman', 'linux-firmware', null], + ['apk', 'docker-engine', 'docker'], + ['apk', 'musl', 'system'], +]); + +it('reads the reboot status and runs its check as one sudo shell script', function () { + $action = new CheckUpdates; + + expect($action->parseRebootStatus("REBOOT=yes\n"))->toBeTrue() + ->and($action->parseRebootStatus("REBOOT=no\n"))->toBeFalse() + ->and($action->parseRebootStatus("REBOOT=unknown\n"))->toBeNull() + ->and($action->parseRebootStatus(''))->toBeNull(); + + $server = patchCommandsNonRootServer(); + foreach ([CheckUpdates::REBOOT_CHECK_COMMAND, CheckUpdates::DNF_SECURITY_COMMAND, CheckUpdates::ZYPPER_SECURITY_COMMAND] as $command) { + expect(parseCommandsByLineForSudo(collect([$command]), $server))->toBe(['sudo '.$command]); + } +});