mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
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>
129 lines
5.8 KiB
PHP
129 lines
5.8 KiB
PHP
<?php
|
|
|
|
use App\Services\TraefikAcmeService;
|
|
|
|
function traefikAcmeFixture(): string
|
|
{
|
|
return json_encode([
|
|
'letsencrypt' => [
|
|
'Account' => ['Email' => 'admin@example.com'],
|
|
'Certificates' => [
|
|
[
|
|
'domain' => [
|
|
'main' => 'example.com',
|
|
'sans' => ['www.example.com', '*.example.com'],
|
|
],
|
|
'certificate' => base64_encode('first-certificate'),
|
|
'key' => base64_encode('first-key'),
|
|
'Store' => 'default',
|
|
],
|
|
[
|
|
'domain' => ['main' => 'api.example.net'],
|
|
'certificate' => base64_encode('second-certificate'),
|
|
'key' => base64_encode('second-key'),
|
|
],
|
|
],
|
|
],
|
|
'zerossl' => [
|
|
'Account' => ['Email' => 'admin@example.org'],
|
|
'Certificates' => [[
|
|
'domain' => ['main' => 'example.org', 'sans' => []],
|
|
'certificate' => base64_encode('third-certificate'),
|
|
'key' => base64_encode('third-key'),
|
|
]],
|
|
],
|
|
], JSON_THROW_ON_ERROR);
|
|
}
|
|
|
|
it('lists every certificate from every ACME resolver without exposing secrets', function () {
|
|
$certificates = app(TraefikAcmeService::class)->certificates(traefikAcmeFixture());
|
|
|
|
expect($certificates)->toHaveCount(3)
|
|
->and($certificates[0])->toMatchArray([
|
|
'resolver' => 'letsencrypt',
|
|
'main_domain' => 'example.com',
|
|
'sans' => ['www.example.com', '*.example.com'],
|
|
'store' => 'default',
|
|
])
|
|
->and($certificates[0])->toHaveKeys(['id', 'expires_at'])
|
|
->and($certificates[0])->not->toHaveKeys(['certificate', 'key', 'account']);
|
|
});
|
|
|
|
it('removes only the selected certificate and preserves all other ACME data', function () {
|
|
$service = app(TraefikAcmeService::class);
|
|
$certificates = $service->certificates(traefikAcmeFixture());
|
|
|
|
$updated = json_decode($service->deleteCertificates(
|
|
traefikAcmeFixture(),
|
|
[$certificates[0]['id']],
|
|
), true, flags: JSON_THROW_ON_ERROR);
|
|
|
|
expect($updated['letsencrypt']['Account']['Email'])->toBe('admin@example.com')
|
|
->and($updated['letsencrypt']['Certificates'])->toHaveCount(1)
|
|
->and($updated['letsencrypt']['Certificates'][0]['domain']['main'])->toBe('api.example.net')
|
|
->and($updated['zerossl']['Certificates'])->toHaveCount(1);
|
|
});
|
|
|
|
it('rejects invalid files and unknown certificate identifiers', function () {
|
|
$service = app(TraefikAcmeService::class);
|
|
|
|
expect(fn () => $service->certificates('{invalid'))
|
|
->toThrow(RuntimeException::class, 'invalid JSON')
|
|
->and(fn () => $service->deleteCertificates(traefikAcmeFixture(), ['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);
|
|
});
|
|
|
|
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],
|
|
]);
|