mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
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:
co-authored by
Marcelo Barbosa
parent
e8108d9092
commit
b0f054de90
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user