diff --git a/CHANGELOG.md b/CHANGELOG.md index d2a7a74d33..6d57334dcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Cancel the older queued webhook deployments of an application when a newer push arrives, so only the latest commit is built. It is on by default and works per application, pull request, and server. Turn it off in **Advanced > Deployment** or with `is_auto_cancel_queued_deployments_enabled` in the applications API. Manual, API, restart, and rollback deployments are never cancelled ([#10921](https://github.com/coollabsio/coolify/discussions/10921), [#7678](https://github.com/coollabsio/coolify/discussions/7678)) - Add an opt-in setting to also stop an older webhook deployment that is still building when a newer push arrives. A deployment that already started to replace containers always finishes. The applications API sets it with `is_auto_cancel_building_deployments_enabled` - Add `cancel_previous` (`none`, `queued`, or `building`) to `/api/v1/deploy` and the MCP `deploy` tool, so a deployment from the API or MCP can cancel the older webhook, API, and MCP deployments of the same application, pull request, and server. The default is `none`. Manual, restart, and rollback deployments are never cancelled +- Show "Not used" on the TLS certificates of the Traefik proxy page when no route and no resource on the server uses the domain, and add a **Delete unused** button that deletes all of them in one step. Traefik renews every certificate in `acme.json`, also after you delete the resource, until you delete the certificate and restart the proxy ([#12447](https://github.com/coollabsio/coolify/issues/12447)) ### Changed diff --git a/app/Actions/Proxy/DeleteTraefikCertificate.php b/app/Actions/Proxy/DeleteTraefikCertificate.php index 71160cf2f8..1f84cf1750 100644 --- a/app/Actions/Proxy/DeleteTraefikCertificate.php +++ b/app/Actions/Proxy/DeleteTraefikCertificate.php @@ -17,8 +17,13 @@ class DeleteTraefikCertificate private TraefikAcmeService $acmeService, ) {} - /** @return array{id: string, resolver: string, main_domain: string, sans: array, store: ?string, expires_at: ?string} */ - public function handle(Server $server, string $certificateId): array + /** + * Deletes the certificates in one write, so acme.json gets one backup. + * + * @param array $certificateIds + * @return array, store: ?string, expires_at: ?string}> + */ + public function handle(Server $server, array $certificateIds): array { if ($server->proxyType() !== ProxyTypes::TRAEFIK->value) { throw new RuntimeException('TLS certificates can only be managed for Traefik proxies.'); @@ -29,24 +34,17 @@ class DeleteTraefikCertificate throw new RuntimeException('The Traefik ACME file could not be found.'); } - $certificate = collect($this->acmeService->certificates($contents)) - ->firstWhere('id', $certificateId); - if ($certificate === null) { - throw new RuntimeException('The selected certificate could not be found.'); - } + $certificates = collect($this->acmeService->certificates($contents)) + ->whereIn('id', $certificateIds) + ->values() + ->all(); - $updatedContents = $this->acmeService->deleteCertificate( - $contents, - $certificate['resolver'], - $certificateId, - ); - - SaveTraefikAcmeFile::run($server, $updatedContents); + SaveTraefikAcmeFile::run($server, $this->acmeService->deleteCertificates($contents, $certificateIds)); // Traefik keeps loaded certificates in memory until it restarts. $server->proxy->certificates_restart_required = true; $server->save(); - return $certificate; + return $certificates; } } diff --git a/app/Actions/Proxy/GetTraefikCertificates.php b/app/Actions/Proxy/GetTraefikCertificates.php index 4c704048d1..c6dbe0ff68 100644 --- a/app/Actions/Proxy/GetTraefikCertificates.php +++ b/app/Actions/Proxy/GetTraefikCertificates.php @@ -16,7 +16,12 @@ class GetTraefikCertificates public function __construct(private TraefikAcmeService $acmeService) {} - /** @return array, store: ?string, expires_at: ?string}> */ + /** + * The certificates of acme.json. `in_use` is false when no route of the proxy and no resource + * on the server uses a domain of the certificate, and null when Coolify could not check it. + * + * @return array, store: ?string, expires_at: ?string, in_use: ?bool}> + */ public function handle(Server $server): array { if ($server->proxyType() !== ProxyTypes::TRAEFIK->value) { @@ -24,8 +29,51 @@ class GetTraefikCertificates } $contents = $this->contents($server); + $certificates = $contents === null ? [] : $this->acmeService->certificates($contents); + if ($certificates === []) { + return []; + } - return $contents === null ? [] : $this->acmeService->certificates($contents); + $routing = $this->routingConfiguration($server); + $hostnames = $routing === null ? null : $this->acmeService->hostnames($routing); + + return array_map(fn (array $certificate): array => [ + ...$certificate, + 'in_use' => $hostnames === null ? null : $this->acmeService->isInUse($certificate, $hostnames), + ], $certificates); + } + + /** + * Text that names the domains the proxy can serve: the Traefik labels of all containers and + * Swarm services, the proxy and dynamic configuration files, and the domains of the resources + * on the server. Stopped resources have no containers, so their domains come from the database. + */ + private function routingConfiguration(Server $server): ?string + { + $proxyPath = escapeshellarg(rtrim($server->proxyPath(), '/')); + $script = implode('; ', [ + "docker ps -aq --filter label=traefik.enable=true | xargs -r docker inspect --format '{{json .Config.Labels}}' 2>/dev/null", + "docker service ls -q 2>/dev/null | xargs -r docker service inspect --format '{{json .Spec.Labels}}' 2>/dev/null", + "cat {$proxyPath}/docker-compose.yml {$proxyPath}/dynamic/*.yaml {$proxyPath}/dynamic/*.yml 2>/dev/null", + 'true', + ]); + + try { + $output = instant_remote_process(['sh -c '.escapeshellarg($script)], $server); + } catch (\Throwable) { + return null; + } + + $applications = $server->applications(); + $previews = $server->previews(); + $domains = $applications->pluck('fqdn') + ->concat($applications->pluck('docker_compose_domains')) + ->concat($previews->pluck('fqdn')) + ->concat($previews->pluck('docker_compose_domains')) + ->concat($server->services()->with('applications')->get()->flatMap->applications->pluck('fqdn')) + ->when($server->isLocalhost(), fn ($domains) => $domains->push(instanceSettings()->fqdn)); + + return $output."\n".$domains->filter()->implode("\n"); } public function contents(Server $server): ?string diff --git a/app/Livewire/Server/Proxy/Certificates.php b/app/Livewire/Server/Proxy/Certificates.php index 93c16ae083..a74d5bb7d3 100644 --- a/app/Livewire/Server/Proxy/Certificates.php +++ b/app/Livewire/Server/Proxy/Certificates.php @@ -118,7 +118,41 @@ class Certificates extends Component { try { $this->authorize('update', $this->server); - $certificate = DeleteTraefikCertificate::run($this->server, $certificateId); + $this->certificatesDeleted(DeleteTraefikCertificate::run($this->server, [$certificateId])); + $this->dispatch('success', 'TLS certificate deleted. Restart Traefik to remove it from the running proxy.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + /** Deletes the certificates that no route and no resource on the server uses, so Traefik stops renewing them. */ + public function deleteUnusedTraefikCertificates(string $password = ''): void + { + try { + $this->authorize('update', $this->server); + $unusedIds = collect(GetTraefikCertificates::run($this->server)) + ->where('in_use', false) + ->pluck('id') + ->all(); + if ($unusedIds === []) { + $this->traefikCertificates = GetTraefikCertificates::run($this->server); + $this->dispatch('info', 'No unused TLS certificates found.'); + + return; + } + + $deleted = DeleteTraefikCertificate::run($this->server, $unusedIds); + $this->certificatesDeleted($deleted); + $this->dispatch('success', count($deleted).' unused TLS certificates deleted. Restart Traefik to remove them from the running proxy.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + /** @param array $certificates */ + private function certificatesDeleted(array $certificates): void + { + foreach ($certificates as $certificate) { auditLog('ui.proxy.certificate_deleted', [ 'team_id' => $this->server->team_id, 'server_uuid' => $this->server->uuid, @@ -126,13 +160,11 @@ class Certificates extends Component 'domain' => $certificate['main_domain'], '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) { - handleError($e, $this); } + + $this->traefikCertificates = GetTraefikCertificates::run($this->server); + $this->loadTraefikAcmeBackups(); + $this->dispatch('refreshServerShow'); } public function render(): View diff --git a/app/Services/TraefikAcmeService.php b/app/Services/TraefikAcmeService.php index 8ab03e1c24..8ee8fcfd6b 100644 --- a/app/Services/TraefikAcmeService.php +++ b/app/Services/TraefikAcmeService.php @@ -54,37 +54,85 @@ class TraefikAcmeService return $certificates; } - public function deleteCertificate(string $contents, string $resolver, string $certificateId): string + /** + * Removes the certificates with the given identifiers from every resolver. + * + * @param array $certificateIds + */ + public function deleteCertificates(string $contents, array $certificateIds): string { $data = $this->decode($contents); - $certificates = $data[$resolver]['Certificates'] ?? null; - - if (! is_array($certificates)) { - throw new RuntimeException('The selected certificate could not be found.'); - } - - $remaining = []; - $deleted = false; - - foreach ($certificates as $certificate) { - if (! $deleted && is_array($certificate) && hash_equals($this->certificateId($resolver, $certificate), $certificateId)) { - $deleted = true; + $pending = array_fill_keys($certificateIds, true); + foreach ($data as $resolver => $resolverData) { + $certificates = is_string($resolver) && is_array($resolverData) ? ($resolverData['Certificates'] ?? null) : null; + if (! is_array($certificates)) { continue; } - $remaining[] = $certificate; + $remaining = []; + foreach ($certificates as $certificate) { + $id = is_array($certificate) ? $this->certificateId($resolver, $certificate) : null; + if ($id !== null && isset($pending[$id])) { + unset($pending[$id]); + + continue; + } + + $remaining[] = $certificate; + } + + $data[$resolver]['Certificates'] = $remaining; } - if (! $deleted) { + if ($certificateIds === [] || $pending !== []) { throw new RuntimeException('The selected certificate could not be found.'); } - $data[$resolver]['Certificates'] = $remaining; - return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR).PHP_EOL; } + /** + * Every host name in the text, such as the hosts of Traefik rules, labels, and resource URLs. + * + * @return array + */ + public function hostnames(string $text): array + { + preg_match_all('/(?} $certificate + * @param array $hostnames + */ + public function isInUse(array $certificate, array $hostnames): bool + { + foreach ([$certificate['main_domain'], ...$certificate['sans']] as $domain) { + $domain = strtolower($domain); + if (isset($hostnames[$domain])) { + return true; + } + + // A wildcard certificate covers exactly one label below its domain. + if (str_starts_with($domain, '*.')) { + $suffix = substr($domain, 1); + foreach (array_keys($hostnames) as $hostname) { + $label = str_ends_with($hostname, $suffix) ? substr($hostname, 0, -strlen($suffix)) : ''; + if ($label !== '' && ! str_contains($label, '.')) { + return true; + } + } + } + } + + return false; + } + /** @return array */ private function decode(string $contents): array { diff --git a/resources/views/livewire/server/proxy/certificates.blade.php b/resources/views/livewire/server/proxy/certificates.blade.php index 076650ba83..2253a0ac51 100644 --- a/resources/views/livewire/server/proxy/certificates.blade.php +++ b/resources/views/livewire/server/proxy/certificates.blade.php @@ -9,8 +9,24 @@ @if ($server->proxyType() === ProxyTypes::TRAEFIK->value) + helper="Review certificates stored in Traefik's acme.json file. Traefik renews every certificate in this file, also when no route uses its domain any more. Deleting an entry removes it from local ACME storage. It does not revoke the certificate at the certificate authority. If a route still uses the domain, Traefik requests a new certificate after the restart."> + @php $unusedCertificates = collect($traefikCertificates)->where('in_use', false); @endphp + @if ($unusedCertificates->isNotEmpty()) + @can('update', $server) + + @endcan + @endif Refresh @@ -44,7 +60,13 @@ @foreach ($traefikCertificates as $certificate) - {{ $certificate['main_domain'] }} +
+ {{ $certificate['main_domain'] }} + @if (($certificate['in_use'] ?? null) === false) + + @endif +
{{ $certificate['resolver'] }} diff --git a/tests/Feature/TraefikAcmeBackupTest.php b/tests/Feature/TraefikAcmeBackupTest.php index fa46a8d46d..87e0284f1b 100644 --- a/tests/Feature/TraefikAcmeBackupTest.php +++ b/tests/Feature/TraefikAcmeBackupTest.php @@ -104,7 +104,7 @@ function filePermissions(string $path): string } it('backs up acme.json before a certificate is deleted', function () { - DeleteTraefikCertificate::run($this->server, GetTraefikCertificates::run($this->server)[0]['id']); + DeleteTraefikCertificate::run($this->server, [GetTraefikCertificates::run($this->server)[0]['id']]); $backups = acmeBackupFiles($this->proxyDirectory); expect($backups)->toHaveCount(1) diff --git a/tests/Feature/TraefikCertificateRemoteFileTest.php b/tests/Feature/TraefikCertificateRemoteFileTest.php index f584461e99..8609553ca5 100644 --- a/tests/Feature/TraefikCertificateRemoteFileTest.php +++ b/tests/Feature/TraefikCertificateRemoteFileTest.php @@ -5,10 +5,14 @@ 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; @@ -39,6 +43,8 @@ beforeEach(function () { $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 ') @@ -48,6 +54,10 @@ beforeEach(function () { 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) : ''); }); }); @@ -63,7 +73,7 @@ it('reads the ACME file with sudo on non-root servers', function () { 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']); + DeleteTraefikCertificate::run($this->server, [$certificate['id']]); $remaining = json_decode($this->uploadedContents, true)['letsencrypt']['Certificates']; expect(strlen($this->acmeContents))->toBeGreaterThan(128 * 1024) @@ -81,7 +91,7 @@ it('uploads a large ACME file instead of passing it as a shell argument', functi 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']); + DeleteTraefikCertificate::run($this->server, [GetTraefikCertificates::run($this->server)[0]['id']]); expect($this->server->fresh()->hasPendingProxyConfiguration())->toBeTrue(); @@ -221,3 +231,70 @@ it('does not load certificate controls for a Caddy server', function () { ->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], +]); diff --git a/tests/Unit/TraefikAcmeServiceTest.php b/tests/Unit/TraefikAcmeServiceTest.php index 631a8edf2f..7ed991e8d0 100644 --- a/tests/Unit/TraefikAcmeServiceTest.php +++ b/tests/Unit/TraefikAcmeServiceTest.php @@ -53,10 +53,9 @@ it('removes only the selected certificate and preserves all other ACME data', fu $service = app(TraefikAcmeService::class); $certificates = $service->certificates(traefikAcmeFixture()); - $updated = json_decode($service->deleteCertificate( + $updated = json_decode($service->deleteCertificates( traefikAcmeFixture(), - 'letsencrypt', - $certificates[0]['id'], + [$certificates[0]['id']], ), true, flags: JSON_THROW_ON_ERROR); expect($updated['letsencrypt']['Account']['Email'])->toBe('admin@example.com') @@ -70,7 +69,7 @@ it('rejects invalid files and unknown certificate identifiers', function () { expect(fn () => $service->certificates('{invalid')) ->toThrow(RuntimeException::class, 'invalid JSON') - ->and(fn () => $service->deleteCertificate(traefikAcmeFixture(), 'letsencrypt', 'missing')) + ->and(fn () => $service->deleteCertificates(traefikAcmeFixture(), ['missing'])) ->toThrow(RuntimeException::class, 'could not be found'); }); @@ -88,3 +87,42 @@ it('reads the expiry date from the base64-encoded PEM chain Traefik stores', fun expect(app(TraefikAcmeService::class)->certificates($contents)[0]['expires_at'])->toBe($expiresAt); }); + +it('removes several certificates of different resolvers in one pass', function () { + $service = app(TraefikAcmeService::class); + $certificates = $service->certificates(traefikAcmeFixture()); + + $updated = json_decode($service->deleteCertificates( + traefikAcmeFixture(), + [$certificates[1]['id'], $certificates[2]['id']], + ), true, flags: JSON_THROW_ON_ERROR); + + expect(collect($updated['letsencrypt']['Certificates'])->pluck('domain.main')->all())->toBe(['example.com']) + ->and($updated['zerossl']['Certificates'])->toBe([]) + ->and($updated['zerossl']['Account']['Email'])->toBe('admin@example.org') + ->and(fn () => $service->deleteCertificates(traefikAcmeFixture(), [$certificates[0]['id'], 'missing'])) + ->toThrow(RuntimeException::class, 'could not be found'); +}); + +it('finds the host names in Traefik labels, configuration files, and resource URLs', function () { + $text = '{"traefik.http.routers.http-0-abc.rule":"Host(`App.Example.com`) && PathPrefix(`/`)"}'."\n" + ."rule: Host(`api.example.net`)\ndomains:\n - main: '*.wild.example.org'\nhttps://shop.example.com:8443/path,http://www.example.com"; + + expect(app(TraefikAcmeService::class)->hostnames($text)) + ->toHaveKeys(['app.example.com', 'api.example.net', '*.wild.example.org', 'shop.example.com', 'www.example.com']); +}); + +it('treats a certificate as used when a host matches its domain, an alternative name, or its wildcard', function (array $hosts, bool $inUse) { + $certificate = ['main_domain' => 'Example.com', 'sans' => ['www.example.com', '*.apps.example.com']]; + + expect(app(TraefikAcmeService::class)->isInUse($certificate, array_fill_keys($hosts, true)))->toBe($inUse); +})->with([ + 'main domain' => [['example.com'], true], + 'alternative name' => [['www.example.com'], true], + 'host below the wildcard' => [['shop.apps.example.com'], true], + 'wildcard named in the configuration' => [['*.apps.example.com'], true], + 'host two levels below the wildcard' => [['a.shop.apps.example.com'], false], + 'parent of the wildcard' => [['apps.example.com'], false], + 'other domain' => [['example.net', 'other.example.com'], false], + 'no hosts' => [[], false], +]);