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.
This commit is contained in:
Peter Steinberger
2026-08-12 00:21:18 -07:00
parent e54be90291
commit 5343d8ec56
2 changed files with 38 additions and 5 deletions
+26 -1
View File
@@ -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<void>((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<void>((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 });
+12 -4
View File
@@ -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);