diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index b21b3daed..e76faa20b 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -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) diff --git a/app/Services/EdgeProxyRemoteRouteService.php b/app/Services/EdgeProxyRemoteRouteService.php index a92425696..4b08b16e0 100644 --- a/app/Services/EdgeProxyRemoteRouteService.php +++ b/app/Services/EdgeProxyRemoteRouteService.php @@ -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); diff --git a/config/constants.php b/config/constants.php index be41c4618..a79faa64b 100644 --- a/config/constants.php +++ b/config/constants.php @@ -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' => [ diff --git a/tests/Unit/EdgeProxyRemoteRouteServiceTest.php b/tests/Unit/EdgeProxyRemoteRouteServiceTest.php index 575188da3..f2570e42a 100644 --- a/tests/Unit/EdgeProxyRemoteRouteServiceTest.php +++ b/tests/Unit/EdgeProxyRemoteRouteServiceTest.php @@ -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 {