feat(patches): confirm before turning automatic security updates on or off

Automatic security updates now have their own "Turn on" / "Turn off"
action with a confirmation, instead of a field that the Save bar
applied. The confirmation explains what can stop:

- Docker from the operating system packages (docker.io, moby-engine)
  restarts on a security update, so all containers stop and start.
- Docker CE from the Docker repository is not updated: its repository
  has no security origin (apt) and no security advisories (dnf).
  Checked on Debian 12 (unattended-upgrade --dry-run skips docker-ce)
  and CentOS Stream 9.
- Services that use an updated library can restart; a new kernel is
  installed without a reboot.

The on/off state is locked, so only the confirmed action can change it.
A new maintenance window is applied to the server on Save only when the
updates are on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-19 13:51:21 +02:00
co-authored by Claude Opus 5.5
parent d6f7353fda
commit 3bc7e47a86
3 changed files with 133 additions and 46 deletions
+47 -21
View File
@@ -36,6 +36,7 @@ class Patches extends Component
public string $patchCheckFrequency = '';
#[Locked]
public bool $autoSecurityUpdates = false;
public string $autoSecurityUpdatesTime = '03:00';
@@ -150,33 +151,15 @@ class Patches extends Component
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.');
}
$auditContext = ['enabled' => $this->autoSecurityUpdates, 'time' => $time, 'package_manager' => $packageManager];
try {
ConfigureAutomaticSecurityUpdates::run($this->server, $packageManager, $this->autoSecurityUpdates, $time);
} catch (\Throwable $e) {
$this->auditPatchAction('auto_security_updates_updated', [...$auditContext, 'result' => 'failed', 'error' => $e->getMessage()], 'error');
throw $e;
}
if ($settings->is_auto_security_updates_enabled && $time !== $settings->auto_security_updates_time) {
$this->applyAutomaticSecurityUpdates(true, $time);
}
$settings->patch_check_frequency = $frequency === '' ? null : $frequency;
$settings->is_auto_security_updates_enabled = $this->autoSecurityUpdates;
$settings->auto_security_updates_time = $time;
$changedFields = auditChangedFields($settings);
$settings->save();
if ($autoUpdatesChanged) {
$this->auditPatchAction('auto_security_updates_updated', [...$auditContext, 'result' => 'success']);
} elseif ($changedFields !== []) {
if ($changedFields !== []) {
$this->auditPatchAction('patch_settings_updated', ['changed_fields' => $changedFields]);
}
$this->syncSettings();
@@ -189,6 +172,49 @@ class Patches extends Component
}
}
/**
* Turns automatic security updates on or off after the user confirmed it.
*/
public function toggleAutomaticSecurityUpdates()
{
try {
$this->authorize('update', $this->server);
$settings = $this->server->settings;
$enabled = ! $settings->is_auto_security_updates_enabled;
$time = $settings->auto_security_updates_time ?: '03:00';
$this->applyAutomaticSecurityUpdates($enabled, $time);
$settings->is_auto_security_updates_enabled = $enabled;
$settings->save();
$this->syncSettings();
$this->dispatch('success', $enabled ? 'Automatic security updates turned on.' : 'Automatic security updates turned off.');
} catch (\Throwable $e) {
$this->server->settings->refresh();
$this->syncSettings();
return handleError($e, $this);
}
}
private function applyAutomaticSecurityUpdates(bool $enabled, string $time): void
{
$this->ensureManagedHere();
$packageManager = data_get($this->server->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.');
}
$auditContext = ['enabled' => $enabled, 'time' => $time, 'package_manager' => $packageManager];
try {
ConfigureAutomaticSecurityUpdates::run($this->server, $packageManager, $enabled, $time);
} catch (\Throwable $e) {
$this->auditPatchAction('auto_security_updates_updated', [...$auditContext, 'result' => 'failed', 'error' => $e->getMessage()], 'error');
throw $e;
}
$this->auditPatchAction('auto_security_updates_updated', [...$auditContext, 'result' => 'success']);
}
/**
* A valid cron expression or preset that runs at most once per hour, because every check runs the package manager.
*/
@@ -99,13 +99,44 @@
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." />
@if ($autoUpdatesSupported)
<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"
<div class="w-full">
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
<span class="flex items-center gap-1 text-sm font-medium leading-4">Automatic security updates</span>
<x-helper helper="{{ $packageManager === 'apt' ? 'Uses unattended-upgrades for the security origins only.' : 'Uses dnf-automatic with upgrade_type = security.' }} Turning it off removes the Coolify configuration and keeps the operating system default." />
</div>
<div class="flex min-h-8 items-center justify-between gap-2">
<x-status-badge :status="$autoSecurityUpdates ? 'On, daily' : 'Off'" :type="$autoSecurityUpdates ? 'success' : 'neutral'" />
@can('update', $server)
<div wire:key="auto-security-updates-{{ $autoSecurityUpdates ? 'on' : 'off' }}">
@if ($autoSecurityUpdates)
<x-modal-confirmation title="Turn off automatic security updates?"
buttonTitle="Turn off" submitAction="toggleAutomaticSecurityUpdates"
warningMessage="Security fixes are then not installed automatically by Coolify."
temporaryDisableTwoStepConfirmation :confirmWithText="false"
:confirmWithPassword="false" :actions="[
'Coolify removes its configuration from the server.',
'The server uses the default of the operating system again. Check it if you want no automatic updates at all.',
]"
step2ButtonText="Turn off" />
@else
<x-modal-confirmation title="Turn on automatic security updates?"
buttonTitle="Turn on" isHighlightedButton
warningMessage="Updates run without you. Your resources can stop for a short time during an update. You can turn this off at any time."
submitAction="toggleAutomaticSecurityUpdates"
temporaryDisableTwoStepConfirmation :confirmWithText="false"
:confirmWithPassword="false" :actions="[
'Every day at ' . $autoSecurityUpdatesTime . ' (time zone of the server), the server installs security updates without asking.',
'If Docker comes from the operating system packages (for example docker.io or moby-engine), a security update of Docker restarts it, and all containers on this server stop and start again. Docker from the Docker repository (docker-ce, as Coolify installs it) has no security updates there and is not updated.',
'Services that use an updated library (for example OpenSSL or glibc) can restart.',
'A new kernel is installed, but the server is not rebooted. Coolify then shows Reboot required.',
]"
step2ButtonText="Turn on" />
@endif
</div>
@endcan
</div>
</div>
<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." />
@endif
+48 -18
View File
@@ -347,36 +347,55 @@ describe('automatic security updates', function () {
$this->server->settings->forceFill(['patch_check_result' => patchesTestResult(0), 'patch_checked_at' => now()])->saveQuietly();
});
it('configures the server and saves the setting', function () {
it('turns automatic security updates on and off with the saved time', function () {
$this->server->settings->forceFill(['auto_security_updates_time' => '04:30'])->saveQuietly();
ConfigureAutomaticSecurityUpdates::shouldRun()->once()
->with(Mockery::on(fn ($server) => $server->is($this->server)), 'apt', true, '04:30');
ConfigureAutomaticSecurityUpdates::shouldRun()->once()
->with(Mockery::on(fn ($server) => $server->is($this->server)), 'apt', false, '04:30');
Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])
->set('autoSecurityUpdates', true)
->set('autoSecurityUpdatesTime', '04:30')
->call('submit')
->assertDispatched('success');
$component = Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])
->assertSee('Turn on automatic security updates?')
->assertSee('all containers on this server stop and start again')
->call('toggleAutomaticSecurityUpdates')
->assertDispatched('success')
->assertSet('autoSecurityUpdates', true);
expect($this->server->settings->fresh()->is_auto_security_updates_enabled)->toBeTrue();
expect($this->server->settings->fresh())
->is_auto_security_updates_enabled->toBeTrue()
->auto_security_updates_time->toBe('04:30');
$component->call('toggleAutomaticSecurityUpdates')->assertSet('autoSecurityUpdates', false);
expect($this->server->settings->fresh()->is_auto_security_updates_enabled)->toBeFalse();
});
it('does not run the server commands when only the frequency changes', function () {
it('does not let the client turn on automatic updates without the confirmed action', function () {
ConfigureAutomaticSecurityUpdates::shouldRun()->never();
expect(fn () => Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])->set('autoSecurityUpdates', true))
->toThrow(\Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException::class);
});
it('applies a new maintenance window only when automatic updates are on', function () {
ConfigureAutomaticSecurityUpdates::shouldRun()->once()
->with(Mockery::on(fn ($server) => $server->is($this->server)), 'apt', true, '05:00');
Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])
->set('patchCheckFrequency', 'daily')
->set('autoSecurityUpdatesTime', '04:00')
->call('submit')
->assertDispatched('success');
expect($this->server->settings->fresh()->auto_security_updates_time)->toBe('04:00');
$this->server->settings->forceFill(['is_auto_security_updates_enabled' => true])->saveQuietly();
Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])
->set('autoSecurityUpdatesTime', '05:00')
->call('submit')
->assertDispatched('success');
expect($this->server->settings->fresh()->auto_security_updates_time)->toBe('05:00');
});
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')
->call('toggleAutomaticSecurityUpdates')
->assertDispatched('error')
->assertSet('autoSecurityUpdates', false);
@@ -389,8 +408,8 @@ describe('automatic security updates', function () {
Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])
->assertSee('this server uses pacman')
->set('autoSecurityUpdates', true)
->call('submit')
->assertDontSee('Turn on automatic security updates?')
->call('toggleAutomaticSecurityUpdates')
->assertDispatched('error');
});
@@ -398,11 +417,23 @@ describe('automatic security updates', function () {
ConfigureAutomaticSecurityUpdates::shouldRun()->never();
Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])
->set('autoSecurityUpdates', true)
->set('autoSecurityUpdatesTime', '25:00')
->call('submit')
->assertDispatched('error');
});
it('does not let a member turn on automatic updates', function () {
$member = User::factory()->create();
$this->team->members()->attach($member, ['role' => 'admin']);
$this->actingAs($member);
$component = Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]);
$member->teams()->updateExistingPivot($this->team->id, ['role' => 'member']);
$member->unsetRelation('teams');
ConfigureAutomaticSecurityUpdates::shouldRun()->never();
$component->call('toggleAutomaticSecurityUpdates')->assertDispatched('error');
expect($this->server->settings->fresh()->is_auto_security_updates_enabled)->toBeFalse();
});
});
describe('audit trail', function () {
@@ -464,8 +495,7 @@ describe('audit trail', function () {
ConfigureAutomaticSecurityUpdates::shouldRun()->once();
Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])
->set('autoSecurityUpdates', true)
->call('submit');
->call('toggleAutomaticSecurityUpdates');
$event = AuditEvent::query()->where('event', 'ui.server.auto_security_updates_updated')->sole();
expect($event->actor_email)->toBe($this->admin->email)