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], ]);