mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-12 15:53:46 -04:00
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:
co-authored by
Claude Opus 5.5
parent
957c01fcc8
commit
c93b765975
@@ -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
|
||||
@@ -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
@@ -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()}",
|
||||
|
||||
@@ -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(`/`)']);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user