Reach IPv6-only workspace services through their proxy URLs (#6753)

* fix(server): reach IPv6-only workspace services

Workspace service forwarding and health probes forced IPv4 loopback,
returning 502 and unhealthy for a reachable IPv6-only service. Select
between both literal loopback addresses for HTTP, upgrades, and probes,
without relying on the system's localhost DNS entries.

Co-authored-by: Marcelo Barbosa <celocb@gmail.com>

* test(server): keep proxy POST mechanics in a helper

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Marcelo Barbosa <celocb@gmail.com>
This commit is contained in:
paseo-bot[bot]
2026-10-13 03:57:12 +02:00
committed by GitHub
co-authored by Marcelo Barbosa
parent e8108d9092
commit b0f054de90
6 changed files with 176 additions and 15 deletions
@@ -0,0 +1,27 @@
import http from "node:http";
import net from "node:net";
// A host can support IPv6 loopback without listing ::1 for localhost in DNS.
const lookupLoopback: net.LookupFunction = (_hostname, _options, callback) => {
callback(null, [
{ address: "127.0.0.1", family: 4 },
{ address: "::1", family: 6 },
]);
};
export function connectToLocalService(port: number): net.Socket {
return net.connect({ host: "localhost", port, autoSelectFamily: true, lookup: lookupLoopback });
}
export function requestLocalService(
options: Pick<http.RequestOptions, "port" | "path" | "method" | "headers">,
onResponse: (response: http.IncomingMessage) => void,
): http.ClientRequest {
const requestOptions = {
...options,
hostname: "localhost",
autoSelectFamily: true,
lookup: lookupLoopback,
};
return http.request(requestOptions, onResponse);
}
@@ -99,14 +99,14 @@ function createStubTerminalManager(
};
}
async function startTcpServer(): Promise<TcpServerHandle> {
async function startTcpServer(host = "127.0.0.1"): Promise<TcpServerHandle> {
const server = net.createServer((socket) => {
socket.end();
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.listen(0, host, () => {
server.off("error", reject);
resolve();
});
@@ -153,6 +153,45 @@ describe("ScriptHealthMonitor", () => {
servers.clear();
});
it.each(["127.0.0.1", "::1"])("reports a service bound only to %s as healthy", async (host) => {
const service = await startTcpServer(host);
servers.add(service.server);
const serviceProxy = new ScriptRouteStore();
serviceProxy.registerWorkspaceService({
workspaceId: "workspace-a",
projectSlug: "repo",
branchName: "main",
scriptName: "api",
port: service.port,
});
const updates: ScriptHealthEntry[][] = [];
const monitor = new ScriptHealthMonitor({
serviceProxy,
onChange: (_workspaceId, scripts) => updates.push(scripts),
pollIntervalMs: 10,
probeTimeoutMs: 100,
graceMs: 0,
failuresBeforeStopped: 1,
});
monitor.start();
try {
await vi.waitFor(() => {
expect(updates).toEqual([
[
{
scriptName: "api",
hostname: "api--repo.localhost",
port: service.port,
health: "healthy",
},
],
]);
});
} finally {
monitor.stop();
}
});
it("starts new service routes in pending and transitions to healthy after the grace period", async () => {
vi.useFakeTimers();
@@ -1,4 +1,4 @@
import net from "node:net";
import { connectToLocalService } from "./local-service-connection.js";
import type { ServiceProxyHealthTarget, ServiceProxySubsystem } from "./service-proxy.js";
export type ScriptHealthState = "pending" | "healthy" | "unhealthy";
@@ -208,7 +208,7 @@ export class ScriptHealthMonitor {
private probeRoute(port: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = net.connect({ host: "127.0.0.1", port });
const socket = connectToLocalService(port);
let settled = false;
const finish = (healthy: boolean) => {
@@ -32,15 +32,20 @@ function readServerSourceFiles(dir = path.resolve(import.meta.dirname)): string[
}
interface HttpGetOptions {
hostname?: string;
path?: string;
headers?: Record<string, string>;
}
function httpGet(port: number, host: string, options: HttpGetOptions = {}) {
const { path: requestPath = "/api/health", headers: extraHeaders = {} } = options;
const {
hostname = "127.0.0.1",
path: requestPath = "/api/health",
headers: extraHeaders = {},
} = options;
return new Promise<{ status: number; body: string }>((resolve, reject) => {
const req = http.get(
{ hostname: "127.0.0.1", port, path: requestPath, headers: { host, ...extraHeaders } },
{ hostname, port, path: requestPath, headers: { host, ...extraHeaders } },
(res) => {
let body = "";
res.on("data", (chunk: Buffer) => {
@@ -53,6 +58,31 @@ function httpGet(port: number, host: string, options: HttpGetOptions = {}) {
});
}
function httpPost(port: number, host: string, requestPath: string, payload: string) {
return new Promise<{ status: number; body: string }>((resolve, reject) => {
const request = http.request(
{
hostname: "127.0.0.1",
port,
method: "POST",
path: requestPath,
headers: { host, "content-type": "application/json" },
},
(res) => {
let body = "";
res.on("data", (chunk: Buffer) => {
body += chunk.toString();
});
res.on("end", () => resolve({ status: res.statusCode ?? 0, body }));
res.on("error", reject);
},
);
request.on("error", reject);
request.write(payload.slice(0, 5));
request.end(payload.slice(5));
});
}
describe("service proxy subsystem shape", () => {
it("keeps production imports behind the service-proxy entrypoint", () => {
const offenders: string[] = [];
@@ -303,6 +333,7 @@ describe("service proxy subsystem shape", () => {
});
interface ForwardedFixture {
upstreamPort: number;
daemonPort: number;
hostname: string;
close(): Promise<void>;
@@ -313,11 +344,16 @@ interface ForwardedFixture {
* echoes the headers it received so tests can assert what actually crossed the
* proxy, not what a helper returned.
*/
async function startForwardedHeadersFixture(): Promise<ForwardedFixture> {
const upstreamPort = await findFreePort();
async function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise<ForwardedFixture> {
const upstream = http.createServer((req, res) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(req.headers));
let body = "";
req.on("data", (chunk: Buffer) => {
body += chunk.toString();
});
req.on("end", () => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ...req.headers, method: req.method, url: req.url, body }));
});
});
const upgradeSockets: net.Socket[] = [];
upstream.on("upgrade", (req, socket) => {
@@ -331,7 +367,11 @@ async function startForwardedHeadersFixture(): Promise<ForwardedFixture> {
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Echo-Length: ${payload.length}\r\n\r\n${payload}`,
);
});
await new Promise<void>((resolve) => upstream.listen(upstreamPort, "127.0.0.1", resolve));
await new Promise<void>((resolve, reject) => {
upstream.once("error", reject);
upstream.listen(0, upstreamHost, resolve);
});
const upstreamPort = (upstream.address() as net.AddressInfo).port;
const serviceProxy = createServiceProxySubsystem({ logger });
const route = serviceProxy.registerWorkspaceService({
@@ -354,6 +394,7 @@ async function startForwardedHeadersFixture(): Promise<ForwardedFixture> {
await new Promise<void>((resolve) => daemon.listen(daemonPort, "127.0.0.1", resolve));
return {
upstreamPort,
daemonPort,
hostname: route.hostname,
async close() {
@@ -416,6 +457,59 @@ function upgradeThroughProxy(
});
}
describe("service proxy loopback services", () => {
it.each(["127.0.0.1", "::1"])("forwards HTTP to a service bound only to %s", async (host) => {
const fixture = await startForwardedHeadersFixture(host);
try {
const direct = await httpGet(fixture.upstreamPort, fixture.hostname, { hostname: host });
expect(direct.status).toBe(200);
const response = await httpGet(fixture.daemonPort, fixture.hostname);
expect(response.status).toBe(200);
expect(JSON.parse(response.body).host).toBe(fixture.hostname);
} finally {
await fixture.close();
}
});
it.each(["127.0.0.1", "::1"])(
"streams POST bodies to a service bound only to %s",
async (host) => {
const fixture = await startForwardedHeadersFixture(host);
try {
const payload = JSON.stringify({ message: "hello" });
const response = await httpPost(
fixture.daemonPort,
fixture.hostname,
"/submit?q=one%20two",
payload,
);
expect(response.status).toBe(200);
expect(JSON.parse(response.body)).toMatchObject({
host: fixture.hostname,
"content-type": "application/json",
method: "POST",
url: "/submit?q=one%20two",
body: payload,
});
} finally {
await fixture.close();
}
},
);
it.each(["127.0.0.1", "::1"])("forwards upgrades to a service bound only to %s", async (host) => {
const fixture = await startForwardedHeadersFixture(host);
try {
const headers = await upgradeThroughProxy(fixture.daemonPort, fixture.hostname);
expect(headers.host).toBe(fixture.hostname);
expect(headers.upgrade).toBe("websocket");
} finally {
await fixture.close();
}
});
});
describe("service proxy forwarded headers", () => {
it("forwards the client authority with its port so services build reachable URLs", async () => {
const fixture = await startForwardedHeadersFixture();
+4 -3
View File
@@ -1,5 +1,6 @@
import http, { createServer as createHTTPServer } from "node:http";
import net from "node:net";
import { connectToLocalService, requestLocalService } from "./local-service-connection.js";
import { createHash } from "node:crypto";
import { existsSync, unlinkSync } from "node:fs";
import type { IncomingMessage } from "node:http";
@@ -328,9 +329,8 @@ function proxyHttpRequest({
}): void {
const forwardedHeaders = buildForwardedHeaders({ req, route, protocol: req.protocol });
const proxyReq = http.request(
const proxyReq = requestLocalService(
{
hostname: "127.0.0.1",
port: route.port,
path: req.originalUrl,
method: req.method,
@@ -368,7 +368,8 @@ function proxyUpgradeRequest({
route: ServiceProxyRoute;
logger: Logger;
}): void {
const targetSocket = net.connect({ host: "127.0.0.1", port: route.port }, () => {
const targetSocket = connectToLocalService(route.port);
targetSocket.on("connect", () => {
const forwardedHeaders = buildForwardedHeaders({ req, route, protocol: "http" });
forwardedHeaders.connection = "Upgrade";
forwardedHeaders.upgrade = req.headers.upgrade ?? "websocket";