edge cases and just safer code

This commit is contained in:
Iisyourdad 2026-02-28 01:12:10 -06:00
parent 35a5a77526
commit 91ea235b5f
2 changed files with 325 additions and 8 deletions

View file

@ -86,8 +86,28 @@ class EdgeProxyRemoteRouteService
->filter();
foreach ($domains as $domain) {
$unsupportedProtocol = $this->detectUnsupportedDomainProtocol($domain);
if (! is_null($unsupportedProtocol)) {
$warnings[] = sprintf(
'Edge proxy route skipped for service %s (%s, domain %s): protocol "%s" is not supported for edge remote routing. Only http:// and https:// domains are currently supported.',
$service->uuid,
$application->name,
$domain,
$unsupportedProtocol
);
continue;
}
$url = $this->parseDomainUrl($domain);
if (! $url instanceof Url) {
$warnings[] = sprintf(
'Edge proxy route skipped for service %s (%s, domain %s): domain format is invalid. Use a valid hostname/domain with optional scheme, port and path.',
$service->uuid,
$application->name,
$domain
);
continue;
}
@ -241,21 +261,27 @@ class EdgeProxyRemoteRouteService
$banner = "# This file is generated by Coolify, do not edit it manually.\n\n";
$payload = base64_encode($banner.$yaml);
$routeFilePath = $this->routeFilePath($edgeProxyServer, $serviceUuid);
$temporaryRouteFilePath = $routeFilePath.'.tmp';
$escapedDirectory = escapeshellarg($this->routeDirectoryPath($edgeProxyServer));
$escapedFilePath = escapeshellarg($this->routeFilePath($edgeProxyServer, $serviceUuid));
$escapedFilePath = escapeshellarg($routeFilePath);
$escapedTemporaryFilePath = escapeshellarg($temporaryRouteFilePath);
$this->runRemoteCommands($edgeProxyServer, [
"mkdir -p $escapedDirectory",
"echo '$payload' | base64 -d | tee $escapedFilePath > /dev/null",
"echo '$payload' | base64 -d | tee $escapedTemporaryFilePath > /dev/null",
"mv $escapedTemporaryFilePath $escapedFilePath",
]);
}
private function deleteRouteFile(Server $edgeProxyServer, string $serviceUuid): void
{
$escapedFilePath = escapeshellarg($this->routeFilePath($edgeProxyServer, $serviceUuid));
$escapedTemporaryFilePath = escapeshellarg($this->routeFilePath($edgeProxyServer, $serviceUuid).'.tmp');
$this->runRemoteCommands($edgeProxyServer, [
"rm -f $escapedFilePath",
"rm -f $escapedFilePath $escapedTemporaryFilePath",
], false);
}
@ -389,6 +415,21 @@ class EdgeProxyRemoteRouteService
->all();
}
private function detectUnsupportedDomainProtocol(string $domain): ?string
{
$trimmedDomain = trim($domain);
if ($trimmedDomain === '' || ! preg_match('/^[a-z][a-z0-9+\-.]*:\/\//i', $trimmedDomain)) {
return null;
}
$protocol = strtolower((string) parse_url($trimmedDomain, PHP_URL_SCHEME));
if ($protocol === '' || in_array($protocol, ['http', 'https'], true)) {
return null;
}
return $protocol;
}
private function parseDomainUrl(string $domain): ?Url
{
$normalizedDomain = trim($domain);
@ -414,7 +455,7 @@ class EdgeProxyRemoteRouteService
private function resolvePublishedPort(array $compose, string $serviceName, ?int $requestedInternalPort, array $environmentMap): ?int
{
$ports = data_get($compose, "services.$serviceName.ports", []);
$ports = $this->resolveComposeServicePorts($compose, $serviceName);
if (! is_array($ports)) {
return null;
}
@ -446,12 +487,42 @@ class EdgeProxyRemoteRouteService
return null;
}
private function resolveComposeServicePorts(array $compose, string $serviceName): ?array
{
$services = data_get($compose, 'services', []);
if (! is_array($services) || empty($services)) {
return null;
}
if (array_key_exists($serviceName, $services)) {
$ports = data_get($services[$serviceName], 'ports');
return is_array($ports) ? $ports : null;
}
// Defensive fallback for templates where application name does not match compose key.
$servicesWithPorts = collect($services)
->filter(fn (mixed $serviceConfig) => is_array($serviceConfig) && is_array(data_get($serviceConfig, 'ports')) && ! empty(data_get($serviceConfig, 'ports')))
->values();
if ($servicesWithPorts->count() === 1) {
return data_get($servicesWithPorts->first(), 'ports');
}
return null;
}
private function parsePortMappings(array $ports, array $environmentMap): Collection
{
$mappings = collect();
foreach ($ports as $portDefinition) {
if (is_array($portDefinition)) {
$protocol = strtolower((string) data_get($portDefinition, 'protocol', 'tcp'));
if ($protocol === 'udp') {
continue;
}
$target = $this->resolvePortValue(data_get($portDefinition, 'target'), $environmentMap);
$published = $this->resolvePortValue(data_get($portDefinition, 'published'), $environmentMap);
@ -483,7 +554,13 @@ class EdgeProxyRemoteRouteService
return null;
}
$normalizedPortDefinition = preg_replace('/\/(tcp|udp)$/i', '', $normalizedPortDefinition) ?? $normalizedPortDefinition;
if (preg_match('/\/(tcp|udp)$/i', $normalizedPortDefinition, $protocolMatches)) {
if (strtolower($protocolMatches[1]) === 'udp') {
return null;
}
$normalizedPortDefinition = preg_replace('/\/(tcp|udp)$/i', '', $normalizedPortDefinition) ?? $normalizedPortDefinition;
}
if (str_contains($normalizedPortDefinition, ':')) {
$segments = explode(':', $normalizedPortDefinition);

View file

@ -4,6 +4,24 @@ use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Services\EdgeProxyRemoteRouteService;
use Illuminate\Container\Container;
use Psr\Log\NullLogger;
$originalLogger = null;
beforeEach(function () use (&$originalLogger) {
$container = Container::getInstance();
$originalLogger = $container->bound('log') ? $container->make('log') : null;
$container->instance('log', new NullLogger);
});
afterEach(function () use (&$originalLogger) {
if (is_null($originalLogger)) {
return;
}
Container::getInstance()->instance('log', $originalLogger);
});
it('generates edge traefik config for a remote domain route', function () {
$service = new EdgeProxyRemoteRouteService;
@ -72,10 +90,13 @@ YAML;
->and($manager->calls)->toHaveCount(1);
$expectedPath = '/tmp/proxy/dynamic/service-remote-service-test-uuid.yaml';
$expectedTempPath = '/tmp/proxy/dynamic/service-remote-service-test-uuid.yaml.tmp';
$firstWriteCommands = implode("\n", $manager->calls[0]['commands']);
expect($firstWriteCommands)->toContain($expectedPath)
->and($firstWriteCommands)->toContain('tee');
->and($firstWriteCommands)->toContain($expectedTempPath)
->and($firstWriteCommands)->toContain('tee')
->and($firstWriteCommands)->toContain('mv');
preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $firstPayloadMatches);
$firstPayload = base64_decode($firstPayloadMatches[1]);
@ -95,7 +116,9 @@ YAML;
$secondWriteCommands = implode("\n", $manager->calls[1]['commands']);
expect($secondWriteCommands)->toContain($expectedPath)
->and($secondWriteCommands)->toContain('tee');
->and($secondWriteCommands)->toContain($expectedTempPath)
->and($secondWriteCommands)->toContain('tee')
->and($secondWriteCommands)->toContain('mv');
preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[1]['commands'][1], $secondPayloadMatches);
$secondPayload = base64_decode($secondPayloadMatches[1]);
@ -105,7 +128,7 @@ YAML;
expect($manager->calls)->toHaveCount(3);
$deleteCommands = implode("\n", $manager->calls[2]['commands']);
expect($deleteCommands)->toContain("rm -f '$expectedPath'");
expect($deleteCommands)->toContain("rm -f '$expectedPath' '$expectedTempPath'");
});
it('does not generate edge route file when published port cannot be resolved and returns actionable warning', function () {
@ -372,3 +395,220 @@ YAML;
expect($payload)->toContain('http://10.8.0.20:9010')
->and($payload)->not->toContain('9443');
});
it('resolves published port when application name differs but compose has a single service with ports', 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 = 16;
$deploymentServer->ip = '10.8.0.21';
$deploymentServer->proxy = ['type' => 'NONE'];
$service = new Service;
$service->uuid = 'service-single-compose-fallback';
$service->docker_compose_raw = <<<'YAML'
services:
app:
ports:
- "9030:3000"
YAML;
$application = new ServiceApplication;
$application->name = 'web';
$application->fqdn = 'https://single-fallback.example.com:3000';
$service->setRelation('applications', collect([$application]));
$application->setRelation('service', $service);
$warnings = $manager->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer);
expect($warnings)->toBe([]);
preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches);
$payload = base64_decode($payloadMatches[1]);
expect($payload)->toContain('http://10.8.0.21:9030');
});
it('ignores udp published ports when resolving upstream for edge routes', 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 = 17;
$deploymentServer->ip = '10.8.0.22';
$deploymentServer->proxy = ['type' => 'NONE'];
$service = new Service;
$service->uuid = 'service-udp-filtering';
$service->docker_compose_raw = <<<'YAML'
services:
app:
ports:
- "9010:3000/udp"
- "9020:3000/tcp"
YAML;
$application = new ServiceApplication;
$application->name = 'app';
$application->fqdn = 'https://udp-filtering.example.com:3000';
$service->setRelation('applications', collect([$application]));
$application->setRelation('service', $service);
$warnings = $manager->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer);
expect($warnings)->toBe([]);
preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches);
$payload = base64_decode($payloadMatches[1]);
expect($payload)->toContain('http://10.8.0.22:9020')
->and($payload)->not->toContain('http://10.8.0.22:9010');
});
it('returns warning for invalid domains while keeping valid remote edge routes', 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 = 18;
$deploymentServer->ip = '10.8.0.23';
$deploymentServer->proxy = ['type' => 'NONE'];
$service = new Service;
$service->uuid = 'service-invalid-domain-warning';
$service->docker_compose_raw = <<<'YAML'
services:
app:
ports:
- "9040:3000"
YAML;
$application = new ServiceApplication;
$application->name = 'app';
$application->fqdn = 'https://valid.example.com:3000,https://:3000';
$service->setRelation('applications', collect([$application]));
$application->setRelation('service', $service);
$warnings = $manager->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer);
expect($warnings)->not->toBeEmpty()
->and(collect($warnings)->contains(fn (string $warning) => str_contains($warning, 'domain format is invalid')))
->and($manager->calls)->toHaveCount(1);
preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches);
$payload = base64_decode($payloadMatches[1]);
expect($payload)->toContain('Host(`valid.example.com`)')
->and($payload)->toContain('http://10.8.0.23:9040')
->and($payload)->not->toContain('https://:3000');
});
it('returns warning for unsupported domain protocols while keeping valid http routes', 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 = 19;
$deploymentServer->ip = '10.8.0.24';
$deploymentServer->proxy = ['type' => 'NONE'];
$service = new Service;
$service->uuid = 'service-unsupported-protocol-warning';
$service->docker_compose_raw = <<<'YAML'
services:
app:
ports:
- "9050:3000"
- "25565:25565"
YAML;
$application = new ServiceApplication;
$application->name = 'app';
$application->fqdn = 'https://valid-http.example.com:3000,tcp://minecraft.example.com:25565';
$service->setRelation('applications', collect([$application]));
$application->setRelation('service', $service);
$warnings = $manager->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer);
expect($warnings)->not->toBeEmpty()
->and(collect($warnings)->contains(fn (string $warning) => str_contains($warning, 'protocol "tcp" is not supported for edge remote routing')))
->and($manager->calls)->toHaveCount(1);
preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches);
$payload = base64_decode($payloadMatches[1]);
expect($payload)->toContain('Host(`valid-http.example.com`)')
->and($payload)->toContain('http://10.8.0.24:9050')
->and($payload)->not->toContain('minecraft.example.com');
});