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:
Andras Bacsai
2026-10-19 10:41:42 +02:00
co-authored by Claude Opus 5.5
parent 3da85e2c07
commit 4ca0b4415c
9 changed files with 467 additions and 16 deletions
@@ -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; ';
}
}
+41 -6
View File
@@ -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);
}
}
+3
View File
@@ -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',
];
/**
@@ -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']);
});
}
};
+1 -1
View File
@@ -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>
+70 -3
View File
@@ -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();
});