diff --git a/app/Http/Controllers/Api/ServerProxyController.php b/app/Http/Controllers/Api/ServerProxyController.php index 6b1e86b78d..8c7bdc90fe 100644 --- a/app/Http/Controllers/Api/ServerProxyController.php +++ b/app/Http/Controllers/Api/ServerProxyController.php @@ -7,6 +7,7 @@ use App\Enums\ProxyTypes; use App\Http\Controllers\Controller; use App\Jobs\RestartProxyJob; use App\Models\Server; +use App\Models\ServerSetting; use App\Rules\SafeExternalUrl; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -41,6 +42,7 @@ class ServerProxyController extends Controller * redirect_enabled: bool, * redirect_url: string|null, * generate_exact_labels: bool, + * traefik_certificate_resolver: string|null, * configuration?: string|null * } */ @@ -52,6 +54,7 @@ class ServerProxyController extends Controller 'redirect_enabled' => (bool) data_get($server->proxy, 'redirect_enabled', true), 'redirect_url' => data_get($server->proxy, 'redirect_url'), 'generate_exact_labels' => (bool) ($server->settings->generate_exact_labels ?? false), + 'traefik_certificate_resolver' => $server->traefikCertificateResolver(), ]; // Proxy compose can contain secrets; only expose with read:sensitive (and admin) like other APIs. @@ -86,6 +89,7 @@ class ServerProxyController extends Controller new OA\Property(property: 'redirect_enabled', type: 'boolean', example: true), new OA\Property(property: 'redirect_url', type: 'string', nullable: true, example: 'https://example.com'), new OA\Property(property: 'generate_exact_labels', type: 'boolean', example: false), + new OA\Property(property: 'traefik_certificate_resolver', type: 'string', nullable: true, example: 'letsencrypt', description: 'Traefik certificate resolver for generated HTTPS routers. Null when the proxy uses its own certificates.'), new OA\Property(property: 'configuration', type: 'string', nullable: true, description: 'Docker Compose proxy configuration when stored in the database. Only present with read:sensitive.'), ] ) @@ -114,7 +118,7 @@ class ServerProxyController extends Controller #[OA\Patch( summary: 'Update server proxy', - description: 'Update proxy redirect settings, exact labels generation, and optionally the proxy type for a team-owned server.', + description: 'Update proxy redirect settings, exact labels generation, the Traefik certificate resolver, and optionally the proxy type for a team-owned server.', path: '/servers/{uuid}/proxy', operationId: 'update-server-proxy', security: [['bearerAuth' => []]], @@ -130,6 +134,7 @@ class ServerProxyController extends Controller new OA\Property(property: 'redirect_enabled', type: 'boolean'), new OA\Property(property: 'redirect_url', type: 'string', nullable: true, description: 'Public http(s) redirect URL, or null to clear.'), new OA\Property(property: 'generate_exact_labels', type: 'boolean'), + new OA\Property(property: 'traefik_certificate_resolver', type: 'string', nullable: true, description: 'Traefik certificate resolver for generated HTTPS routers (letters, digits, _ and -). Null or empty to use your own certificates. Redeploy resources to apply.'), new OA\Property(property: 'proxy_type', type: 'string', enum: ['traefik', 'caddy', 'nginx', 'none'], description: 'Proxy type (case-insensitive).'), ] ) @@ -146,6 +151,7 @@ class ServerProxyController extends Controller new OA\Property(property: 'redirect_enabled', type: 'boolean'), new OA\Property(property: 'redirect_url', type: 'string', nullable: true), new OA\Property(property: 'generate_exact_labels', type: 'boolean'), + new OA\Property(property: 'traefik_certificate_resolver', type: 'string', nullable: true), new OA\Property(property: 'configuration', type: 'string', nullable: true), ] ) @@ -168,11 +174,12 @@ class ServerProxyController extends Controller return $return; } - $allowedFields = ['redirect_enabled', 'redirect_url', 'generate_exact_labels', 'proxy_type']; + $allowedFields = ['redirect_enabled', 'redirect_url', 'generate_exact_labels', 'traefik_certificate_resolver', 'proxy_type']; $validator = customApiValidator($request->all(), [ 'redirect_enabled' => 'boolean', 'redirect_url' => ['nullable', 'string', new SafeExternalUrl], 'generate_exact_labels' => 'boolean', + 'traefik_certificate_resolver' => ['nullable', 'string', 'max:255', 'regex:'.ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_PATTERN], 'proxy_type' => 'string|nullable', ]); @@ -228,16 +235,29 @@ class ServerProxyController extends Controller $server->settings->save(); } + $certificateResolverChanged = false; + if ($request->exists('traefik_certificate_resolver')) { + $server->settings->traefik_certificate_resolver = $request->input('traefik_certificate_resolver') ?: null; + $certificateResolverChanged = $server->settings->isDirty('traefik_certificate_resolver'); + $server->settings->save(); + } + if ($request->has('proxy_type') && filled($request->proxy_type)) { $server->changeProxy($request->proxy_type, async: true); $server->refresh(); } // Apply redirect file on the server only when reachable (DB settings always saved above). - if ($redirectChanged && $server->isFunctional()) { + // The catch-all router in that file uses the certificate resolver too. + if (($redirectChanged || $certificateResolverChanged) && $server->isFunctional()) { $server->setupDefaultRedirect(); } + // Coolify's own dashboard routes use the resolver too. + if ($certificateResolverChanged && $server->isFunctional()) { + $server->setupDynamicProxyConfiguration(); + } + auditLog('api.server.proxy.updated', [ 'team_id' => $teamId, 'server_uuid' => $server->uuid, diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index b1a37f4846..13a3f21381 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -6,6 +6,7 @@ use App\Actions\Proxy\GetProxyConfiguration; use App\Actions\Proxy\SaveProxyConfiguration; use App\Enums\ProxyTypes; use App\Models\Server; +use App\Models\ServerSetting; use App\Rules\SafeExternalUrl; use App\Traits\ListensToTeamChannel; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -28,6 +29,8 @@ class Proxy extends Component public bool $generateExactLabels = false; + public ?string $certificateResolver = null; + /** * Cache the versions.json file data in memory for this component instance. * This avoids multiple file reads during a single request/render cycle. @@ -49,6 +52,7 @@ class Proxy extends Component return [ 'generateExactLabels' => 'required|boolean', 'redirectUrl' => ['nullable', new SafeExternalUrl], + 'certificateResolver' => ['nullable', 'string', 'max:255', 'regex:'.ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_PATTERN], ]; } @@ -65,8 +69,10 @@ class Proxy extends Component { if ($toModel) { $this->server->settings->generate_exact_labels = $this->generateExactLabels; + $this->server->settings->traefik_certificate_resolver = $this->certificateResolver ?: null; } else { $this->generateExactLabels = $this->server->settings->generate_exact_labels ?? false; + $this->certificateResolver = $this->server->settings->traefik_certificate_resolver; } } @@ -162,16 +168,27 @@ class Proxy extends Component try { $this->authorize('update', $this->server); $this->validate(); - $this->syncData(true); - $changedFields = auditChangedFields($this->server->settings); - $this->server->settings->save(); - $this->auditProxyUpdate($changedFields); + $this->saveSettings(); $this->dispatch('success', 'Settings saved.'); } catch (\Throwable $e) { return handleError($e, $this); } } + private function saveSettings(): void + { + $this->syncData(true); + $changedFields = auditChangedFields($this->server->settings); + $this->server->settings->save(); + $this->auditProxyUpdate($changedFields); + + // Coolify's own dashboard and catch-all routes use the resolver too. + if (in_array('traefik_certificate_resolver', $changedFields, true)) { + $this->server->setupDynamicProxyConfiguration(); + $this->server->setupDefaultRedirect(); + } + } + public function instantSaveRedirect() { try { @@ -195,6 +212,7 @@ class Proxy extends Component SaveProxyConfiguration::run($this->server, $this->proxySettings); $this->server->proxy->redirect_url = $this->redirectUrl; $this->server->save(); + $this->saveSettings(); auditLog('ui.server.proxy.configuration_saved', $this->proxyAuditContext()); $this->server->setupDefaultRedirect(); $this->dispatch('refreshServerShow'); diff --git a/app/Models/Server.php b/app/Models/Server.php index 8bbbc4a439..9de37d974e 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -667,9 +667,7 @@ class Server extends BaseModel ], 'service' => 'noop', 'rule' => 'PathPrefix(`/`)', - 'tls' => [ - 'certResolver' => 'letsencrypt', - ], + 'tls' => $this->traefikCertificateResolver() ? ['certResolver' => $this->traefikCertificateResolver()] : [], 'priority' => -1000, ], ], @@ -806,6 +804,8 @@ class Server extends BaseModel ]; if ($schema === 'https') { + $certificateResolver = $this->traefikCertificateResolver(); + $tls = $certificateResolver ? ['certresolver' => $certificateResolver] : []; $traefik_dynamic_conf['http']['routers']['coolify-http']['middlewares'] = $this->dashboardHttpMiddlewares($settings); $traefik_dynamic_conf['http']['routers']['coolify-https'] = [ @@ -817,9 +817,7 @@ class Server extends BaseModel ], 'service' => 'coolify', 'rule' => "Host(`{$host}`)", - 'tls' => [ - 'certresolver' => 'letsencrypt', - ], + 'tls' => $tls, ]; $traefik_dynamic_conf['http']['routers']['coolify-reverb-wss'] = [ 'entryPoints' => [ @@ -827,9 +825,7 @@ class Server extends BaseModel ], 'service' => 'coolify-reverb', 'rule' => "Host(`{$host}`) && PathPrefix(`/app`)", - 'tls' => [ - 'certresolver' => 'letsencrypt', - ], + 'tls' => $tls, ]; $traefik_dynamic_conf['http']['routers']['coolify-reverb-api-https'] = [ 'entryPoints' => [ @@ -837,9 +833,7 @@ class Server extends BaseModel ], 'service' => 'coolify-reverb', 'rule' => "Host(`{$host}`) && PathPrefix(`/apps`)", - 'tls' => [ - 'certresolver' => 'letsencrypt', - ], + 'tls' => $tls, ]; $traefik_dynamic_conf['http']['routers']['coolify-terminal-wss'] = [ 'entryPoints' => [ @@ -847,9 +841,7 @@ class Server extends BaseModel ], 'service' => 'coolify-terminal', 'rule' => "Host(`{$host}`) && PathPrefix(`/terminal/ws`)", - 'tls' => [ - 'certresolver' => 'letsencrypt', - ], + 'tls' => $tls, ]; } $yaml = Yaml::dump($traefik_dynamic_conf, 12, 2); @@ -1169,6 +1161,16 @@ $siteAddress { return $this->settings->is_metrics_enabled; } + /** + * Traefik certificate resolver for generated HTTPS routers, or null when the proxy uses its own certificates. + */ + public function traefikCertificateResolver(): ?string + { + $resolver = data_get($this, 'settings.traefik_certificate_resolver'); + + return filled($resolver) ? $resolver : null; + } + public function isTrafficAnalyticsEnabled(): bool { return (bool) data_get($this, 'settings.is_traffic_analytics_enabled', false); diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 8aa3dedf6b..afe18607b8 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -38,6 +38,7 @@ use OpenApi\Attributes as OA; 'is_geoip_enabled' => ['type' => 'boolean'], 'geoip_refresh_days' => ['type' => 'integer'], 'traffic_ip_mode' => ['type' => 'string', 'enum' => ['full', 'anonymized', 'off']], + 'traefik_certificate_resolver' => ['type' => 'string', 'nullable' => true, 'description' => 'Traefik certificate resolver for generated HTTPS routers. Null when the proxy uses its own certificates.'], 'is_reachable' => ['type' => 'boolean'], 'is_sentinel_enabled' => ['type' => 'boolean'], 'is_swarm_manager' => ['type' => 'boolean'], @@ -71,6 +72,11 @@ use OpenApi\Attributes as OA; )] class ServerSetting extends Model { + /** + * Allowed Traefik certificate resolver names; also keeps the value safe inside generated labels. + */ + public const string TRAEFIK_CERTIFICATE_RESOLVER_PATTERN = '/^[A-Za-z0-9_-]+$/'; + public const int DEFAULT_SENTINEL_METRICS_REFRESH_RATE_SECONDS = 10; public const int DEFAULT_SENTINEL_METRICS_HISTORY_DAYS = 7; @@ -134,6 +140,7 @@ class ServerSetting extends Model 'geoip_refresh_days', 'geoip_maxmind_license_key', 'traffic_ip_mode', + 'traefik_certificate_resolver', 'docker_version', 'docker_version_checked_at', 'compose_version', diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 076d03d40c..9427ccd267 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -924,7 +924,7 @@ function traefikHostRulesWithoutIpv6Brackets(Collection $labels): Collection ) : $label); } -function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = []) +function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = [], ?string $certificate_resolver = 'letsencrypt') { $labels = collect([]); $labels->push('traefik.enable=true'); @@ -1092,7 +1092,9 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ } } $labels->push("traefik.http.routers.{$https_label}.tls=true"); - $labels->push("traefik.http.routers.{$https_label}.tls.certresolver=letsencrypt"); + if (filled($certificate_resolver)) { + $labels->push("traefik.http.routers.{$https_label}.tls.certresolver={$certificate_resolver}"); + } // Set labels for http (redirect to https) $labels->push("traefik.http.routers.{$http_label}.rule=Host(`{$traefikHost}`) && PathPrefix(`{$path}`)"); @@ -1226,6 +1228,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, domainPortOverrides: $application->domain_port_overrides ?? [], + certificate_resolver: $application->destination->server->traefikCertificateResolver(), )); break; case ProxyTypes::CADDY->value: @@ -1264,6 +1267,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview noindex_domains: $noindexDomains, escape_redirect_replacement_for_compose: false, domainPortOverrides: $application->domain_port_overrides ?? [], + certificate_resolver: $application->destination->server->traefikCertificateResolver(), )); $labels = $labels->merge(fqdnLabelsForCaddy( network: $application->destination->network, @@ -1310,6 +1314,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview noindex_domains: $noindexDomains, escape_redirect_replacement_for_compose: false, domainPortOverrides: $preview->domain_port_overrides ?? [], + certificate_resolver: $application->destination->server->traefikCertificateResolver(), )); break; case ProxyTypes::CADDY->value: @@ -1346,6 +1351,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview noindex_domains: $noindexDomains, escape_redirect_replacement_for_compose: false, domainPortOverrides: $preview->domain_port_overrides ?? [], + certificate_resolver: $application->destination->server->traefikCertificateResolver(), )); $labels = $labels->merge(fqdnLabelsForCaddy( network: $application->destination->network, diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 991023b621..cc862c790f 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -2029,6 +2029,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, domainPortOverrides: $domainPortOverrides, + certificate_resolver: $server->traefikCertificateResolver(), )); break; case ProxyTypes::CADDY->value: @@ -2066,6 +2067,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, domainPortOverrides: $domainPortOverrides, + certificate_resolver: $server->traefikCertificateResolver(), )); $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( network: $labelNetwork, @@ -3277,7 +3279,8 @@ function serviceParser(Service $resource): Collection onlyPort: $onlyPort, domainPortOverrides: $originalResource->domain_port_overrides ?? [], noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + certificate_resolver: $server->traefikCertificateResolver(), )); break; case ProxyTypes::CADDY->value: @@ -3314,7 +3317,8 @@ function serviceParser(Service $resource): Collection onlyPort: $onlyPort, domainPortOverrides: $originalResource->domain_port_overrides ?? [], noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + certificate_resolver: $server->traefikCertificateResolver(), )); $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( network: $network, diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 137964d43c..512d18aec0 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -3488,6 +3488,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, domainPortOverrides: $domainPortOverrides, + certificate_resolver: $resource->server->traefikCertificateResolver(), )); break; case ProxyTypes::CADDY->value: @@ -3525,6 +3526,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, domainPortOverrides: $domainPortOverrides, + certificate_resolver: $resource->server->traefikCertificateResolver(), )); $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( network: $resource->destination->network, @@ -4299,6 +4301,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, domainPortOverrides: $domainPortOverrides, + certificate_resolver: $server->traefikCertificateResolver(), ) ); break; @@ -4339,6 +4342,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, domainPortOverrides: $domainPortOverrides, + certificate_resolver: $server->traefikCertificateResolver(), ) ); $serviceLabels = $serviceLabels->merge( diff --git a/database/migrations/2026_10_11_075120_add_traefik_certificate_resolver_to_server_settings_table.php b/database/migrations/2026_10_11_075120_add_traefik_certificate_resolver_to_server_settings_table.php new file mode 100644 index 0000000000..bdfb2586f0 --- /dev/null +++ b/database/migrations/2026_10_11_075120_add_traefik_certificate_resolver_to_server_settings_table.php @@ -0,0 +1,28 @@ +string('traefik_certificate_resolver')->nullable()->default('letsencrypt'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn('traefik_certificate_resolver'); + }); + } +}; diff --git a/openapi.json b/openapi.json index 99587429fa..e892ace63f 100644 --- a/openapi.json +++ b/openapi.json @@ -16670,6 +16670,14 @@ "type": "boolean", "example": false }, + "traefik_certificate_resolver": { + "description": "Traefik certificate resolver for generated HTTPS routers. Null when the proxy uses its own certificates.", + "type": [ + "string", + "null" + ], + "example": "letsencrypt" + }, "configuration": { "description": "Docker Compose proxy configuration when stored in the database. Only present with read:sensitive.", "type": [ @@ -16704,7 +16712,7 @@ "Servers" ], "summary": "Update server proxy", - "description": "Update proxy redirect settings, exact labels generation, and optionally the proxy type for a team-owned server.", + "description": "Update proxy redirect settings, exact labels generation, the Traefik certificate resolver, and optionally the proxy type for a team-owned server.", "operationId": "update-server-proxy", "parameters": [ { @@ -16736,6 +16744,13 @@ "generate_exact_labels": { "type": "boolean" }, + "traefik_certificate_resolver": { + "description": "Traefik certificate resolver for generated HTTPS routers (letters, digits, _ and -). Null or empty to use your own certificates. Redeploy resources to apply.", + "type": [ + "string", + "null" + ] + }, "proxy_type": { "description": "Proxy type (case-insensitive).", "type": "string", @@ -16783,6 +16798,12 @@ "generate_exact_labels": { "type": "boolean" }, + "traefik_certificate_resolver": { + "type": [ + "string", + "null" + ] + }, "configuration": { "type": [ "string", @@ -24387,6 +24408,11 @@ "off" ] }, + "traefik_certificate_resolver": { + "type": "string", + "nullable": true, + "description": "Traefik certificate resolver for generated HTTPS routers. Null when the proxy uses its own certificates." + }, "is_reachable": { "type": "boolean" }, diff --git a/openapi.yaml b/openapi.yaml index e298549e80..9ffee61780 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -10571,6 +10571,7 @@ paths: redirect_enabled: { type: boolean, example: true } redirect_url: { type: [string, 'null'], example: 'https://example.com' } generate_exact_labels: { type: boolean, example: false } + traefik_certificate_resolver: { description: 'Traefik certificate resolver for generated HTTPS routers. Null when the proxy uses its own certificates.', type: [string, 'null'], example: letsencrypt } configuration: { description: 'Docker Compose proxy configuration when stored in the database. Only present with read:sensitive.', type: [string, 'null'] } type: object '401': @@ -10586,7 +10587,7 @@ paths: tags: - Servers summary: 'Update server proxy' - description: 'Update proxy redirect settings, exact labels generation, and optionally the proxy type for a team-owned server.' + description: 'Update proxy redirect settings, exact labels generation, the Traefik certificate resolver, and optionally the proxy type for a team-owned server.' operationId: update-server-proxy parameters: - @@ -10609,6 +10610,9 @@ paths: type: [string, 'null'] generate_exact_labels: type: boolean + traefik_certificate_resolver: + description: 'Traefik certificate resolver for generated HTTPS routers (letters, digits, _ and -). Null or empty to use your own certificates. Redeploy resources to apply.' + type: [string, 'null'] proxy_type: description: 'Proxy type (case-insensitive).' type: string @@ -10626,6 +10630,7 @@ paths: redirect_enabled: { type: boolean } redirect_url: { type: [string, 'null'] } generate_exact_labels: { type: boolean } + traefik_certificate_resolver: { type: [string, 'null'] } configuration: { type: [string, 'null'] } type: object '401': @@ -15586,6 +15591,10 @@ components: - full - anonymized - 'off' + traefik_certificate_resolver: + type: string + nullable: true + description: 'Traefik certificate resolver for generated HTTPS routers. Null when the proxy uses its own certificates.' is_reachable: type: boolean is_sentinel_enabled: diff --git a/resources/views/livewire/server/proxy.blade.php b/resources/views/livewire/server/proxy.blade.php index 037466932a..5bebd04419 100644 --- a/resources/views/livewire/server/proxy.blade.php +++ b/resources/views/livewire/server/proxy.blade.php @@ -80,6 +80,11 @@ label="Redirect URL" helper="Leave empty to keep a custom 503 response without redirecting." /> @endif + @if ($server->proxyType() === ProxyTypes::TRAEFIK->value) + + @endif diff --git a/tests/Feature/Api/ServerProxyApiTest.php b/tests/Feature/Api/ServerProxyApiTest.php index 8ba56290bd..a78fc81c3e 100644 --- a/tests/Feature/Api/ServerProxyApiTest.php +++ b/tests/Feature/Api/ServerProxyApiTest.php @@ -152,6 +152,37 @@ test('PATCH /api/v1/servers/{uuid}/proxy updates redirect and label settings wit ->and((bool) $server->settings->generate_exact_labels)->toBeTrue(); }); +test('PATCH /api/v1/servers/{uuid}/proxy updates the Traefik certificate resolver', function (?string $input, ?string $expected) { + $this->withHeaders(serverProxyApiHeaders($this->bearerToken)) + ->getJson("/api/v1/servers/{$this->server->uuid}/proxy") + ->assertOk() + ->assertJsonPath('traefik_certificate_resolver', 'letsencrypt'); + + $this->withHeaders(serverProxyApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", [ + 'traefik_certificate_resolver' => $input, + ]) + ->assertOk() + ->assertJsonPath('traefik_certificate_resolver', $expected); + + expect($this->server->settings->fresh()->traefik_certificate_resolver)->toBe($expected); +})->with([ + 'custom resolver' => ['cloudflare', 'cloudflare'], + 'no resolver' => [null, null], + 'empty resolver' => ['', null], +]); + +test('PATCH /api/v1/servers/{uuid}/proxy rejects unsafe certificate resolver names', function () { + $this->withHeaders(serverProxyApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", [ + 'traefik_certificate_resolver' => 'letsencrypt.acme=x', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('traefik_certificate_resolver'); + + expect($this->server->settings->fresh()->traefik_certificate_resolver)->toBe('letsencrypt'); +}); + test('PATCH /api/v1/servers/{uuid}/proxy rejects unknown fields', function () { $this->withHeaders(serverProxyApiHeaders($this->bearerToken)) ->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", [ diff --git a/tests/Feature/Proxy/TraefikCertificateResolverTest.php b/tests/Feature/Proxy/TraefikCertificateResolverTest.php new file mode 100644 index 0000000000..340cb81e35 --- /dev/null +++ b/tests/Feature/Proxy/TraefikCertificateResolverTest.php @@ -0,0 +1,151 @@ + false]); + InstanceSettings::forceCreate(['id' => 0]); + Once::flush(); + + $this->team = Team::factory()->create(); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, + 'proxy' => ['type' => ProxyTypes::TRAEFIK->value], + ]); +}); + +function traefikCertificateResolverApplicationLabels(Server $server): array +{ + $project = Project::factory()->create(['team_id' => $server->team_id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $application = Application::factory()->createOne([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'fqdn' => 'https://example.com', + 'redirect' => 'both', + 'is_http_basic_auth_enabled' => false, + ]); + + return generateLabelsApplication($application); +} + +test('new servers keep the letsencrypt resolver on generated HTTPS routers', function () { + $labels = collect(traefikCertificateResolverApplicationLabels($this->server)); + + expect($labels->filter(fn ($label) => str_contains($label, '.tls.certresolver=letsencrypt')))->not->toBeEmpty(); +}); + +test('servers without a resolver generate HTTPS routers without certresolver labels', function () { + $this->server->settings->update(['traefik_certificate_resolver' => null]); + + $labels = collect(traefikCertificateResolverApplicationLabels($this->server->fresh())); + + expect($labels->filter(fn ($label) => str_contains($label, '.tls=true')))->not->toBeEmpty() + ->and($labels->filter(fn ($label) => str_contains($label, 'certresolver')))->toBeEmpty(); +}); + +test('servers with a custom resolver use it on generated HTTPS routers', function () { + $this->server->settings->update(['traefik_certificate_resolver' => 'cloudflare']); + + $labels = collect(traefikCertificateResolverApplicationLabels($this->server->fresh())); + + expect($labels->filter(fn ($label) => str_contains($label, '.tls.certresolver=cloudflare')))->not->toBeEmpty() + ->and($labels->filter(fn ($label) => str_contains($label, 'certresolver=letsencrypt')))->toBeEmpty(); +}); + +test('coolify dashboard routes follow the server certificate resolver', function (?string $resolver, array $expectedTls) { + Process::fake(); + InstanceSettings::query()->whereKey(0)->update(['fqdn' => 'https://coolify.example.com']); + Once::flush(); + $this->server->update(['ip' => 'host.docker.internal']); + $this->server->settings->update(['traefik_certificate_resolver' => $resolver]); + + $this->server->fresh()->setupDynamicProxyConfiguration(); + + $command = ''; + Process::assertRan(function ($process) use (&$command) { + $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + + return str_contains($command, 'coolify.yaml'); + }); + preg_match("/echo '([A-Za-z0-9+\/=]+)' \| base64 -d/", $command, $matches); + $routers = Yaml::parse(base64_decode($matches[1]))['http']['routers']; + + foreach (['coolify-https', 'coolify-reverb-wss', 'coolify-reverb-api-https', 'coolify-terminal-wss'] as $router) { + expect($routers[$router])->toHaveKey('tls') + ->and($routers[$router]['tls'] ?? [])->toBe($expectedTls); + } +})->with([ + 'letsencrypt' => ['letsencrypt', ['certresolver' => 'letsencrypt']], + 'no resolver' => [null, []], +]); + +test('the catch-all router follows the server certificate resolver', function (?string $resolver, array $expectedTls) { + Process::fake(); + $this->server->settings->update(['traefik_certificate_resolver' => $resolver]); + + $this->server->fresh()->setupDefaultRedirect(); + + $command = ''; + Process::assertRan(function ($process) use (&$command) { + $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + + return str_contains($command, 'default_redirect_503.yaml') && str_contains($command, 'base64 -d'); + }); + preg_match("/echo '([A-Za-z0-9+\/=]+)' \| base64 -d/", $command, $matches); + $catchall = Yaml::parse(base64_decode($matches[1]))['http']['routers']['catchall']; + + expect($catchall)->toHaveKey('tls') + ->and($catchall['tls'] ?? [])->toBe($expectedTls); +})->with([ + 'letsencrypt' => ['letsencrypt', ['certResolver' => 'letsencrypt']], + 'no resolver' => [null, []], +]); + +test('proxy page saves an empty resolver as none and rejects unsafe names', function () { + SaveProxyConfiguration::shouldRun(); + Process::fake(); + $user = User::factory()->create(); + $this->team->members()->attach($user->id, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $this->team]); + + Livewire::test(Proxy::class, ['server' => $this->server]) + ->assertSet('certificateResolver', 'letsencrypt') + ->set('certificateResolver', 'letsencrypt.acme=x') + ->call('instantSave') + ->assertDispatched('error'); + + expect($this->server->settings->fresh()->traefik_certificate_resolver)->toBe('letsencrypt'); + + Livewire::test(Proxy::class, ['server' => $this->server->fresh()]) + ->set('certificateResolver', '') + ->call('instantSave') + ->assertDispatched('success'); + + expect($this->server->settings->fresh()->traefik_certificate_resolver)->toBeNull(); +});