mirror of
https://github.com/coollabsio/coolify.git
synced 2026-03-11 08:55:47 +00:00
warn when no master router and make edge traefik routing configurable/safe
This commit is contained in:
parent
ef727c14f4
commit
8fc7bc7fa3
4 changed files with 277 additions and 8 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue