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 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-19 10:39:51 +02:00
co-authored by Claude Opus 5.5
parent 6806ed31fa
commit 3da85e2c07
9 changed files with 489 additions and 71 deletions
+179 -30
View File
@@ -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<string, array<string, list<string>>>
*/
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<string>|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<string>
*/
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<string>
*/
public function parseZypperSecurityPackages(string $output): array
{
if (! preg_match_all('#<to-upgrade>(.*?)</to-upgrade>#s', $output, $blocks)) {
return [];
}
$packages = [];
foreach ($blocks[1] as $block) {
preg_match_all('#<solvable\b[^>]*>#', $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 = [];
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace App\Actions\Server;
use App\Jobs\ServerPatchCheckJob;
use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction;
class RebootServer
{
use AsAction;
/**
* Starts the reboot a few seconds later, so the SSH command returns before the connection drops.
* systemd-run runs the reboot outside of the SSH session; other systems use a detached `reboot`.
*/
public const COMMAND = <<<'SH'
sh -c 'if command -v systemd-run >/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 </dev/null & fi; echo REBOOT_SCHEDULED'
SH;
public function handle(Server $server): void
{
if ($server->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));
}
}
+1 -1
View File
@@ -57,7 +57,7 @@ class ServerPatchCheckJob implements ShouldBeEncrypted, ShouldQueue
{
try {
$team = data_get($this->server, 'team');
if (! $team) {
if (! $team || $this->server->isTransferredAway()) {
return;
}
+24
View File
@@ -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');
+18 -30
View File
@@ -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();
}
}
@@ -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)
@@ -15,11 +15,17 @@
</x-process-dialog>
@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
<div
@@ -37,6 +43,18 @@
</x-forms.button>
@endif
@can('update', $server)
@if ($rebootRequired)
<x-modal-confirmation title="Reboot server?" buttonTitle="Reboot server" isErrorButton
submitAction="rebootServer" :actions="[
'The server reboots in a few seconds.',
'All containers on this server stop, and the containers with a restart policy start again after the reboot.',
'The server is not reachable for a few minutes.',
]"
confirmationText="{{ $server->name }}"
confirmationLabel="Enter the server name to confirm"
shortConfirmationLabel="Server name" :confirmWithPassword="false"
step2ButtonText="Reboot server" />
@endif
<x-forms.button type="button" wire:click="checkForUpdates" :disabled="$checking">
<x-reicon name="refresh" class="size-3.5" />
Check for updates
@@ -59,6 +77,12 @@
@else
<x-status-badge :status="$totalUpdates . ' ' . Str::plural('update', $totalUpdates)" type="warning" />
@endif
@if ($securityUpdates > 0)
<x-status-badge :status="$securityUpdates . ' ' . Str::plural('security update', $securityUpdates)" type="error" />
@endif
@if ($rebootRequired)
<x-status-badge status="Reboot required" type="warning" />
@endif
@endif
</div>
@@ -95,14 +119,16 @@
</x-application.settings-section>
@elseif ($totalUpdates > 0)
<x-application.settings-section id="server-package-updates-section" title="Package updates"
helper="{{ $totalUpdates }} {{ Str::plural('update', $totalUpdates) }} available."
helper="{{ $totalUpdates }} {{ Str::plural('update', $totalUpdates) }} available{{ $securityUpdates !== null ? ', ' . $securityUpdates . ' of them security ' . Str::plural('update', $securityUpdates) : '' }}."
flush>
@can('update', $server)
<x-slot:actions>
<x-modal-confirmation title="Confirm package update?" buttonTitle="Update all packages"
isHighlightedButton submitAction="updateAllPackages" :actions="[
'All packages will be updated to their latest available versions.',
'Docker or kernel updates may restart running containers.',
$riskyUpdates > 0
? $riskyUpdates . ' ' . Str::plural('update', $riskyUpdates) . ' can restart Docker, the running containers, or core system services, or need a reboot.'
: 'Some services on the server can restart.',
]"
confirmationText="Update All Packages"
confirmationLabel="Confirm by entering the text below"
@@ -129,9 +155,18 @@
<div wire:key="package-update-{{ data_get($update, 'package') }}-{{ data_get($update, 'architecture') }}"
class="data-table-row package-updates-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.08]">
<div class="flex min-w-0 items-center gap-2">
@if (filled(data_get($update, 'risk')))
<span class="shrink-0" data-tooltip="{{ \App\Actions\Server\CheckUpdates::RISK_LABELS[data_get($update, 'risk')] ?? '' }}">
<x-reicon name="alert-triangle" class="size-4 text-red-500" />
<span class="sr-only">{{ \App\Actions\Server\CheckUpdates::RISK_LABELS[data_get($update, 'risk')] ?? '' }}</span>
</span>
@endif
<span class="min-w-0 truncate font-mono text-[12px] text-neutral-950 dark:text-fg">
{{ data_get($update, 'package') }}
</span>
@if (data_get($update, 'security'))
<x-status-badge status="Security" type="error" />
@endif
</div>
<div class="min-w-0">
<p class="truncate font-mono text-[12px] text-neutral-700 dark:text-fg-dim">
@@ -1,6 +1,7 @@
<?php
use App\Actions\Server\CheckUpdates;
use App\Actions\Server\RebootServer;
use App\Events\ServerPackagesUpdateFinished;
use App\Events\ServerPatchCheckFinished;
use App\Jobs\ServerManagerJob;
@@ -263,3 +264,73 @@ it('runs a new check when a package update finishes', function () {
Queue::assertPushed(ServerPatchCheckJob::class, fn ($job) => $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();
});
});
@@ -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'
<?xml version='1.0'?>
<stream>
<message type="info">Loading repository data...</message>
<install-summary download-size="1024" space-usage-diff="0" packages-to-change="3">
<to-install>
<solvable type="patch" kind="patch" name="openSUSE-SLE-15.6-2024-1234" edition="1" arch="noarch"/>
</to-install>
<to-upgrade>
<solvable type="package" kind="package" name="openssh" edition="9.6p1-150600.6.3.1" arch="x86_64" edition-old="9.6p1-150600.6.1"/>
<solvable kind="package" name="libopenssl3" edition="3.1.4-150600.5.7.1" arch="x86_64"/>
</to-upgrade>
</install-summary>
</stream>
XML;
expect((new CheckUpdates)->parseZypperSecurityPackages($output))->toBe(['openssh', 'libopenssl3'])
->and((new CheckUpdates)->parseZypperSecurityPackages('<stream><message>Nothing to do.</message></stream>'))->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]);
}
});