From 5343d8ec56e4351440260b27e0ad92d8af978137 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 00:21:18 -0700 Subject: [PATCH] fix(gateway): dial portal targets via localhost dual-stack Vite and other Node >=17 dev servers bind ::1 only for localhost, so a fixed 127.0.0.1 dial 502s on the default path. Use hostname localhost with family autoselection and rewrite Host to match. --- src/gateway/portals/portal-http-proxy.test.ts | 27 ++++++++++++++++++- src/gateway/portals/portal-http-proxy.ts | 16 ++++++++--- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/gateway/portals/portal-http-proxy.test.ts b/src/gateway/portals/portal-http-proxy.test.ts index 615d7cd510d6..4e64de4c805b 100644 --- a/src/gateway/portals/portal-http-proxy.test.ts +++ b/src/gateway/portals/portal-http-proxy.test.ts @@ -163,7 +163,7 @@ describe("portal HTTP proxy", () => { // Node may add its own connection-local Keep-Alive header; the upstream value must not pass. expect(result.headers["keep-alive"]).not.toBe("upstream-secret=17"); expect(received).toMatchObject({ - host: `127.0.0.1:${targetPort}`, + host: `localhost:${targetPort}`, cookie: "app=ok; theme=dark", proto: "http", forwardedHost: "portal.example:9999", @@ -218,6 +218,31 @@ describe("portal HTTP proxy", () => { expect(result.body).toContain('http-equiv="refresh" content="2"'); }); + it("reaches IPv6-only targets through the localhost dual-stack dial", async () => { + // Node >=17 dev servers (Vite, Next.js) often bind ::1 only on "localhost". + const v6Target = createServer((req, res) => { + res.statusCode = 200; + res.end("v6 proxied"); + }); + await new Promise((resolve, reject) => { + v6Target.once("error", reject); + v6Target.listen(0, "::1", () => resolve()); + }); + try { + const v6Port = (v6Target.address() as AddressInfo).port; + const portal = await portalService().open({ targetPort: v6Port }); + const result = await httpCall({ + port: portal.listenPort, + path: `/?${portal.tokenQuery}`, + }); + expect(result).toMatchObject({ status: 200, body: "v6 proxied" }); + } finally { + await new Promise((resolve) => { + v6Target.close(() => resolve()); + }); + } + }); + it("splices WebSockets and destroys upgraded sockets and listeners on close", async () => { const service = portalService(); const portal = await service.open({ targetPort }); diff --git a/src/gateway/portals/portal-http-proxy.ts b/src/gateway/portals/portal-http-proxy.ts index 28fe3faa2c92..b19fde225ee5 100644 --- a/src/gateway/portals/portal-http-proxy.ts +++ b/src/gateway/portals/portal-http-proxy.ts @@ -200,14 +200,17 @@ export function handlePortalProxyRequest(params: { const headers = proxyHeaders(req.headers); const originalHost = req.headers.host; - headers.host = `127.0.0.1:${target.targetPort}`; + headers.host = `localhost:${target.targetPort}`; headers["x-forwarded-for"] = req.socket.remoteAddress ?? ""; headers["x-forwarded-proto"] = tls ? "https" : "http"; if (originalHost) { headers["x-forwarded-host"] = originalHost; } + // Dial "localhost", not a fixed loopback literal: Node >=17 dev servers (Vite, + // Next.js) often bind ::1 only, and family autoselection reaches either stack. const proxyReq = requestHttp({ - hostname: "127.0.0.1", + hostname: "localhost", + autoSelectFamily: true, port: target.targetPort, method: req.method, path: authorization.requestPath, @@ -257,7 +260,7 @@ function websocketHeaders(req: IncomingMessage, targetPort: number, requestPath: lines.push(`${normalized}: ${item}`); } } - lines.push(`host: 127.0.0.1:${targetPort}`, "", ""); + lines.push(`host: localhost:${targetPort}`, "", ""); return lines.join("\r\n"); } @@ -283,7 +286,12 @@ export function handlePortalProxyUpgrade(params: { return; } - const targetSocket: Socket = net.connect({ host: "127.0.0.1", port: target.targetPort }); + // Same localhost/dual-stack contract as the HTTP path above. + const targetSocket: Socket = net.connect({ + host: "localhost", + autoSelectFamily: true, + port: target.targetPort, + }); upgradedSockets.add(socket); upgradedSockets.add(targetSocket); const release = (stream: Duplex) => upgradedSockets.delete(stream);