Files
coolify/app/Notifications/Server/ServerPatchCheck.php
T
Andras BacsaiandClaude Opus 5.5 3da85e2c07 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>
2026-10-19 10:39:51 +02:00

377 lines
15 KiB
PHP

<?php
namespace App\Notifications\Server;
use App\Models\Server;
use App\Notifications\CustomEmailNotification;
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
{
public string $serverUrl;
public function __construct(public Server $server, public array $patchData)
{
$this->onQueue('high');
$this->serverUrl = base_url().'/server/'.$this->server->uuid.'/security/patches';
}
public function via(object $notifiable): array
{
return $notifiable->getEnabledChannels('server_patch');
}
public function toMail($notifiable = null): MailMessage
{
$mail = new MailMessage;
// Handle error case
if (isset($this->patchData['error'])) {
$mail->subject("Coolify: [ERROR] Failed to check patches on {$this->server->name}");
$mail->view('emails.server-patches-error', [
'name' => $this->server->name,
'error' => $this->patchData['error'],
'osId' => $this->patchData['osId'] ?? 'unknown',
'package_manager' => $this->patchData['package_manager'] ?? 'unknown',
'server_url' => $this->serverUrl,
]);
return $mail;
}
$totalUpdates = $this->patchData['total_updates'] ?? 0;
$mail->subject("Coolify: [ACTION REQUIRED] {$totalUpdates} server patches available on {$this->server->name}");
$mail->view('emails.server-patches', [
'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,
]);
return $mail;
}
public function toDiscord(): DiscordMessage
{
// Handle error case
if (isset($this->patchData['error'])) {
$osId = $this->patchData['osId'] ?? 'unknown';
$packageManager = $this->patchData['package_manager'] ?? 'unknown';
$error = $this->patchData['error'];
$description = "**Failed to check for updates** on server {$this->server->name}\n\n";
$description .= "**Error Details:**\n";
$description .= '• OS: '.ucfirst($osId)."\n";
$description .= "• Package Manager: {$packageManager}\n";
$description .= "• Error: {$error}\n\n";
$description .= "[Manage Server]($this->serverUrl)";
return new DiscordMessage(
title: ':x: Coolify: [ERROR] Failed to check patches on '.$this->server->name,
description: $description,
color: DiscordMessage::errorColor(),
);
}
$totalUpdates = $this->patchData['total_updates'] ?? 0;
$updates = $this->patchData['updates'] ?? [];
$osId = $this->patchData['osId'] ?? 'unknown';
$packageManager = $this->patchData['package_manager'] ?? 'unknown';
$description = "**{$totalUpdates} package updates** available for server {$this->server->name}\n\n";
$description .= "**Summary:**\n";
$description .= '• OS: '.ucfirst($osId)."\n";
$description .= "• Package Manager: {$packageManager}\n";
$description .= "• Total Updates: {$totalUpdates}\n\n";
// Show first few packages
if (count($updates) > 0) {
$description .= "**Sample Updates:**\n";
$sampleUpdates = array_slice($updates, 0, 5);
foreach ($sampleUpdates as $update) {
$description .= "• {$update['package']}: {$update['current_version']} → {$update['new_version']}\n";
}
if (count($updates) > 5) {
$description .= '• ... and '.(count($updates) - 5)." more packages\n";
}
// Check for critical packages
$criticalPackages = $this->criticalPackages();
if ($criticalPackages->count() > 0) {
$description .= "\n **Critical packages detected** ({$criticalPackages->count()} packages may require restarts)";
}
$description .= "\n [Manage Server Patches]($this->serverUrl)";
}
return new DiscordMessage(
title: ':warning: Coolify: [ACTION REQUIRED] Server patches available on '.$this->server->name,
description: $description,
color: DiscordMessage::errorColor(),
);
}
public function toTelegram(): array
{
// Handle error case
if (isset($this->patchData['error'])) {
$osId = $this->patchData['osId'] ?? 'unknown';
$packageManager = $this->patchData['package_manager'] ?? 'unknown';
$error = $this->patchData['error'];
$message = "❌ Coolify: [ERROR] Failed to check patches on {$this->server->name}!\n\n";
$message .= "📊 Error Details:\n";
$message .= '• OS: '.ucfirst($osId)."\n";
$message .= "• Package Manager: {$packageManager}\n";
$message .= "• Error: {$error}\n\n";
return [
'message' => $message,
'buttons' => [
[
'text' => 'Manage Server',
'url' => $this->serverUrl,
],
],
];
}
$totalUpdates = $this->patchData['total_updates'] ?? 0;
$updates = $this->patchData['updates'] ?? [];
$osId = $this->patchData['osId'] ?? 'unknown';
$packageManager = $this->patchData['package_manager'] ?? 'unknown';
$message = "🔧 Coolify: [ACTION REQUIRED] {$totalUpdates} server patches available on {$this->server->name}!\n\n";
$message .= "📊 Summary:\n";
$message .= '• OS: '.ucfirst($osId)."\n";
$message .= "• Package Manager: {$packageManager}\n";
$message .= "• Total Updates: {$totalUpdates}\n\n";
if (count($updates) > 0) {
$message .= "📦 Sample Updates:\n";
$sampleUpdates = array_slice($updates, 0, 5);
foreach ($sampleUpdates as $update) {
$message .= "• {$update['package']}: {$update['current_version']} → {$update['new_version']}\n";
}
if (count($updates) > 5) {
$message .= '• ... and '.(count($updates) - 5)." more packages\n";
}
// Check for critical packages
$criticalPackages = $this->criticalPackages();
if ($criticalPackages->count() > 0) {
$message .= "\n⚠️ Critical packages detected: {$criticalPackages->count()} packages may require restarts\n";
foreach ($criticalPackages->take(3) as $package) {
$message .= "• {$package['package']}: {$package['current_version']} → {$package['new_version']}\n";
}
if ($criticalPackages->count() > 3) {
$message .= '• ... and '.($criticalPackages->count() - 3)." more critical packages\n";
}
}
}
return [
'message' => $message,
'buttons' => [
[
'text' => 'Manage Server Patches',
'url' => $this->serverUrl,
],
],
];
}
public function toPushover(): PushoverMessage
{
// Handle error case
if (isset($this->patchData['error'])) {
$osId = $this->patchData['osId'] ?? 'unknown';
$packageManager = $this->patchData['package_manager'] ?? 'unknown';
$error = $this->patchData['error'];
$message = "[ERROR] Failed to check patches on {$this->server->name}!\n\n";
$message .= "Error Details:\n";
$message .= '• OS: '.ucfirst($osId)."\n";
$message .= "• Package Manager: {$packageManager}\n";
$message .= "• Error: {$error}\n\n";
return new PushoverMessage(
title: 'Server patch check failed',
level: 'error',
message: $message,
buttons: [
[
'text' => 'Manage Server',
'url' => $this->serverUrl,
],
],
);
}
$totalUpdates = $this->patchData['total_updates'] ?? 0;
$updates = $this->patchData['updates'] ?? [];
$osId = $this->patchData['osId'] ?? 'unknown';
$packageManager = $this->patchData['package_manager'] ?? 'unknown';
$message = "[ACTION REQUIRED] {$totalUpdates} server patches available on {$this->server->name}!\n\n";
$message .= "Summary:\n";
$message .= '• OS: '.ucfirst($osId)."\n";
$message .= "• Package Manager: {$packageManager}\n";
$message .= "• Total Updates: {$totalUpdates}\n\n";
if (count($updates) > 0) {
$message .= "Sample Updates:\n";
$sampleUpdates = array_slice($updates, 0, 3);
foreach ($sampleUpdates as $update) {
$message .= "• {$update['package']}: {$update['current_version']} → {$update['new_version']}\n";
}
if (count($updates) > 3) {
$message .= '• ... and '.(count($updates) - 3)." more packages\n";
}
// Check for critical packages
$criticalPackages = $this->criticalPackages();
if ($criticalPackages->count() > 0) {
$message .= "\nCritical packages detected: {$criticalPackages->count()} may require restarts";
}
}
return new PushoverMessage(
title: 'Server patches available',
level: 'error',
message: $message,
buttons: [
[
'text' => 'Manage Server Patches',
'url' => $this->serverUrl,
],
],
);
}
public function toSlack(): SlackMessage
{
// Handle error case
if (isset($this->patchData['error'])) {
$osId = $this->patchData['osId'] ?? 'unknown';
$packageManager = $this->patchData['package_manager'] ?? 'unknown';
$error = $this->patchData['error'];
$description = "Failed to check patches on '{$this->server->name}'!\n\n";
$description .= "*Error Details:*\n";
$description .= '• OS: '.ucfirst($osId)."\n";
$description .= "• Package Manager: {$packageManager}\n";
$description .= "• Error: `{$error}`\n\n";
$description .= "\n:link: <{$this->serverUrl}|Manage Server>";
return new SlackMessage(
title: 'Coolify: [ERROR] Server patch check failed',
description: $description,
color: SlackMessage::errorColor()
);
}
$totalUpdates = $this->patchData['total_updates'] ?? 0;
$updates = $this->patchData['updates'] ?? [];
$osId = $this->patchData['osId'] ?? 'unknown';
$packageManager = $this->patchData['package_manager'] ?? 'unknown';
$description = "{$totalUpdates} server patches available on '{$this->server->name}'!\n\n";
$description .= "*Summary:*\n";
$description .= '• OS: '.ucfirst($osId)."\n";
$description .= "• Package Manager: {$packageManager}\n";
$description .= "• Total Updates: {$totalUpdates}\n\n";
if (count($updates) > 0) {
$description .= "*Sample Updates:*\n";
$sampleUpdates = array_slice($updates, 0, 5);
foreach ($sampleUpdates as $update) {
$description .= "• `{$update['package']}`: {$update['current_version']} → {$update['new_version']}\n";
}
if (count($updates) > 5) {
$description .= '• ... and '.(count($updates) - 5)." more packages\n";
}
// Check for critical packages
$criticalPackages = $this->criticalPackages();
if ($criticalPackages->count() > 0) {
$description .= "\n:warning: *Critical packages detected:* {$criticalPackages->count()} packages may require restarts\n";
foreach ($criticalPackages->take(3) as $package) {
$description .= "• `{$package['package']}`: {$package['current_version']} → {$package['new_version']}\n";
}
if ($criticalPackages->count() > 3) {
$description .= '• ... and '.($criticalPackages->count() - 3)." more critical packages\n";
}
}
}
$description .= "\n:link: <{$this->serverUrl}|Manage Server Patches>";
return new SlackMessage(
title: 'Coolify: [ACTION REQUIRED] Server patches available',
description: $description,
color: SlackMessage::errorColor()
);
}
public function toWebhook(): array
{
// Handle error case
if (isset($this->patchData['error'])) {
return [
'success' => false,
'message' => 'Failed to check patches',
'event' => 'server_patch_check_error',
'server_name' => $this->server->name,
'server_uuid' => $this->server->uuid,
'os_id' => $this->patchData['osId'] ?? 'unknown',
'package_manager' => $this->patchData['package_manager'] ?? 'unknown',
'error' => $this->patchData['error'],
'url' => $this->serverUrl,
];
}
$totalUpdates = $this->patchData['total_updates'] ?? 0;
$updates = $this->patchData['updates'] ?? [];
// Check for critical packages
$criticalPackages = $this->criticalPackages();
return [
'success' => false,
'message' => 'Server patches available',
'event' => 'server_patch_check',
'server_name' => $this->server->name,
'server_uuid' => $this->server->uuid,
'total_updates' => $totalUpdates,
'os_id' => $this->patchData['osId'] ?? 'unknown',
'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();
}
}