fix(proxy): route maintenance mode through custom container labels

Closes #12498

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-11-09 14:53:10 +01:00
co-authored by Claude Opus 5.5
parent 957c01fcc8
commit c93b765975
5 changed files with 234 additions and 22 deletions
@@ -0,0 +1,6 @@
---
type: fixed
---
Ignored custom container labels and wildcard domains in maintenance mode ([#12498](https://github.com/coollabsio/coolify/issues/12498))
- With editable container labels, maintenance mode uses the Traefik router rules and the Caddy site addresses of the labels
+16
View File
@@ -640,6 +640,22 @@ class Application extends BaseModel
->values();
}
/**
* The custom container labels, when the user edits them. Docker Compose applications
* take their labels from the compose file, so their domain routes apply.
*
* @return Collection<int, string>|null
*/
public function maintenanceProxyLabels(): ?Collection
{
if ($this->build_pack === 'dockercompose' || $this->settings?->is_container_label_readonly_enabled !== false || blank($this->custom_labels)) {
return null;
}
$labels = decodeBase64EncodedLabels($this->custom_labels) ?? $this->custom_labels;
return collect(preg_split("/\r\n|\n|\r/", $labels));
}
/**
* @return Collection<int, Server>
*/
+14 -22
View File
@@ -1019,9 +1019,9 @@ class Server extends BaseModel
}
/**
* Traefik routers that use the same rule as the resource routers, with a priority one
* higher. Traefik uses the rule length as the default priority, so a resource on a longer
* path of the same host keeps its own router.
* Traefik routers that use the same rule as the resource routers (from the domains, or
* from custom labels), with a priority one higher. Traefik uses the rule length as the
* default priority, so a resource on a longer path of the same host keeps its own router.
*
* @param Collection<int, Application|Service> $resources
*/
@@ -1030,7 +1030,7 @@ class Server extends BaseModel
$routers = [];
$middlewares = [];
foreach ($resources as $resource) {
$routes = $resource->maintenanceRoutes();
$routes = $resource->maintenanceTraefikRouters();
if ($routes->isEmpty()) {
continue;
}
@@ -1042,22 +1042,15 @@ class Server extends BaseModel
'query' => '/'.$resource->maintenancePageFile(),
],
];
foreach ($routes as $index => $route) {
$rule = "Host(`{$route['host']}`) && PathPrefix(`{$route['path']}`)";
$router = [
'entryPoints' => ['http'],
'rule' => $rule,
foreach ($routes as $route) {
$routers["coolify-maintenance-{$resource->uuid}-{$route['name']}"] = array_filter([
'entryPoints' => $route['entryPoints'],
'rule' => $route['rule'],
'service' => 'coolify-maintenance-noop',
'middlewares' => ['coolify-maintenance-headers', $middleware],
'priority' => strlen($rule) + 1,
];
$routers["coolify-maintenance-{$resource->uuid}-{$index}-http"] = $router;
if ($route['scheme'] === 'https') {
$routers["coolify-maintenance-{$resource->uuid}-{$index}-https"] = array_merge($router, [
'entryPoints' => ['https'],
'tls' => ['certResolver' => 'letsencrypt'],
]);
}
'priority' => $route['priority'],
'tls' => $route['tls'],
], fn ($value) => $value !== null);
}
}
@@ -1096,11 +1089,10 @@ class Server extends BaseModel
$resources ??= $this->maintenanceResources();
$sites = [];
foreach ($resources as $resource) {
foreach ($resource->maintenanceRoutes() as $index => $route) {
$address = caddySiteAddress($route['scheme'], $route['host'], $route['force_https']);
foreach ($resource->maintenanceCaddySites() as $index => $site) {
$matcher = "@coolify-maintenance-{$resource->uuid}-{$index}";
$sites[$address][] = implode("\n", [
"\t{$matcher} path {$route['path']}*",
$sites[$site['address']][] = implode("\n", [
"\t{$matcher} path {$site['path']}",
"\thandle {$matcher} {",
"\t\treverse_proxy ".self::PROXY_ERROR_PAGE_CONTAINER.':80 {',
"\t\t\trewrite /{$resource->maintenancePageFile()}",
+137
View File
@@ -58,6 +58,143 @@ trait HasMaintenancePage
->values();
}
/**
* Container labels that the proxy routes the resource with, when the user edits them.
* Null when the labels come from the domains, so the domain routes apply.
*
* @return Collection<int, string>|null
*/
public function maintenanceProxyLabels(): ?Collection
{
return null;
}
/**
* Traefik routers that show the maintenance page. With custom labels, each label router
* gets a maintenance router with the same rule, so wildcard and custom rules also match.
*
* @return Collection<int, array{name: string, rule: string, entryPoints: array<int, string>|null, tls: array<string, mixed>|null, priority: int}>
*/
public function maintenanceTraefikRouters(): Collection
{
$labels = $this->maintenanceProxyLabels();
if ($labels !== null) {
return $this->maintenanceTraefikRoutersFromLabels($labels);
}
return $this->maintenanceRoutes()->flatMap(function (array $route, int $index): array {
$rule = "Host(`{$route['host']}`) && PathPrefix(`{$route['path']}`)";
$router = ['name' => "{$index}-http", 'rule' => $rule, 'entryPoints' => ['http'], 'tls' => null, 'priority' => strlen($rule) + 1];
if ($route['scheme'] !== 'https') {
return [$router];
}
return [$router, array_merge($router, ['name' => "{$index}-https", 'entryPoints' => ['https'], 'tls' => ['certResolver' => 'letsencrypt']])];
})->values();
}
/**
* Caddy site addresses and path matchers that show the maintenance page. A site address
* must be the same as in the labels, so caddy-docker-proxy merges both into one site block.
*
* @return Collection<int, array{address: string, path: string}>
*/
public function maintenanceCaddySites(): Collection
{
$labels = $this->maintenanceProxyLabels();
if ($labels !== null) {
return $this->maintenanceCaddySitesFromLabels($labels);
}
return $this->maintenanceRoutes()
->map(fn (array $route) => [
'address' => caddySiteAddress($route['scheme'], $route['host'], $route['force_https']),
'path' => "{$route['path']}*",
])
->values();
}
/**
* @param Collection<int, string> $labels
* @return Collection<int, array{name: string, rule: string, entryPoints: array<int, string>|null, tls: array<string, mixed>|null, priority: int}>
*/
protected function maintenanceTraefikRoutersFromLabels(Collection $labels): Collection
{
$values = $this->maintenanceLabelValues($labels);
if (strtolower($values['traefik.enable'] ?? 'true') === 'false') {
return collect();
}
$routers = [];
foreach ($values as $key => $value) {
if (preg_match('/^traefik\.http\.routers\.([A-Za-z0-9_-]+)\.(.+)$/', $key, $matches)) {
$routers[$matches[1]][$matches[2]] = $value;
}
}
return collect($routers)
->filter(fn (array $router) => filled($router['rule'] ?? null))
->map(function (array $router, string $name): array {
$options = array_change_key_case($router);
$tls = null;
if (collect($options)->keys()->contains(fn (string $key) => str_starts_with($key, 'tls.'))
|| strtolower($options['tls'] ?? '') === 'true') {
$tls = filled($options['tls.certresolver'] ?? null)
? ['certResolver' => $options['tls.certresolver']]
: ['options' => $options['tls.options'] ?? 'default'];
}
$entryPoints = filled($options['entrypoints'] ?? null)
? array_values(array_filter(array_map('trim', explode(',', $options['entrypoints']))))
: null;
$priority = is_numeric($options['priority'] ?? null) ? (int) $options['priority'] : strlen($router['rule']);
return ['name' => $name, 'rule' => $router['rule'], 'entryPoints' => $entryPoints, 'tls' => $tls, 'priority' => $priority + 1];
})
->values();
}
/**
* @param Collection<int, string> $labels
* @return Collection<int, array{address: string, path: string}>
*/
protected function maintenanceCaddySitesFromLabels(Collection $labels): Collection
{
$values = $this->maintenanceLabelValues($labels);
return collect($values)
->filter(fn (string $value, string $key) => preg_match('/^caddy(_\d+)?$/', $key) === 1)
->map(function (string $address, string $key) use ($values): ?array {
$address = trim($address);
$path = trim($values["{$key}.handle_path"] ?? $values["{$key}.handle"] ?? '') ?: '/*';
// The values go into the Caddyfile as plain text.
if (! preg_match('#^[A-Za-z0-9.*:/_\[\], -]+$#', $address) || ! preg_match('#^/[A-Za-z0-9/._~%*-]*$#', $path)) {
return null;
}
return ['address' => $address, 'path' => $path];
})
->filter()
->values();
}
/**
* @param Collection<int, string> $labels
* @return array<string, string>
*/
private function maintenanceLabelValues(Collection $labels): array
{
$values = [];
foreach ($labels as $label) {
$label = trim((string) $label);
if (str_contains($label, '=') && ! str_starts_with($label, '#')) {
[$key, $value] = explode('=', $label, 2);
$values[trim($key)] = $value;
}
}
return $values;
}
/**
* The custom maintenance page HTML, or the Coolify default page.
*/
@@ -302,3 +302,64 @@ it('does not let a member or another team change maintenance mode', function (st
->and($service->maintenance_page)->toBeNull();
Process::assertNothingRan();
})->with(['member', 'other team']);
it('routes the Traefik routers of custom labels, so wildcard rules also get the maintenance page', function () {
$wildcardRule = 'HostRegexp(`^.+\.example\.com$`)';
$application = maintenanceApplication(['fqdn' => null, 'is_maintenance_enabled' => true, 'custom_labels' => base64_encode(implode("\n", [
'traefik.enable=true',
'traefik.http.routers.wildcard-http.entryPoints=http',
"traefik.http.routers.wildcard-http.rule={$wildcardRule}",
'traefik.http.routers.wildcard-https.entrypoints=https',
"traefik.http.routers.wildcard-https.rule={$wildcardRule}",
'traefik.http.routers.wildcard-https.priority=500',
'traefik.http.routers.wildcard-https.tls=true',
'traefik.http.routers.wildcard-https.tls.certresolver=dns',
'traefik.http.routers.internal.tls=true',
]))]);
$application->settings->update(['is_container_label_readonly_enabled' => false]);
$application->refresh();
$routers = Yaml::parse(maintenanceServer()->maintenanceTraefikConfiguration(collect([$application])))['http']['routers'];
expect($routers)->toHaveCount(2)
->and($routers["coolify-maintenance-{$application->uuid}-wildcard-http"])->toBe([
'entryPoints' => ['http'],
'rule' => $wildcardRule,
'service' => 'coolify-maintenance-noop',
'middlewares' => ['coolify-maintenance-headers', "coolify-maintenance-{$application->uuid}"],
'priority' => strlen($wildcardRule) + 1,
])
->and($routers["coolify-maintenance-{$application->uuid}-wildcard-https"])->toMatchArray([
'entryPoints' => ['https'],
'priority' => 501,
'tls' => ['certResolver' => 'dns'],
]);
});
it('writes Caddy maintenance blocks with the site addresses of custom labels', function () {
$this->server->proxy->type = ProxyTypes::CADDY->value;
$this->server->save();
$application = maintenanceApplication(['fqdn' => null, 'is_maintenance_enabled' => true, 'custom_labels' => base64_encode(implode("\n", [
'caddy_0=https://*.example.com',
'caddy_0.handle_path.0_reverse_proxy={{upstreams 3000}}',
'caddy_0.handle_path=/*',
'caddy_1=https://evil.example.com {',
]))]);
$application->settings->update(['is_container_label_readonly_enabled' => false]);
$application->refresh();
$caddyfile = maintenanceServer()->caddyBaseCaddyfile(collect([$application]));
expect($caddyfile)
->toContain("\n\nhttps://*.example.com {\n\t@coolify-maintenance-{$application->uuid}-0 path /*\n\thandle @coolify-maintenance-{$application->uuid}-0 {")
->not->toContain('evil.example.com');
});
it('uses the domains when the container labels are read-only', function () {
$application = maintenanceApplication(['is_maintenance_enabled' => true, 'custom_labels' => base64_encode('traefik.http.routers.custom.rule=Host(`other.example.com`)')]);
$routers = Yaml::parse(maintenanceServer()->maintenanceTraefikConfiguration(collect([$application])))['http']['routers'];
expect($routers)->toHaveKeys(["coolify-maintenance-{$application->uuid}-0-http", "coolify-maintenance-{$application->uuid}-0-https"])
->and(collect($routers)->pluck('rule')->unique()->all())->toBe(['Host(`app.example.com`) && PathPrefix(`/`)']);
});