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.
This commit is contained in:
Andras Bacsai
2026-09-30 18:10:10 +02:00
parent f53a883a60
commit ef90fe06f1
8 changed files with 657 additions and 14 deletions
@@ -0,0 +1,19 @@
<?php
namespace App\Actions\Proxy;
use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction;
class DeleteTraefikAcmeBackup
{
use AsAction;
public function handle(Server $server, string $backupName): void
{
$backupName = ListTraefikAcmeBackups::resolve($server, $backupName);
$path = escapeshellarg(ListTraefikAcmeBackups::directory($server)."/{$backupName}");
instant_remote_process(['sh -c '.escapeshellarg("rm -f -- {$path}")], $server);
}
}
@@ -0,0 +1,111 @@
<?php
namespace App\Actions\Proxy;
use App\Enums\ProxyTypes;
use App\Models\Server;
use Carbon\CarbonImmutable;
use Lorisleiva\Actions\Concerns\AsAction;
use RuntimeException;
/**
* Backups of Traefik's acme.json, stored next to it as acme.json.backup-<UTC timestamp>-<random>.
*/
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<int, array{name: string, created_at: string, size: int}> */
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,
);
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Actions\Proxy;
use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction;
class RestoreTraefikAcmeBackup
{
use AsAction;
/**
* Replace acme.json with a backup. The current acme.json is backed up first.
*/
public function handle(Server $server, string $backupName): void
{
$backupName = ListTraefikAcmeBackups::resolve($server, $backupName);
$directory = ListTraefikAcmeBackups::directory($server);
$backup = ListTraefikAcmeBackups::backupCommands($server);
$restorePath = "{$directory}/.acme.json.restore-".bin2hex(random_bytes(8)).'.tmp';
$source = escapeshellarg("{$directory}/{$backupName}");
$temporary = escapeshellarg($restorePath);
// Copy the chosen backup before pruning, which may remove it when it is the oldest one.
$script = implode('; ', [
'set -e',
'trap '.escapeshellarg('rm -f -- '.escapeshellarg($restorePath).' '.escapeshellarg($backup['temporary_path'])).' EXIT',
'umask 077',
"[ -f {$source} ]",
"cp -- {$source} {$temporary}",
"chmod 600 {$temporary}",
$backup['script'],
"mv -- {$temporary} ".escapeshellarg("{$directory}/acme.json"),
ListTraefikAcmeBackups::pruneCommands($server),
]);
instant_remote_process(['sh -c '.escapeshellarg($script)], $server);
// Traefik reads acme.json only when it starts.
$server->proxy->certificates_restart_required = true;
$server->save();
}
}
+12 -9
View File
@@ -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);
}
+82
View File
@@ -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<int, array{name: string, created_at: string, size: int}> */
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<int, string> $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) {
@@ -39,7 +39,7 @@
<x-callout type="warning" title="Your configuration changed, please restart the proxy." />
@elseif ($server->hasPendingProxyConfiguration())
<x-callout type="warning" title="Restart required">
Restart the proxy to stop serving deleted TLS certificates.
Restart the proxy to apply TLS certificate changes.
</x-callout>
@else
<div class="flex items-start gap-3">
@@ -103,7 +103,7 @@
@if ($traefikCertificatesLoaded && count($traefikCertificates) === 0)
<x-empty size="sm" title="No TLS certificates found"
description="Traefik's ACME storage does not contain certificate entries."
icon-name="security" />
icon-name="shield-star" />
@elseif (count($traefikCertificates) > 0)
<div class="overflow-hidden rounded-lg ring-1 ring-neutral-200 dark:ring-white/[0.08]">
<div class="overflow-x-auto">
@@ -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 @@
</div>
</div>
@endif
@can('manageProxy', $server)
@if (count($traefikAcmeBackups) > 0)
<div class="mt-6 flex flex-col gap-2">
<div>
<h4 class="text-sm font-medium text-neutral-950 dark:text-fg">acme.json backups</h4>
<p class="mt-1 text-xs text-neutral-500 dark:text-fg-dim">
Coolify saves a copy of acme.json before it changes the file and keeps the
last {{ \App\Actions\Proxy\ListTraefikAcmeBackups::KEEP }} copies.
</p>
</div>
<div class="overflow-hidden rounded-lg ring-1 ring-neutral-200 dark:ring-white/[0.08]">
<div class="overflow-x-auto">
<table class="w-full min-w-2xl">
<thead>
<tr>
<th>Backup</th>
<th>Created</th>
<th>Size</th>
<th><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
@foreach ($traefikAcmeBackups as $backup)
<tr wire:key="traefik-acme-backup-{{ $backup['name'] }}">
<td class="font-mono text-xs text-neutral-950 dark:text-fg">
{{ $backup['name'] }}
</td>
<td>{{ $backup['created_at'] }}</td>
<td>{{ formatBytes($backup['size']) }}</td>
<td>
<div class="flex justify-end gap-2">
<x-modal-confirmation title="Restore acme.json Backup?"
buttonTitle="Restore"
submitAction="restoreTraefikAcmeBackup('{{ $backup['name'] }}')"
:checkboxes="[
['id' => 'restartProxyAfterAcmeRestore', 'label' => 'Restart the proxy now. Sites on this server are unavailable for a few seconds.'],
]"
:actions="[
'Save the current acme.json as a new backup.',
'Replace acme.json with the backup from '.$backup['created_at'].'.',
]"
warningMessage="Until the proxy restarts, it keeps its loaded certificates and can write them back to acme.json. Certificates issued after this backup are removed from acme.json."
step2ButtonText="Restore Backup"
:confirmWithPassword="false"
:confirmWithText="false" />
<x-modal-confirmation title="Delete acme.json Backup?"
buttonTitle="Delete"
submitAction="deleteTraefikAcmeBackup('{{ $backup['name'] }}')"
:actions="['Delete the backup '.$backup['name'].'.']"
step2ButtonText="Delete Backup"
isErrorButton :confirmWithPassword="false"
:confirmWithText="false" />
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
@endif
@endcan
</div>
</x-application.settings-section>
@endif
+319
View File
@@ -0,0 +1,319 @@
<?php
use App\Actions\Proxy\DeleteTraefikAcmeBackup;
use App\Actions\Proxy\DeleteTraefikCertificate;
use App\Actions\Proxy\GetTraefikCertificates;
use App\Actions\Proxy\ListTraefikAcmeBackups;
use App\Actions\Proxy\RestoreTraefikAcmeBackup;
use App\Actions\Proxy\SaveTraefikAcmeFile;
use App\Enums\ProxyTypes;
use App\Jobs\RestartProxyJob;
use App\Livewire\Server\Proxy;
use App\Models\AuditEvent;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
/**
* Runs the remote commands against a local directory instead of a server, so the shell scripts are really executed.
*/
beforeEach(function () {
config(['constants.ssh.mux_enabled' => 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']);
});
@@ -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');