From 91ea235b5f1c941f48f24a09f32b33705fb5174f Mon Sep 17 00:00:00 2001 From: Iisyourdad Date: Sat, 28 Feb 2026 01:12:10 -0600 Subject: [PATCH] edge cases and just safer code --- app/Services/EdgeProxyRemoteRouteService.php | 87 ++++++- .../Unit/EdgeProxyRemoteRouteServiceTest.php | 246 +++++++++++++++++- 2 files changed, 325 insertions(+), 8 deletions(-) diff --git a/app/Services/EdgeProxyRemoteRouteService.php b/app/Services/EdgeProxyRemoteRouteService.php index f4eebeb38..74dc1a5fa 100644 --- a/app/Services/EdgeProxyRemoteRouteService.php +++ b/app/Services/EdgeProxyRemoteRouteService.php @@ -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); diff --git a/tests/Unit/EdgeProxyRemoteRouteServiceTest.php b/tests/Unit/EdgeProxyRemoteRouteServiceTest.php index 2e70b7faf..5531ccdad 100644 --- a/tests/Unit/EdgeProxyRemoteRouteServiceTest.php +++ b/tests/Unit/EdgeProxyRemoteRouteServiceTest.php @@ -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'); +});