fix(proxy): flag Traefik restart after TLS certificate deletion (#12015)

This commit is contained in:
Andras Bacsai
2026-09-28 09:35:27 +02:00
committed by GitHub
parent aef0f29b84
commit 5b61467e73
15 changed files with 395 additions and 32 deletions
@@ -0,0 +1,73 @@
<?php
namespace App\Actions\Development;
use App\Actions\Proxy\GetTraefikCertificates;
use App\Actions\Proxy\SaveTraefikAcmeFile;
use App\Enums\ProxyTypes;
use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction;
use RuntimeException;
class SeedDevelopmentTraefikCertificates
{
use AsAction;
public const RESOLVER = 'coolify-dev-examples';
/** @var array<int, array{main: string, sans: array<int, string>, days: int}> */
private const EXAMPLES = [
['main' => 'app.coolify.test', 'sans' => ['www.app.coolify.test'], 'days' => 60],
['main' => 'api.coolify.test', 'sans' => [], 'days' => 5],
['main' => '*.preview.coolify.test', 'sans' => ['preview.coolify.test'], 'days' => 89],
['main' => 'docs.coolify.test', 'sans' => ['help.coolify.test', 'status.coolify.test'], 'days' => 30],
];
public function __construct(private GetTraefikCertificates $getTraefikCertificates) {}
/**
* Replace the example resolver in the server's acme.json with self-signed certificates.
* Entries from other resolvers stay unchanged.
*/
public function handle(Server $server): int
{
if (! isDev()) {
throw new RuntimeException('Example TLS certificates may only be seeded in development mode.');
}
if ($server->proxyType() !== ProxyTypes::TRAEFIK->value) {
throw new RuntimeException("Server {$server->name} does not use Traefik.");
}
$contents = $this->getTraefikCertificates->contents($server);
$data = $contents === null ? [] : json_decode($contents, true, flags: JSON_THROW_ON_ERROR);
$data[self::RESOLVER] = [
'Account' => null,
'Certificates' => array_map($this->certificate(...), self::EXAMPLES),
];
SaveTraefikAcmeFile::run($server, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR).PHP_EOL);
return count(self::EXAMPLES);
}
/**
* @param array{main: string, sans: array<int, string>, days: int} $example
* @return array{domain: array{main: string, sans: array<int, string>}, certificate: string, key: string, Store: string}
*/
private function certificate(array $example): array
{
$key = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']);
$csr = openssl_csr_new(['commonName' => $example['main']], $key);
$certificate = openssl_csr_sign($csr, null, $key, $example['days'], serial: random_int(1, PHP_INT_MAX));
openssl_x509_export($certificate, $certificatePem);
openssl_pkey_export($key, $keyPem);
return [
'domain' => ['main' => $example['main'], 'sans' => $example['sans']],
'certificate' => base64_encode($certificatePem),
'key' => base64_encode($keyPem),
'Store' => 'default',
];
}
}
+8 -13
View File
@@ -17,7 +17,8 @@ class DeleteTraefikCertificate
private TraefikAcmeService $acmeService,
) {}
public function handle(Server $server, string $certificateId): void
/** @return array{id: string, resolver: string, main_domain: string, sans: array<int, string>, store: ?string, expires_at: ?string} */
public function handle(Server $server, string $certificateId): array
{
if ($server->proxyType() !== ProxyTypes::TRAEFIK->value) {
throw new RuntimeException('TLS certificates can only be managed for Traefik proxies.');
@@ -40,18 +41,12 @@ class DeleteTraefikCertificate
$certificateId,
);
$path = rtrim($server->proxyPath(), '/').'/acme.json';
$temporaryPath = $path.'.coolify-'.bin2hex(random_bytes(8));
$encodedContents = base64_encode($updatedContents);
$script = sprintf(
'set -e; umask 077; printf %%s %s | base64 -d > %s; chmod 600 %s; mv -- %s %s',
escapeshellarg($encodedContents),
escapeshellarg($temporaryPath),
escapeshellarg($temporaryPath),
escapeshellarg($temporaryPath),
escapeshellarg($path),
);
SaveTraefikAcmeFile::run($server, $updatedContents);
instant_remote_process(['sh -c '.escapeshellarg($script)], $server);
// Traefik keeps loaded certificates in memory until it restarts.
$server->proxy->certificates_restart_required = true;
$server->save();
return $certificate;
}
}
+2 -3
View File
@@ -33,9 +33,8 @@ class GetTraefikCertificates
$path = escapeshellarg(rtrim($server->proxyPath(), '/').'/acme.json');
$readLimit = self::MAX_FILE_SIZE_BYTES + 1;
$tooLargeMarker = '__COOLIFY_ACME_FILE_TOO_LARGE__';
$output = instant_remote_process([
"if [ ! -f {$path} ]; then exit 0; elif [ \"\$(wc -c < {$path})\" -gt ".self::MAX_FILE_SIZE_BYTES." ]; then echo '{$tooLargeMarker}'; else head -c {$readLimit} {$path} | base64 | tr -d '\\n'; fi",
], $server, false);
$script = "if [ ! -f {$path} ]; then exit 0; elif [ \"\$(wc -c < {$path})\" -gt ".self::MAX_FILE_SIZE_BYTES." ]; then echo '{$tooLargeMarker}'; else head -c {$readLimit} {$path} | base64 | tr -d '\\n'; fi";
$output = instant_remote_process(['sh -c '.escapeshellarg($script)], $server, false);
if ($output === null || trim($output) === '') {
return null;
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Actions\Proxy;
use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction;
class SaveTraefikAcmeFile
{
use AsAction;
public function handle(Server $server, string $contents): void
{
$path = rtrim($server->proxyPath(), '/').'/acme.json';
$suffix = bin2hex(random_bytes(8));
$temporaryPath = "{$path}.coolify-{$suffix}";
$uploadPath = "/tmp/coolify-acme-{$suffix}.json";
// Upload the file with scp: a certificate store can exceed the size limit of one shell argument.
$localPath = tempnam(sys_get_temp_dir(), 'coolify-acme-');
try {
chmod($localPath, 0600);
file_put_contents($localPath, $contents);
instant_scp($localPath, $uploadPath, $server);
} finally {
@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),
);
instant_remote_process(['sh -c '.escapeshellarg($script)], $server);
}
}
+1 -3
View File
@@ -36,9 +36,7 @@ class StartProxy
$commands = collect([]);
$proxy_path = $server->proxyPath();
SaveProxyConfiguration::run($server, $configuration);
$docker_compose_yml_base64 = base64_encode($configuration);
$server->proxy->last_applied_settings = str($docker_compose_yml_base64)->pipe('md5')->value();
$server->save();
$server->markProxyConfigurationApplied($configuration);
if ($server->isSwarmManager()) {
$commands = $commands->merge([
@@ -0,0 +1,29 @@
<?php
namespace App\Console\Commands;
use App\Actions\Development\SeedDevelopmentTraefikCertificates;
use App\Models\Server;
use Illuminate\Console\Command;
class SeedDevelopmentTraefikCertificatesCommand extends Command
{
protected $signature = 'dev:traefik-certificates {server=0 : Server id}';
protected $description = 'Add example TLS certificates to the Traefik acme.json of a development server';
public function handle(): int
{
if (! isDev()) {
$this->error('This command may only run in development mode.');
return self::FAILURE;
}
$server = Server::query()->findOrFail((int) $this->argument('server'));
$count = SeedDevelopmentTraefikCertificates::run($server);
$this->info("Added {$count} example certificates to {$server->name} under the ".SeedDevelopmentTraefikCertificates::RESOLVER.' resolver.');
return self::SUCCESS;
}
}
+1 -3
View File
@@ -91,9 +91,7 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue
$stopTimeout = 30;
SaveProxyConfiguration::run($this->server, $configuration);
$docker_compose_yml_base64 = base64_encode($configuration);
$this->server->proxy->last_applied_settings = str($docker_compose_yml_base64)->pipe('md5')->value();
$this->server->save();
$this->server->markProxyConfigurationApplied($configuration);
$commands = collect([]);
+9 -1
View File
@@ -245,8 +245,16 @@ class Proxy extends Component
{
try {
$this->authorize('update', $this->server);
DeleteTraefikCertificate::run($this->server, $certificateId);
$certificate = DeleteTraefikCertificate::run($this->server, $certificateId);
auditLog('ui.proxy.certificate_deleted', [
'team_id' => $this->server->team_id,
'server_uuid' => $this->server->uuid,
'server_name' => $this->server->name,
'domain' => $certificate['main_domain'],
'resolver' => $certificate['resolver'],
]);
$this->traefikCertificates = GetTraefikCertificates::run($this->server);
$this->dispatch('refreshServerShow');
$this->dispatch('success', 'TLS certificate deleted. Restart Traefik to remove it from the running proxy.');
} catch (\Throwable $e) {
handleError($e, $this);
+15
View File
@@ -920,12 +920,26 @@ $siteAddress {
return false;
}
if ($this->proxy->get('certificates_restart_required')) {
return true;
}
$savedSettings = $this->proxy->get('last_saved_settings');
$appliedSettings = $this->proxy->get('last_applied_settings');
return filled($savedSettings) && filled($appliedSettings) && $savedSettings !== $appliedSettings;
}
/**
* Record the configuration the proxy runs with after a start or restart.
*/
public function markProxyConfigurationApplied(string $configuration): void
{
$this->proxy->last_applied_settings = str(base64_encode($configuration))->pipe('md5')->value();
$this->proxy->certificates_restart_required = false;
$this->save();
}
public function hasCurrentTraefikOutdatedInfo(): bool
{
if ($this->proxyType() !== ProxyTypes::TRAEFIK->value) {
@@ -2031,6 +2045,7 @@ $siteAddress {
$this->proxy->set('last_saved_proxy_configuration', null);
$this->proxy->set('last_saved_settings', null);
$this->proxy->set('last_applied_settings', null);
$this->proxy->set('certificates_restart_required', false);
$this->detected_traefik_version = null;
$this->traefik_outdated_info = null;
$this->save();
+3 -3
View File
@@ -113,12 +113,12 @@ class TraefikAcmeService
return null;
}
$der = base64_decode($encodedCertificate, true);
if ($der === false) {
// Traefik stores the PEM certificate chain as base64. OpenSSL reads the first certificate.
$pem = base64_decode($encodedCertificate, true);
if ($pem === false) {
return null;
}
$pem = "-----BEGIN CERTIFICATE-----\n".chunk_split(base64_encode($der), 64, "\n")."-----END CERTIFICATE-----\n";
$details = openssl_x509_parse($pem);
$expiresAt = data_get($details, 'validTo_time_t');
@@ -1,7 +1,7 @@
@props(['canRestart' => false])
<div class="relative" x-data="{ open: false }" @click.outside="open = false" @keydown.escape.window="open = false">
<button type="button" aria-label="Proxy configuration changes not applied" aria-haspopup="dialog"
<button type="button" aria-label="Proxy changes not applied" aria-haspopup="dialog"
:aria-expanded="open" @click="open = !open"
class="flex h-8 items-center justify-center gap-1.5 rounded-lg px-2 text-amber-700 transition-colors hover:bg-amber-100 dark:text-warning dark:hover:bg-warning/10">
<x-reicon name="alert-triangle" class="size-4" />
@@ -37,6 +37,10 @@
$server->proxy->last_applied_settings &&
$server->proxy->last_saved_settings !== $server->proxy->last_applied_settings)
<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.
</x-callout>
@else
<div class="flex items-start gap-3">
<div
@@ -82,7 +86,7 @@
@if ($server->proxyType() === ProxyTypes::TRAEFIK->value)
<x-application.settings-section id="server-proxy-certificates-section" title="TLS certificates"
x-init="$wire.loadTraefikCertificates()"
helper="Review certificates stored in Traefik's acme.json file. Deleting an entry removes it from local ACME storage. It does not revoke the certificate at the certificate authority.">
helper="Review certificates stored in Traefik's acme.json file. 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.">
<x-slot:actions>
<x-forms.button type="button" wire:click="loadTraefikCertificates"
wire:loading.attr="disabled" wire:target="loadTraefikCertificates">
@@ -151,7 +155,7 @@
submitAction="deleteTraefikCertificate({{ $certificate['id'] }})"
:actions="[
'Delete the certificate for '.$certificate['main_domain'].' from acme.json.',
'Restart Traefik before the running proxy stops using the certificate.',
'Restart the proxy before it stops using the certificate. If a route still uses the domain, Traefik requests a new certificate after the restart.',
]"
confirmationText="{{ $certificate['main_domain'] }}"
confirmationLabel="Confirm by entering the domain"
+3 -3
View File
@@ -9,6 +9,7 @@ it('shows Traefik ACME certificates with protected delete controls', function ()
expect($view)
->toContain('TLS certificates')
->toContain('Traefik requests a new certificate after the restart')
->toContain('wire:click="loadTraefikCertificates"')
->toContain('submitAction="deleteTraefikCertificate')
->toContain("@can('update', \$server)")
@@ -22,7 +23,7 @@ it('shows Traefik ACME certificates with protected delete controls', function ()
it('uses bounded reads and atomic restricted writes for the ACME file', function () {
$reader = file_get_contents(app_path('Actions/Proxy/GetTraefikCertificates.php'));
$writer = file_get_contents(app_path('Actions/Proxy/DeleteTraefikCertificate.php'));
$writer = file_get_contents(app_path('Actions/Proxy/SaveTraefikAcmeFile.php'));
expect($reader)
->toContain('MAX_FILE_SIZE_BYTES')
@@ -31,6 +32,5 @@ it('uses bounded reads and atomic restricted writes for the ACME file', function
->and($writer)
->toContain('umask 077')
->toContain('chmod 600')
->toContain('mv --')
->not->toContain('rm -f');
->toContain('mv --');
});
@@ -0,0 +1,188 @@
<?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;
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 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;
Process::fake(function ($process) {
$command = $process->command;
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();
}
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($process->command, "\nsudo bash -c 'sh -c '\\''if [ ! -f")
&& str_contains($process->command, '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($process->command, "\nsudo bash -c 'sh -c '\\''set -e;")
&& str_contains($process->command, 'umask 077')
&& str_contains($process->command, 'chmod 600')
&& str_contains($process->command, '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(Proxy::class, ['server' => $this->server])
->assertDontSee('Restart the proxy to stop serving deleted TLS certificates.')
->call('deleteTraefikCertificate', $certificateId)
->assertDispatched('refreshServerShow')
->assertSee('Restart the proxy to stop serving deleted TLS certificates.');
});
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(Proxy::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(Proxy::class, ['server' => $this->server])
->call('loadTraefikCertificates')
->assertSet('traefikCertificates', fn (array $certificates): bool => count($certificates) === 40)
->assertDontSeeHtml('submitAction="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(Proxy::class, ['server' => $this->server])
->call('loadTraefikCertificates')
->assertSet('traefikCertificates', [])
->assertDontSee('app1.example.com')
->call('deleteTraefikCertificate', $certificateId)
->assertDispatched('error');
Process::assertDidntRun(fn ($process) => str_contains($process->command, '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();
});
+15
View File
@@ -73,3 +73,18 @@ it('rejects invalid files and unknown certificate identifiers', function () {
->and(fn () => $service->deleteCertificate(traefikAcmeFixture(), 'letsencrypt', 'missing'))
->toThrow(RuntimeException::class, 'could not be found');
});
it('reads the expiry date from the base64-encoded PEM chain Traefik stores', function () {
$key = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']);
$certificate = openssl_csr_sign(openssl_csr_new(['commonName' => 'example.com'], $key), null, $key, 30);
openssl_x509_export($certificate, $pem);
$expiresAt = gmdate('Y-m-d H:i:s', openssl_x509_parse($pem)['validTo_time_t']);
$contents = json_encode(['letsencrypt' => ['Certificates' => [[
'domain' => ['main' => 'example.com'],
'certificate' => base64_encode($pem),
'key' => base64_encode('key'),
]]]], JSON_THROW_ON_ERROR);
expect(app(TraefikAcmeService::class)->certificates($contents)[0]['expires_at'])->toBe($expiresAt);
});