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'); +});