mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
feat(patches): add automatic security updates per server
- New server settings: is_auto_security_updates_enabled and auto_security_updates_time (maintenance window, HH:MM in the time zone of the server). Both move with a server transfer. - Debian/Ubuntu: install unattended-upgrades and write 52coolify-unattended-upgrades. `#clear` drops the distro origin lists, so only the security origins stay (Debian <codename>-security, Ubuntu <codename>-security, and the Ubuntu Pro ESM security origins). A drop-in for apt-daily-upgrade.timer sets the time. https://manpages.debian.org/bookworm/apt/apt.conf.5.en.html https://github.com/mvo5/unattended-upgrades/blob/master/README.md - RHEL family: install dnf-automatic (dnf5-plugin-automatic on dnf5), set upgrade_type = security and apply_updates = yes in /etc/dnf/automatic.conf, and add a drop-in for dnf-automatic.timer (dnf4) or dnf5-automatic.timer (dnf5). https://dnf.readthedocs.io/en/latest/automatic.html https://dnf5.readthedocs.io/en/latest/dnf5_plugins/automatic.8.html - Off removes the Coolify files, so the operating system default comes back. Other package managers show "not supported". - The schedule and the automatic updates share one form and one unsaved bar. The remote commands run only when the automatic update settings change; a failure keeps the old setting. - Commands are written for non-root SSH users (tee and single sh -c scripts) and were run in Debian 12, Ubuntu 24.04, Rocky 9, and Fedora containers as root and as a sudo user. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3da85e2c07
commit
4ca0b4415c
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Server;
|
||||
|
||||
use App\Models\Server;
|
||||
use InvalidArgumentException;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Turns automatic security updates on or off on a server.
|
||||
*
|
||||
* apt (Debian, Ubuntu): unattended-upgrades with a Coolify file that allows only the security origins.
|
||||
* `#clear` drops the origin lists of the distro files (apt.conf(5),
|
||||
* https://manpages.debian.org/bookworm/apt/apt.conf.5.en.html); the origin patterns follow
|
||||
* https://github.com/mvo5/unattended-upgrades/blob/master/README.md. The Debian security suite keeps
|
||||
* the `<codename>-security` codename (for Debian 12 the suite is now `oldstable-security`). The Ubuntu Pro (ESM)
|
||||
* security origins are kept, as in the Ubuntu default file.
|
||||
* The time is set with a drop-in for `apt-daily-upgrade.timer`.
|
||||
*
|
||||
* dnf (RHEL family, Fedora): dnf-automatic with `upgrade_type = security` and `apply_updates = yes` in
|
||||
* `/etc/dnf/automatic.conf`. dnf4 reads only that file (https://dnf.readthedocs.io/en/latest/automatic.html);
|
||||
* dnf5 reads its defaults from /usr/share/dnf5/dnf5-plugins/automatic.conf and the overrides from that file
|
||||
* (https://dnf5.readthedocs.io/en/latest/dnf5_plugins/automatic.8.html). The timer is `dnf5-automatic.timer`
|
||||
* on dnf5 and `dnf-automatic.timer` on dnf4.
|
||||
*
|
||||
* Commands are written for non-root SSH users: files are written with `tee`, and multi-step logic is one
|
||||
* `sh -c` script, so `parseCommandsByLineForSudo()` only puts sudo in front.
|
||||
*/
|
||||
class ConfigureAutomaticSecurityUpdates
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public const TIME_PATTERN = '/^([01]\d|2[0-3]):[0-5]\d$/';
|
||||
|
||||
public const APT_CONFIG_FILE = '/etc/apt/apt.conf.d/52coolify-unattended-upgrades';
|
||||
|
||||
public const APT_TIMER_DROP_IN_DIRECTORY = '/etc/systemd/system/apt-daily-upgrade.timer.d';
|
||||
|
||||
public const DNF_CONFIG_FILE = '/etc/dnf/automatic.conf';
|
||||
|
||||
private const SUPPORTED_PACKAGE_MANAGERS = ['apt', 'dnf'];
|
||||
|
||||
public function handle(Server $server, string $packageManager, bool $enabled, string $time): void
|
||||
{
|
||||
if (preg_match(self::TIME_PATTERN, $time) !== 1) {
|
||||
throw new InvalidArgumentException('The time must be in HH:MM format (00:00 to 23:59).');
|
||||
}
|
||||
if (! self::supportFor($packageManager)) {
|
||||
throw new RuntimeException("Automatic security updates are not supported for the package manager {$packageManager}.");
|
||||
}
|
||||
|
||||
instant_remote_process(self::commands($packageManager, $enabled, $time), $server);
|
||||
}
|
||||
|
||||
public static function supportFor(?string $packageManager): bool
|
||||
{
|
||||
return in_array($packageManager, self::SUPPORTED_PACKAGE_MANAGERS, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function commands(string $packageManager, bool $enabled, string $time): array
|
||||
{
|
||||
return match ($packageManager) {
|
||||
'apt' => $enabled ? self::aptEnableCommands($time) : self::aptDisableCommands(),
|
||||
'dnf' => $enabled ? self::dnfEnableCommands($time) : self::dnfDisableCommands(),
|
||||
default => throw new RuntimeException("Automatic security updates are not supported for the package manager {$packageManager}."),
|
||||
};
|
||||
}
|
||||
|
||||
public static function aptConfig(): string
|
||||
{
|
||||
return <<<'CONF'
|
||||
// Managed by Coolify: install security updates only.
|
||||
APT::Periodic::Update-Package-Lists "1";
|
||||
APT::Periodic::Unattended-Upgrade "1";
|
||||
#clear Unattended-Upgrade::Allowed-Origins;
|
||||
#clear Unattended-Upgrade::Origins-Pattern;
|
||||
Unattended-Upgrade::Origins-Pattern {
|
||||
"origin=Debian,codename=${distro_codename}-security,label=Debian-Security";
|
||||
"origin=Debian,codename=${distro_codename},label=Debian-Security";
|
||||
"origin=Ubuntu,archive=${distro_codename}-security";
|
||||
"origin=UbuntuESMApps,archive=${distro_codename}-apps-security";
|
||||
"origin=UbuntuESM,archive=${distro_codename}-infra-security";
|
||||
};
|
||||
|
||||
CONF;
|
||||
}
|
||||
|
||||
public static function timerDropIn(string $time): string
|
||||
{
|
||||
return "[Timer]\nOnCalendar=\nOnCalendar=*-*-* {$time}\nRandomizedDelaySec=0\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function aptEnableCommands(string $time): array
|
||||
{
|
||||
$dropIn = self::APT_TIMER_DROP_IN_DIRECTORY.'/coolify.conf';
|
||||
|
||||
return [
|
||||
"sh -c 'command -v unattended-upgrade >/dev/null 2>&1 || { apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y unattended-upgrades; }'",
|
||||
'mkdir -p '.self::APT_TIMER_DROP_IN_DIRECTORY,
|
||||
"echo '".base64_encode(self::aptConfig())."' | base64 -d | tee ".self::APT_CONFIG_FILE.' > /dev/null',
|
||||
"echo '".base64_encode(self::timerDropIn($time))."' | base64 -d | tee {$dropIn} > /dev/null",
|
||||
'chmod 644 '.self::APT_CONFIG_FILE." {$dropIn}",
|
||||
'systemctl daemon-reload',
|
||||
'systemctl enable --now apt-daily.timer apt-daily-upgrade.timer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function aptDisableCommands(): array
|
||||
{
|
||||
return [
|
||||
'rm -f '.self::APT_CONFIG_FILE.' '.self::APT_TIMER_DROP_IN_DIRECTORY.'/coolify.conf',
|
||||
'systemctl daemon-reload',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings are removed wherever they are and written again right below `[commands]`, which works for
|
||||
* the full dnf4 file and for a missing or short dnf5 override file.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function dnfEnableCommands(string $time): array
|
||||
{
|
||||
$file = self::DNF_CONFIG_FILE;
|
||||
$dropIn = base64_encode(self::timerDropIn($time));
|
||||
|
||||
return [
|
||||
'dnf install -y dnf-automatic',
|
||||
"sh -c 'set -e; f={$file}; touch \$f; "
|
||||
.'sed -i -E "/^[[:space:]]*(upgrade_type|apply_updates)[[:space:]]*=/d" $f; '
|
||||
.'grep -q "^\[commands\]" $f || printf "\n[commands]\n" >> $f; '
|
||||
.'sed -i -e "/^\[commands\]/a upgrade_type = security" -e "/^\[commands\]/a apply_updates = yes" $f; '
|
||||
."chmod 644 \$f'",
|
||||
"sh -c 'set -e; ".self::dnfTimerSelection()
|
||||
.'mkdir -p /etc/systemd/system/$t.d; '
|
||||
."echo {$dropIn} | base64 -d > /etc/systemd/system/\$t.d/coolify.conf; "
|
||||
.'chmod 644 /etc/systemd/system/$t.d/coolify.conf; '
|
||||
."systemctl daemon-reload; systemctl enable --now \$t'",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function dnfDisableCommands(): array
|
||||
{
|
||||
return [
|
||||
"sh -c 'set -e; ".self::dnfTimerSelection()
|
||||
.'if [ -e /usr/lib/systemd/system/$t ]; then systemctl disable --now $t; fi; '
|
||||
."rm -f /etc/systemd/system/\$t.d/coolify.conf; systemctl daemon-reload'",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets `$t` to the timer unit of the installed dnf-automatic (dnf5 or dnf4).
|
||||
*/
|
||||
private static function dnfTimerSelection(): string
|
||||
{
|
||||
return 't=dnf-automatic.timer; if [ -e /usr/lib/systemd/system/dnf5-automatic.timer ]; then t=dnf5-automatic.timer; fi; ';
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Server\Security;
|
||||
|
||||
use App\Actions\Server\ConfigureAutomaticSecurityUpdates;
|
||||
use App\Actions\Server\RebootServer;
|
||||
use App\Actions\Server\UpdatePackage;
|
||||
use App\Jobs\ServerPatchCheckJob;
|
||||
@@ -35,6 +36,10 @@ class Patches extends Component
|
||||
|
||||
public string $patchCheckFrequency = '';
|
||||
|
||||
public bool $autoSecurityUpdates = false;
|
||||
|
||||
public string $autoSecurityUpdatesTime = '03:00';
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
return $this->teamChannelListeners([
|
||||
@@ -47,7 +52,14 @@ class Patches extends Component
|
||||
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
|
||||
$this->authorize('viewSecurity', $this->server);
|
||||
$this->loadResult();
|
||||
$this->syncSettings();
|
||||
}
|
||||
|
||||
private function syncSettings(): void
|
||||
{
|
||||
$this->patchCheckFrequency = (string) $this->server->settings->patch_check_frequency;
|
||||
$this->autoSecurityUpdates = (bool) $this->server->settings->is_auto_security_updates_enabled;
|
||||
$this->autoSecurityUpdatesTime = (string) ($this->server->settings->auto_security_updates_time ?? '03:00');
|
||||
}
|
||||
|
||||
public function loadResult(): void
|
||||
@@ -116,20 +128,43 @@ class Patches extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function savePatchCheckFrequency()
|
||||
public function submit()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
$settings = $this->server->settings;
|
||||
|
||||
$frequency = trim($this->patchCheckFrequency);
|
||||
if ($frequency !== '' && ! self::isValidPatchCheckFrequency($frequency)) {
|
||||
$this->patchCheckFrequency = (string) $this->server->settings->patch_check_frequency;
|
||||
throw new \Exception('Invalid check frequency. Use a cron expression or a preset (daily, weekly, monthly) that runs at most once per hour.');
|
||||
}
|
||||
$this->server->settings->patch_check_frequency = $frequency === '' ? null : $frequency;
|
||||
$this->server->settings->save();
|
||||
$this->patchCheckFrequency = $frequency;
|
||||
$this->dispatch('success', 'Check frequency saved.');
|
||||
$time = trim($this->autoSecurityUpdatesTime);
|
||||
if (preg_match(ConfigureAutomaticSecurityUpdates::TIME_PATTERN, $time) !== 1) {
|
||||
throw new \Exception('Invalid time. Use the 24-hour format HH:MM, for example 03:00.');
|
||||
}
|
||||
|
||||
$wasEnabled = (bool) $settings->is_auto_security_updates_enabled;
|
||||
$autoUpdatesChanged = $this->autoSecurityUpdates !== $wasEnabled
|
||||
|| ($this->autoSecurityUpdates && $time !== $settings->auto_security_updates_time);
|
||||
if ($autoUpdatesChanged) {
|
||||
$this->ensureManagedHere();
|
||||
$packageManager = data_get($settings->patch_check_result, 'package_manager');
|
||||
if (! ConfigureAutomaticSecurityUpdates::supportFor($packageManager)) {
|
||||
throw new \Exception('Automatic security updates are supported only on Debian, Ubuntu, and RHEL-family servers. Run "Check for updates" first, so Coolify knows the package manager.');
|
||||
}
|
||||
ConfigureAutomaticSecurityUpdates::run($this->server, $packageManager, $this->autoSecurityUpdates, $time);
|
||||
}
|
||||
|
||||
$settings->patch_check_frequency = $frequency === '' ? null : $frequency;
|
||||
$settings->is_auto_security_updates_enabled = $this->autoSecurityUpdates;
|
||||
$settings->auto_security_updates_time = $time;
|
||||
$settings->save();
|
||||
$this->syncSettings();
|
||||
$this->dispatch('success', 'Patch settings saved.');
|
||||
} catch (\Throwable $e) {
|
||||
$this->server->settings->refresh();
|
||||
$this->syncSettings();
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +157,8 @@ class ServerSetting extends Model
|
||||
'patch_check_result',
|
||||
'patch_check_error',
|
||||
'patch_checked_at',
|
||||
'is_auto_security_updates_enabled',
|
||||
'auto_security_updates_time',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -196,6 +198,7 @@ class ServerSetting extends Model
|
||||
'server_disk_usage_notification_interval_hours' => 'integer',
|
||||
'patch_check_result' => 'array',
|
||||
'patch_checked_at' => 'datetime',
|
||||
'is_auto_security_updates_enabled' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('server_settings', function (Blueprint $table) {
|
||||
$table->boolean('is_auto_security_updates_enabled')->default(false);
|
||||
$table->string('auto_security_updates_time')->default('03:00');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('server_settings', function (Blueprint $table) {
|
||||
$table->dropColumn(['is_auto_security_updates_enabled', 'auto_security_updates_time']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -32,7 +32,7 @@ When you add a model or a setting to the transfer, add it to this file too.
|
||||
| ➕ | Server timezone | `Europe/Budapest` |
|
||||
| ➕ | Docker cleanup | force cleanup on, frequency `0 4 * * *`, threshold 70, delete unused volumes on, delete unused networks on |
|
||||
| ➕ | Disk usage notification | threshold 70, check frequency `*/30 * * * *`, interval 12 h |
|
||||
| ➕ | Security > Patches | check frequency `daily` (the last check result is not moved; the target shows "never" until its first check) |
|
||||
| ➕ | Security > Patches | check frequency `daily`, automatic security updates on at `04:30` (the last check result is not moved; the target shows "never" until its first check) |
|
||||
| ➕ | Terminal | terminal access off |
|
||||
| ➕ | Image retention | "disable application image retention" on |
|
||||
| ➕ | Labels / Traefik | generate exact labels on, Traefik certificate resolver `custom` |
|
||||
|
||||
@@ -198,18 +198,42 @@
|
||||
</x-application.settings-section>
|
||||
@endif
|
||||
|
||||
<form wire:submit="savePatchCheckFrequency">
|
||||
<form wire:submit="submit" class="flex flex-col gap-6">
|
||||
<x-unsaved-bar action="submit" />
|
||||
|
||||
<x-application.settings-section id="server-patching-schedule-section" title="Schedule"
|
||||
helper="Control when Coolify checks this server for package updates.">
|
||||
<x-slot:actions>
|
||||
@can('update', $server)
|
||||
<x-forms.button type="submit">Save</x-forms.button>
|
||||
@endcan
|
||||
</x-slot:actions>
|
||||
<x-forms.input canGate="update" :canResource="$server" id="patchCheckFrequency"
|
||||
label="Check frequency" placeholder="weekly (default, on Sunday)"
|
||||
helper="Cron expression or preset such as daily, weekly, or monthly. Leave empty for the default: weekly, at a fixed time on Sunday. Coolify notifies your team when updates are available and when a check starts to fail." />
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section id="server-patching-automatic-section"
|
||||
title="Automatic security updates"
|
||||
helper="Let the server install security updates by itself every day, in a maintenance window.">
|
||||
@if (\App\Actions\Server\ConfigureAutomaticSecurityUpdates::supportFor($packageManager))
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.listbox canGate="update" :canResource="$server" id="autoSecurityUpdates"
|
||||
label="Automatic security updates" :options="[
|
||||
['value' => false, 'label' => 'Off'],
|
||||
['value' => true, 'label' => 'Install security updates daily'],
|
||||
]"
|
||||
helper="{{ $packageManager === 'apt' ? 'Configures unattended-upgrades for the security origins only.' : 'Configures dnf-automatic with upgrade_type = security.' }} Off removes the Coolify configuration and keeps the operating system default." />
|
||||
<x-forms.input canGate="update" :canResource="$server" id="autoSecurityUpdatesTime"
|
||||
label="Maintenance window" placeholder="03:00"
|
||||
helper="Daily start time (24-hour HH:MM) in the time zone of the server. Updates that need a reboot are installed, but the server is not rebooted." />
|
||||
</div>
|
||||
@elseif ($packageManager)
|
||||
<x-callout type="info" title="Not supported">
|
||||
Automatic security updates are available for Debian, Ubuntu, and RHEL-family servers
|
||||
(apt and dnf). This server uses {{ $packageManager }}.
|
||||
</x-callout>
|
||||
@else
|
||||
<x-callout type="info" title="Check for updates first">
|
||||
Coolify needs one successful check to know the package manager of this server.
|
||||
</x-callout>
|
||||
@endif
|
||||
</x-application.settings-section>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Server\CheckUpdates;
|
||||
use App\Actions\Server\ConfigureAutomaticSecurityUpdates;
|
||||
use App\Actions\Server\RebootServer;
|
||||
use App\Events\ServerPackagesUpdateFinished;
|
||||
use App\Events\ServerPatchCheckFinished;
|
||||
@@ -149,10 +150,10 @@ describe('check frequency', function () {
|
||||
|
||||
Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])
|
||||
->set('patchCheckFrequency', 'daily')
|
||||
->call('savePatchCheckFrequency')
|
||||
->call('submit')
|
||||
->assertDispatched('success')
|
||||
->set('patchCheckFrequency', '* * * * *')
|
||||
->call('savePatchCheckFrequency')
|
||||
->call('submit')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect($this->server->settings->fresh()->patch_check_frequency)->toBe('daily');
|
||||
@@ -249,7 +250,7 @@ describe('patches authorization', function () {
|
||||
session(['currentTeam' => $otherTeam]);
|
||||
|
||||
$component->call('checkForUpdates')->assertDispatched('error');
|
||||
$component->set('patchCheckFrequency', 'daily')->call('savePatchCheckFrequency')->assertDispatched('error');
|
||||
$component->set('patchCheckFrequency', 'daily')->call('submit')->assertDispatched('error');
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
expect($this->server->settings->fresh()->patch_check_frequency)->toBeNull();
|
||||
@@ -334,3 +335,69 @@ describe('security, reboot, and risk', function () {
|
||||
->and($payload['reboot_required'])->toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
describe('automatic security updates', function () {
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
$this->server->settings->forceFill(['patch_check_result' => patchesTestResult(0), 'patch_checked_at' => now()])->saveQuietly();
|
||||
});
|
||||
|
||||
it('configures the server and saves the setting', function () {
|
||||
ConfigureAutomaticSecurityUpdates::shouldRun()->once()
|
||||
->with(Mockery::on(fn ($server) => $server->is($this->server)), 'apt', true, '04:30');
|
||||
|
||||
Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])
|
||||
->set('autoSecurityUpdates', true)
|
||||
->set('autoSecurityUpdatesTime', '04:30')
|
||||
->call('submit')
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($this->server->settings->fresh())
|
||||
->is_auto_security_updates_enabled->toBeTrue()
|
||||
->auto_security_updates_time->toBe('04:30');
|
||||
});
|
||||
|
||||
it('does not run the server commands when only the frequency changes', function () {
|
||||
ConfigureAutomaticSecurityUpdates::shouldRun()->never();
|
||||
|
||||
Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])
|
||||
->set('patchCheckFrequency', 'daily')
|
||||
->call('submit')
|
||||
->assertDispatched('success');
|
||||
});
|
||||
|
||||
it('keeps the setting off when the server commands fail', function () {
|
||||
ConfigureAutomaticSecurityUpdates::shouldRun()->once()->andThrow(new RuntimeException('apt-get failed'));
|
||||
|
||||
Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])
|
||||
->set('autoSecurityUpdates', true)
|
||||
->call('submit')
|
||||
->assertDispatched('error')
|
||||
->assertSet('autoSecurityUpdates', false);
|
||||
|
||||
expect($this->server->settings->fresh()->is_auto_security_updates_enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
it('shows that other package managers are not supported', function () {
|
||||
$this->server->settings->forceFill(['patch_check_result' => [...patchesTestResult(0), 'package_manager' => 'pacman']])->saveQuietly();
|
||||
ConfigureAutomaticSecurityUpdates::shouldRun()->never();
|
||||
|
||||
Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])
|
||||
->assertSee('This server uses pacman')
|
||||
->set('autoSecurityUpdates', true)
|
||||
->call('submit')
|
||||
->assertDispatched('error');
|
||||
});
|
||||
|
||||
it('rejects an invalid maintenance window', function () {
|
||||
ConfigureAutomaticSecurityUpdates::shouldRun()->never();
|
||||
|
||||
Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])
|
||||
->set('autoSecurityUpdates', true)
|
||||
->set('autoSecurityUpdatesTime', '25:00')
|
||||
->call('submit')
|
||||
->assertDispatched('error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Server\ConfigureAutomaticSecurityUpdates;
|
||||
use App\Models\Server;
|
||||
|
||||
/**
|
||||
* @param list<string> $commands
|
||||
* @return list<string>
|
||||
*/
|
||||
function autoSecurityUpdatesDecodedPayloads(array $commands): array
|
||||
{
|
||||
preg_match_all("/echo '?([A-Za-z0-9+\/=]{8,})'? \| base64 -d/", implode("\n", $commands), $matches);
|
||||
|
||||
return array_map(fn (string $payload) => base64_decode($payload, true), $matches[1]);
|
||||
}
|
||||
|
||||
function autoSecurityUpdatesNonRootServer(): Server
|
||||
{
|
||||
$server = Mockery::mock(Server::class)->makePartial();
|
||||
$server->shouldReceive('getAttribute')->with('user')->andReturn('ubuntu');
|
||||
$server->shouldReceive('setAttribute')->andReturnSelf();
|
||||
$server->user = 'ubuntu';
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
test('only apt and dnf support automatic security updates', function (?string $packageManager, bool $supported) {
|
||||
expect(ConfigureAutomaticSecurityUpdates::supportFor($packageManager))->toBe($supported);
|
||||
})->with([
|
||||
['apt', true],
|
||||
['dnf', true],
|
||||
['zypper', false],
|
||||
['apk', false],
|
||||
['pacman', false],
|
||||
[null, false],
|
||||
]);
|
||||
|
||||
test('apt installs unattended-upgrades and allows only the security origins at the chosen time', function () {
|
||||
$commands = ConfigureAutomaticSecurityUpdates::commands('apt', true, '04:30');
|
||||
[$config, $dropIn] = autoSecurityUpdatesDecodedPayloads($commands);
|
||||
|
||||
expect($commands[0])->toContain('command -v unattended-upgrade')
|
||||
->toContain('DEBIAN_FRONTEND=noninteractive apt-get install -y unattended-upgrades')
|
||||
->and(implode("\n", $commands))
|
||||
->toContain('tee /etc/apt/apt.conf.d/52coolify-unattended-upgrades > /dev/null')
|
||||
->toContain('tee /etc/systemd/system/apt-daily-upgrade.timer.d/coolify.conf > /dev/null')
|
||||
->toContain('chmod 644 /etc/apt/apt.conf.d/52coolify-unattended-upgrades /etc/systemd/system/apt-daily-upgrade.timer.d/coolify.conf')
|
||||
->and($commands)->toContain('systemctl daemon-reload')
|
||||
->toContain('systemctl enable --now apt-daily.timer apt-daily-upgrade.timer')
|
||||
->and($config)
|
||||
->toContain('APT::Periodic::Unattended-Upgrade "1";')
|
||||
->toContain('#clear Unattended-Upgrade::Allowed-Origins;')
|
||||
->toContain('#clear Unattended-Upgrade::Origins-Pattern;')
|
||||
->toContain('"origin=Debian,codename=${distro_codename}-security,label=Debian-Security";')
|
||||
->toContain('"origin=Ubuntu,archive=${distro_codename}-security";')
|
||||
->not->toContain('archive=${distro_codename}-updates')
|
||||
->and($dropIn)->toBe("[Timer]\nOnCalendar=\nOnCalendar=*-*-* 04:30\nRandomizedDelaySec=0\n");
|
||||
});
|
||||
|
||||
test('apt disable removes only the Coolify files', function () {
|
||||
expect(ConfigureAutomaticSecurityUpdates::commands('apt', false, '03:00'))->toBe([
|
||||
'rm -f /etc/apt/apt.conf.d/52coolify-unattended-upgrades /etc/systemd/system/apt-daily-upgrade.timer.d/coolify.conf',
|
||||
'systemctl daemon-reload',
|
||||
]);
|
||||
});
|
||||
|
||||
test('dnf installs dnf-automatic, applies only security updates and sets the timer of dnf4 or dnf5', function () {
|
||||
$commands = ConfigureAutomaticSecurityUpdates::commands('dnf', true, '22:15');
|
||||
[$dropIn] = autoSecurityUpdatesDecodedPayloads($commands);
|
||||
|
||||
expect($commands[0])->toBe('dnf install -y dnf-automatic')
|
||||
->and($commands[1])
|
||||
->toContain('f=/etc/dnf/automatic.conf')
|
||||
->toContain('(upgrade_type|apply_updates)[[:space:]]*=/d')
|
||||
->toContain('a upgrade_type = security')
|
||||
->toContain('a apply_updates = yes')
|
||||
->and($commands[2])
|
||||
->toContain('t=dnf-automatic.timer; if [ -e /usr/lib/systemd/system/dnf5-automatic.timer ]; then t=dnf5-automatic.timer; fi;')
|
||||
->toContain('/etc/systemd/system/$t.d/coolify.conf')
|
||||
->toContain('systemctl enable --now $t')
|
||||
->and($dropIn)->toBe("[Timer]\nOnCalendar=\nOnCalendar=*-*-* 22:15\nRandomizedDelaySec=0\n");
|
||||
});
|
||||
|
||||
test('dnf disable stops the timer and removes the Coolify drop-in', function () {
|
||||
$commands = ConfigureAutomaticSecurityUpdates::commands('dnf', false, '03:00');
|
||||
|
||||
expect($commands)->toHaveCount(1)
|
||||
->and($commands[0])
|
||||
->toContain('systemctl disable --now $t')
|
||||
->toContain('rm -f /etc/systemd/system/$t.d/coolify.conf')
|
||||
->toContain('systemctl daemon-reload')
|
||||
->not->toContain('automatic.conf');
|
||||
});
|
||||
|
||||
test('every command runs as root for a non-root SSH user', function (string $packageManager, bool $enabled) {
|
||||
$commands = ConfigureAutomaticSecurityUpdates::commands($packageManager, $enabled, '03:00');
|
||||
|
||||
$parsed = parseCommandsByLineForSudo(collect($commands), autoSecurityUpdatesNonRootServer());
|
||||
|
||||
foreach ($parsed as $line) {
|
||||
$isSudoLine = str_starts_with($line, 'sudo ') && ! str_contains($line, ' | ');
|
||||
$isSudoShellScript = isSingleSudoShellScript($line) && substr_count($line, 'sudo') === 1;
|
||||
$isSudoTeePipe = preg_match("/^echo '[A-Za-z0-9+\/=]+' \| sudo base64 -d \| sudo tee \/etc\/\S+ > \/dev\/null$/", $line) === 1;
|
||||
|
||||
expect($isSudoLine || $isSudoShellScript || $isSudoTeePipe)->toBeTrue("Line does not run as root: {$line}");
|
||||
}
|
||||
})->with([
|
||||
'apt enabled' => ['apt', true],
|
||||
'apt disabled' => ['apt', false],
|
||||
'dnf enabled' => ['dnf', true],
|
||||
'dnf disabled' => ['dnf', false],
|
||||
]);
|
||||
|
||||
test('handle rejects a time that is not HH:MM', function (string $time) {
|
||||
ConfigureAutomaticSecurityUpdates::run(new Server, 'apt', true, $time);
|
||||
})->throws(InvalidArgumentException::class)->with(['24:00', '3:00', '03:60', '03:00; reboot', '']);
|
||||
|
||||
test('handle rejects an unsupported package manager', function () {
|
||||
ConfigureAutomaticSecurityUpdates::run(new Server, 'zypper', true, '03:00');
|
||||
})->throws(RuntimeException::class);
|
||||
@@ -396,6 +396,8 @@ test('the patch check frequency moves with the server and the last patch check r
|
||||
'patch_check_result' => ['osId' => 'ubuntu', 'package_manager' => 'apt', 'total_updates' => 0, 'updates' => []],
|
||||
'patch_check_error' => 'old error',
|
||||
'patch_checked_at' => now(),
|
||||
'is_auto_security_updates_enabled' => true,
|
||||
'auto_security_updates_time' => '04:30',
|
||||
])->saveQuietly();
|
||||
|
||||
$bundle = $this->exporter->export($this->server);
|
||||
@@ -411,6 +413,8 @@ test('the patch check frequency moves with the server and the last patch check r
|
||||
|
||||
$settings = Server::where('uuid', $result['server_uuid'])->first()->settings;
|
||||
expect($settings->patch_check_frequency)->toBe('daily')
|
||||
->and($settings->is_auto_security_updates_enabled)->toBeTrue()
|
||||
->and($settings->auto_security_updates_time)->toBe('04:30')
|
||||
->and($settings->patch_check_result)->toBeNull()
|
||||
->and($settings->patch_checked_at)->toBeNull();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user