diff --git a/.changeset/dashboard-public-url.md b/.changeset/dashboard-public-url.md new file mode 100644 index 000000000..433d7463c --- /dev/null +++ b/.changeset/dashboard-public-url.md @@ -0,0 +1,7 @@ +--- +"@aoagents/ao-cli": minor +--- + +Add `AO_PUBLIC_URL` environment variable for users running AO behind a reverse proxy (remote dev containers, VPS deployments, internal tooling). When set, all user-facing dashboard URLs — `ao start` / `ao dashboard` console output, `ao open` browser launches, and `projectSessionUrl()` links surfaced to the orchestrator agent — use the public URL instead of `http://localhost:`. Internal IPC (`daemon.ts` reload calls) still uses localhost since that traffic stays on the host. + +Also documents the existing `TERMINAL_WS_PATH` env var and the dashboard's automatic path-based mux WebSocket routing for standard-port (HTTPS / HTTP) deployments — these together let users front AO with a single hostname and one reverse-proxy rule, no extra ports or subdomains. diff --git a/SETUP.md b/SETUP.md index 81782df10..08402f832 100644 --- a/SETUP.md +++ b/SETUP.md @@ -59,6 +59,13 @@ Comprehensive guide to installing, configuring, and troubleshooting Agent Orches - Create incoming webhook: https://api.slack.com/messaging/webhooks - Set environment variable: `export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/..."` +- **Public dashboard URL** - If running AO behind a reverse proxy (e.g. inside a remote dev container, on a VPS fronted by Caddy/nginx/Traefik) + - Set `AO_PUBLIC_URL` to the externally-reachable URL of the dashboard + - All console output, `ao open` browser launches, and orchestrator-prompt session links use this URL instead of `http://localhost:` + - Example: `export AO_PUBLIC_URL="https://ao.example.com"` + - When the dashboard is served on a standard port (HTTPS 443 / HTTP 80) the dashboard JS connects the mux WebSocket to `/ao-terminal-mux` on the same hostname. Your proxy needs to forward that path to the direct terminal server (`DIRECT_TERMINAL_PORT`, default 14801) — its upgrade handler accepts both `/mux` and `/ao-terminal-mux`. For custom paths set `TERMINAL_WS_PATH=/your/path`. + - **`AO_PATH_BASED_MUX=1`** (opt-in) — if your proxy can only forward one hostname:port pair (e.g. Cloudflare Tunnel pointed at a single `service:` URL with no path-based ingress), set this and `ao start` will run a small bundled HTTP/WS proxy on `PORT` that demultiplexes: HTTP forwards to Next.js (shifted to `PORT + 1000`, override with `NEXT_INTERNAL_PORT`), and `wss://hostname/ao-terminal-mux` is tunneled to `DIRECT_TERMINAL_PORT/mux`. Tradeoff: an extra Node process and one extra hop per HTTP request, in exchange for a one-line proxy config on the operator side. + ## Installation ### Install via npm (recommended) diff --git a/packages/cli/__tests__/lib/dashboard-url.test.ts b/packages/cli/__tests__/lib/dashboard-url.test.ts new file mode 100644 index 000000000..e97b83b3f --- /dev/null +++ b/packages/cli/__tests__/lib/dashboard-url.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { dashboardUrl } from "../../src/lib/dashboard-url.js"; + +describe("dashboardUrl", () => { + const original = process.env.AO_PUBLIC_URL; + + beforeEach(() => { + delete process.env.AO_PUBLIC_URL; + }); + + afterEach(() => { + if (original === undefined) { + delete process.env.AO_PUBLIC_URL; + } else { + process.env.AO_PUBLIC_URL = original; + } + }); + + it("falls back to localhost when AO_PUBLIC_URL is unset", () => { + expect(dashboardUrl(3000)).toBe("http://localhost:3000"); + }); + + it("falls back to localhost when AO_PUBLIC_URL is empty", () => { + process.env.AO_PUBLIC_URL = ""; + expect(dashboardUrl(8094)).toBe("http://localhost:8094"); + }); + + it("falls back to localhost when AO_PUBLIC_URL is whitespace only", () => { + process.env.AO_PUBLIC_URL = " "; + expect(dashboardUrl(8094)).toBe("http://localhost:8094"); + }); + + it("uses AO_PUBLIC_URL when set", () => { + process.env.AO_PUBLIC_URL = "https://ao.example.com"; + expect(dashboardUrl(3000)).toBe("https://ao.example.com"); + }); + + it("ignores the port argument when AO_PUBLIC_URL is set", () => { + process.env.AO_PUBLIC_URL = "https://ao.example.com"; + expect(dashboardUrl(3000)).toBe("https://ao.example.com"); + expect(dashboardUrl(8094)).toBe("https://ao.example.com"); + }); + + it("strips a trailing slash from AO_PUBLIC_URL", () => { + process.env.AO_PUBLIC_URL = "https://ao.example.com/"; + expect(dashboardUrl(3000)).toBe("https://ao.example.com"); + }); + + it("strips multiple trailing slashes from AO_PUBLIC_URL", () => { + process.env.AO_PUBLIC_URL = "https://ao.example.com///"; + expect(dashboardUrl(3000)).toBe("https://ao.example.com"); + }); + + it("preserves a sub-path in AO_PUBLIC_URL", () => { + process.env.AO_PUBLIC_URL = "https://example.com/ao"; + expect(dashboardUrl(3000)).toBe("https://example.com/ao"); + }); + + it("trims surrounding whitespace from AO_PUBLIC_URL", () => { + process.env.AO_PUBLIC_URL = " https://ao.example.com "; + expect(dashboardUrl(3000)).toBe("https://ao.example.com"); + }); + + it("supports a non-default port in AO_PUBLIC_URL", () => { + process.env.AO_PUBLIC_URL = "http://192.168.1.5:9000"; + expect(dashboardUrl(3000)).toBe("http://192.168.1.5:9000"); + }); +}); diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index addd9bd30..f9abbf817 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -12,6 +12,7 @@ import { } from "../lib/dashboard-rebuild.js"; import { preflight } from "../lib/preflight.js"; import { DEFAULT_PORT } from "../lib/constants.js"; +import { dashboardUrl } from "../lib/dashboard-url.js"; export function registerDashboard(program: Command): void { program @@ -42,7 +43,7 @@ export function registerDashboard(program: Command): void { const webDir = localWebDir; - console.log(chalk.bold(`Starting dashboard on http://localhost:${port}\n`)); + console.log(chalk.bold(`Starting dashboard on ${dashboardUrl(port)}\n`)); const env = await buildDashboardEnv( port, @@ -90,7 +91,7 @@ export function registerDashboard(program: Command): void { if (opts.open !== false) { openAbort = new AbortController(); - void waitForPortAndOpen(port, `http://localhost:${port}`, openAbort.signal); + void waitForPortAndOpen(port, dashboardUrl(port), openAbort.signal); } child.on("exit", (code) => { diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 0602bba8b..27e660bd8 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -87,6 +87,7 @@ import { type DetectedAgent, } from "../lib/detect-agent.js"; import { detectDefaultBranch } from "../lib/git-utils.js"; +import { dashboardUrl } from "../lib/dashboard-url.js"; import { promptConfirm, promptSelect, promptText } from "../lib/prompts.js"; import { extractOwnerRepo, isValidRepoString } from "../lib/repo-utils.js"; import { @@ -936,7 +937,7 @@ async function runStartup( config.directTerminalPort, opts?.dev, ); - spinner.succeed(`Dashboard starting on http://localhost:${port}`); + spinner.succeed(`Dashboard starting on ${dashboardUrl(port)}`); console.log(chalk.dim(" (Dashboard will be ready in a few seconds)\n")); } @@ -1188,7 +1189,7 @@ async function runStartup( console.log(chalk.bold.green("\n✓ Startup complete\n")); if (opts?.dashboard !== false) { - console.log(chalk.cyan("Dashboard:"), `http://localhost:${port}`); + console.log(chalk.cyan("Dashboard:"), dashboardUrl(port)); } if (shouldStartLifecycle) { @@ -1222,7 +1223,7 @@ async function runStartup( openAbort = new AbortController(); const orchestratorUrl = selectedOrchestratorId ? projectSessionUrl(port, projectId, selectedOrchestratorId) - : `http://localhost:${port}`; + : dashboardUrl(port); void waitForPortAndOpen(port, orchestratorUrl, openAbort.signal); } @@ -1417,10 +1418,10 @@ async function attachAndSpawnOrchestrator(opts: { } if (isHumanCaller()) { - console.log(chalk.dim(` Opening dashboard: http://localhost:${daemon.port}\n`)); - openUrl(`http://localhost:${daemon.port}`); + console.log(chalk.dim(` Opening dashboard: ${dashboardUrl(daemon.port)}\n`)); + openUrl(dashboardUrl(daemon.port)); } else { - console.log(`Dashboard: http://localhost:${daemon.port}`); + console.log(`Dashboard: ${dashboardUrl(daemon.port)}`); } } @@ -1502,7 +1503,7 @@ export function registerStart(program: Command): void { // exit. Project-id args fall through to attach+spawn so // automation can `ao start ` against a live daemon. console.log(`AO is already running.`); - console.log(`Dashboard: http://localhost:${running.port}`); + console.log(`Dashboard: ${dashboardUrl(running.port)}`); console.log(`PID: ${running.pid}`); console.log(`Projects: ${running.projects.join(", ")}`); console.log(`To restart: ao stop && ao start`); @@ -1512,7 +1513,7 @@ export function registerStart(program: Command): void { if (isHumanCaller() && !projectArg) { console.log(chalk.cyan(`\nℹ AO is already running.`)); - console.log(` Dashboard: ${chalk.cyan(`http://localhost:${running.port}`)}`); + console.log(` Dashboard: ${chalk.cyan(dashboardUrl(running.port))}`); console.log(` PID: ${running.pid} | Up since: ${running.startedAt}`); console.log(` Projects: ${running.projects.join(", ")}\n`); @@ -1559,7 +1560,7 @@ export function registerStart(program: Command): void { ); if (choice === "open") { - openUrl(`http://localhost:${running.port}`); + openUrl(dashboardUrl(running.port)); unlockStartup(); process.exit(0); } else if (choice === "quit") { @@ -1591,7 +1592,7 @@ export function registerStart(program: Command): void { ), ); } - openUrl(`http://localhost:${running.port}`); + openUrl(dashboardUrl(running.port)); unlockStartup(); process.exit(0); } else if (choice === "new") { @@ -1677,9 +1678,9 @@ export function registerStart(program: Command): void { running.projects.includes(projectId) ) { console.log(chalk.cyan(`\nℹ AO is already running.`)); - console.log(` Dashboard: ${chalk.cyan(`http://localhost:${running.port}`)}`); + console.log(` Dashboard: ${chalk.cyan(dashboardUrl(running.port))}`); console.log(` Project "${projectId}" is already registered and running.\n`); - openUrl(`http://localhost:${running.port}`); + openUrl(dashboardUrl(running.port)); unlockStartup(); process.exit(0); } diff --git a/packages/cli/src/lib/dashboard-url.ts b/packages/cli/src/lib/dashboard-url.ts new file mode 100644 index 000000000..a27c2c2a2 --- /dev/null +++ b/packages/cli/src/lib/dashboard-url.ts @@ -0,0 +1,24 @@ +/** + * Returns the user-facing base URL of the dashboard. + * + * When `AO_PUBLIC_URL` is set in the environment, AO is being fronted by a + * reverse proxy (e.g. when running inside a remote dev container or behind + * Caddy/nginx). All console output, `ao open` browser launches, and session + * URLs surfaced to humans should use that public URL instead of localhost. + * + * The trailing slash is stripped for consistency so callers can append paths + * without producing `//`. + * + * Internal IPC (the daemon hitting its own dashboard's API) is intentionally + * **not** routed through this helper — those calls always use localhost since + * they happen on the same host as the dashboard process. + * + * @param port - the local dashboard port; only used in the localhost fallback + */ +export function dashboardUrl(port: number): string { + const publicUrl = process.env.AO_PUBLIC_URL?.trim(); + if (publicUrl) { + return publicUrl.replace(/\/+$/, ""); + } + return `http://localhost:${port}`; +} diff --git a/packages/cli/src/lib/routes.ts b/packages/cli/src/lib/routes.ts index 959b76424..1aab21280 100644 --- a/packages/cli/src/lib/routes.ts +++ b/packages/cli/src/lib/routes.ts @@ -1,3 +1,5 @@ +import { dashboardUrl } from "./dashboard-url.js"; + export function projectSessionUrl(port: number, projectId: string, sessionId: string): string { - return `http://localhost:${port}/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}`; + return `${dashboardUrl(port)}/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}`; } diff --git a/packages/web/server/__tests__/direct-terminal-ws.integration.test.ts b/packages/web/server/__tests__/direct-terminal-ws.integration.test.ts index 39f2caaa6..236a0f84e 100644 --- a/packages/web/server/__tests__/direct-terminal-ws.integration.test.ts +++ b/packages/web/server/__tests__/direct-terminal-ws.integration.test.ts @@ -180,6 +180,19 @@ describeWithTmux("WebSocket upgrade routing", () => { ws.close(); }); + it("accepts connections on /ao-terminal-mux (alias for /mux)", async () => { + // The dashboard's MuxProvider uses this path on standard-port deployments + // so a path-routing reverse proxy can forward it here without a rewrite. + const ws = await new Promise((resolve, reject) => { + const sock = new WebSocket(`ws://localhost:${port}/ao-terminal-mux`); + sock.on("open", () => resolve(sock)); + sock.on("error", reject); + setTimeout(() => reject(new Error("WebSocket connect timeout")), 5000); + }); + expect(ws.readyState).toBe(WebSocket.OPEN); + ws.close(); + }); + it("destroys connections on unknown paths", async () => { const result = await new Promise<{ code: number }>((resolve) => { const ws = new WebSocket(`ws://localhost:${port}/ws`); diff --git a/packages/web/server/__tests__/single-port-server.integration.test.ts b/packages/web/server/__tests__/single-port-server.integration.test.ts new file mode 100644 index 000000000..249f4f039 --- /dev/null +++ b/packages/web/server/__tests__/single-port-server.integration.test.ts @@ -0,0 +1,308 @@ +/** + * Integration tests for single-port-server — the opt-in HTTP + WebSocket + * proxy used when AO_PATH_BASED_MUX=1. + * + * These pin the four behaviours surfaced in review of PR #1757: + * 1. Hop-by-hop request headers are stripped before reaching the upstream. + * 2. X-Forwarded-For/-Proto/-Host are added so the upstream sees the client. + * 3. A non-101 response on the WS upgrade path is relayed, not left to hang. + * 4. shutdown() closes promptly even with a live connection open. + * + * The proxy is pointed at lightweight fake upstreams, so no tmux / Next.js is + * needed — these run everywhere, including CI on Windows. + */ + +import { describe, it, expect, afterEach } from "vitest"; +import { + createServer as createHttpServer, + request, + type Server, + type IncomingMessage, +} from "node:http"; +import { connect as netConnect, type AddressInfo } from "node:net"; +import { randomBytes } from "node:crypto"; +import { WebSocketServer, WebSocket } from "ws"; +import { createSinglePortServer, type SinglePortServer } from "../single-port-server.js"; + +// ============================================================================= +// Teardown registry — every server/proxy created by a test is closed here. +// ============================================================================= + +const closers: Array<() => void | Promise> = []; + +afterEach(async () => { + while (closers.length > 0) { + const close = closers.pop(); + if (close) await close(); + } +}); + +function portOf(server: Server): number { + const addr = server.address() as AddressInfo | null; + return addr ? addr.port : 0; +} + +/** A fake "Next.js" upstream that echoes the request it received as JSON. */ +async function startEchoUpstream(): Promise { + const server = createHttpServer((req, res) => { + let body = ""; + req.on("data", (c: Buffer) => (body += c.toString())); + req.on("end", () => { + // Set an explicit Content-Length (not chunked) so the raw-socket test + // can read the body without decoding chunk framing. + const payload = JSON.stringify({ + method: req.method, + url: req.url, + headers: req.headers, + body, + }); + res.writeHead(200, { + "content-type": "application/json", + "content-length": Buffer.byteLength(payload), + }); + res.end(payload); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + closers.push(() => new Promise((r) => server.close(() => r()))); + return portOf(server); +} + +/** A fake direct-terminal-ws upstream: real WS server on /mux that echoes. */ +async function startWsUpstream(): Promise { + const server = createHttpServer(); + const wss = new WebSocketServer({ server, path: "/mux" }); + wss.on("connection", (socket) => { + socket.on("message", (data) => socket.send(data)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + closers.push(() => { + wss.close(); + return new Promise((r) => server.close(() => r())); + }); + return portOf(server); +} + +/** + * A fake upstream that answers a WS upgrade with a plain non-101 response — + * the case fix #3 exists for. + */ +async function startNon101Upstream(): Promise { + const server = createHttpServer(); + server.on("upgrade", (_req, socket) => { + socket.end( + "HTTP/1.1 503 Service Unavailable\r\n" + + "content-type: text/plain\r\n" + + "content-length: 11\r\n" + + "connection: close\r\n\r\n" + + "unavailable", + ); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + closers.push(() => new Promise((r) => server.close(() => r()))); + return portOf(server); +} + +/** Start the proxy in front of the given upstream ports. */ +async function startProxy(opts: { + nextInternalPort: number; + directTerminalPort: number; +}): Promise<{ proxy: SinglePortServer; port: number }> { + const proxy = createSinglePortServer({ + port: 0, + nextInternalPort: opts.nextInternalPort, + directTerminalPort: opts.directTerminalPort, + }); + await proxy.listen(); + closers.push(() => proxy.shutdown()); + return { proxy, port: portOf(proxy.server) }; +} + +/** + * Write a raw HTTP request and collect the full raw response. `closedByPeer` + * reports whether the server closed the connection on its own — used to prove + * the proxy honoured the client's `Connection: close` instead of forwarding + * the upstream's keep-alive. + */ +function rawHttpRequest( + port: number, + requestLines: string[], +): Promise<{ raw: string; closedByPeer: boolean }> { + return new Promise((resolve, reject) => { + const socket = netConnect({ port, host: "127.0.0.1" }, () => { + socket.write(requestLines.join("\r\n") + "\r\n\r\n"); + }); + let raw = ""; + let closedByPeer = false; + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => (raw += chunk)); + socket.on("error", reject); + socket.on("end", () => { + closedByPeer = true; + resolve({ raw, closedByPeer }); + }); + setTimeout(() => { + socket.destroy(); + resolve({ raw, closedByPeer }); + }, 2000); + }); +} + +// ============================================================================= +// HTTP forwarding — header sanitisation +// ============================================================================= + +describe("single-port HTTP forwarding", () => { + it("strips hop-by-hop headers and adds X-Forwarded-* before reaching upstream", async () => { + const nextPort = await startEchoUpstream(); + const { port } = await startProxy({ nextInternalPort: nextPort, directTerminalPort: 1 }); + + const { raw, closedByPeer } = await rawHttpRequest(port, [ + "GET /echo HTTP/1.1", + "Host: dashboard.example", + // "close" ends our client connection; "x-custom-hop" is named in + // Connection so it is hop-by-hop too — both must be stripped. + "Connection: close, X-Custom-Hop", + "X-Custom-Hop: should-be-stripped", + "Keep-Alive: timeout=99", + "X-Forwarded-For: 1.2.3.4", + ]); + + // The proxy must honour the client's Connection: close and not forward + // the upstream's keep-alive — otherwise the connection lingers. + expect(closedByPeer).toBe(true); + + const body = raw.slice(raw.indexOf("\r\n\r\n") + 4); + const seen = JSON.parse(body) as { headers: Record }; + + // The client's hop-by-hop headers must not leak to the upstream. + // X-Custom-Hop is listed in the client's Connection header, marking it + // hop-by-hop — its absence proves Connection-token parsing works. + expect(seen.headers["x-custom-hop"]).toBeUndefined(); + // The standard hop-by-hop Keep-Alive header is dropped. + expect(seen.headers["keep-alive"]).not.toBe("timeout=99"); + // The client's Connection value ("close") must not propagate. The + // proxy↔upstream hop has its own Connection header, managed by Node — + // that is correct and expected, so only assert the client's value is gone. + expect(String(seen.headers.connection ?? "")).not.toMatch(/close|x-custom-hop/); + + // X-Forwarded-For preserves the prior value and appends the proxy client. + expect(seen.headers["x-forwarded-for"]).toMatch(/^1\.2\.3\.4, /); + expect(seen.headers["x-forwarded-proto"]).toBe("http"); + expect(seen.headers["x-forwarded-host"]).toBe("dashboard.example"); + }); + + it("returns 502 when the Next.js upstream is unreachable", async () => { + // Point at a port nothing is listening on. + const { port } = await startProxy({ nextInternalPort: 1, directTerminalPort: 1 }); + + const status = await new Promise((resolve, reject) => { + const req = request( + { host: "127.0.0.1", port, path: "/", method: "GET" }, + (res: IncomingMessage) => { + res.resume(); + resolve(res.statusCode ?? 0); + }, + ); + req.on("error", reject); + req.end(); + }); + + expect(status).toBe(502); + }); +}); + +// ============================================================================= +// WebSocket upgrade path +// ============================================================================= + +describe("single-port WebSocket upgrade", () => { + it("tunnels /ao-terminal-mux to the terminal upstream's /mux", async () => { + const wsPort = await startWsUpstream(); + const { port } = await startProxy({ nextInternalPort: 1, directTerminalPort: wsPort }); + + const ws = await new Promise((resolve, reject) => { + const sock = new WebSocket(`ws://127.0.0.1:${port}/ao-terminal-mux`); + sock.on("open", () => resolve(sock)); + sock.on("error", reject); + setTimeout(() => reject(new Error("WS connect timeout")), 3000); + }); + + const echoed = await new Promise((resolve, reject) => { + ws.on("message", (data) => resolve(data.toString())); + ws.on("error", reject); + ws.send("ping-through-proxy"); + setTimeout(() => reject(new Error("WS echo timeout")), 3000); + }); + + expect(echoed).toBe("ping-through-proxy"); + ws.close(); + }); + + it("relays a non-101 upstream response instead of hanging the client", async () => { + // Regression test for the WS-upgrade hang: before the `response` handler, + // a non-101 upstream answer left the client socket open until TCP timeout. + const badPort = await startNon101Upstream(); + const { port } = await startProxy({ nextInternalPort: 1, directTerminalPort: badPort }); + + const result = await new Promise<{ type: string; status?: number }>((resolve) => { + const req = request({ + host: "127.0.0.1", + port, + path: "/ao-terminal-mux", + headers: { + Connection: "Upgrade", + Upgrade: "websocket", + "Sec-WebSocket-Version": "13", + // Generated rather than hardcoded — a literal base64 nonce trips + // the repo's gitleaks pre-commit hook as a high-entropy string. + "Sec-WebSocket-Key": randomBytes(16).toString("base64"), + }, + }); + req.on("upgrade", (res) => resolve({ type: "upgrade", status: res.statusCode })); + req.on("response", (res) => { + res.resume(); + resolve({ type: "response", status: res.statusCode }); + }); + req.on("error", () => resolve({ type: "error" })); + // If the proxy hangs, none of the above fire and this surfaces it. + setTimeout(() => resolve({ type: "hang" }), 3000); + req.end(); + }); + + expect(result.type).toBe("response"); + expect(result.status).toBe(503); + }); +}); + +// ============================================================================= +// Shutdown +// ============================================================================= + +describe("single-port shutdown", () => { + it("closes promptly with a live WebSocket connection open", async () => { + // Regression test for shutdown hitting the force-exit timer: server.close() + // alone waits forever for the piped WS tunnel; closeAllConnections() fixes it. + const wsPort = await startWsUpstream(); + const { proxy, port } = await startProxy({ + nextInternalPort: 1, + directTerminalPort: wsPort, + }); + + const ws = await new Promise((resolve, reject) => { + const sock = new WebSocket(`ws://127.0.0.1:${port}/ao-terminal-mux`); + sock.on("open", () => resolve(sock)); + sock.on("error", reject); + setTimeout(() => reject(new Error("WS connect timeout")), 3000); + }); + + const start = Date.now(); + await proxy.shutdown(); + const elapsed = Date.now() - start; + + // Comfortably under the 5s force-exit timer the entrypoint arms. + expect(elapsed).toBeLessThan(1000); + + ws.close(); + }); +}); diff --git a/packages/web/server/direct-terminal-ws.ts b/packages/web/server/direct-terminal-ws.ts index 99f028b1e..b54ff73c9 100644 --- a/packages/web/server/direct-terminal-ws.ts +++ b/packages/web/server/direct-terminal-ws.ts @@ -61,10 +61,15 @@ export function createDirectTerminalServer(tmuxPath?: string | null): DirectTerm // Manual upgrade routing — ws library doesn't support multiple WebSocketServer // instances with different `path` options on the same HTTP server. + // `/ao-terminal-mux` is accepted as an alias of `/mux` so deployments fronted + // by a path-routing reverse proxy (e.g. cloudflared, nginx) can forward the + // dashboard's path-based mux URL straight at this port without needing a + // path-rewrite rule. The dashboard's MuxProvider already constructs that + // path when accessed on a standard HTTPS port; see `packages/web/src/providers/MuxProvider.tsx`. server.on("upgrade", (request, socket, head) => { const pathname = new URL(request.url ?? "/", "ws://localhost").pathname; - if (pathname === "/mux" && muxWss) { + if ((pathname === "/mux" || pathname === "/ao-terminal-mux") && muxWss) { muxWss.handleUpgrade(request, socket, head, (ws) => { muxWss!.emit("connection", ws, request); }); diff --git a/packages/web/server/single-port-server.ts b/packages/web/server/single-port-server.ts new file mode 100644 index 000000000..d344d0df6 --- /dev/null +++ b/packages/web/server/single-port-server.ts @@ -0,0 +1,378 @@ +/** + * Single-port server (opt-in) — a thin HTTP + WebSocket proxy that puts + * Next.js and the `/ao-terminal-mux` WebSocket upgrade on the same public + * port. Spawned by start-all.ts when AO_PATH_BASED_MUX=1, in front of a + * Next.js process that has shifted to an internal port. + * + * ┌──────────────────────┐ HTTP ┌──────────────────────┐ + * │ proxy on PORT │───────▶│ next start │ + * │ (this file) │ │ on NEXT_INTERNAL_PORT │ + * │ │ └──────────────────────┘ + * │ │ WS upgrade /ao-terminal-mux + * │ │───────▶┌──────────────────────┐ + * │ │ │ direct-terminal-ws │ + * │ │ │ on DIRECT_TERMINAL │ + * │ │ └──────────────────────┘ + * └──────────────────────┘ + * + * The default flow (AO_PATH_BASED_MUX unset) is unchanged: Next.js runs on + * PORT directly, direct-terminal-ws runs on DIRECT_TERMINAL_PORT, and the + * dashboard JS picks one of three URLs at connection time + * (see `packages/web/src/providers/MuxProvider.tsx`): + * + * 1. proxyWsPath (TERMINAL_WS_PATH) — explicit path-based routing + * 2. standard port (loc.port "" / 443 / 80) — `/ao-terminal-mux` on same host + * 3. fallback — direct connection to `:DIRECT_TERMINAL_PORT/mux` + * + * Path #1 and #3 require the operator to do something at the proxy layer + * (path rewrite or per-port routing). Path #2 only works if *something* is + * listening for the `/ao-terminal-mux` upgrade on the dashboard port. Until + * now, nothing was — Next.js doesn't handle upgrades, so the request fell + * through to its 404 handler. This server is that something. + * + * Use this when the reverse proxy in front of AO can only forward one + * hostname:port pair upstream (e.g. Cloudflare Tunnel pointed at one + * `service:` URL with no path-based ingress). With this enabled, a single + * proxy rule pointing at PORT is sufficient — the WS path is multiplexed + * onto the same TCP port and demuxed here. + * + * `createSinglePortServer()` is exported so the proxy behaviour can be + * exercised in tests; the bottom-of-file entrypoint wires it to env vars and + * process signals when this file is run directly (as start-all.ts spawns it). + */ + +import { + createServer, + request as httpRequest, + type IncomingHttpHeaders, + type IncomingMessage, + type OutgoingHttpHeaders, + type Server, +} from "node:http"; +import type { Socket } from "node:net"; +import { realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const MUX_PATH = "/ao-terminal-mux"; +const SHUTDOWN_TIMEOUT_MS = 5_000; + +/** + * Hop-by-hop headers (RFC 9110 §7.6.1) are meaningful only on a single + * transport connection and must not be forwarded by an intermediary. + * Forwarding e.g. a client's `Connection: close` would tear down the + * keep-alive socket to the upstream; a stray `Transfer-Encoding` would + * desync framing once the body is re-encoded. + */ +const HOP_BY_HOP = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); + +export interface SinglePortConfig { + /** Public-facing port this proxy listens on. */ + port: number; + /** Internal port Next.js has been shifted to. */ + nextInternalPort: number; + /** Port direct-terminal-ws listens on. */ + directTerminalPort: number; +} + +export interface SinglePortServer { + /** The underlying HTTP server — exposed for `.address()` in tests. */ + server: Server; + /** Begin listening on the configured port; resolves once bound. */ + listen(): Promise; + /** + * Stop listening and force-close every live connection, resolving once the + * server is fully closed. `server.close()` alone waits for keep-alive HTTP + * sockets and piped WS tunnels to drain on their own, which they never do. + */ + shutdown(): Promise; +} + +/** + * Build the header set for an upstream request: strip hop-by-hop headers + * (including any extra ones named in the client's `Connection` header) and + * append the standard `X-Forwarded-*` trio so the upstream still sees the + * real client IP / proto / host instead of `127.0.0.1`. + * + * On the WebSocket upgrade path, `keepUpgrade` retains `Connection` and + * `Upgrade` — the handshake is exactly the case where those headers are + * load-bearing rather than hop-by-hop noise. + */ +function buildUpstreamHeaders( + req: IncomingMessage, + opts: { keepUpgrade: boolean }, +): OutgoingHttpHeaders { + const drop = new Set(HOP_BY_HOP); + + const connection = req.headers.connection; + if (connection) { + const tokens = Array.isArray(connection) ? connection : connection.split(","); + for (const token of tokens) { + const name = token.trim().toLowerCase(); + if (name) drop.add(name); + } + } + if (opts.keepUpgrade) { + drop.delete("connection"); + drop.delete("upgrade"); + } + + const headers: OutgoingHttpHeaders = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (value === undefined) continue; + if (drop.has(key.toLowerCase())) continue; + headers[key] = value; + } + + // X-Forwarded-*: preserve anything an outer proxy already set, then add ours. + const clientIp = req.socket.remoteAddress ?? ""; + const priorFor = headers["x-forwarded-for"]; + headers["x-forwarded-for"] = priorFor + ? `${Array.isArray(priorFor) ? priorFor.join(", ") : String(priorFor)}, ${clientIp}` + : clientIp; + // This proxy itself terminates plain HTTP; an outer TLS proxy would have + // set x-forwarded-proto already, so only fill it in when absent. + if (headers["x-forwarded-proto"] === undefined) { + headers["x-forwarded-proto"] = "http"; + } + if (headers["x-forwarded-host"] === undefined && req.headers.host) { + headers["x-forwarded-host"] = req.headers.host; + } + return headers; +} + +/** + * Drop hop-by-hop headers from an upstream *response* before relaying it to + * the client. Without this the upstream's `Connection`/`Keep-Alive` would + * override the proxy↔client connection's own semantics — e.g. forwarding the + * upstream's `Connection: keep-alive` ignores a client that asked for `close`. + * Framing headers (`transfer-encoding`) are dropped here too so the client's + * `ServerResponse` re-derives framing for its own hop. + */ +function filterResponseHeaders(headers: IncomingHttpHeaders): OutgoingHttpHeaders { + const out: OutgoingHttpHeaders = {}; + for (const [key, value] of Object.entries(headers)) { + if (value === undefined) continue; + if (HOP_BY_HOP.has(key.toLowerCase())) continue; + out[key] = value; + } + return out; +} + +function tunnelUpgrade( + req: IncomingMessage, + clientSocket: Socket, + clientHead: Buffer, + target: { host: string; port: number; path: string }, +): void { + const proxyReq = httpRequest({ + host: target.host, + port: target.port, + method: "GET", + path: target.path, + headers: buildUpstreamHeaders(req, { keepUpgrade: true }), + }); + + proxyReq.on("upgrade", (proxyRes, proxySocket, proxyHead) => { + const lines = [ + `HTTP/1.1 ${proxyRes.statusCode ?? 101} ${proxyRes.statusMessage ?? "Switching Protocols"}`, + ]; + for (const [key, value] of Object.entries(proxyRes.headers)) { + if (value === undefined) continue; + lines.push(`${key}: ${Array.isArray(value) ? value.join(", ") : String(value)}`); + } + lines.push("\r\n"); + clientSocket.write(lines.join("\r\n")); + + if (proxyHead.length > 0) clientSocket.write(proxyHead); + if (clientHead.length > 0) proxySocket.write(clientHead); + + clientSocket.pipe(proxySocket); + proxySocket.pipe(clientSocket); + + const teardown = (): void => { + clientSocket.destroy(); + proxySocket.destroy(); + }; + proxySocket.on("error", teardown); + proxySocket.on("close", teardown); + clientSocket.on("error", teardown); + clientSocket.on("close", teardown); + }); + + // Upstream answered the upgrade with an ordinary response (404, 502, + // mid-restart, path not in its allow-list, …) instead of a 101. Without + // this handler the `upgrade` event never fires and the client socket + // hangs until its TCP timeout. Relay the response and close cleanly. + proxyReq.on("response", (proxyRes) => { + if (clientSocket.writableEnded || clientSocket.destroyed) { + proxyRes.destroy(); + return; + } + const lines = [`HTTP/1.1 ${proxyRes.statusCode ?? 502} ${proxyRes.statusMessage ?? ""}`]; + for (const [key, value] of Object.entries(proxyRes.headers)) { + if (value === undefined) continue; + const lower = key.toLowerCase(); + // Body is delimited by connection close below, so drop framing headers. + if (HOP_BY_HOP.has(lower) || lower === "content-length") continue; + lines.push(`${key}: ${Array.isArray(value) ? value.join(", ") : String(value)}`); + } + lines.push("connection: close"); + lines.push("\r\n"); + clientSocket.write(lines.join("\r\n")); + proxyRes.pipe(clientSocket); + proxyRes.on("end", () => clientSocket.end()); + }); + + proxyReq.on("error", (err) => { + console.error( + `[single-port] upstream upgrade error (${target.host}:${target.port}${target.path}): ${err.message}`, + ); + clientSocket.destroy(); + }); + + proxyReq.end(); +} + +/** + * Create the single-port proxy. The returned server is not yet listening — + * call `listen()`. + */ +export function createSinglePortServer(config: SinglePortConfig): SinglePortServer { + const { port, nextInternalPort, directTerminalPort } = config; + + // Sockets handed off via the 'upgrade' event are no longer tracked by the + // HTTP server, so `server.closeAllConnections()` does not destroy them and + // `server.close()`'s callback would wait on them forever. Track them here. + const upgradedSockets = new Set(); + + const server = createServer((req, res) => { + const proxyReq = httpRequest( + { + host: "127.0.0.1", + port: nextInternalPort, + method: req.method, + path: req.url, + headers: buildUpstreamHeaders(req, { keepUpgrade: false }), + }, + (proxyRes) => { + res.writeHead(proxyRes.statusCode ?? 502, filterResponseHeaders(proxyRes.headers)); + proxyRes.pipe(res); + }, + ); + + proxyReq.on("error", (err) => { + if (!res.headersSent) { + res.writeHead(502, { "content-type": "text/plain" }); + } + res.end(`Bad gateway: ${err.message}`); + }); + + req.pipe(proxyReq); + }); + + server.on("upgrade", (req, socket, head) => { + const clientSocket = socket as Socket; + upgradedSockets.add(clientSocket); + clientSocket.once("close", () => upgradedSockets.delete(clientSocket)); + + const pathname = new URL(req.url ?? "/", "http://localhost").pathname; + const target = + pathname === MUX_PATH + ? { host: "127.0.0.1", port: directTerminalPort, path: "/mux" } + : { host: "127.0.0.1", port: nextInternalPort, path: req.url ?? "/" }; + + tunnelUpgrade(req, clientSocket, head, target); + }); + + return { + server, + listen() { + return new Promise((resolve) => server.listen(port, () => resolve())); + }, + shutdown() { + return new Promise((resolve) => { + server.close(() => resolve()); + // closeAllConnections() handles keep-alive HTTP sockets; the upgraded + // WS tunnels are tracked separately and destroyed here. Destroying the + // client socket triggers tunnelUpgrade's teardown for the upstream side. + server.closeAllConnections(); + for (const socket of upgradedSockets) socket.destroy(); + }); + }, + }; +} + +/** Parse and validate the proxy config from env vars, exiting on bad input. */ +function configFromEnv(): SinglePortConfig { + const port = parseInt(process.env.PORT ?? "3000", 10); + const directTerminalPort = parseInt(process.env.DIRECT_TERMINAL_PORT ?? "14801", 10); + const nextInternalPort = parseInt(process.env.NEXT_INTERNAL_PORT ?? "0", 10); + + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + console.error(`[single-port] Invalid PORT: ${process.env.PORT}`); + process.exit(1); + } + if ( + !Number.isInteger(directTerminalPort) || + directTerminalPort < 1 || + directTerminalPort > 65_535 + ) { + console.error( + `[single-port] Invalid DIRECT_TERMINAL_PORT: ${process.env.DIRECT_TERMINAL_PORT}`, + ); + process.exit(1); + } + if ( + !Number.isInteger(nextInternalPort) || + nextInternalPort < 1 || + nextInternalPort > 65_535 || + nextInternalPort === port + ) { + console.error( + `[single-port] Invalid NEXT_INTERNAL_PORT (must differ from PORT): ${process.env.NEXT_INTERNAL_PORT}`, + ); + process.exit(1); + } + return { port, nextInternalPort, directTerminalPort }; +} + +function main(): void { + const config = configFromEnv(); + const proxy = createSinglePortServer(config); + + void proxy.listen().then(() => { + console.log( + `[single-port] listening on ${config.port}; HTTP → 127.0.0.1:${config.nextInternalPort}; ${MUX_PATH} → 127.0.0.1:${config.directTerminalPort}/mux`, + ); + }); + + const onSignal = (): void => { + void proxy.shutdown().then(() => process.exit(0)); + setTimeout(() => process.exit(1), SHUTDOWN_TIMEOUT_MS).unref(); + }; + process.on("SIGINT", onSignal); + process.on("SIGTERM", onSignal); +} + +/** True when this file was run directly (`node single-port-server.js`). */ +function isMainModule(): boolean { + if (!process.argv[1]) return false; + try { + return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); + } catch { + return false; + } +} + +if (isMainModule()) { + main(); +} diff --git a/packages/web/server/start-all.ts b/packages/web/server/start-all.ts index d54b11c33..971fbbf82 100644 --- a/packages/web/server/start-all.ts +++ b/packages/web/server/start-all.ts @@ -107,14 +107,32 @@ function resolveNextBin(): string { // Start Next.js production server const port = process.env["PORT"] || "3000"; +const pathBasedMux = process.env["AO_PATH_BASED_MUX"] === "1"; + +// When AO_PATH_BASED_MUX=1, single-port-server.js owns PORT and Next.js is +// shifted to PORT + 1000 (overridable via NEXT_INTERNAL_PORT). The proxy +// forwards HTTP to Next.js and tunnels `/ao-terminal-mux` WS upgrades to +// direct-terminal-ws. Default off — Next.js stays on PORT directly. +const NEXT_INTERNAL_OFFSET = 1000; +const nextPort = pathBasedMux + ? (process.env["NEXT_INTERNAL_PORT"] ?? String(parseInt(port, 10) + NEXT_INTERNAL_OFFSET)) + : port; + const nextBin = resolveNextBin(); if (isWindows() && nextBin !== "next") { // On Windows, run the JS entry point via the current node binary. // spawn() can't execute .js files directly on Windows. - spawnProcess("next", process.execPath, [nextBin, "start", "-p", port]); + spawnProcess("next", process.execPath, [nextBin, "start", "-p", nextPort]); } else { - spawnProcess("next", nextBin, ["start", "-p", port]); + spawnProcess("next", nextBin, ["start", "-p", nextPort]); +} + +if (pathBasedMux) { + // Surface the internal port to the child so it doesn't have to re-derive + // the offset; pin it explicitly. + process.env["NEXT_INTERNAL_PORT"] = nextPort; + spawnProcess("single-port", process.execPath, [resolve(__dirname, "single-port-server.js")]); } // Start direct terminal WebSocket server (auto-restart on crash)