From ef90fe06f15408c36b5abf2885d89d2a555b284c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:10:10 +0200 Subject: [PATCH] feat(proxy): back up acme.json before changes and allow restoring backups Coolify copies acme.json to a timestamped backup before every change and keeps the last 10. The proxy page lists the backups with Restore and Delete for team admins, and the restore dialog can restart the proxy right away. --- app/Actions/Proxy/DeleteTraefikAcmeBackup.php | 19 ++ app/Actions/Proxy/ListTraefikAcmeBackups.php | 111 ++++++ .../Proxy/RestoreTraefikAcmeBackup.php | 44 +++ app/Actions/Proxy/SaveTraefikAcmeFile.php | 21 +- app/Livewire/Server/Proxy.php | 82 +++++ .../views/livewire/server/proxy.blade.php | 69 +++- tests/Feature/TraefikAcmeBackupTest.php | 319 ++++++++++++++++++ .../TraefikCertificateRemoteFileTest.php | 6 +- 8 files changed, 657 insertions(+), 14 deletions(-) create mode 100644 app/Actions/Proxy/DeleteTraefikAcmeBackup.php create mode 100644 app/Actions/Proxy/ListTraefikAcmeBackups.php create mode 100644 app/Actions/Proxy/RestoreTraefikAcmeBackup.php create mode 100644 tests/Feature/TraefikAcmeBackupTest.php diff --git a/app/Actions/Proxy/DeleteTraefikAcmeBackup.php b/app/Actions/Proxy/DeleteTraefikAcmeBackup.php new file mode 100644 index 0000000000..df28e571a9 --- /dev/null +++ b/app/Actions/Proxy/DeleteTraefikAcmeBackup.php @@ -0,0 +1,19 @@ +-. + */ +class ListTraefikAcmeBackups +{ + use AsAction; + + public const KEEP = 10; + + public const NAME_PATTERN = '/^acme\.json\.backup-(\d{8}T\d{6}Z)-[0-9a-f]{8}$/'; + + private const SHELL_NAME_PATTERN = '^acme\.json\.backup-[0-9]{8}T[0-9]{6}Z-[0-9a-f]{8}$'; + + /** @return array */ + public function handle(Server $server): array + { + if ($server->proxyType() !== ProxyTypes::TRAEFIK->value) { + return []; + } + + $pattern = escapeshellarg(self::directory($server)).'/acme.json.backup-*'; + $script = "for file in {$pattern}; do if [ -f \"\$file\" ]; then printf '%s %s\\n' \"\${file##*/}\" \"\$(wc -c < \"\$file\")\"; fi; done"; + $output = instant_remote_process(['sh -c '.escapeshellarg($script)], $server); + + return collect(preg_split('/\R/', (string) $output)) + ->map(function (string $line): ?array { + if (! preg_match('/^(\S+) +(\d+)$/', trim($line), $matches) + || ! preg_match(self::NAME_PATTERN, $matches[1], $name)) { + return null; + } + + return [ + 'name' => $matches[1], + 'created_at' => CarbonImmutable::createFromFormat('Ymd\THis\Z', $name[1], 'UTC')->format('Y-m-d H:i:s').' UTC', + 'size' => (int) $matches[2], + ]; + }) + ->filter() + ->sortByDesc('name') + ->values() + ->all(); + } + + /** + * Resolve a backup name from the client against the backups that exist on the server. + */ + public static function resolve(Server $server, string $name): string + { + if ($server->proxyType() !== ProxyTypes::TRAEFIK->value) { + throw new RuntimeException('TLS certificates can only be managed for Traefik proxies.'); + } + + if (preg_match(self::NAME_PATTERN, $name) !== 1 + || ! collect(self::run($server))->contains('name', $name)) { + throw new RuntimeException('The selected acme.json backup could not be found.'); + } + + return $name; + } + + public static function directory(Server $server): string + { + return rtrim($server->proxyPath(), '/'); + } + + /** + * Shell commands that copy the current acme.json, if any, to a new backup with mode 600. + * + * @return array{script: string, temporary_path: string} + */ + public static function backupCommands(Server $server): array + { + $directory = self::directory($server); + $suffix = bin2hex(random_bytes(4)); + $acmePath = escapeshellarg("{$directory}/acme.json"); + $temporaryPath = "{$directory}/.acme.json.backup-{$suffix}.tmp"; + $backupPath = escapeshellarg("{$directory}/acme.json.backup-".now()->utc()->format('Ymd\THis\Z')."-{$suffix}"); + $temporary = escapeshellarg($temporaryPath); + + return [ + 'script' => "if [ -f {$acmePath} ]; then cp -- {$acmePath} {$temporary}; chmod 600 {$temporary}; mv -- {$temporary} {$backupPath}; fi", + 'temporary_path' => $temporaryPath, + ]; + } + + /** + * Shell commands that delete all but the newest backups. + */ + public static function pruneCommands(Server $server): string + { + $directory = escapeshellarg(self::directory($server)); + + return sprintf( + 'ls -1 -- %s | grep -E %s | sort -r | tail -n +%d | while IFS= read -r name; do rm -f -- %s/"$name"; done', + $directory, + escapeshellarg(self::SHELL_NAME_PATTERN), + self::KEEP + 1, + $directory, + ); + } +} diff --git a/app/Actions/Proxy/RestoreTraefikAcmeBackup.php b/app/Actions/Proxy/RestoreTraefikAcmeBackup.php new file mode 100644 index 0000000000..458b2e76cb --- /dev/null +++ b/app/Actions/Proxy/RestoreTraefikAcmeBackup.php @@ -0,0 +1,44 @@ +proxy->certificates_restart_required = true; + $server->save(); + } +} diff --git a/app/Actions/Proxy/SaveTraefikAcmeFile.php b/app/Actions/Proxy/SaveTraefikAcmeFile.php index de469e7551..586e094bc4 100644 --- a/app/Actions/Proxy/SaveTraefikAcmeFile.php +++ b/app/Actions/Proxy/SaveTraefikAcmeFile.php @@ -26,15 +26,18 @@ class SaveTraefikAcmeFile @unlink($localPath); } - $script = sprintf( - 'set -e; trap "rm -f -- %s" EXIT; umask 077; cat -- %s > %s; chmod 600 %s; mv -- %s %s', - escapeshellarg($uploadPath), - escapeshellarg($uploadPath), - escapeshellarg($temporaryPath), - escapeshellarg($temporaryPath), - escapeshellarg($temporaryPath), - escapeshellarg($path), - ); + // Keep a copy of the current file, so a change can be rolled back from the proxy page. + $backup = ListTraefikAcmeBackups::backupCommands($server); + $script = implode('; ', [ + 'set -e', + 'trap '.escapeshellarg('rm -f -- '.escapeshellarg($uploadPath).' '.escapeshellarg($temporaryPath).' '.escapeshellarg($backup['temporary_path'])).' EXIT', + 'umask 077', + 'cat -- '.escapeshellarg($uploadPath).' > '.escapeshellarg($temporaryPath), + 'chmod 600 '.escapeshellarg($temporaryPath), + $backup['script'], + 'mv -- '.escapeshellarg($temporaryPath).' '.escapeshellarg($path), + ListTraefikAcmeBackups::pruneCommands($server), + ]); instant_remote_process(['sh -c '.escapeshellarg($script)], $server); } diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index f5da7146af..8a369edc1d 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -2,15 +2,20 @@ namespace App\Livewire\Server; +use App\Actions\Proxy\DeleteTraefikAcmeBackup; use App\Actions\Proxy\DeleteTraefikCertificate; use App\Actions\Proxy\GetProxyConfiguration; use App\Actions\Proxy\GetTraefikCertificates; +use App\Actions\Proxy\ListTraefikAcmeBackups; +use App\Actions\Proxy\RestoreTraefikAcmeBackup; use App\Actions\Proxy\SaveProxyConfiguration; use App\Enums\ProxyTypes; +use App\Jobs\RestartProxyJob; use App\Models\Server; use App\Rules\SafeExternalUrl; use App\Traits\ListensToTeamChannel; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\Gate; use Livewire\Component; class Proxy extends Component @@ -34,6 +39,9 @@ class Proxy extends Component public bool $traefikCertificatesLoaded = false; + /** @var array */ + public array $traefikAcmeBackups = []; + /** * Cache the versions.json file data in memory for this component instance. * This avoids multiple file reads during a single request/render cycle. @@ -241,6 +249,79 @@ class Proxy extends Component $this->traefikCertificatesLoaded = true; handleError($e, $this); } + + $this->loadTraefikAcmeBackups(); + } + + private function loadTraefikAcmeBackups(): void + { + $this->traefikAcmeBackups = []; + + try { + if (Gate::allows('manageProxy', $this->server)) { + $this->traefikAcmeBackups = ListTraefikAcmeBackups::run($this->server); + } + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + /** Default of the "Restart the proxy now" option in the acme.json restore dialog. */ + public bool $restartProxyAfterAcmeRestore = true; + + /** + * @param array $selectedActions checkbox ids selected in the restore dialog + */ + public function restoreTraefikAcmeBackup(string $backupName, string $password = '', array $selectedActions = []): void + { + try { + $this->authorize('manageProxy', $this->server); + RestoreTraefikAcmeBackup::run($this->server, $backupName); + auditLog('ui.proxy.acme_backup_restored', [ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + 'backup' => $backupName, + ]); + $this->loadTraefikCertificates(); + + // A running Traefik keeps its certificates in memory and can write them back to acme.json. + if (in_array('restartProxyAfterAcmeRestore', $selectedActions, true)) { + RestartProxyJob::dispatch($this->server); + auditLog('ui.proxy.restarted', [ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + ]); + $this->dispatch('refreshServerShow'); + $this->dispatch('success', 'acme.json restored. The proxy is restarting to load the restored certificates.'); + + return; + } + + $this->dispatch('refreshServerShow'); + $this->dispatch('success', 'acme.json restored. Restart the proxy to load the restored certificates.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function deleteTraefikAcmeBackup(string $backupName, string $password = ''): void + { + try { + $this->authorize('manageProxy', $this->server); + DeleteTraefikAcmeBackup::run($this->server, $backupName); + auditLog('ui.proxy.acme_backup_deleted', [ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + 'backup' => $backupName, + ]); + $this->loadTraefikAcmeBackups(); + $this->dispatch('success', 'acme.json backup deleted.'); + } catch (\Throwable $e) { + handleError($e, $this); + } } public function deleteTraefikCertificate(string $certificateId, string $password = ''): void @@ -256,6 +337,7 @@ class Proxy extends Component 'resolver' => $certificate['resolver'], ]); $this->traefikCertificates = GetTraefikCertificates::run($this->server); + $this->loadTraefikAcmeBackups(); $this->dispatch('refreshServerShow'); $this->dispatch('success', 'TLS certificate deleted. Restart Traefik to remove it from the running proxy.'); } catch (\Throwable $e) { diff --git a/resources/views/livewire/server/proxy.blade.php b/resources/views/livewire/server/proxy.blade.php index 7d7bd93db6..3b4a465ff3 100644 --- a/resources/views/livewire/server/proxy.blade.php +++ b/resources/views/livewire/server/proxy.blade.php @@ -39,7 +39,7 @@ @elseif ($server->hasPendingProxyConfiguration()) - Restart the proxy to stop serving deleted TLS certificates. + Restart the proxy to apply TLS certificate changes. @else
@@ -103,7 +103,7 @@ @if ($traefikCertificatesLoaded && count($traefikCertificates) === 0) + icon-name="shield-star" /> @elseif (count($traefikCertificates) > 0)
@@ -154,6 +154,7 @@ buttonTitle="Delete" submitAction="deleteTraefikCertificate({{ $certificate['id'] }})" :actions="[ + 'Save the current acme.json as a backup that you can restore below.', 'Delete the certificate for '.$certificate['main_domain'].' from acme.json.', 'Restart the proxy before it stops using the certificate. If a route still uses the domain, Traefik requests a new certificate after the restart.', ]" @@ -172,6 +173,70 @@
@endif + + @can('manageProxy', $server) + @if (count($traefikAcmeBackups) > 0) +
+
+

acme.json backups

+

+ Coolify saves a copy of acme.json before it changes the file and keeps the + last {{ \App\Actions\Proxy\ListTraefikAcmeBackups::KEEP }} copies. +

+
+
+
+ + + + + + + + + + + @foreach ($traefikAcmeBackups as $backup) + + + + + + + @endforeach + +
BackupCreatedSizeActions
+ {{ $backup['name'] }} + {{ $backup['created_at'] }}{{ formatBytes($backup['size']) }} +
+ + +
+
+
+
+
+ @endif + @endcan
@endif diff --git a/tests/Feature/TraefikAcmeBackupTest.php b/tests/Feature/TraefikAcmeBackupTest.php new file mode 100644 index 0000000000..25d17a4976 --- /dev/null +++ b/tests/Feature/TraefikAcmeBackupTest.php @@ -0,0 +1,319 @@ + false]); + + $this->baseConfigPath = sys_get_temp_dir().'/coolify-acme-backup-test-'.bin2hex(random_bytes(6)); + mkdir($this->baseConfigPath.'/proxy', 0700, true); + config(['constants.coolify.base_config_path' => $this->baseConfigPath]); + $this->proxyDirectory = $this->baseConfigPath.'/proxy'; + $this->acmePath = $this->proxyDirectory.'/acme.json'; + + $team = Team::factory()->create(); + $key = PrivateKey::factory()->create(['team_id' => $team->id]); + $this->server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => $key->id, + 'user' => 'deploy', + 'proxy' => ['type' => ProxyTypes::TRAEFIK->value, 'status' => 'running'], + ]); + + $this->acmeContents = json_encode(['letsencrypt' => ['Certificates' => [ + ['domain' => ['main' => 'one.example.com'], 'certificate' => base64_encode('one'), 'key' => base64_encode('key-one')], + ['domain' => ['main' => 'two.example.com'], 'certificate' => base64_encode('two'), 'key' => base64_encode('key-two')], + ]]]); + file_put_contents($this->acmePath, $this->acmeContents); + chmod($this->acmePath, 0600); + + $this->remoteLines = []; + Process::fake(function ($process) { + $command = $process->command; + if (str_contains($command, ' scp ') && preg_match("~'([^']+)' \\S+:'([^']+)'$~", $command, $matches)) { + copy($matches[1], $matches[2]); + + return Process::result(); + } + + $lines = explode("\n", $command); + $body = array_slice($lines, 1, -1); + array_push($this->remoteLines, ...$body); + $script = implode("\n", array_map(fn (string $line): string => preg_replace('/^sudo /', '', $line), $body)); + + $handle = proc_open(['sh'], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + fwrite($pipes[0], $script); + fclose($pipes[0]); + $output = stream_get_contents($pipes[1]); + $errorOutput = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + return Process::result(output: $output, errorOutput: $errorOutput, exitCode: proc_close($handle)); + }); +}); + +afterEach(function () { + File::deleteDirectory($this->baseConfigPath); +}); + +function acmeBackupFiles(string $directory): array +{ + return collect(scandir($directory)) + ->filter(fn (string $name): bool => preg_match(ListTraefikAcmeBackups::NAME_PATTERN, $name) === 1) + ->sort() + ->values() + ->all(); +} + +function createAcmeBackup(string $directory, string $name, string $contents): void +{ + file_put_contents("{$directory}/{$name}", $contents); + chmod("{$directory}/{$name}", 0600); +} + +function filePermissions(string $path): string +{ + clearstatcache(); + + return substr(sprintf('%o', fileperms($path)), -3); +} + +it('backs up acme.json before a certificate is deleted', function () { + DeleteTraefikCertificate::run($this->server, GetTraefikCertificates::run($this->server)[0]['id']); + + $backups = acmeBackupFiles($this->proxyDirectory); + expect($backups)->toHaveCount(1) + ->and($backups[0])->toMatch('/^acme\.json\.backup-\d{8}T\d{6}Z-[0-9a-f]{8}$/') + ->and(file_get_contents($this->proxyDirectory.'/'.$backups[0]))->toBe($this->acmeContents) + ->and(filePermissions($this->proxyDirectory.'/'.$backups[0]))->toBe('600') + ->and(json_decode(file_get_contents($this->acmePath), true)['letsencrypt']['Certificates'])->toHaveCount(1) + ->and(filePermissions($this->acmePath))->toBe('600'); + + // Every remote line must stay one quoted sudo shell script for non-root SSH users. + expect($this->remoteLines)->not->toBeEmpty(); + foreach ($this->remoteLines as $line) { + expect(isSingleSudoShellScript($line))->toBeTrue(); + } +}); + +it('does not create a backup when acme.json does not exist yet', function () { + unlink($this->acmePath); + + SaveTraefikAcmeFile::run($this->server, '{}'); + + expect(acmeBackupFiles($this->proxyDirectory))->toBeEmpty() + ->and(file_get_contents($this->acmePath))->toBe('{}'); +}); + +it('keeps only the newest acme.json backups', function () { + foreach (range(1, 12) as $day) { + createAcmeBackup($this->proxyDirectory, sprintf('acme.json.backup-202601%02dT000000Z-0000000%s', $day, dechex($day)), "old-{$day}"); + } + file_put_contents($this->proxyDirectory.'/acme.json.backup-unrelated', 'keep'); + + SaveTraefikAcmeFile::run($this->server, '{}'); + + $backups = acmeBackupFiles($this->proxyDirectory); + expect($backups)->toHaveCount(ListTraefikAcmeBackups::KEEP) + ->and($backups)->not->toContain('acme.json.backup-20260101T000000Z-00000001', 'acme.json.backup-20260103T000000Z-00000003') + ->and($backups)->toContain('acme.json.backup-20260104T000000Z-00000004') + ->and(file_get_contents($this->proxyDirectory.'/'.end($backups)))->toBe($this->acmeContents) + ->and(file_exists($this->proxyDirectory.'/acme.json.backup-unrelated'))->toBeTrue(); +}); + +it('lists acme.json backups newest first with time and size', function () { + createAcmeBackup($this->proxyDirectory, 'acme.json.backup-20260101T101500Z-0000abcd', 'older'); + createAcmeBackup($this->proxyDirectory, 'acme.json.backup-20260202T101500Z-0000abce', 'newer backup'); + file_put_contents($this->proxyDirectory.'/acme.json.backup-$(touch pwned)', 'ignored'); + + $backups = ListTraefikAcmeBackups::run($this->server); + + expect($backups)->toBe([ + ['name' => 'acme.json.backup-20260202T101500Z-0000abce', 'created_at' => '2026-02-02 10:15:00 UTC', 'size' => 12], + ['name' => 'acme.json.backup-20260101T101500Z-0000abcd', 'created_at' => '2026-01-01 10:15:00 UTC', 'size' => 5], + ])->and(file_exists($this->proxyDirectory.'/pwned'))->toBeFalse(); +}); + +it('restores a backup after backing up the current acme.json', function () { + // The restored backup is the oldest one, which the pruning must not remove before it is copied. + foreach (range(1, 10) as $day) { + createAcmeBackup($this->proxyDirectory, sprintf('acme.json.backup-202601%02dT000000Z-000000%02x', $day, $day), "backup-{$day}"); + } + + RestoreTraefikAcmeBackup::run($this->server, 'acme.json.backup-20260101T000000Z-00000001'); + + $backups = acmeBackupFiles($this->proxyDirectory); + expect(file_get_contents($this->acmePath))->toBe('backup-1') + ->and(filePermissions($this->acmePath))->toBe('600') + ->and($backups)->toHaveCount(ListTraefikAcmeBackups::KEEP) + ->and(file_get_contents($this->proxyDirectory.'/'.end($backups)))->toBe($this->acmeContents) + ->and($this->server->fresh()->hasPendingProxyConfiguration())->toBeTrue(); + foreach ($this->remoteLines as $line) { + expect(isSingleSudoShellScript($line))->toBeTrue(); + } +}); + +it('refuses to restore or delete backups that are not listed', function (string $name) { + createAcmeBackup($this->proxyDirectory, 'acme.json.backup-20260101T000000Z-00000001', 'backup'); + file_put_contents($this->baseConfigPath.'/acme.json.backup-20260101T000000Z-00000002', 'outside'); + + expect(fn () => RestoreTraefikAcmeBackup::run($this->server, $name))->toThrow(RuntimeException::class) + ->and(fn () => DeleteTraefikAcmeBackup::run($this->server, $name))->toThrow(RuntimeException::class) + ->and(file_get_contents($this->acmePath))->toBe($this->acmeContents) + ->and(acmeBackupFiles($this->proxyDirectory))->toBe(['acme.json.backup-20260101T000000Z-00000001']) + ->and($this->server->fresh()->hasPendingProxyConfiguration())->toBeFalse(); +})->with([ + 'path traversal' => '../acme.json.backup-20260101T000000Z-00000002', + 'shell injection' => "acme.json.backup-20260101T000000Z-00000001'; rm -rf /; '", + 'not a backup' => 'acme.json', + 'missing backup' => 'acme.json.backup-20260101T000000Z-0000000f', +]); + +it('deletes a backup', function () { + createAcmeBackup($this->proxyDirectory, 'acme.json.backup-20260101T000000Z-00000001', 'backup'); + + DeleteTraefikAcmeBackup::run($this->server, 'acme.json.backup-20260101T000000Z-00000001'); + + expect(acmeBackupFiles($this->proxyDirectory))->toBeEmpty() + ->and(file_get_contents($this->acmePath))->toBe($this->acmeContents); +}); + +function actingAsAcmeBackupUser(Team $team, string $role): User +{ + InstanceSettings::forceCreate(['id' => 0]); + $user = User::factory()->create(); + $user->teams()->attach($team->id, ['role' => $role]); + test()->actingAs($user); + session(['currentTeam' => $team]); + + return $user; +} + +it('lets an admin see and restore acme.json backups from the proxy page', function () { + $this->withoutDefer(); + actingAsAcmeBackupUser($this->server->team, 'admin'); + createAcmeBackup($this->proxyDirectory, 'acme.json.backup-20260101T000000Z-00000001', '{}'); + + Livewire::test(Proxy::class, ['server' => $this->server]) + ->call('loadTraefikCertificates') + ->assertSee('acme.json.backup-20260101T000000Z-00000001') + ->assertSee('2026-01-01 00:00:00 UTC') + ->assertSeeHtml(['restoreTraefikAcmeBackup(', 'deleteTraefikAcmeBackup(']) + ->call('restoreTraefikAcmeBackup', 'acme.json.backup-20260101T000000Z-00000001') + ->assertDispatched('success') + ->assertDispatched('refreshServerShow') + ->assertSet('traefikCertificates', []) + ->assertSee('Restart the proxy to apply TLS certificate changes.'); + + $event = AuditEvent::query()->where('event', 'ui.proxy.acme_backup_restored')->sole(); + expect(file_get_contents($this->acmePath))->toBe('{}') + ->and($event->team_id)->toBe($this->server->team_id) + ->and($event->metadata)->toMatchArray([ + 'server_uuid' => $this->server->uuid, + 'backup' => 'acme.json.backup-20260101T000000Z-00000001', + ]); +}); + +it('restarts the proxy after a restore when the restart option is selected', function () { + Queue::fake(); + $this->withoutDefer(); + actingAsAcmeBackupUser($this->server->team, 'admin'); + createAcmeBackup($this->proxyDirectory, 'acme.json.backup-20260101T000000Z-00000001', '{}'); + + Livewire::test(Proxy::class, ['server' => $this->server]) + ->assertSet('restartProxyAfterAcmeRestore', true) + ->call('restoreTraefikAcmeBackup', 'acme.json.backup-20260101T000000Z-00000001', '', ['restartProxyAfterAcmeRestore']) + ->assertDispatched('success'); + + Queue::assertPushed(RestartProxyJob::class, fn (RestartProxyJob $job): bool => $job->server->is($this->server)); + expect(file_get_contents($this->acmePath))->toBe('{}') + ->and(AuditEvent::query()->where('event', 'ui.proxy.restarted')->exists())->toBeTrue(); +}); + +it('does not restart the proxy after a restore when the restart option is cleared', function () { + Queue::fake(); + actingAsAcmeBackupUser($this->server->team, 'admin'); + createAcmeBackup($this->proxyDirectory, 'acme.json.backup-20260101T000000Z-00000001', '{}'); + + Livewire::test(Proxy::class, ['server' => $this->server]) + ->call('restoreTraefikAcmeBackup', 'acme.json.backup-20260101T000000Z-00000001', '') + ->assertDispatched('success'); + + Queue::assertNotPushed(RestartProxyJob::class); +}); + +it('lets an admin delete an acme.json backup from the proxy page', function () { + actingAsAcmeBackupUser($this->server->team, 'admin'); + createAcmeBackup($this->proxyDirectory, 'acme.json.backup-20260101T000000Z-00000001', '{}'); + + Livewire::test(Proxy::class, ['server' => $this->server]) + ->call('deleteTraefikAcmeBackup', 'acme.json.backup-20260101T000000Z-00000001') + ->assertDispatched('success') + ->assertSet('traefikAcmeBackups', []); + + expect(acmeBackupFiles($this->proxyDirectory))->toBeEmpty(); +}); + +it('does not let a member list, restore, or delete acme.json backups', function () { + actingAsAcmeBackupUser($this->server->team, 'member'); + createAcmeBackup($this->proxyDirectory, 'acme.json.backup-20260101T000000Z-00000001', '{}'); + + Livewire::test(Proxy::class, ['server' => $this->server]) + ->call('loadTraefikCertificates') + ->assertSet('traefikCertificates', fn (array $certificates): bool => count($certificates) === 2) + ->assertSet('traefikAcmeBackups', []) + ->assertDontSee('acme.json.backup-20260101T000000Z-00000001') + ->assertDontSeeHtml(['restoreTraefikAcmeBackup(', 'deleteTraefikAcmeBackup(']) + ->call('restoreTraefikAcmeBackup', 'acme.json.backup-20260101T000000Z-00000001') + ->assertDispatched('error') + ->assertNotDispatched('success') + ->call('deleteTraefikAcmeBackup', 'acme.json.backup-20260101T000000Z-00000001') + ->assertNotDispatched('success'); + + expect(file_get_contents($this->acmePath))->toBe($this->acmeContents) + ->and(acmeBackupFiles($this->proxyDirectory))->toBe(['acme.json.backup-20260101T000000Z-00000001']) + ->and($this->server->fresh()->hasPendingProxyConfiguration())->toBeFalse() + ->and(AuditEvent::query()->where('event', 'ui.proxy.acme_backup_restored')->exists())->toBeFalse(); +}); + +it('does not list, restore, or delete acme.json backups of another team', function () { + actingAsAcmeBackupUser(Team::factory()->create(), 'owner'); + createAcmeBackup($this->proxyDirectory, 'acme.json.backup-20260101T000000Z-00000001', '{}'); + + Livewire::test(Proxy::class, ['server' => $this->server]) + ->call('loadTraefikCertificates') + ->assertSet('traefikAcmeBackups', []) + ->call('restoreTraefikAcmeBackup', 'acme.json.backup-20260101T000000Z-00000001') + ->assertDispatched('error') + ->call('deleteTraefikAcmeBackup', 'acme.json.backup-20260101T000000Z-00000001') + ->assertNotDispatched('success'); + + expect($this->remoteLines)->toBeEmpty() + ->and(file_get_contents($this->acmePath))->toBe($this->acmeContents) + ->and(acmeBackupFiles($this->proxyDirectory))->toBe(['acme.json.backup-20260101T000000Z-00000001']); +}); diff --git a/tests/Feature/TraefikCertificateRemoteFileTest.php b/tests/Feature/TraefikCertificateRemoteFileTest.php index ae50e45236..f5f80efbbb 100644 --- a/tests/Feature/TraefikCertificateRemoteFileTest.php +++ b/tests/Feature/TraefikCertificateRemoteFileTest.php @@ -100,10 +100,10 @@ it('shows the restart warning after deleting a certificate from the proxy page', $certificateId = GetTraefikCertificates::run($this->server)[0]['id']; Livewire::test(Proxy::class, ['server' => $this->server]) - ->assertDontSee('Restart the proxy to stop serving deleted TLS certificates.') + ->assertDontSee('Restart the proxy to apply TLS certificate changes.') ->call('deleteTraefikCertificate', $certificateId) ->assertDispatched('refreshServerShow') - ->assertSee('Restart the proxy to stop serving deleted TLS certificates.'); + ->assertSee('Restart the proxy to apply TLS certificate changes.'); }); function actingAsTraefikCertificateUser(Team $team, string $role): User @@ -143,7 +143,7 @@ it('lets a member list certificates but not delete them', function () { Livewire::test(Proxy::class, ['server' => $this->server]) ->call('loadTraefikCertificates') ->assertSet('traefikCertificates', fn (array $certificates): bool => count($certificates) === 40) - ->assertDontSeeHtml('submitAction="deleteTraefikCertificate') + ->assertDontSeeHtml('deleteTraefikCertificate(') ->call('deleteTraefikCertificate', $certificateId) ->assertDispatched('error') ->assertNotDispatched('success');