diff --git a/app/Actions/Development/SeedDevelopmentTraefikCertificates.php b/app/Actions/Development/SeedDevelopmentTraefikCertificates.php new file mode 100644 index 0000000000..371214848e --- /dev/null +++ b/app/Actions/Development/SeedDevelopmentTraefikCertificates.php @@ -0,0 +1,73 @@ +, 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, days: int} $example + * @return array{domain: array{main: string, sans: array}, 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', + ]; + } +} diff --git a/app/Actions/Proxy/DeleteTraefikCertificate.php b/app/Actions/Proxy/DeleteTraefikCertificate.php index 7ce79dca18..71160cf2f8 100644 --- a/app/Actions/Proxy/DeleteTraefikCertificate.php +++ b/app/Actions/Proxy/DeleteTraefikCertificate.php @@ -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, 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; } } diff --git a/app/Actions/Proxy/GetTraefikCertificates.php b/app/Actions/Proxy/GetTraefikCertificates.php index 7ca7f11bc3..4c704048d1 100644 --- a/app/Actions/Proxy/GetTraefikCertificates.php +++ b/app/Actions/Proxy/GetTraefikCertificates.php @@ -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; diff --git a/app/Actions/Proxy/SaveTraefikAcmeFile.php b/app/Actions/Proxy/SaveTraefikAcmeFile.php new file mode 100644 index 0000000000..de469e7551 --- /dev/null +++ b/app/Actions/Proxy/SaveTraefikAcmeFile.php @@ -0,0 +1,41 @@ +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); + } +} diff --git a/app/Actions/Proxy/StartProxy.php b/app/Actions/Proxy/StartProxy.php index 5caf492ccf..2639ef35ef 100644 --- a/app/Actions/Proxy/StartProxy.php +++ b/app/Actions/Proxy/StartProxy.php @@ -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([ diff --git a/app/Console/Commands/SeedDevelopmentTraefikCertificatesCommand.php b/app/Console/Commands/SeedDevelopmentTraefikCertificatesCommand.php new file mode 100644 index 0000000000..f3af6ec6c7 --- /dev/null +++ b/app/Console/Commands/SeedDevelopmentTraefikCertificatesCommand.php @@ -0,0 +1,29 @@ +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; + } +} diff --git a/app/Jobs/RestartProxyJob.php b/app/Jobs/RestartProxyJob.php index 7df13e775c..d314bd5bb7 100644 --- a/app/Jobs/RestartProxyJob.php +++ b/app/Jobs/RestartProxyJob.php @@ -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([]); diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 567eefe3c4..cc46364272 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -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); diff --git a/app/Models/Server.php b/app/Models/Server.php index 109ed038c7..c5bb8814fb 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -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(); diff --git a/app/Services/TraefikAcmeService.php b/app/Services/TraefikAcmeService.php index 577fe9ae8c..8ab03e1c24 100644 --- a/app/Services/TraefikAcmeService.php +++ b/app/Services/TraefikAcmeService.php @@ -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'); diff --git a/resources/views/components/proxy-configuration-warning.blade.php b/resources/views/components/proxy-configuration-warning.blade.php index 5deb10dc21..9014f18b6b 100644 --- a/resources/views/components/proxy-configuration-warning.blade.php +++ b/resources/views/components/proxy-configuration-warning.blade.php @@ -1,7 +1,7 @@ @props(['canRestart' => false])
-