Files
coolify/tests/Feature/TraefikCertificateRemoteFileTest.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

301 lines
13 KiB
PHP

<?php
use App\Actions\Development\SeedDevelopmentTraefikCertificates;
use App\Actions\Proxy\DeleteTraefikCertificate;
use App\Actions\Proxy\GetTraefikCertificates;
use App\Enums\ProxyTypes;
use App\Livewire\Server\Proxy\Certificates;
use App\Models\Application;
use App\Models\AuditEvent;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use App\Services\TraefikAcmeService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
config(['constants.ssh.mux_enabled' => false]);
$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'],
]);
// Large enough that the old inline write exceeded the 128 KiB single-argument limit.
$certificates = collect(range(1, 40))->map(fn (int $number): array => [
'domain' => ['main' => "app{$number}.example.com"],
'certificate' => base64_encode(str_repeat("certificate-{$number}", 400)),
'key' => base64_encode(str_repeat("key-{$number}", 150)),
])->all();
$this->acmeContents = json_encode(['letsencrypt' => ['Certificates' => $certificates]]);
$this->uploadedContents = null;
// Every certificate except app1 and app2 is unused unless a test adds a route or a resource.
$this->routingOutput = '{"traefik.http.routers.http-0-app1.rule":"Host(`app1.example.com`)"}';
Process::fake(function ($process) {
$command = remoteProcessScript($process);
if (str_starts_with($command, 'timeout') && str_contains($command, ' scp ')
&& preg_match("~'(".preg_quote(sys_get_temp_dir(), '~')."/coolify-acme-[^']+)'~", $command, $matches)) {
$this->uploadedContents = file_get_contents($matches[1]);
return Process::result();
}
if (str_contains($command, 'docker ps -aq --filter label=traefik.enable=true')) {
return Process::result(output: $this->routingOutput);
}
return Process::result(output: str_contains($command, 'head -c') ? base64_encode($this->acmeContents) : '');
});
});
it('reads the ACME file with sudo on non-root servers', function () {
$certificates = GetTraefikCertificates::run($this->server);
expect($certificates)->toHaveCount(40);
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "\nsudo sh -c 'if [ ! -f")
&& str_contains(remoteProcessScript($process), 'head -c'));
});
it('uploads a large ACME file instead of passing it as a shell argument', function () {
$certificate = GetTraefikCertificates::run($this->server)[0];
DeleteTraefikCertificate::run($this->server, [$certificate['id']]);
$remaining = json_decode($this->uploadedContents, true)['letsencrypt']['Certificates'];
expect(strlen($this->acmeContents))->toBeGreaterThan(128 * 1024)
->and($remaining)->toHaveCount(39)
->and(collect($remaining)->pluck('domain.main'))->not->toContain('app1.example.com');
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "\nsudo sh -c 'set -e;")
&& str_contains(remoteProcessScript($process), 'umask 077')
&& str_contains(remoteProcessScript($process), 'chmod 600')
&& str_contains(remoteProcessScript($process), 'mv --'));
Process::assertDidntRun(fn ($process) => strlen($process->command) > 128 * 1024);
expect(glob(sys_get_temp_dir().'/coolify-acme-*'))->toBeEmpty();
});
it('asks for a proxy restart after a certificate is deleted until the proxy is restarted', function () {
expect($this->server->hasPendingProxyConfiguration())->toBeFalse();
DeleteTraefikCertificate::run($this->server, [GetTraefikCertificates::run($this->server)[0]['id']]);
expect($this->server->fresh()->hasPendingProxyConfiguration())->toBeTrue();
$this->server->markProxyConfigurationApplied('services: {}');
expect($this->server->fresh()->hasPendingProxyConfiguration())->toBeFalse()
->and($this->server->fresh()->proxy->get('last_applied_settings'))->toBe(md5(base64_encode('services: {}')));
});
it('shows the restart warning after deleting a certificate from the proxy page', function () {
InstanceSettings::forceCreate(['id' => 0]);
$user = User::factory()->create();
$user->teams()->attach($this->server->team_id, ['role' => 'admin']);
$this->actingAs($user);
session(['currentTeam' => $this->server->team]);
$certificateId = GetTraefikCertificates::run($this->server)[0]['id'];
Livewire::test(Certificates::class, ['server' => $this->server])
->assertDontSee('Restart the proxy to apply TLS certificate changes.')
->call('deleteTraefikCertificate', $certificateId)
->assertDispatched('refreshServerShow')
->assertSee('Restart the proxy to apply TLS certificate changes.');
});
function actingAsTraefikCertificateUser(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 delete a certificate and records an audit event', function () {
$this->withoutDefer();
actingAsTraefikCertificateUser($this->server->team, 'admin');
$certificate = GetTraefikCertificates::run($this->server)[0];
Livewire::test(Certificates::class, ['server' => $this->server])
->call('deleteTraefikCertificate', $certificate['id'])
->assertDispatched('success');
$event = AuditEvent::query()->where('event', 'ui.proxy.certificate_deleted')->sole();
expect($this->uploadedContents)->not->toBeNull()
->and($event->team_id)->toBe($this->server->team_id)
->and($event->metadata)->toMatchArray([
'server_uuid' => $this->server->uuid,
'domain' => 'app1.example.com',
'resolver' => 'letsencrypt',
]);
});
it('lets a member list certificates but not delete them', function () {
actingAsTraefikCertificateUser($this->server->team, 'member');
$certificateId = GetTraefikCertificates::run($this->server)[0]['id'];
Livewire::test(Certificates::class, ['server' => $this->server])
->call('loadTraefikCertificates')
->assertSet('traefikCertificates', fn (array $certificates): bool => count($certificates) === 40)
->assertDontSeeHtml('deleteTraefikCertificate(')
->call('deleteTraefikCertificate', $certificateId)
->assertDispatched('error')
->assertNotDispatched('success');
expect($this->uploadedContents)->toBeNull()
->and($this->server->fresh()->hasPendingProxyConfiguration())->toBeFalse()
->and(AuditEvent::query()->where('event', 'ui.proxy.certificate_deleted')->exists())->toBeFalse();
});
it('does not show or delete certificates of another team', function () {
actingAsTraefikCertificateUser(Team::factory()->create(), 'owner');
$certificateId = app(TraefikAcmeService::class)->certificates($this->acmeContents)[0]['id'];
Livewire::test(Certificates::class, ['server' => $this->server])
->call('loadTraefikCertificates')
->assertSet('traefikCertificates', [])
->assertDontSee('app1.example.com')
->call('deleteTraefikCertificate', $certificateId)
->assertDispatched('error');
Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'acme.json'));
expect($this->uploadedContents)->toBeNull()
->and($this->server->fresh()->hasPendingProxyConfiguration())->toBeFalse();
});
it('adds example certificates in development without touching other resolvers', function () {
config(['app.env' => 'local']);
$this->artisan('dev:traefik-certificates', ['server' => $this->server->id])->assertSuccessful();
$certificates = collect(app(TraefikAcmeService::class)->certificates($this->uploadedContents));
$examples = $certificates->where('resolver', SeedDevelopmentTraefikCertificates::RESOLVER);
expect($certificates->where('resolver', 'letsencrypt'))->toHaveCount(40)
->and($examples->pluck('main_domain')->all())->toContain('app.coolify.test', '*.preview.coolify.test')
->and($examples->pluck('expires_at')->filter())->toHaveCount($examples->count());
});
it('refuses to add example certificates outside development', function () {
$this->artisan('dev:traefik-certificates', ['server' => $this->server->id])->assertFailed();
expect($this->uploadedContents)->toBeNull();
});
it('serves the certificate page for team users', function (string $role) {
$this->server->team->update(['show_boarding' => false]);
$this->server->settings->update(['is_reachable' => true, 'is_usable' => true]);
actingAsTraefikCertificateUser($this->server->team, $role);
$this->get(route('server.proxy.certificates', ['server_uuid' => $this->server->uuid]))
->assertOk()
->assertSeeLivewire(Certificates::class);
})->with(['admin', 'member']);
it('does not serve the certificate page of another team', function () {
actingAsTraefikCertificateUser(Team::factory()->create(['show_boarding' => false]), 'owner');
$this->get(route('server.proxy.certificates', ['server_uuid' => $this->server->uuid]))
->assertNotFound();
});
it('requires login to open the certificate page', function () {
$this->get(route('server.proxy.certificates', ['server_uuid' => $this->server->uuid]))
->assertRedirect(route('login'));
});
it('does not load certificate controls for a Caddy server', function () {
actingAsTraefikCertificateUser($this->server->team, 'admin');
$this->server->team->update(['show_boarding' => false]);
$this->server->settings->update(['is_reachable' => true, 'is_usable' => true]);
$this->server->proxy = ['type' => ProxyTypes::CADDY->value, 'status' => 'running'];
$this->server->save();
$this->get(route('server.proxy.certificates', ['server_uuid' => $this->server->uuid]))
->assertOk()
->assertDontSeeLivewire(Certificates::class)
->assertSee('Traefik required');
});
it('marks certificates that no route and no resource on the server uses', function () {
$destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$project = Project::factory()->create(['team_id' => $this->server->team_id]);
// A stopped application has no container, so only the database names its domain.
Application::factory()->create([
'environment_id' => Environment::factory()->create(['project_id' => $project->id])->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'fqdn' => 'https://app2.example.com',
]);
$usage = collect(GetTraefikCertificates::run($this->server))->pluck('in_use', 'main_domain');
expect($usage['app1.example.com'])->toBeTrue()
->and($usage['app2.example.com'])->toBeTrue()
->and($usage->filter(fn (bool $inUse): bool => ! $inUse))->toHaveCount(38);
});
it('does not mark certificates as unused when the routes cannot be read', function () {
Process::fake(function ($process) {
$command = remoteProcessScript($process);
if (str_contains($command, 'docker ps -aq')) {
return Process::result(errorOutput: 'Connection refused', exitCode: 255);
}
return Process::result(output: str_contains($command, 'head -c') ? base64_encode($this->acmeContents) : '');
});
expect(collect(GetTraefikCertificates::run($this->server))->pluck('in_use')->unique()->all())->toBe([null]);
});
it('lets an admin delete all unused certificates in one write', function () {
$this->withoutDefer();
actingAsTraefikCertificateUser($this->server->team, 'admin');
Livewire::test(Certificates::class, ['server' => $this->server])
->call('loadTraefikCertificates')
->assertSee('Delete unused (39)')
->assertSee('Not used')
->call('deleteUnusedTraefikCertificates')
->assertDispatched('success')
->assertDispatched('refreshServerShow');
$remaining = json_decode($this->uploadedContents, true)['letsencrypt']['Certificates'];
expect(collect($remaining)->pluck('domain.main')->all())->toBe(['app1.example.com'])
->and($this->server->fresh()->hasPendingProxyConfiguration())->toBeTrue()
->and(AuditEvent::query()->where('event', 'ui.proxy.certificate_deleted')->count())->toBe(39);
Process::assertRanTimes(fn ($process) => str_contains(remoteProcessScript($process), 'mv --'), 1);
});
it('does not let a member or another team delete unused certificates', function (string $role, bool $ownTeam) {
actingAsTraefikCertificateUser($ownTeam ? $this->server->team : Team::factory()->create(), $role);
Livewire::test(Certificates::class, ['server' => $this->server])
->call('loadTraefikCertificates')
->assertDontSeeHtml('deleteUnusedTraefikCertificates')
->call('deleteUnusedTraefikCertificates')
->assertDispatched('error')
->assertNotDispatched('success');
expect($this->uploadedContents)->toBeNull()
->and($this->server->fresh()->hasPendingProxyConfiguration())->toBeFalse();
})->with([
'member' => ['member', true],
'owner of another team' => ['owner', false],
]);