warn when no master router and make edge traefik routing configurable/safe

This commit is contained in:
Iisyourdad 2026-03-05 13:19:50 -06:00
parent ef727c14f4
commit 8fc7bc7fa3
4 changed files with 277 additions and 8 deletions

View file

@ -510,6 +510,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
\Log::warning('Failed to mark configuration as changed for deployment '.$this->deployment_uuid.': '.$e->getMessage());
}
// Intentionally skip preview deployments here; preview routing has separate lifecycle/hostnames.
if ($this->pull_request_id === 0) {
try {
$edgeRoutingWarnings = app(EdgeProxyRemoteRouteService::class)

View file

@ -22,14 +22,21 @@ class EdgeProxyRemoteRouteService
public function syncService(Service $service): array
{
$teamId = $this->extractServiceTeamId($service);
$edgeProxyServer = $this->resolveEdgeProxyServerByTeamId($teamId);
$deploymentServer = $this->resolveDeploymentServer($service);
if (! $deploymentServer instanceof Server) {
return [];
}
$edgeProxyServer = $this->resolveEdgeProxyServerByTeamId($teamId);
if (! $edgeProxyServer instanceof Server) {
$warning = $this->missingMasterDomainRouterWarning('service', $service->uuid, $teamId);
if (! is_null($warning)) {
$this->logWarning($warning);
return [$warning];
}
return [];
}
@ -109,6 +116,20 @@ class EdgeProxyRemoteRouteService
continue;
}
if (
$this->hasUnsafeTraefikRuleValue($url->getHost()) ||
$this->hasUnsafeTraefikRuleValue($url->getPath())
) {
$warnings[] = sprintf(
'Edge proxy route skipped for service %s (%s, domain %s): domain contains unsupported characters for Traefik host/path rules.',
$service->uuid,
$application->name,
$domain
);
continue;
}
$requestedInternalPort = $url->getPort() ?? $application->getRequiredPort();
$publishedPort = $this->resolvePublishedPort($compose, $application->name, $requestedInternalPort, $environmentMap);
@ -162,10 +183,21 @@ class EdgeProxyRemoteRouteService
public function syncApplication(Application $application): array
{
$teamId = $this->extractApplicationTeamId($application);
$edgeProxyServer = $this->resolveEdgeProxyServerByTeamId($teamId);
$deploymentServer = $this->resolveApplicationDeploymentServer($application);
if (! $deploymentServer instanceof Server || ! $edgeProxyServer instanceof Server) {
if (! $deploymentServer instanceof Server) {
return [];
}
$edgeProxyServer = $this->resolveEdgeProxyServerByTeamId($teamId);
if (! $edgeProxyServer instanceof Server) {
$warning = $this->missingMasterDomainRouterWarning('application', $application->uuid, $teamId);
if (! is_null($warning)) {
$this->logWarning($warning);
return [$warning];
}
return [];
}
@ -177,6 +209,13 @@ class EdgeProxyRemoteRouteService
$teamId = $this->extractApplicationTeamId($application);
$edgeProxyServer = $this->resolveEdgeProxyServerByTeamId($teamId);
if (! $edgeProxyServer instanceof Server) {
$warning = $this->missingMasterDomainRouterWarning('application', $application->uuid, $teamId);
if (! is_null($warning)) {
$this->logWarning($warning);
return [$warning];
}
return [];
}
@ -252,6 +291,19 @@ class EdgeProxyRemoteRouteService
continue;
}
if (
$this->hasUnsafeTraefikRuleValue($url->getHost()) ||
$this->hasUnsafeTraefikRuleValue($url->getPath())
) {
$warnings[] = sprintf(
'Edge proxy route skipped for application %s (domain %s): domain contains unsupported characters for Traefik host/path rules.',
$application->uuid,
$domain
);
continue;
}
$requestedInternalPort = $url->getPort();
$publishedPort = $this->resolvePublishedPortForApplication(
$application,
@ -366,20 +418,23 @@ class EdgeProxyRemoteRouteService
$httpsRouterName = "edge-{$serviceKey}-https-{$suffix}";
$serviceName = "edge-{$serviceKey}-svc-{$suffix}";
$rule = $this->buildTraefikRule($route['host'], $route['path']);
if (is_null($rule)) {
continue;
}
$config['http']['routers'][$httpRouterName] = [
'rule' => $rule,
'entryPoints' => ['http'],
'entryPoints' => [$this->httpEntryPointName()],
'middlewares' => [$redirectMiddlewareName],
'service' => $serviceName,
];
$config['http']['routers'][$httpsRouterName] = [
'rule' => $rule,
'entryPoints' => ['https'],
'entryPoints' => [$this->httpsEntryPointName()],
'service' => $serviceName,
'tls' => [
'certResolver' => 'letsencrypt',
'certResolver' => $this->certResolverName(),
],
];
@ -456,8 +511,12 @@ class EdgeProxyRemoteRouteService
], false);
}
private function buildTraefikRule(string $host, ?string $path): string
private function buildTraefikRule(string $host, ?string $path): ?string
{
if ($this->hasUnsafeTraefikRuleValue($host) || (! is_null($path) && $this->hasUnsafeTraefikRuleValue($path))) {
return null;
}
$rule = sprintf('Host(`%s`)', $host);
if (! is_null($path) && $path !== '' && $path !== '/') {
@ -467,7 +526,7 @@ class EdgeProxyRemoteRouteService
return $rule;
}
private function resolveEdgeProxyServerByTeamId(?int $teamId): ?Server
protected function resolveEdgeProxyServerByTeamId(?int $teamId): ?Server
{
if (is_null($teamId)) {
return null;
@ -480,6 +539,20 @@ class EdgeProxyRemoteRouteService
->first();
}
private function missingMasterDomainRouterWarning(string $resourceType, string $resourceUuid, ?int $teamId): ?string
{
if (is_null($teamId)) {
return null;
}
return sprintf(
'Edge proxy route skipped for %s %s: no master domain router is configured for team %d. Enable "Master Domain Router" on exactly one team server.',
$resourceType,
$resourceUuid,
$teamId
);
}
private function resolveDeploymentServer(Service $service): ?Server
{
$server = data_get($service, 'server');
@ -1123,6 +1196,38 @@ class EdgeProxyRemoteRouteService
error_log($message);
}
private function httpEntryPointName(): string
{
return $this->configString('constants.coolify.proxy.traefik.entrypoints.http', 'http');
}
private function httpsEntryPointName(): string
{
return $this->configString('constants.coolify.proxy.traefik.entrypoints.https', 'https');
}
private function certResolverName(): string
{
return $this->configString('constants.coolify.proxy.traefik.cert_resolver', 'letsencrypt');
}
private function configString(string $key, string $default): string
{
$container = Container::getInstance();
if (! ($container instanceof Container) || ! $container->bound('config')) {
return $default;
}
$value = trim((string) $container->make('config')->get($key, $default));
return $value !== '' ? $value : $default;
}
private function hasUnsafeTraefikRuleValue(string $value): bool
{
return str_contains($value, '`') || preg_match('/[\r\n]/', $value) === 1;
}
private function normalizeRemoteHost(string $rawHost): ?string
{
$host = trim($rawHost);

View file

@ -16,6 +16,15 @@ return [
'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'),
'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'),
'releases_url' => 'https://cdn.coolify.io/releases.json',
'proxy' => [
'traefik' => [
'entrypoints' => [
'http' => env('TRAEFIK_HTTP_ENTRYPOINT', 'http'),
'https' => env('TRAEFIK_HTTPS_ENTRYPOINT', 'https'),
],
'cert_resolver' => env('TRAEFIK_CERT_RESOLVER', 'letsencrypt'),
],
],
],
'urls' => [

View file

@ -5,6 +5,7 @@ use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Services\EdgeProxyRemoteRouteService;
use Illuminate\Config\Repository;
use Illuminate\Container\Container;
use Psr\Log\NullLogger;
@ -42,6 +43,100 @@ it('generates edge traefik config for a remote domain route', function () {
->and(data_get($config, 'http.services.edge-service-uuid-svc-1.loadBalancer.servers.0.url'))->toBe('http://10.8.0.15:9010');
});
it('uses configured traefik entrypoints and cert resolver for remote routes', function () {
$container = Container::getInstance();
$hadOriginalConfig = $container->bound('config');
$originalConfig = $hadOriginalConfig ? $container->make('config') : null;
$container->instance('config', new Repository([
'constants' => [
'coolify' => [
'proxy' => [
'traefik' => [
'entrypoints' => [
'http' => 'web',
'https' => 'websecure',
],
'cert_resolver' => 'myresolver',
],
],
],
],
]));
try {
$service = new EdgeProxyRemoteRouteService;
$config = $service->generateTraefikConfig('service-uuid', [[
'host' => 'demo.example.com',
'path' => '/',
'upstream_url' => 'http://10.8.0.15:9010',
]]);
expect(data_get($config, 'http.routers.edge-service-uuid-http-1.entryPoints'))->toBe(['web'])
->and(data_get($config, 'http.routers.edge-service-uuid-https-1.entryPoints'))->toBe(['websecure'])
->and(data_get($config, 'http.routers.edge-service-uuid-https-1.tls.certResolver'))->toBe('myresolver');
} finally {
if ($hadOriginalConfig && ! is_null($originalConfig)) {
$container->instance('config', $originalConfig);
} else {
unset($container['config']);
}
}
});
it('returns warning when syncing service route without a master domain router', function () {
$manager = new class extends EdgeProxyRemoteRouteService
{
protected function resolveEdgeProxyServerByTeamId(?int $teamId): ?Server
{
return null;
}
};
$deploymentServer = Mockery::mock(Server::class)->makePartial();
$deploymentServer->id = 10;
$service = new Service;
$service->uuid = 'service-no-master-router';
$service->setRelation('server', $deploymentServer);
$service->setRelation('environment', (object) [
'project' => (object) [
'team_id' => 42,
],
]);
$warnings = $manager->syncService($service);
expect($warnings)->toHaveCount(1)
->and($warnings[0])->toContain('no master domain router is configured for team 42');
});
it('returns warning when syncing application route without a master domain router', function () {
$manager = new class extends EdgeProxyRemoteRouteService
{
protected function resolveEdgeProxyServerByTeamId(?int $teamId): ?Server
{
return null;
}
};
$deploymentServer = Mockery::mock(Server::class)->makePartial();
$deploymentServer->id = 11;
$application = new Application;
$application->uuid = 'application-no-master-router';
$application->setRelation('environment', (object) [
'project' => (object) [
'team_id' => 52,
],
]);
$warnings = $manager->syncApplicationOnDeploymentServer($application, $deploymentServer);
expect($warnings)->toHaveCount(1)
->and($warnings[0])->toContain('no master domain router is configured for team 52');
});
it('creates, updates, and deletes a stable edge route file per service uuid', function () {
$manager = new class extends EdgeProxyRemoteRouteService
{
@ -292,6 +387,65 @@ it('returns actionable warning and does not write route file when application pu
->and(implode("\n", $manager->calls[0]['commands']))->not->toContain('tee');
});
it('keeps valid application edge routes when one domain port cannot be resolved and returns warning only for invalid domain', function () {
$manager = new class extends EdgeProxyRemoteRouteService
{
public array $calls = [];
protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string
{
$this->calls[] = [
'commands' => $commands,
'throw_error' => $throwError,
];
return null;
}
};
$edgeProxyServer = Mockery::mock(Server::class)->makePartial();
$edgeProxyServer->id = 0;
$edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK');
$edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy');
$deploymentServer = Mockery::mock(Server::class)->makePartial();
$deploymentServer->id = 33;
$deploymentServer->ip = '10.8.0.33';
$deploymentServer->proxy = ['type' => 'NONE'];
$application = new Application;
$application->uuid = 'application-partial-routes';
$application->build_pack = 'dockercompose';
$application->docker_compose_domains = json_encode([
'web' => ['domain' => 'https://good-app.example.com:3000,https://bad-app.example.com:9999'],
]);
$application->docker_compose_raw = <<<'YAML'
services:
web:
ports:
- "9060:3000"
- "9070:4000"
YAML;
$warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer);
expect($warnings)->not->toBeEmpty()
->and($warnings[0])->toContain('published host port could not be resolved')
->and($manager->calls)->toHaveCount(1);
$writeCommands = implode("\n", $manager->calls[0]['commands']);
expect($writeCommands)->toContain('/tmp/proxy/dynamic/application-remote-application-partial-routes.yaml')
->and($writeCommands)->toContain('tee')
->and($writeCommands)->not->toContain('rm -f');
preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches);
$payload = base64_decode($payloadMatches[1]);
expect($payload)->toContain('Host(`good-app.example.com`)')
->and($payload)->not->toContain('Host(`bad-app.example.com`)')
->and($payload)->toContain('http://10.8.0.33:9060');
});
it('does not generate edge route file when published port cannot be resolved and returns actionable warning', function () {
$manager = new class extends EdgeProxyRemoteRouteService
{