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
+1 -1
View File
@@ -4,7 +4,7 @@ Paseo proxies HTTP traffic to services running inside your workspaces. Localhost
## How it works
When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a local port and registers a route in the service proxy. Incoming requests whose `Host` header matches the script's generated hostname are forwarded to that port.
When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a local port and registers a route in the service proxy. Incoming requests whose `Host` header matches the script's generated hostname are forwarded to that port. HTTP requests, WebSocket upgrades, and service health checks select the reachable IPv4 or IPv6 loopback address. Services can bind to `127.0.0.1` or `::1` without changing their proxy URL.
The generated hostname is built from the script name, branch, and project:
@@ -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";