diff --git a/.changes/ignored-custom-container-labels-and-wildcard.md b/.changes/ignored-custom-container-labels-and-wildcard.md new file mode 100644 index 0000000000..ae8b8fb8a7 --- /dev/null +++ b/.changes/ignored-custom-container-labels-and-wildcard.md @@ -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 diff --git a/app/Models/Application.php b/app/Models/Application.php index d2a498e730..a775819fe2 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -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|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 */ diff --git a/app/Models/Server.php b/app/Models/Server.php index 7996df3a25..e053edc830 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -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 $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()}", diff --git a/app/Traits/HasMaintenancePage.php b/app/Traits/HasMaintenancePage.php index ed5b9d3827..be7cacb055 100644 --- a/app/Traits/HasMaintenancePage.php +++ b/app/Traits/HasMaintenancePage.php @@ -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|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|null, tls: array|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 + */ + 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 $labels + * @return Collection|null, tls: array|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 $labels + * @return Collection + */ + 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 $labels + * @return array + */ + 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. */ diff --git a/tests/Feature/Proxy/MaintenanceModeTest.php b/tests/Feature/Proxy/MaintenanceModeTest.php index fb2eb30faa..d6e005ffb8 100644 --- a/tests/Feature/Proxy/MaintenanceModeTest.php +++ b/tests/Feature/Proxy/MaintenanceModeTest.php @@ -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(`/`)']); +});