From aaf0b94bcea5deb270a6614b3ecdea10f605cf12 Mon Sep 17 00:00:00 2001 From: Iisyourdad Date: Tue, 3 Mar 2026 20:30:47 -0600 Subject: [PATCH] djsisson comment about addressing overlapping of CIDR network overlaps. --- app/Services/EdgeProxyRemoteRouteService.php | 86 +++++++++++++++++++ .../Unit/EdgeProxyRemoteRouteServiceTest.php | 64 ++++++++++++++ 2 files changed, 150 insertions(+) diff --git a/app/Services/EdgeProxyRemoteRouteService.php b/app/Services/EdgeProxyRemoteRouteService.php index 74dc1a5fa..f7ce93202 100644 --- a/app/Services/EdgeProxyRemoteRouteService.php +++ b/app/Services/EdgeProxyRemoteRouteService.php @@ -79,6 +79,10 @@ class EdgeProxyRemoteRouteService $routes = []; $warnings = []; + $networkOverlapWarning = $this->detectDockerNetworkOverlapWarning($service, $edgeProxyServer, $tunnelHost); + if (! is_null($networkOverlapWarning)) { + $warnings[] = $networkOverlapWarning; + } foreach ($applications as $application) { $domains = collect(explode(',', (string) $application->fqdn)) @@ -430,6 +434,88 @@ class EdgeProxyRemoteRouteService return $protocol; } + private function detectDockerNetworkOverlapWarning(Service $service, Server $edgeProxyServer, string $tunnelHost): ?string + { + // Only run this for persisted server models (normal runtime) to avoid noisy checks in synthetic test stubs. + if (! $edgeProxyServer->exists) { + return null; + } + + $normalizedTunnelHost = trim($tunnelHost, '[]'); + if (! filter_var($normalizedTunnelHost, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return null; + } + + foreach ($this->resolveEdgeDockerSubnets($edgeProxyServer) as $subnet) { + if ($this->ipv4InCidr($normalizedTunnelHost, $subnet)) { + return sprintf( + 'Edge proxy route warning for service %s: remote host %s overlaps edge Docker network subnet %s. This can break VPN/WireGuard routing. Configure Docker default-address-pools to a non-overlapping range (for example base 172.20.0.0/16 with size 24) and recreate overlapping networks.', + $service->uuid, + $normalizedTunnelHost, + $subnet + ); + } + } + + return null; + } + + private function resolveEdgeDockerSubnets(Server $edgeProxyServer): array + { + try { + $subnetsOutput = $this->runRemoteCommands($edgeProxyServer, [ + "docker network inspect \$(docker network ls -q) --format '{{range .IPAM.Config}}{{println .Subnet}}{{end}}' 2>/dev/null | sort -u || true", + ], false); + } catch (\Throwable) { + return []; + } + + if (! is_string($subnetsOutput) || trim($subnetsOutput) === '') { + return []; + } + + return collect(preg_split('/\R+/', $subnetsOutput) ?: []) + ->map(fn (string $line) => trim($line)) + ->filter(fn (string $line) => preg_match('/^\d{1,3}(?:\.\d{1,3}){3}\/\d{1,2}$/', $line) === 1) + ->values() + ->all(); + } + + private function ipv4InCidr(string $ip, string $cidr): bool + { + [$networkIp, $prefixLength] = array_pad(explode('/', $cidr, 2), 2, null); + if (! is_string($networkIp) || ! is_string($prefixLength)) { + return false; + } + + if (! filter_var($networkIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return false; + } + + if (! preg_match('/^\d+$/', $prefixLength)) { + return false; + } + + $prefixLength = (int) $prefixLength; + if ($prefixLength < 0 || $prefixLength > 32) { + return false; + } + + $ipLong = ip2long($ip); + $networkLong = ip2long($networkIp); + if ($ipLong === false || $networkLong === false) { + return false; + } + + if ($prefixLength === 0) { + return true; + } + + $mask = -1 << (32 - $prefixLength); + + return ($ipLong & $mask) === ($networkLong & $mask); + } + private function parseDomainUrl(string $domain): ?Url { $normalizedDomain = trim($domain); diff --git a/tests/Unit/EdgeProxyRemoteRouteServiceTest.php b/tests/Unit/EdgeProxyRemoteRouteServiceTest.php index 5531ccdad..c3585b8f3 100644 --- a/tests/Unit/EdgeProxyRemoteRouteServiceTest.php +++ b/tests/Unit/EdgeProxyRemoteRouteServiceTest.php @@ -612,3 +612,67 @@ YAML; ->and($payload)->toContain('http://10.8.0.24:9050') ->and($payload)->not->toContain('minecraft.example.com'); }); + +it('returns warning when remote tunnel ip overlaps with an edge docker subnet', 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, + ]; + + if (str_contains(implode("\n", $commands), 'docker network inspect')) { + return "10.8.0.0/24\n172.18.0.0/16\n"; + } + + return null; + } + }; + + $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); + $edgeProxyServer->id = 0; + $edgeProxyServer->exists = true; + $edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK'); + $edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy'); + + $deploymentServer = Mockery::mock(Server::class)->makePartial(); + $deploymentServer->id = 20; + $deploymentServer->ip = '10.8.0.40'; + $deploymentServer->proxy = ['type' => 'NONE']; + + $service = new Service; + $service->uuid = 'service-overlap-warning'; + $service->docker_compose_raw = <<<'YAML' +services: + app: + ports: + - "9060:3000" +YAML; + + $application = new ServiceApplication; + $application->name = 'app'; + $application->fqdn = 'https://overlap.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, 'overlaps edge Docker network subnet 10.8.0.0/24'))); + + $writeCall = collect($manager->calls)->first( + fn (array $call) => str_contains(implode("\n", $call['commands']), 'base64 -d | tee') + ); + expect($writeCall)->not->toBeNull(); + + preg_match("/echo '([^']+)' \\| base64 -d/", $writeCall['commands'][1], $payloadMatches); + $payload = base64_decode($payloadMatches[1]); + + expect($payload)->toContain('Host(`overlap.example.com`)') + ->and($payload)->toContain('http://10.8.0.40:9060'); +});