mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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:
co-authored by
Claude Opus 5.5
parent
6806ed31fa
commit
3da85e2c07
@@ -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 = [];
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ class ServerPatchCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
try {
|
||||
$team = data_get($this->server, 'team');
|
||||
if (! $team) {
|
||||
if (! $team || $this->server->isTransferredAway()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user