mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(proxy): flag Traefik restart after TLS certificate deletion (#12015)
This commit is contained in:
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user