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
This commit is contained in:
Andras Bacsai
2026-10-11 19:01:27 +02:00
parent 90c2e58e52
commit 1e42284db0
27 changed files with 821 additions and 18 deletions
@@ -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);
}
@@ -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.'],
@@ -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',
+86 -1
View File
@@ -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<int, string> $changedFields */
+44 -1
View File
@@ -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,
]);
}
/**
+2
View File
@@ -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',
+4 -3
View File
@@ -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
+5
View File
@@ -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;
+1
View File
@@ -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',
];
@@ -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),
@@ -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())
: [],
+3
View File
@@ -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');
+4 -4
View File
@@ -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,
+4 -4
View File
@@ -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,
+4 -4
View File
@@ -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(
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Per-resource Traefik certificate resolver: null uses the server setting, "none" turns it off.
*/
public function up(): void
{
Schema::table('application_settings', function (Blueprint $table) {
$table->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');
});
}
};
+42
View File
@@ -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"
},
+31
View File
@@ -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:
@@ -138,6 +138,25 @@
['value' => true, 'label' => 'Strip prefixes'],
['value' => false, 'label' => 'Keep paths as-is'],
]" :disabled="! $canUpdate" />
@if ($isTraefikProxy)
<x-forms.listbox id="certificateResolverMode" label="Certificate resolver"
onChange="saveCertificateResolverMode"
helper="Traefik certificate resolver for this application's HTTPS routes. Choose <span class='font-bold'>None</span> for domains that use certificates from Traefik's dynamic configuration (for example Cloudflare Origin CA certificates). Redeploy the application to apply the change."
:options="[
['value' => 'server', 'label' => 'Server setting ('.$serverCertificateResolver.')'],
['value' => 'none', 'label' => 'None (use your own certificates)'],
['value' => 'custom', 'label' => 'Custom resolver'],
]" :disabled="! $canUpdate" />
@if ($certificateResolverMode === 'custom')
<form wire:submit="saveCertificateResolver" class="w-full">
<x-unsaved-bar action="saveCertificateResolver" targets="certificateResolverName" />
<x-forms.input id="certificateResolverName" label="Resolver name" placeholder="cloudflare"
required
helper="Name of a certificate resolver defined in your Traefik configuration. Letters, numbers, hyphens, and underscores only."
canGate="update" :canResource="$application" />
</form>
@endif
@endif
</div>
@else
<x-empty size="sm" title="Proxy behavior is managed through labels"
@@ -32,6 +32,20 @@
<x-forms.input type="number" min="0" id="maxRestartCount" label="Max restart count"
helper="Maximum number of Docker restarts before Coolify stops this container. Set to 0 to disable the limit. Docker counts expected and unexpected restarts."
canGate="update" :canResource="$serviceApplication" />
@if ($showCertificateResolver)
<x-forms.listbox id="certificateResolverMode" label="Certificate resolver" live
helper="Sets the Traefik certificate resolver for this service's HTTPS routes. Choose None for domains that use certificates from Traefik's dynamic configuration (for example Cloudflare Origin CA certificates). Restart or redeploy the service to apply the change."
canGate="update" :canResource="$serviceApplication" :options="[
['value' => 'server', 'label' => 'Server setting ('.$serverCertificateResolver.')'],
['value' => 'none', 'label' => 'None (use your own certificates)'],
['value' => 'custom', 'label' => 'Custom resolver'],
]" />
@if ($certificateResolverMode === 'custom')
<x-forms.input id="certificateResolverName" label="Certificate resolver name" placeholder="cloudflare"
required helper="Name of a certificate resolver defined in the Traefik configuration of this server."
canGate="update" :canResource="$serviceApplication" />
@endif
@endif
@else
<x-forms.listbox id="excludeFromStatus" label="Service status"
:options="[
@@ -607,3 +607,71 @@ test('PATCH /api/v1/applications/{uuid} rejects a non-string container name pref
expect($this->application->fresh()->settings->custom_container_name_prefix)->toBeNull();
});
test('PATCH /api/v1/applications/{uuid} sets the Traefik certificate resolver', function (?string $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])
->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();
});
@@ -0,0 +1,137 @@
<?php
use App\Enums\ProxyTypes;
use App\Livewire\Project\Application\Advanced;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
InstanceSettings::forceCreate(['id' => 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();
});
@@ -0,0 +1,70 @@
<?php
use App\Enums\ProxyTypes;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Bus;
uses(RefreshDatabase::class);
function serviceTraefikCertificateResolverLabels(?string $resourceResolver): Collection
{
Bus::fake();
$team = Team::factory()->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();
});
@@ -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'],
]);
@@ -0,0 +1,119 @@
<?php
use App\Enums\ProxyTypes;
use App\Livewire\Project\Service\Index;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Bus::fake();
$this->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();
});
@@ -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);
@@ -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');
});