diff --git a/docs/service-proxy.md b/docs/service-proxy.md index 3ec3144289..cab7f42774 100644 --- a/docs/service-proxy.md +++ b/docs/service-proxy.md @@ -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: diff --git a/packages/server/src/server/local-service-connection.ts b/packages/server/src/server/local-service-connection.ts new file mode 100644 index 0000000000..a96a0a7094 --- /dev/null +++ b/packages/server/src/server/local-service-connection.ts @@ -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, + onResponse: (response: http.IncomingMessage) => void, +): http.ClientRequest { + const requestOptions = { + ...options, + hostname: "localhost", + autoSelectFamily: true, + lookup: lookupLoopback, + }; + return http.request(requestOptions, onResponse); +} diff --git a/packages/server/src/server/script-health-monitor.test.ts b/packages/server/src/server/script-health-monitor.test.ts index bbc5acf3c7..96af0133ca 100644 --- a/packages/server/src/server/script-health-monitor.test.ts +++ b/packages/server/src/server/script-health-monitor.test.ts @@ -99,14 +99,14 @@ function createStubTerminalManager( }; } -async function startTcpServer(): Promise { +async function startTcpServer(host = "127.0.0.1"): Promise { const server = net.createServer((socket) => { socket.end(); }); await new Promise((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(); diff --git a/packages/server/src/server/script-health-monitor.ts b/packages/server/src/server/script-health-monitor.ts index 810518f999..7992608f24 100644 --- a/packages/server/src/server/script-health-monitor.ts +++ b/packages/server/src/server/script-health-monitor.ts @@ -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 { 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) => { diff --git a/packages/server/src/server/service-proxy.test.ts b/packages/server/src/server/service-proxy.test.ts index 5313b01d2d..5764b19539 100644 --- a/packages/server/src/server/service-proxy.test.ts +++ b/packages/server/src/server/service-proxy.test.ts @@ -32,15 +32,20 @@ function readServerSourceFiles(dir = path.resolve(import.meta.dirname)): string[ } interface HttpGetOptions { + hostname?: string; path?: string; headers?: Record; } 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; @@ -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 { - const upstreamPort = await findFreePort(); +async function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise { 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 { `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((resolve) => upstream.listen(upstreamPort, "127.0.0.1", resolve)); + await new Promise((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 { await new Promise((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(); diff --git a/packages/server/src/server/service-proxy.ts b/packages/server/src/server/service-proxy.ts index 525a95ac5d..ef93a27f9a 100644 --- a/packages/server/src/server/service-proxy.ts +++ b/packages/server/src/server/service-proxy.ts @@ -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";