Files
coolify/tests/Feature/TraefikAcmeBackupTest.php
T
Andras BacsaiandClaude Opus 5.5 777362cd97 feat(proxy): mark unused Traefik certificates and delete them in one step
Traefik renews every certificate in acme.json, also when no route uses
its domain. The TLS certificates page now marks certificates that no
Traefik label, proxy config file, or resource domain on the server uses,
and a "Delete unused" button removes them in one write with one backup.

Closes #12447

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:12:40 +01:00

320 lines
15 KiB
PHP

<?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\Certificates;
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 = remoteProcessScript($process);
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(Certificates::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(Certificates::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(Certificates::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(Certificates::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(Certificates::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(Certificates::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']);
});