mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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:
co-authored by
Claude Opus 5.5
parent
d6f7353fda
commit
3bc7e47a86
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user