From 31b10e7f74a8d329755446900188a52fac811160 Mon Sep 17 00:00:00 2001 From: Iisyourdad Date: Sat, 7 Mar 2026 21:58:07 -0600 Subject: [PATCH] Preserve remote edge TLS routes when published ports are unresolved. AKA, fix the entire thing. --- app/Services/EdgeProxyRemoteRouteService.php | 203 ++++++++++++++++-- .../Unit/EdgeProxyRemoteRouteServiceTest.php | 164 ++++++++++++++ 2 files changed, 354 insertions(+), 13 deletions(-) diff --git a/app/Services/EdgeProxyRemoteRouteService.php b/app/Services/EdgeProxyRemoteRouteService.php index 4b08b16e0..2a5229c99 100644 --- a/app/Services/EdgeProxyRemoteRouteService.php +++ b/app/Services/EdgeProxyRemoteRouteService.php @@ -133,7 +133,19 @@ class EdgeProxyRemoteRouteService $requestedInternalPort = $url->getPort() ?? $application->getRequiredPort(); $publishedPort = $this->resolvePublishedPort($compose, $application->name, $requestedInternalPort, $environmentMap); - if (is_null($publishedPort)) { + $upstream = $this->resolveRouteUpstream( + $deploymentServer, + $tunnelHost, + $publishedPort, + $this->canFallbackToDeploymentProxyForServiceApplication( + $application, + $requestedInternalPort, + $compose, + $environmentMap + ) + ); + + if (is_null($upstream)) { $warnings[] = sprintf( 'Edge proxy route skipped for service %s (%s, domain %s): published host port could not be resolved. Expose the container port in docker-compose "ports:" and/or include an explicit port in the domain.', $service->uuid, @@ -144,10 +156,19 @@ class EdgeProxyRemoteRouteService continue; } + if ($this->isDeploymentProxyFallbackUpstream($upstream)) { + $warnings[] = sprintf( + 'Edge proxy route fallback for service %s (%s, domain %s): published host port could not be resolved, so traffic will be forwarded to the deployment server HTTPS proxy instead.', + $service->uuid, + $application->name, + $domain + ); + } + $routes[] = [ 'host' => $url->getHost(), 'path' => $url->getPath(), - 'upstream_url' => sprintf('http://%s:%d', $tunnelHost, $publishedPort), + ...$upstream, ]; } } @@ -313,7 +334,20 @@ class EdgeProxyRemoteRouteService $environmentMap ); - if (is_null($publishedPort)) { + $upstream = $this->resolveRouteUpstream( + $deploymentServer, + $tunnelHost, + $publishedPort, + $this->canFallbackToDeploymentProxyForApplication( + $application, + $requestedInternalPort, + $composeServiceName, + $compose, + $environmentMap + ) + ); + + if (is_null($upstream)) { $warnings[] = sprintf( 'Edge proxy route skipped for application %s (domain %s): published host port could not be resolved. Expose the application port in host mappings/compose ports and/or include an explicit port in the domain.', $application->uuid, @@ -323,10 +357,18 @@ class EdgeProxyRemoteRouteService continue; } + if ($this->isDeploymentProxyFallbackUpstream($upstream)) { + $warnings[] = sprintf( + 'Edge proxy route fallback for application %s (domain %s): published host port could not be resolved, so traffic will be forwarded to the deployment server HTTPS proxy instead.', + $application->uuid, + $domain + ); + } + $routes[] = [ 'host' => $url->getHost(), 'path' => $url->getPath(), - 'upstream_url' => sprintf('http://%s:%d', $tunnelHost, $publishedPort), + ...$upstream, ]; } @@ -445,6 +487,18 @@ class EdgeProxyRemoteRouteService ], ], ]; + + if (data_get($route, 'pass_host_header') === true) { + $config['http']['services'][$serviceName]['loadBalancer']['passHostHeader'] = true; + } + + if (data_get($route, 'use_insecure_transport') === true) { + $transportName = "edge-{$serviceKey}-transport-{$suffix}"; + $config['http']['services'][$serviceName]['loadBalancer']['serversTransport'] = $transportName; + $config['http']['serversTransports'][$transportName] = [ + 'insecureSkipVerify' => true, + ]; + } } return $config; @@ -819,6 +873,103 @@ class EdgeProxyRemoteRouteService return $this->selectPublishedPortFromMappings($portMappings, $requestedInternalPort, $fallbackInternalPort); } + private function resolveRouteUpstream( + Server $deploymentServer, + string $tunnelHost, + ?int $publishedPort, + bool $allowDeploymentProxyFallback + ): ?array { + if (! is_null($publishedPort)) { + return [ + 'upstream_url' => sprintf('http://%s:%d', $tunnelHost, $publishedPort), + ]; + } + + if (! $allowDeploymentProxyFallback) { + return null; + } + + if ($deploymentServer->proxyType() === ProxyTypes::NONE->value) { + return null; + } + + return [ + 'upstream_url' => sprintf('https://%s:443', $tunnelHost), + 'pass_host_header' => true, + 'use_insecure_transport' => true, + ]; + } + + private function isDeploymentProxyFallbackUpstream(array $upstream): bool + { + return data_get($upstream, 'use_insecure_transport') === true; + } + + private function canFallbackToDeploymentProxyForServiceApplication( + ServiceApplication $application, + ?int $requestedInternalPort, + array $compose, + array $environmentMap + ): bool { + $candidatePorts = $this->resolveComposeServiceInternalPorts($compose, $application->name, $environmentMap); + $requiredPort = $application->getRequiredPort(); + if (! is_null($requiredPort)) { + $candidatePorts->push($requiredPort); + } + + return $this->candidatePortsSupportProxyFallback($requestedInternalPort, $candidatePorts); + } + + private function canFallbackToDeploymentProxyForApplication( + Application $application, + ?int $requestedInternalPort, + ?string $composeServiceName, + array $compose, + array $environmentMap + ): bool { + if ($application->build_pack === 'dockercompose') { + $serviceName = blank($composeServiceName) ? $application->uuid : $composeServiceName; + + return $this->candidatePortsSupportProxyFallback( + $requestedInternalPort, + $this->resolveComposeServiceInternalPorts($compose, $serviceName, $environmentMap) + ); + } + + return $this->candidatePortsSupportProxyFallback( + $requestedInternalPort, + $this->applicationInternalPorts($application) + ); + } + + private function candidatePortsSupportProxyFallback(?int $requestedInternalPort, Collection $candidatePorts): bool + { + $candidatePorts = $candidatePorts + ->filter(fn (mixed $port) => is_int($port) || (is_string($port) && is_numeric($port))) + ->map(fn (mixed $port) => (int) $port) + ->unique() + ->values(); + + if ($candidatePorts->isEmpty()) { + return false; + } + + if (! is_null($requestedInternalPort)) { + return $candidatePorts->contains($requestedInternalPort); + } + + return $candidatePorts->count() === 1; + } + + private function applicationInternalPorts(Application $application): Collection + { + if ($application->relationLoaded('settings') && data_get($application, 'settings.is_static', false)) { + return collect([80]); + } + + return collect($application->ports_exposes_array ?? []); + } + private function detectUnsupportedDomainProtocol(string $domain): ?string { $trimmedDomain = trim($domain); @@ -958,6 +1109,24 @@ class EdgeProxyRemoteRouteService return $this->selectPublishedPortFromMappings($portMappings, $requestedInternalPort); } + private function resolveComposeServiceInternalPorts(array $compose, string $serviceName, array $environmentMap): Collection + { + $serviceConfig = $this->resolveComposeServiceConfig($compose, $serviceName); + if (! is_array($serviceConfig)) { + return collect(); + } + + $ports = $this->parsePortMappings((array) data_get($serviceConfig, 'ports', []), $environmentMap) + ->pluck('target') + ->filter(fn (mixed $port) => ! is_null($port)); + + $exposedPorts = collect((array) data_get($serviceConfig, 'expose', [])) + ->map(fn (mixed $port) => $this->resolvePortValue($port, $environmentMap)) + ->filter(fn (?int $port) => ! is_null($port)); + + return $ports->merge($exposedPorts)->values(); + } + private function selectPublishedPortFromMappings(Collection $portMappings, ?int $requestedInternalPort, ?int $fallbackInternalPort = null): ?int { if (! is_null($requestedInternalPort)) { @@ -987,28 +1156,36 @@ class EdgeProxyRemoteRouteService } private function resolveComposeServicePorts(array $compose, string $serviceName): ?array + { + $serviceConfig = $this->resolveComposeServiceConfig($compose, $serviceName); + if (! is_array($serviceConfig)) { + return null; + } + + $ports = data_get($serviceConfig, 'ports'); + + return is_array($ports) ? $ports : null; + } + + private function resolveComposeServiceConfig(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; + if (array_key_exists($serviceName, $services) && is_array($services[$serviceName])) { + return $services[$serviceName]; } $normalizedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value(); foreach ($services as $composeServiceName => $serviceConfig) { $normalizedComposeServiceName = str((string) $composeServiceName)->replace('-', '_')->replace('.', '_')->value(); - if ($normalizedComposeServiceName !== $normalizedServiceName) { + if ($normalizedComposeServiceName !== $normalizedServiceName || ! is_array($serviceConfig)) { continue; } - $ports = data_get($serviceConfig, 'ports'); - - return is_array($ports) ? $ports : null; + return $serviceConfig; } // Defensive fallback for templates where application name does not match compose key. @@ -1017,7 +1194,7 @@ class EdgeProxyRemoteRouteService ->values(); if ($servicesWithPorts->count() === 1) { - return data_get($servicesWithPorts->first(), 'ports'); + return $servicesWithPorts->first(); } return null; diff --git a/tests/Unit/EdgeProxyRemoteRouteServiceTest.php b/tests/Unit/EdgeProxyRemoteRouteServiceTest.php index f2570e42a..f244c0745 100644 --- a/tests/Unit/EdgeProxyRemoteRouteServiceTest.php +++ b/tests/Unit/EdgeProxyRemoteRouteServiceTest.php @@ -84,6 +84,22 @@ it('uses configured traefik entrypoints and cert resolver for remote routes', fu } }); +it('adds an insecure transport when an edge route falls back to the deployment proxy https entrypoint', function () { + $service = new EdgeProxyRemoteRouteService; + + $config = $service->generateTraefikConfig('service-uuid', [[ + 'host' => 'demo.example.com', + 'path' => '/', + 'upstream_url' => 'https://10.8.0.15:443', + 'pass_host_header' => true, + 'use_insecure_transport' => true, + ]]); + + expect(data_get($config, 'http.services.edge-service-uuid-svc-1.loadBalancer.passHostHeader'))->toBeTrue() + ->and(data_get($config, 'http.services.edge-service-uuid-svc-1.loadBalancer.serversTransport'))->toBe('edge-service-uuid-transport-1') + ->and(data_get($config, 'http.serversTransports.edge-service-uuid-transport-1.insecureSkipVerify'))->toBeTrue(); +}); + it('returns warning when syncing service route without a master domain router', function () { $manager = new class extends EdgeProxyRemoteRouteService { @@ -387,6 +403,97 @@ 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 application edge route files for HSTS-sensitive domains by falling back to the deployment proxy https entrypoint', 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 = 32; + $deploymentServer->ip = '10.8.0.32'; + $deploymentServer->proxy = ['type' => 'TRAEFIK']; + + $application = new Application; + $application->uuid = 'application-missing-port-fallback'; + $application->build_pack = 'nixpacks'; + $application->fqdn = 'https://missing-port.example.com:3000'; + $application->ports_mappings = null; + $application->ports_exposes = '3000'; + + $warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); + + expect($warnings)->not->toBeEmpty() + ->and(collect($warnings)->contains(fn (string $warning) => str_contains($warning, 'deployment server HTTPS proxy instead'))) + ->and($manager->calls)->toHaveCount(1); + + preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches); + $payload = base64_decode($payloadMatches[1]); + + expect($payload)->toContain('https://10.8.0.32:443') + ->and($payload)->toContain('passHostHeader: true') + ->and($payload)->toContain('serversTransport: edge-application-missing-port-fallback-transport-1') + ->and($payload)->toContain('insecureSkipVerify: true') + ->and($payload)->toContain('certResolver: letsencrypt'); +}); + +it('does not fall back through the deployment proxy when an application domain targets an unknown internal port', 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' => 'TRAEFIK']; + + $application = new Application; + $application->uuid = 'application-invalid-fallback-port'; + $application->build_pack = 'nixpacks'; + $application->fqdn = 'https://invalid-port.example.com:9999'; + $application->ports_mappings = null; + $application->ports_exposes = '3000,4000'; + + $warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); + + expect($warnings)->not->toBeEmpty() + ->and($warnings[0])->toContain('published host port could not be resolved') + ->and(collect($warnings)->contains(fn (string $warning) => ! str_contains($warning, 'deployment server HTTPS proxy instead')))->toBeTrue() + ->and(implode("\n", $manager->calls[0]['commands']))->toContain('/tmp/proxy/dynamic/application-remote-application-invalid-fallback-port.yaml') + ->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 { @@ -494,6 +601,63 @@ YAML; ->and(implode("\n", $manager->calls[0]['commands']))->not->toContain('tee'); }); +it('keeps service edge route files for HSTS-sensitive domains by falling back to the deployment proxy https entrypoint', 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 = 11; + $deploymentServer->ip = '10.8.0.16'; + $deploymentServer->proxy = ['type' => 'TRAEFIK']; + + $service = new Service; + $service->uuid = 'service-without-port-fallback'; + $service->docker_compose_raw = <<<'YAML' +services: + app: + ports: + - "3000" +YAML; + + $application = new ServiceApplication; + $application->name = 'app'; + $application->fqdn = 'https://broken.example.com: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, 'deployment server HTTPS proxy instead'))) + ->and($manager->calls)->toHaveCount(1); + + preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches); + $payload = base64_decode($payloadMatches[1]); + + expect($payload)->toContain('https://10.8.0.16:443') + ->and($payload)->toContain('Host(`broken.example.com`)') + ->and($payload)->toContain('insecureSkipVerify: true') + ->and($payload)->toContain('certResolver: letsencrypt'); +}); + it('keeps valid edge routes when one domain port cannot be resolved and returns warning only for invalid domain', function () { $manager = new class extends EdgeProxyRemoteRouteService {