From 1e42284db0960d6433c1e7b0892d12c977f8b9b2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:01:27 +0200 Subject: [PATCH] feat(proxy): override the Traefik certificate resolver per resource Applications and service applications can now use the server resolver, no resolver ("none"), or a custom resolver name. This allows servers that mix Let's Encrypt domains with domains that use their own certificates, such as Cloudflare Origin CA. The setting is available in the UI and the API. Closes #12138 --- .../UpdateServiceApplicationFromApi.php | 4 + .../Api/ApplicationsController.php | 11 +- .../Api/ServiceApplicationsController.php | 4 + app/Livewire/Project/Application/Advanced.php | 87 ++++++++++- app/Livewire/Project/Service/Index.php | 45 +++++- app/Models/ApplicationSetting.php | 2 + app/Models/Server.php | 7 +- app/Models/ServerSetting.php | 5 + app/Models/ServiceApplication.php | 1 + .../ApplicationConfigurationSnapshot.php | 1 + .../ServerTransfer/ServerTransferExporter.php | 1 + bootstrap/helpers/api.php | 3 + bootstrap/helpers/docker.php | 8 +- bootstrap/helpers/parsers.php | 8 +- bootstrap/helpers/shared.php | 8 +- ...er_to_application_and_service_settings.php | 34 +++++ openapi.json | 42 ++++++ openapi.yaml | 31 ++++ .../project/application/advanced.blade.php | 19 +++ .../service/advanced-settings.blade.php | 14 ++ .../Api/ApplicationSettingsApiTest.php | 68 +++++++++ .../AdvancedCertificateResolverTest.php | 137 ++++++++++++++++++ .../ServiceTraefikCertificateResolverTest.php | 70 +++++++++ .../Proxy/TraefikCertificateResolverTest.php | 40 +++++ ...ServiceCertificateResolverSettingsTest.php | 119 +++++++++++++++ tests/Feature/ServiceApplicationsApiTest.php | 32 ++++ .../ServerTransferExporterImporterTest.php | 38 +++++ 27 files changed, 821 insertions(+), 18 deletions(-) create mode 100644 database/migrations/2026_10_11_145529_add_traefik_certificate_resolver_to_application_and_service_settings.php create mode 100644 tests/Feature/Livewire/Project/Application/AdvancedCertificateResolverTest.php create mode 100644 tests/Feature/Proxy/ServiceTraefikCertificateResolverTest.php create mode 100644 tests/Feature/Service/ServiceCertificateResolverSettingsTest.php diff --git a/app/Actions/Service/UpdateServiceApplicationFromApi.php b/app/Actions/Service/UpdateServiceApplicationFromApi.php index 8065e11e05..6e1bdccd55 100644 --- a/app/Actions/Service/UpdateServiceApplicationFromApi.php +++ b/app/Actions/Service/UpdateServiceApplicationFromApi.php @@ -87,6 +87,10 @@ class UpdateServiceApplicationFromApi $serviceApplication->is_gzip_enabled = filter_var($payload['is_gzip_enabled'], FILTER_VALIDATE_BOOLEAN); } + if (array_key_exists('traefik_certificate_resolver', $payload)) { + $serviceApplication->traefik_certificate_resolver = filled($payload['traefik_certificate_resolver']) ? $payload['traefik_certificate_resolver'] : null; + } + if (array_key_exists('is_stripprefix_enabled', $payload)) { $serviceApplication->is_stripprefix_enabled = filter_var($payload['is_stripprefix_enabled'], FILTER_VALIDATE_BOOLEAN); } diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index d2918e947c..973a119354 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -54,6 +54,7 @@ class ApplicationsController extends Controller 'docker_images_to_keep', 'is_gzip_enabled', 'is_stripprefix_enabled', + 'traefik_certificate_resolver', 'is_raw_compose_deployment_enabled', 'is_log_drain_enabled', 'is_gpu_enabled', @@ -210,7 +211,9 @@ class ApplicationsController extends Controller $regenerateLabels = ! $application->wasRecentlyCreated && $application->settings->is_container_label_readonly_enabled - && (array_key_exists('is_gzip_enabled', $settings) || array_key_exists('is_stripprefix_enabled', $settings)); + && (array_key_exists('is_gzip_enabled', $settings) + || array_key_exists('is_stripprefix_enabled', $settings) + || array_key_exists('traefik_certificate_resolver', $settings)); $application->settings->fill($settings)->save(); @@ -430,6 +433,7 @@ class ApplicationsController extends Controller 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], + 'traefik_certificate_resolver' => ['type' => 'string', 'nullable' => true, 'description' => 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.'], 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], 'is_log_drain_enabled' => ['type' => 'boolean', 'description' => 'Enable log drain for the application.'], 'is_gpu_enabled' => ['type' => 'boolean', 'description' => 'Enable GPU support.'], @@ -625,6 +629,7 @@ class ApplicationsController extends Controller 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], + 'traefik_certificate_resolver' => ['type' => 'string', 'nullable' => true, 'description' => 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.'], 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], 'is_log_drain_enabled' => ['type' => 'boolean', 'description' => 'Enable log drain for the application.'], 'is_gpu_enabled' => ['type' => 'boolean', 'description' => 'Enable GPU support.'], @@ -820,6 +825,7 @@ class ApplicationsController extends Controller 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], + 'traefik_certificate_resolver' => ['type' => 'string', 'nullable' => true, 'description' => 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.'], 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], 'is_log_drain_enabled' => ['type' => 'boolean', 'description' => 'Enable log drain for the application.'], 'is_gpu_enabled' => ['type' => 'boolean', 'description' => 'Enable GPU support.'], @@ -986,6 +992,7 @@ class ApplicationsController extends Controller 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], + 'traefik_certificate_resolver' => ['type' => 'string', 'nullable' => true, 'description' => 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.'], 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], 'is_log_drain_enabled' => ['type' => 'boolean', 'description' => 'Enable log drain for the application.'], 'is_gpu_enabled' => ['type' => 'boolean', 'description' => 'Enable GPU support.'], @@ -1148,6 +1155,7 @@ class ApplicationsController extends Controller 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], + 'traefik_certificate_resolver' => ['type' => 'string', 'nullable' => true, 'description' => 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.'], 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], 'is_log_drain_enabled' => ['type' => 'boolean', 'description' => 'Enable log drain for the application.'], 'is_gpu_enabled' => ['type' => 'boolean', 'description' => 'Enable GPU support.'], @@ -3142,6 +3150,7 @@ class ApplicationsController extends Controller 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], + 'traefik_certificate_resolver' => ['type' => 'string', 'nullable' => true, 'description' => 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.'], 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], 'is_log_drain_enabled' => ['type' => 'boolean', 'description' => 'Enable log drain for the application.'], 'is_gpu_enabled' => ['type' => 'boolean', 'description' => 'Enable GPU support.'], diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php index 586ee7f538..e97e5e42a9 100644 --- a/app/Http/Controllers/Api/ServiceApplicationsController.php +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -7,6 +7,7 @@ use App\Actions\Service\RestartServiceApplication; use App\Actions\Service\StopServiceApplication; use App\Actions\Service\UpdateServiceApplicationFromApi; use App\Http\Controllers\Controller; +use App\Models\ServerSetting; use App\Models\Service; use App\Models\ServiceApplication; use App\Support\ValidationPatterns; @@ -257,6 +258,7 @@ class ServiceApplicationsController extends Controller 'exclude_from_status' => new OA\Property(property: 'exclude_from_status', type: 'boolean', nullable: true), 'is_log_drain_enabled' => new OA\Property(property: 'is_log_drain_enabled', type: 'boolean', nullable: true), 'is_gzip_enabled' => new OA\Property(property: 'is_gzip_enabled', type: 'boolean', nullable: true), + 'traefik_certificate_resolver' => new OA\Property(property: 'traefik_certificate_resolver', type: 'string', nullable: true, description: 'Traefik certificate resolver for this service application. Null uses the server setting, "none" uses no resolver.'), 'is_stripprefix_enabled' => new OA\Property(property: 'is_stripprefix_enabled', type: 'boolean', nullable: true), 'is_force_https_enabled' => new OA\Property(property: 'is_force_https_enabled', type: 'boolean', nullable: true), 'max_restart_count' => new OA\Property(property: 'max_restart_count', type: 'integer', minimum: 0, nullable: true, description: 'Maximum Docker restart count before Coolify stops the container. Set to 0 to disable the limit.'), @@ -331,6 +333,7 @@ class ServiceApplicationsController extends Controller 'exclude_from_status', 'is_log_drain_enabled', 'is_gzip_enabled', + 'traefik_certificate_resolver', 'is_stripprefix_enabled', 'is_force_https_enabled', 'max_restart_count', @@ -346,6 +349,7 @@ class ServiceApplicationsController extends Controller 'exclude_from_status' => 'sometimes|boolean', 'is_log_drain_enabled' => 'sometimes|boolean', 'is_gzip_enabled' => 'sometimes|boolean', + 'traefik_certificate_resolver' => ['sometimes', 'nullable', 'string', 'max:255', 'regex:'.ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_PATTERN], 'is_stripprefix_enabled' => 'sometimes|boolean', 'is_force_https_enabled' => 'sometimes|boolean', 'max_restart_count' => 'sometimes|integer|min:0', diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index e7edb958ba..86b406a6b5 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -2,11 +2,14 @@ namespace App\Livewire\Project\Application; +use App\Enums\ProxyTypes; use App\Models\Application; use App\Models\ApplicationSetting; +use App\Models\ServerSetting; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; use Livewire\Attributes\Validate; use Livewire\Component; @@ -83,6 +86,11 @@ class Advanced extends Component #[Validate(['boolean'])] public bool $isStripprefixEnabled = true; + /** One of "server" (use the server setting), "none" (no resolver), or "custom". */ + public string $certificateResolverMode = 'server'; + + public ?string $certificateResolverName = null; + #[Validate(['boolean'])] public bool $isRawComposeDeploymentEnabled = false; @@ -135,6 +143,7 @@ class Advanced extends Component $this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled(); $this->isGzipEnabled = $this->application->isGzipEnabled(); $this->isStripprefixEnabled = $this->application->isStripprefixEnabled(); + $this->syncCertificateResolverFromModel(); $this->isLogDrainEnabled = $this->application->isLogDrainEnabled(); $this->isGitSubmodulesEnabled = $this->application->settings->is_git_submodules_enabled; @@ -163,6 +172,18 @@ class Advanced extends Component $this->stopGracePeriod = $this->application->settings->stop_grace_period ?? ''; } + private function syncCertificateResolverFromModel(): void + { + $resolver = $this->application->settings->traefik_certificate_resolver; + + $this->certificateResolverMode = match (true) { + blank($resolver) => 'server', + $resolver === ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_NONE => 'none', + default => 'custom', + }; + $this->certificateResolverName = $this->certificateResolverMode === 'custom' ? $resolver : null; + } + /** * An unchanged stored prefix is skipped, so older values do not block other settings. */ @@ -314,6 +335,65 @@ class Advanced extends Component } } + /** + * Listbox change: "custom" waits for a resolver name before it saves. + */ + public function saveCertificateResolverMode() + { + if ($this->certificateResolverMode === 'custom' && blank($this->certificateResolverName)) { + $this->resetErrorBag('certificateResolverName'); + + return; + } + + return $this->saveCertificateResolver(); + } + + public function saveCertificateResolver() + { + try { + $this->authorize('update', $this->application); + + $this->validate([ + 'certificateResolverMode' => ['required', Rule::in(['server', 'none', 'custom'])], + 'certificateResolverName' => [ + 'exclude_unless:certificateResolverMode,custom', + 'required', + 'string', + 'max:255', + 'regex:'.ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_PATTERN, + Rule::notIn([ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_NONE]), + ], + ], [ + 'certificateResolverName.regex' => 'The resolver name may only contain letters, numbers, hyphens, and underscores.', + 'certificateResolverName.not_in' => 'Choose "None" instead of a custom resolver named "none".', + ], [ + 'certificateResolverName' => 'resolver name', + ]); + + $this->application->settings->traefik_certificate_resolver = match ($this->certificateResolverMode) { + 'none' => ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_NONE, + 'custom' => $this->certificateResolverName, + default => null, + }; + $changedFields = array_keys($this->application->settings->getDirty()); + $this->application->settings->save(); + $this->auditSettingsUpdate($changedFields); + $this->syncCertificateResolverFromModel(); + + if (in_array('traefik_certificate_resolver', $changedFields, true)) { + $this->resetDefaultLabels(); + } + + $this->dispatch('success', 'Certificate resolver saved.'); + $this->dispatch('configurationChanged'); + } catch (ValidationException $e) { + throw $e; + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function saveStopGracePeriod() { try { @@ -359,7 +439,12 @@ class Advanced extends Component public function render() { - return view('livewire.project.application.advanced'); + $server = data_get($this->application, 'destination.server'); + + return view('livewire.project.application.advanced', [ + 'isTraefikProxy' => $server?->proxyType() === ProxyTypes::TRAEFIK->value, + 'serverCertificateResolver' => $server?->traefikCertificateResolver() ?? ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_NONE, + ]); } /** @param array $changedFields */ diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php index fd3b8c54da..2c91c31c35 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -5,8 +5,10 @@ namespace App\Livewire\Project\Service; use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; use App\Actions\Service\DeleteService; +use App\Enums\ProxyTypes; use App\Models\S3Storage; use App\Models\Server; +use App\Models\ServerSetting; use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; @@ -87,6 +89,13 @@ class Index extends Component public bool $isStripprefixEnabled = false; + /** + * How the Traefik certificate resolver is chosen: `server`, `none`, or `custom`. + */ + public string $certificateResolverMode = 'server'; + + public ?string $certificateResolverName = null; + public mixed $maxRestartCount = 0; protected $listeners = ['generateDockerCompose', 'refreshScheduledBackups' => '$refresh', 'refreshFileStorages']; @@ -105,6 +114,13 @@ class Index extends Component 'fqdn' => 'nullable', 'isGzipEnabled' => 'nullable|boolean', 'isStripprefixEnabled' => 'nullable|boolean', + 'certificateResolverMode' => 'required|in:server,none,custom', + 'certificateResolverName' => ['exclude_unless:certificateResolverMode,custom', 'required', 'string', 'max:255', 'regex:'.ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_PATTERN, 'not_in:'.ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_NONE], + ]; + + protected $validationAttributes = [ + 'certificateResolverMode' => 'certificate resolver', + 'certificateResolverName' => 'certificate resolver name', ]; public function mount( @@ -447,6 +463,11 @@ class Index extends Component $this->serviceApplication->is_log_drain_enabled = $this->isLogDrainEnabled; $this->serviceApplication->is_gzip_enabled = $this->isGzipEnabled; $this->serviceApplication->is_stripprefix_enabled = $this->isStripprefixEnabled; + $this->serviceApplication->traefik_certificate_resolver = match ($this->certificateResolverMode) { + 'none' => ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_NONE, + 'custom' => $this->certificateResolverName, + default => null, + }; if ($this->serviceApplication->max_restart_count !== (int) $this->maxRestartCount) { $this->serviceApplication->restart_limit_reached = false; } @@ -460,10 +481,25 @@ class Index extends Component $this->isLogDrainEnabled = data_get($this->serviceApplication, 'is_log_drain_enabled', false); $this->isGzipEnabled = data_get($this->serviceApplication, 'is_gzip_enabled', true); $this->isStripprefixEnabled = data_get($this->serviceApplication, 'is_stripprefix_enabled', true); + $resolver = $this->serviceApplication->traefik_certificate_resolver; + $this->certificateResolverMode = match (true) { + blank($resolver) => 'server', + $resolver === ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_NONE => 'none', + default => 'custom', + }; + $this->certificateResolverName = $this->certificateResolverMode === 'custom' ? $resolver : null; $this->maxRestartCount = $this->serviceApplication->max_restart_count ?? 0; } } + private function validateCertificateResolver(): void + { + $this->validate([ + 'certificateResolverMode' => $this->rules['certificateResolverMode'], + 'certificateResolverName' => $this->rules['certificateResolverName'], + ]); + } + public function saveMaxRestartCount(): void { $this->authorize('update', $this->serviceApplication); @@ -517,6 +553,7 @@ class Index extends Component return; } + $this->validateCertificateResolver(); $this->syncApplicationData(true); $changedFields = auditChangedFields($this->serviceApplication); $this->serviceApplication->save(); @@ -620,6 +657,7 @@ class Index extends Component $this->validate([ 'fqdn' => ValidationPatterns::applicationDomainRules(), ]); + $this->validateCertificateResolver(); $this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn); $warning = sslipDomainWarning($this->fqdn); @@ -692,7 +730,12 @@ class Index extends Component public function render() { - return view('livewire.project.service.index'); + $server = $this->resourceType === 'application' ? $this->service?->destination?->server : null; + + return view('livewire.project.service.index', [ + 'showCertificateResolver' => $server?->proxyType() === ProxyTypes::TRAEFIK->value, + 'serverCertificateResolver' => $server?->traefikCertificateResolver() ?? ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_NONE, + ]); } /** diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php index 18b26f454f..2e45fa8788 100644 --- a/app/Models/ApplicationSetting.php +++ b/app/Models/ApplicationSetting.php @@ -29,6 +29,7 @@ use OpenApi\Attributes as OA; 'is_build_server_enabled' => ['type' => 'boolean'], 'is_consistent_container_name_enabled' => ['type' => 'boolean'], 'is_gzip_enabled' => ['type' => 'boolean'], + 'traefik_certificate_resolver' => ['type' => 'string', 'nullable' => true, 'description' => 'Traefik certificate resolver override. Null uses the server setting, "none" uses no resolver.'], 'is_stripprefix_enabled' => ['type' => 'boolean'], 'connect_to_docker_network' => ['type' => 'boolean'], 'custom_internal_name' => ['type' => 'string', 'nullable' => true], @@ -110,6 +111,7 @@ class ApplicationSetting extends Model 'is_build_server_enabled', 'is_consistent_container_name_enabled', 'is_gzip_enabled', + 'traefik_certificate_resolver', 'is_stripprefix_enabled', 'connect_to_docker_network', 'custom_internal_name', diff --git a/app/Models/Server.php b/app/Models/Server.php index 9de37d974e..340d4d2bcc 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -1163,12 +1163,13 @@ $siteAddress { /** * Traefik certificate resolver for generated HTTPS routers, or null when the proxy uses its own certificates. + * A resource value overrides the server value; "none" turns the resolver off. */ - public function traefikCertificateResolver(): ?string + public function traefikCertificateResolver(?string $resourceResolver = null): ?string { - $resolver = data_get($this, 'settings.traefik_certificate_resolver'); + $resolver = filled($resourceResolver) ? $resourceResolver : data_get($this, 'settings.traefik_certificate_resolver'); - return filled($resolver) ? $resolver : null; + return filled($resolver) && $resolver !== ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_NONE ? $resolver : null; } public function isTrafficAnalyticsEnabled(): bool diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index afe18607b8..79b541a328 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -77,6 +77,11 @@ class ServerSetting extends Model */ public const string TRAEFIK_CERTIFICATE_RESOLVER_PATTERN = '/^[A-Za-z0-9_-]+$/'; + /** + * Resolver value that turns the resolver off (no certresolver label) on a resource or server. + */ + public const string TRAEFIK_CERTIFICATE_RESOLVER_NONE = 'none'; + public const int DEFAULT_SENTINEL_METRICS_REFRESH_RATE_SECONDS = 10; public const int DEFAULT_SENTINEL_METRICS_HISTORY_DAYS = 7; diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index 42dd88f169..c6962347c0 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -38,6 +38,7 @@ class ServiceApplication extends BaseModel 'is_gzip_enabled', 'is_stripprefix_enabled', 'is_force_https_enabled', + 'traefik_certificate_resolver', 'last_online_at', 'is_migrated', ]; diff --git a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php index b974e03db3..ab057c2219 100644 --- a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php +++ b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php @@ -205,6 +205,7 @@ class ApplicationConfigurationSnapshot $this->item('is_force_https_enabled', 'Force HTTPS', data_get($this->application, 'settings.is_force_https_enabled'), 'redeploy'), $this->item('is_gzip_enabled', 'Gzip', data_get($this->application, 'settings.is_gzip_enabled'), 'redeploy'), $this->item('is_stripprefix_enabled', 'Strip prefix', data_get($this->application, 'settings.is_stripprefix_enabled'), 'redeploy'), + $this->item('traefik_certificate_resolver', 'Certificate resolver', data_get($this->application, 'settings.traefik_certificate_resolver'), 'redeploy'), $this->item('is_http_basic_auth_enabled', 'HTTP basic auth', $this->application->is_http_basic_auth_enabled, 'redeploy'), $this->item('http_basic_auth_username', 'HTTP basic auth username', $this->application->http_basic_auth_username, 'redeploy'), $this->item('http_basic_auth_password', 'HTTP basic auth password', $this->application->http_basic_auth_password, 'redeploy', sensitive: true), diff --git a/app/Services/ServerTransfer/ServerTransferExporter.php b/app/Services/ServerTransfer/ServerTransferExporter.php index 8db6e5afd1..0d65f0223d 100644 --- a/app/Services/ServerTransfer/ServerTransferExporter.php +++ b/app/Services/ServerTransfer/ServerTransferExporter.php @@ -733,6 +733,7 @@ class ServerTransferExporter 'is_include_timestamps' => (bool) ($app->is_include_timestamps ?? false), 'is_gzip_enabled' => (bool) ($app->is_gzip_enabled ?? true), 'is_stripprefix_enabled' => (bool) ($app->is_stripprefix_enabled ?? true), + 'traefik_certificate_resolver' => $app->traefik_certificate_resolver ?? null, 'environment_variables' => method_exists($app, 'environment_variables') ? $this->exportEnvVars($app->environment_variables()->get()) : [], diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 0c17dab4e3..57ed374085 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -8,6 +8,7 @@ use App\Models\Application; use App\Models\ApplicationSetting; use App\Models\Environment; use App\Models\EnvironmentVariable; +use App\Models\ServerSetting; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; use App\Rules\ManualWebhookSecret; @@ -204,6 +205,7 @@ function sharedDataApplications(?Application $application = null): array 'is_pr_deployments_public_enabled' => 'boolean', 'is_gzip_enabled' => 'boolean', 'is_stripprefix_enabled' => 'boolean', + 'traefik_certificate_resolver' => ['nullable', 'string', 'max:255', 'regex:'.ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_PATTERN], 'is_raw_compose_deployment_enabled' => 'boolean', 'is_log_drain_enabled' => 'boolean', 'is_gpu_enabled' => 'boolean', @@ -476,6 +478,7 @@ function removeUnnecessaryFieldsFromRequest(Request $request) $request->offsetUnset('docker_images_to_keep'); $request->offsetUnset('is_gzip_enabled'); $request->offsetUnset('is_stripprefix_enabled'); + $request->offsetUnset('traefik_certificate_resolver'); $request->offsetUnset('is_raw_compose_deployment_enabled'); $request->offsetUnset('is_log_drain_enabled'); $request->offsetUnset('is_gpu_enabled'); diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 9427ccd267..e7a39342f4 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -1228,7 +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(), + certificate_resolver: $application->destination->server->traefikCertificateResolver($application->settings?->traefik_certificate_resolver), )); break; case ProxyTypes::CADDY->value: @@ -1267,7 +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(), + certificate_resolver: $application->destination->server->traefikCertificateResolver($application->settings?->traefik_certificate_resolver), )); $labels = $labels->merge(fqdnLabelsForCaddy( network: $application->destination->network, @@ -1314,7 +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(), + certificate_resolver: $application->destination->server->traefikCertificateResolver($application->settings?->traefik_certificate_resolver), )); break; case ProxyTypes::CADDY->value: @@ -1351,7 +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(), + certificate_resolver: $application->destination->server->traefikCertificateResolver($application->settings?->traefik_certificate_resolver), )); $labels = $labels->merge(fqdnLabelsForCaddy( network: $application->destination->network, diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index cc862c790f..e0d857baf8 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -2029,7 +2029,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, domainPortOverrides: $domainPortOverrides, - certificate_resolver: $server->traefikCertificateResolver(), + certificate_resolver: $server->traefikCertificateResolver(data_get($resource, 'settings.traefik_certificate_resolver')), )); break; case ProxyTypes::CADDY->value: @@ -2067,7 +2067,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, domainPortOverrides: $domainPortOverrides, - certificate_resolver: $server->traefikCertificateResolver(), + certificate_resolver: $server->traefikCertificateResolver(data_get($resource, 'settings.traefik_certificate_resolver')), )); $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( network: $labelNetwork, @@ -3280,7 +3280,7 @@ function serviceParser(Service $resource): Collection domainPortOverrides: $originalResource->domain_port_overrides ?? [], noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, - certificate_resolver: $server->traefikCertificateResolver(), + certificate_resolver: $server->traefikCertificateResolver(data_get($originalResource, 'traefik_certificate_resolver')), )); break; case ProxyTypes::CADDY->value: @@ -3318,7 +3318,7 @@ function serviceParser(Service $resource): Collection domainPortOverrides: $originalResource->domain_port_overrides ?? [], noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, - certificate_resolver: $server->traefikCertificateResolver(), + certificate_resolver: $server->traefikCertificateResolver(data_get($originalResource, 'traefik_certificate_resolver')), )); $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( network: $network, diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 512d18aec0..050685f396 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -3488,7 +3488,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, domainPortOverrides: $domainPortOverrides, - certificate_resolver: $resource->server->traefikCertificateResolver(), + certificate_resolver: $resource->server->traefikCertificateResolver(data_get($savedService, 'traefik_certificate_resolver')), )); break; case ProxyTypes::CADDY->value: @@ -3526,7 +3526,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, domainPortOverrides: $domainPortOverrides, - certificate_resolver: $resource->server->traefikCertificateResolver(), + certificate_resolver: $resource->server->traefikCertificateResolver(data_get($savedService, 'traefik_certificate_resolver')), )); $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( network: $resource->destination->network, @@ -4301,7 +4301,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, domainPortOverrides: $domainPortOverrides, - certificate_resolver: $server->traefikCertificateResolver(), + certificate_resolver: $server->traefikCertificateResolver(data_get($resource, 'settings.traefik_certificate_resolver')), ) ); break; @@ -4342,7 +4342,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, domainPortOverrides: $domainPortOverrides, - certificate_resolver: $server->traefikCertificateResolver(), + certificate_resolver: $server->traefikCertificateResolver(data_get($resource, 'settings.traefik_certificate_resolver')), ) ); $serviceLabels = $serviceLabels->merge( diff --git a/database/migrations/2026_10_11_145529_add_traefik_certificate_resolver_to_application_and_service_settings.php b/database/migrations/2026_10_11_145529_add_traefik_certificate_resolver_to_application_and_service_settings.php new file mode 100644 index 0000000000..4afd513e71 --- /dev/null +++ b/database/migrations/2026_10_11_145529_add_traefik_certificate_resolver_to_application_and_service_settings.php @@ -0,0 +1,34 @@ +string('traefik_certificate_resolver')->nullable(); + }); + Schema::table('service_applications', function (Blueprint $table) { + $table->string('traefik_certificate_resolver')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('application_settings', function (Blueprint $table) { + $table->dropColumn('traefik_certificate_resolver'); + }); + Schema::table('service_applications', function (Blueprint $table) { + $table->dropColumn('traefik_certificate_resolver'); + }); + } +}; diff --git a/openapi.json b/openapi.json index e892ace63f..4f221bdb1d 100644 --- a/openapi.json +++ b/openapi.json @@ -911,6 +911,11 @@ "type": "boolean", "description": "Enable path prefix stripping." }, + "traefik_certificate_resolver": { + "type": "string", + "nullable": true, + "description": "Traefik certificate resolver for this application. Null uses the server setting, \"none\" uses no resolver." + }, "is_raw_compose_deployment_enabled": { "type": "boolean", "description": "Deploy the raw Docker Compose definition." @@ -1502,6 +1507,11 @@ "type": "boolean", "description": "Enable path prefix stripping." }, + "traefik_certificate_resolver": { + "type": "string", + "nullable": true, + "description": "Traefik certificate resolver for this application. Null uses the server setting, \"none\" uses no resolver." + }, "is_raw_compose_deployment_enabled": { "type": "boolean", "description": "Deploy the raw Docker Compose definition." @@ -2093,6 +2103,11 @@ "type": "boolean", "description": "Enable path prefix stripping." }, + "traefik_certificate_resolver": { + "type": "string", + "nullable": true, + "description": "Traefik certificate resolver for this application. Null uses the server setting, \"none\" uses no resolver." + }, "is_raw_compose_deployment_enabled": { "type": "boolean", "description": "Deploy the raw Docker Compose definition." @@ -2579,6 +2594,11 @@ "type": "boolean", "description": "Enable path prefix stripping." }, + "traefik_certificate_resolver": { + "type": "string", + "nullable": true, + "description": "Traefik certificate resolver for this application. Null uses the server setting, \"none\" uses no resolver." + }, "is_raw_compose_deployment_enabled": { "type": "boolean", "description": "Deploy the raw Docker Compose definition." @@ -3045,6 +3065,11 @@ "type": "boolean", "description": "Enable path prefix stripping." }, + "traefik_certificate_resolver": { + "type": "string", + "nullable": true, + "description": "Traefik certificate resolver for this application. Null uses the server setting, \"none\" uses no resolver." + }, "is_raw_compose_deployment_enabled": { "type": "boolean", "description": "Deploy the raw Docker Compose definition." @@ -3756,6 +3781,11 @@ "type": "boolean", "description": "Enable path prefix stripping." }, + "traefik_certificate_resolver": { + "type": "string", + "nullable": true, + "description": "Traefik certificate resolver for this application. Null uses the server setting, \"none\" uses no resolver." + }, "is_raw_compose_deployment_enabled": { "type": "boolean", "description": "Deploy the raw Docker Compose definition." @@ -18038,6 +18068,13 @@ "null" ] }, + "traefik_certificate_resolver": { + "description": "Traefik certificate resolver for this service application. Null uses the server setting, \"none\" uses no resolver.", + "type": [ + "string", + "null" + ] + }, "is_stripprefix_enabled": { "type": [ "boolean", @@ -23939,6 +23976,11 @@ "is_gzip_enabled": { "type": "boolean" }, + "traefik_certificate_resolver": { + "type": "string", + "nullable": true, + "description": "Traefik certificate resolver override. Null uses the server setting, \"none\" uses no resolver." + }, "is_stripprefix_enabled": { "type": "boolean" }, diff --git a/openapi.yaml b/openapi.yaml index 9ffee61780..41f74e13f9 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -592,6 +592,10 @@ paths: is_stripprefix_enabled: type: boolean description: 'Enable path prefix stripping.' + traefik_certificate_resolver: + type: string + nullable: true + description: 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.' is_raw_compose_deployment_enabled: type: boolean description: 'Deploy the raw Docker Compose definition.' @@ -978,6 +982,10 @@ paths: is_stripprefix_enabled: type: boolean description: 'Enable path prefix stripping.' + traefik_certificate_resolver: + type: string + nullable: true + description: 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.' is_raw_compose_deployment_enabled: type: boolean description: 'Deploy the raw Docker Compose definition.' @@ -1364,6 +1372,10 @@ paths: is_stripprefix_enabled: type: boolean description: 'Enable path prefix stripping.' + traefik_certificate_resolver: + type: string + nullable: true + description: 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.' is_raw_compose_deployment_enabled: type: boolean description: 'Deploy the raw Docker Compose definition.' @@ -1691,6 +1703,10 @@ paths: is_stripprefix_enabled: type: boolean description: 'Enable path prefix stripping.' + traefik_certificate_resolver: + type: string + nullable: true + description: 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.' is_raw_compose_deployment_enabled: type: boolean description: 'Deploy the raw Docker Compose definition.' @@ -2004,6 +2020,10 @@ paths: is_stripprefix_enabled: type: boolean description: 'Enable path prefix stripping.' + traefik_certificate_resolver: + type: string + nullable: true + description: 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.' is_raw_compose_deployment_enabled: type: boolean description: 'Deploy the raw Docker Compose definition.' @@ -2471,6 +2491,10 @@ paths: is_stripprefix_enabled: type: boolean description: 'Enable path prefix stripping.' + traefik_certificate_resolver: + type: string + nullable: true + description: 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.' is_raw_compose_deployment_enabled: type: boolean description: 'Deploy the raw Docker Compose definition.' @@ -11361,6 +11385,9 @@ paths: type: [boolean, 'null'] is_gzip_enabled: type: [boolean, 'null'] + traefik_certificate_resolver: + description: 'Traefik certificate resolver for this service application. Null uses the server setting, "none" uses no resolver.' + type: [string, 'null'] is_stripprefix_enabled: type: [boolean, 'null'] is_force_https_enabled: @@ -15257,6 +15284,10 @@ components: type: boolean is_gzip_enabled: type: boolean + traefik_certificate_resolver: + type: string + nullable: true + description: 'Traefik certificate resolver override. Null uses the server setting, "none" uses no resolver.' is_stripprefix_enabled: type: boolean connect_to_docker_network: diff --git a/resources/views/livewire/project/application/advanced.blade.php b/resources/views/livewire/project/application/advanced.blade.php index bff96bbf69..c5e5aa89c3 100644 --- a/resources/views/livewire/project/application/advanced.blade.php +++ b/resources/views/livewire/project/application/advanced.blade.php @@ -138,6 +138,25 @@ ['value' => true, 'label' => 'Strip prefixes'], ['value' => false, 'label' => 'Keep paths as-is'], ]" :disabled="! $canUpdate" /> + @if ($isTraefikProxy) + + @if ($certificateResolverMode === 'custom') +
+ + + + @endif + @endif @else + @if ($showCertificateResolver) + + @if ($certificateResolverMode === 'custom') + + @endif + @endif @else application->uuid}", ['traefik_certificate_resolver' => $resolver]) + ->assertOk(); + + expect($this->application->fresh()->settings->traefik_certificate_resolver)->toBe($resolver); +})->with([ + 'custom resolver' => ['cloudflare'], + 'no resolver' => ['none'], + 'server setting' => [null], +]); + +test('PATCH /api/v1/applications/{uuid} rejects an unsafe Traefik certificate resolver', function (mixed $resolver) { + $this->application->settings->update(['traefik_certificate_resolver' => 'previous']); + + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", ['traefik_certificate_resolver' => $resolver]) + ->assertUnprocessable() + ->assertJsonValidationErrors('traefik_certificate_resolver'); + + expect($this->application->fresh()->settings->traefik_certificate_resolver)->toBe('previous'); +})->with([ + 'label injection' => ['a.b=c'], + 'whitespace' => ['lets encrypt'], + 'non-string' => [['cloudflare']], +]); + +test('application creation accepts a Traefik certificate resolver', function () { + Queue::fake(); + + $response = $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->postJson('/api/v1/applications/public', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'git_repository' => 'https://gitlab.com/coolify/resolver-test', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'traefik_certificate_resolver' => 'none', + ]) + ->assertCreated(); + + expect(Application::where('uuid', $response->json('uuid'))->firstOrFail()->settings->traefik_certificate_resolver)->toBe('none'); +}); + +test('application creation rejects an unsafe Traefik certificate resolver', function () { + Queue::fake(); + + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->postJson('/api/v1/applications/public', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'git_repository' => 'https://gitlab.com/coolify/resolver-test', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'traefik_certificate_resolver' => 'a.b=c', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('traefik_certificate_resolver'); + + expect(Application::query()->where('git_repository', 'like', '%resolver-test%')->exists())->toBeFalse(); +}); diff --git a/tests/Feature/Livewire/Project/Application/AdvancedCertificateResolverTest.php b/tests/Feature/Livewire/Project/Application/AdvancedCertificateResolverTest.php new file mode 100644 index 0000000000..e67340af9c --- /dev/null +++ b/tests/Feature/Livewire/Project/Application/AdvancedCertificateResolverTest.php @@ -0,0 +1,137 @@ + 0]); + + $this->team = Team::factory()->create(); + $this->owner = User::factory()->create(); + $this->team->members()->attach($this->owner->id, ['role' => 'owner']); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'proxy' => ['type' => ProxyTypes::TRAEFIK->value], + ]); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $destination = $this->server->standaloneDockers()->firstOrFail(); + + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'fqdn' => 'https://resolver.example.com', + ]); + $this->application->settings->update(['is_container_label_readonly_enabled' => true]); +}); + +function advancedCertificateResolverComponent(Application $application, User $user, Team $team) +{ + test()->actingAs($user); + session(['currentTeam' => $team]); + + return Livewire::test(Advanced::class, ['application' => $application->fresh()]); +} + +test('each certificate resolver mode stores its column value', function (?string $stored, string $mode, ?string $name, ?string $expected) { + $this->application->settings->update(['traefik_certificate_resolver' => $stored]); + + advancedCertificateResolverComponent($this->application, $this->owner, $this->team) + ->set('certificateResolverMode', $mode) + ->set('certificateResolverName', $name) + ->call('saveCertificateResolver') + ->assertHasNoErrors() + ->assertDispatched('success') + ->assertDispatched('configurationChanged'); + + expect($this->application->settings()->first()->traefik_certificate_resolver)->toBe($expected); +})->with([ + 'server setting' => ['cloudflare', 'server', null, null], + 'none' => [null, 'none', null, 'none'], + 'custom' => [null, 'custom', 'cloudflare', 'cloudflare'], + 'server setting ignores a leftover name' => ['cloudflare', 'server', 'a.b=c', null], +]); + +test('the stored resolver is loaded back into the mode and name', function (?string $stored, string $mode, ?string $name) { + $this->application->settings->update(['traefik_certificate_resolver' => $stored]); + + advancedCertificateResolverComponent($this->application, $this->owner, $this->team) + ->assertSet('certificateResolverMode', $mode) + ->assertSet('certificateResolverName', $name); +})->with([ + 'server setting' => [null, 'server', null], + 'none' => ['none', 'none', null], + 'custom' => ['cloudflare', 'custom', 'cloudflare'], +]); + +test('saving a custom resolver regenerates the managed labels', function () { + advancedCertificateResolverComponent($this->application, $this->owner, $this->team) + ->set('certificateResolverMode', 'custom') + ->set('certificateResolverName', 'cloudflare') + ->call('saveCertificateResolver') + ->assertHasNoErrors(); + + $labels = base64_decode($this->application->fresh()->custom_labels); + + expect($labels)->toContain('.tls.certresolver=cloudflare') + ->and($labels)->not->toContain('certresolver=letsencrypt'); +}); + +test('choosing custom without a name waits for the name instead of saving', function () { + $this->application->settings->update(['traefik_certificate_resolver' => 'none']); + + advancedCertificateResolverComponent($this->application, $this->owner, $this->team) + ->set('certificateResolverMode', 'custom') + ->call('saveCertificateResolverMode') + ->assertHasNoErrors() + ->assertNotDispatched('success'); + + expect($this->application->settings()->first()->traefik_certificate_resolver)->toBe('none'); +}); + +test('invalid custom resolver names are rejected and nothing is stored', function (?string $name, string $rule) { + $this->application->settings->update(['traefik_certificate_resolver' => 'letsencrypt']); + + advancedCertificateResolverComponent($this->application, $this->owner, $this->team) + ->set('certificateResolverMode', 'custom') + ->set('certificateResolverName', $name) + ->call('saveCertificateResolver') + ->assertHasErrors(['certificateResolverName' => [$rule]]); + + expect($this->application->settings()->first()->traefik_certificate_resolver)->toBe('letsencrypt'); +})->with([ + 'label injection' => ['a.b=c', 'regex'], + 'reserved none' => ['none', 'not_in'], + 'missing name' => [null, 'required'], +]); + +test('a team member without update permission cannot change the resolver', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $otherTeam = Team::factory()->create(); + $otherTeam->members()->attach($member->id, ['role' => 'owner']); + + $component = advancedCertificateResolverComponent($this->application, $member, $this->team); + session(['currentTeam' => $otherTeam]); + + $component->set('certificateResolverMode', 'none') + ->call('saveCertificateResolver') + ->assertDispatched('error') + ->assertNotDispatched('success'); + + expect($this->application->settings()->first()->traefik_certificate_resolver)->toBeNull(); +}); diff --git a/tests/Feature/Proxy/ServiceTraefikCertificateResolverTest.php b/tests/Feature/Proxy/ServiceTraefikCertificateResolverTest.php new file mode 100644 index 0000000000..27587fc744 --- /dev/null +++ b/tests/Feature/Proxy/ServiceTraefikCertificateResolverTest.php @@ -0,0 +1,70 @@ +create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'proxy' => ['type' => ProxyTypes::TRAEFIK->value], + ]); + $server->settings->update(['traefik_certificate_resolver' => 'letsencrypt']); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $service = Service::factory()->create([ + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'docker_compose_raw' => <<<'YAML' +services: + app: + image: nginx:latest +YAML, + ]); + + ServiceApplication::create([ + 'name' => 'app', + 'service_id' => $service->id, + 'fqdn' => 'https://example.com', + 'traefik_certificate_resolver' => $resourceResolver, + ]); + + return collect(data_get(serviceParser($service->fresh()), 'services.app.labels'))->values(); +} + +it('uses the server certificate resolver when the service application has no override', function () { + $labels = serviceTraefikCertificateResolverLabels(null); + + expect($labels->filter(fn ($label) => str_contains($label, '.tls.certresolver=letsencrypt')))->not->toBeEmpty(); +}); + +it('omits the certificate resolver when the service application uses none', function () { + $labels = serviceTraefikCertificateResolverLabels('none'); + + expect($labels->filter(fn ($label) => str_contains($label, '.tls=true')))->not->toBeEmpty() + ->and($labels->filter(fn ($label) => str_contains($label, 'certresolver')))->toBeEmpty(); +}); + +it('uses the custom certificate resolver of the service application', function () { + $labels = serviceTraefikCertificateResolverLabels('cloudflare'); + + expect($labels->filter(fn ($label) => str_contains($label, '.tls.certresolver=cloudflare')))->not->toBeEmpty() + ->and($labels->filter(fn ($label) => str_contains($label, 'certresolver=letsencrypt')))->toBeEmpty(); +}); diff --git a/tests/Feature/Proxy/TraefikCertificateResolverTest.php b/tests/Feature/Proxy/TraefikCertificateResolverTest.php index 340cb81e35..9b698f4d78 100644 --- a/tests/Feature/Proxy/TraefikCertificateResolverTest.php +++ b/tests/Feature/Proxy/TraefikCertificateResolverTest.php @@ -149,3 +149,43 @@ test('proxy page saves an empty resolver as none and rejects unsafe names', func expect($this->server->settings->fresh()->traefik_certificate_resolver)->toBeNull(); }); + +function traefikCertificateResolverApplicationOverrideLabels(Server $server, ?string $applicationResolver): 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, + ]); + $application->settings->update(['traefik_certificate_resolver' => $applicationResolver]); + + return generateLabelsApplication($application->fresh()); +} + +test('an application resolver overrides the server resolver on generated HTTPS routers', function (?string $applicationResolver, ?string $expectedResolver) { + $this->server->settings->update(['traefik_certificate_resolver' => 'letsencrypt']); + + $labels = collect(traefikCertificateResolverApplicationOverrideLabels($this->server->fresh(), $applicationResolver)); + $resolverLabels = $labels->filter(fn ($label) => str_contains($label, 'certresolver')); + + expect($labels->filter(fn ($label) => str_contains($label, '.tls=true')))->not->toBeEmpty(); + + if ($expectedResolver === null) { + expect($resolverLabels)->toBeEmpty(); + + return; + } + + expect($resolverLabels)->not->toBeEmpty() + ->and($resolverLabels->every(fn ($label) => str_ends_with($label, '.tls.certresolver='.$expectedResolver)))->toBeTrue(); +})->with([ + 'none turns the resolver off' => ['none', null], + 'custom resolver' => ['cloudflare', 'cloudflare'], + 'null uses the server resolver' => [null, 'letsencrypt'], +]); diff --git a/tests/Feature/Service/ServiceCertificateResolverSettingsTest.php b/tests/Feature/Service/ServiceCertificateResolverSettingsTest.php new file mode 100644 index 0000000000..7bfc335df7 --- /dev/null +++ b/tests/Feature/Service/ServiceCertificateResolverSettingsTest.php @@ -0,0 +1,119 @@ +withoutVite(); + InstanceSettings::forceCreate(['id' => 0]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'proxy' => ['type' => ProxyTypes::TRAEFIK->value], + ]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $service = Service::factory()->create([ + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", + ]); + $this->serviceApplication = ServiceApplication::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'web', + 'service_id' => $service->id, + 'image' => 'nginx:alpine', + ]); +}); + +it('stores the certificate resolver for each mode', function (string $mode, ?string $name, ?string $expected) { + Livewire::test(Index::class, ['serviceApplication' => $this->serviceApplication->fresh()]) + ->set('certificateResolverMode', $mode) + ->set('certificateResolverName', $name) + ->call('submitApplication') + ->assertHasNoErrors() + ->assertNotDispatched('error'); + + expect($this->serviceApplication->fresh()->traefik_certificate_resolver)->toBe($expected); + + Livewire::test(Index::class, ['serviceApplication' => $this->serviceApplication->fresh()]) + ->assertSet('certificateResolverMode', $mode) + ->assertSet('certificateResolverName', $mode === 'custom' ? $name : null); +})->with([ + 'server setting' => ['server', null, null], + 'no resolver' => ['none', null, 'none'], + 'custom resolver' => ['custom', 'cloudflare', 'cloudflare'], +]); + +it('switches back to the server setting', function () { + $this->serviceApplication->update(['traefik_certificate_resolver' => 'cloudflare']); + + Livewire::test(Index::class, ['serviceApplication' => $this->serviceApplication->fresh()]) + ->assertSet('certificateResolverMode', 'custom') + ->set('certificateResolverMode', 'server') + ->call('submitApplication') + ->assertNotDispatched('error'); + + expect($this->serviceApplication->fresh()->traefik_certificate_resolver)->toBeNull(); +}); + +it('rejects an invalid custom resolver name', function (?string $name) { + $this->serviceApplication->update(['traefik_certificate_resolver' => 'letsencrypt']); + + Livewire::test(Index::class, ['serviceApplication' => $this->serviceApplication->fresh()]) + ->set('certificateResolverMode', 'custom') + ->set('certificateResolverName', $name) + ->call('submitApplication') + ->assertDispatched('error'); + + expect($this->serviceApplication->fresh()->traefik_certificate_resolver)->toBe('letsencrypt'); +})->with([ + 'invalid characters' => ['a.b=c'], + 'reserved none' => ['none'], + 'empty' => [null], +]); + +it('rejects an unknown certificate resolver mode', function () { + Livewire::test(Index::class, ['serviceApplication' => $this->serviceApplication->fresh()]) + ->set('certificateResolverMode', 'letsencrypt') + ->call('submitApplication') + ->assertDispatched('error'); + + expect($this->serviceApplication->fresh()->traefik_certificate_resolver)->toBeNull(); +}); + +it('does not let a team member change the certificate resolver', function () { + $this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']); + + Livewire::test(Index::class, ['serviceApplication' => $this->serviceApplication->fresh()]) + ->set('certificateResolverMode', 'none') + ->call('submitApplication') + ->assertDispatched('error'); + + expect($this->serviceApplication->fresh()->traefik_certificate_resolver)->toBeNull(); +}); diff --git a/tests/Feature/ServiceApplicationsApiTest.php b/tests/Feature/ServiceApplicationsApiTest.php index 58f7ea0092..59f3414376 100644 --- a/tests/Feature/ServiceApplicationsApiTest.php +++ b/tests/Feature/ServiceApplicationsApiTest.php @@ -290,6 +290,38 @@ describe('PATCH /api/v1/services/{uuid}/applications/{app_uuid}', function () { 'text' => 'unlimited', ]); + test('updates the traefik certificate resolver', function (?string $initial, ?string $value) { + $ctx = createServiceWithApplicationForApiTest($this); + $ctx->serviceApplication->update(['traefik_certificate_resolver' => $initial]); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [ + 'traefik_certificate_resolver' => $value, + ])->assertSuccessful() + ->assertJsonPath('traefik_certificate_resolver', $value); + + expect($ctx->serviceApplication->fresh()->traefik_certificate_resolver)->toBe($value); + })->with([ + 'custom resolver' => [null, 'cloudflare'], + 'no resolver' => [null, 'none'], + 'server setting' => ['cloudflare', null], + ]); + + test('rejects an invalid traefik certificate resolver', function () { + $ctx = createServiceWithApplicationForApiTest($this); + $ctx->serviceApplication->update(['traefik_certificate_resolver' => 'cloudflare']); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [ + 'traefik_certificate_resolver' => 'a.b=c', + ])->assertUnprocessable() + ->assertJsonValidationErrors('traefik_certificate_resolver'); + + expect($ctx->serviceApplication->fresh()->traefik_certificate_resolver)->toBe('cloudflare'); + }); + test('returns 422 for invalid url scheme', function () { $ctx = createServiceWithApplicationForApiTest($this); diff --git a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php index d5f1059768..b2e009899d 100644 --- a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php +++ b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php @@ -1013,3 +1013,41 @@ test('export warns about secret manager links without exporting the integration expect($result['warnings'])->toContain($warning); }); + +test('transfer keeps the Traefik certificate resolver of applications and service applications', function () { + $this->application->settings->update(['traefik_certificate_resolver' => 'none']); + $serviceApp = new ServiceApplication; + $serviceApp->forceFill([ + 'service_id' => $this->service->id, + 'name' => 'whoami', + 'fqdn' => 'https://whoami.example.com', + 'image' => 'traefik/whoami:latest', + 'traefik_certificate_resolver' => 'cloudflare', + 'status' => 'running:healthy', + ]); + $serviceApp->uuid = 'svc-app-resolver'; + $serviceApp->save(); + $originalAppUuid = $this->application->uuid; + + $bundle = $this->exporter->export($this->server); + + ServiceApplication::withoutEvents(function () { + ServiceApplication::withTrashed()->get()->each->forceDelete(); + }); + DB::table('environment_variables')->delete(); + DB::table('local_persistent_volumes')->delete(); + DB::table('scheduled_database_backups')->delete(); + DB::table('scheduled_tasks')->delete(); + DB::table('services')->delete(); + DB::table('applications')->delete(); + DB::table('standalone_postgresqls')->delete(); + DB::table('standalone_dockers')->delete(); + DB::table('servers')->delete(); + $this->privateKey->delete(); + Tag::query()->delete(); + + $this->importer->import($bundle, teamId: $this->team->id, preserveUuids: true, adoptMode: true); + + expect(Application::where('uuid', $originalAppUuid)->firstOrFail()->settings->traefik_certificate_resolver)->toBe('none') + ->and(ServiceApplication::where('uuid', 'svc-app-resolver')->firstOrFail()->traefik_certificate_resolver)->toBe('cloudflare'); +});