diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 6c2ca81e053..2d28fea9a7d 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -30,9 +30,59 @@ the admin token (`OPENCODEX_ADMIN_AUTH_TOKEN`, or the auto-generated `~/.opencodex/admin-api-token` file). When a remote dashboard needs that credential, it presents a standard password form so a browser -password manager can offer to save and autofill it. The dashboard itself still keeps the token only -in memory and does not write it to `localStorage` or `sessionStorage`; whether it is saved is entirely -the browser or password manager's decision. +password manager can offer to save and autofill it. The raw admin token stays only in memory; after +sign-in, the dashboard stores a separate opaque session for that exact browser origin. + +## Remote dashboard access (Tailscale) + +opencodex can serve the dashboard to another device — a phone, a second computer — without opening +the proxy itself to the network. The opt-in `dashboardListener` config key opens a second listener +that serves only the dashboard: the web app and the `/api/*` management API. The loopback-only +`/opencodex-session` bootstrap is not part of this remote surface. + +```json +{ + "dashboardListener": { + "enabled": true, + "port": 10101, + "hostname": "100.88.9.100" + } +} +``` + +`hostname` is required and must be a literal private address: RFC1918 IPv4, Tailscale CGNAT +(`100.64.0.0/10`), or IPv6 ULA (`fc00::/7`). The typical choice is the machine's Tailscale IP, +making the dashboard reachable at `http://100.88.9.100:10101` from anywhere on your tailnet. +Wildcard, loopback, public, and DNS addresses are rejected. The key is absent by default, and +`{ "enabled": false }` is also accepted. The `port` must differ from the proxy port and from +`unauthenticatedLoopbackListener`'s port — a collision is rejected when the config is written. + +The main proxy listener is completely unchanged: it stays on `127.0.0.1`, and Codex and every other +client keep pointing at the original proxy port. Nothing about provider or API-key mode changes. + +Every data-plane route is refused on this listener: `/v1/*` (Responses, Chat Completions, models, +Messages, Realtime/live), data-plane WebSocket upgrades, and `/readyz` all return `404`. `GET /healthz` is served because the dashboard itself polls it; it returns the standard health payload — service name, version, uptime, pid, port, and restart/provider-reload capability flags — operational metadata only, with no credentials or provider data. + +### Sign-in on the remote dashboard + +Management calls on this listener require the existing admin token (`OPENCODEX_ADMIN_AUTH_TOKEN` or +the generated `~/.opencodex/admin-api-token` file) — the same credential as the local dashboard. +After sign-in the dashboard exchanges the token for a 12-hour opaque session automatically. +`POST /api/auth/session` receives the admin token in `X-OpenCodex-API-Key` and returns the opaque +session, CSRF token, bound origin, and expiry. The browser stores that separate session only for the +exact scheme, host, and port; it never stores the raw admin token. Mutations additionally require the +per-session CSRF token. The phone therefore asks for the admin token once per 12 hours instead of on +every refresh. + +Loopback dashboard behavior is unchanged: the local dashboard keeps auto-minting its 5-minute +sessions into the served page. + +:::caution[Keep it on the tailnet] +The bind hostname is limited to a private or tailnet address; Tailscale is the recommended setup. +This feature does not add TLS, so use it only over a trusted private transport such as a +WireGuard-encrypted tailnet. The persistent opaque session is readable by JavaScript at that exact +origin, so keep the dashboard free of untrusted scripts and do not expose it on an untrusted network. +::: ## What you can do diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 939ed837897..5be3f7269f1 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -12,6 +12,7 @@ runs helper features around provider requests. | --- | --- | --- | --- | | `port` | `number` | `10100` | Proxy listen port. | | `hostname?` | `string` | `"127.0.0.1"` | Bind address. Non-loopback binds require `OPENCODEX_API_AUTH_TOKEN`. | +| `dashboardListener?` | `{ enabled?: boolean; port: number; hostname: string }` | off | Opt-in second listener serving only the dashboard and `/api/*` on a private/tailnet address. The main proxy listener is unchanged. See [Remote dashboard access](/guides/web-dashboard/#remote-dashboard-access-tailscale). | | `proxy?` | `string` | — | Outbound HTTP(S) proxy URL or `${ENV_VAR}`. Applied to `HTTP_PROXY` / `HTTPS_PROXY` only when those variables are unset; loopback remains in `NO_PROXY`. | | `noProxy?` | `string \| string[]` | — | Hosts that bypass `proxy`, merged with inherited `NO_PROXY` and loopback entries. A string may use comma-separated `NO_PROXY` syntax or `${ENV_VAR}`. | | `emptyCompletionRetry?` | `boolean` | `false` | Opt in to one identical Responses retry when a turn has no text or tool call, including a stream that ends before a terminal event. The retry may be billable. `OCX_EMPTY_COMPLETION_RETRY=0` disables it without changing config; combo and routed-compaction turns remain excluded. | @@ -125,6 +126,46 @@ a page you visit can make your browser connect to `127.0.0.1`. The listener ther same `Host` and `Origin` checks as an ordinary loopback bind. Off by default. ::: +### Remote dashboard listener + +`dashboardListener` opens a second listener that serves only the dashboard: the web app and the +`/api/*` management API. The loopback-only `/opencodex-session` bootstrap is not part of this +remote surface. The main proxy listener — and +its `hostname` and `port` — are completely unchanged; Codex and every other client keep using the +original `127.0.0.1` address exactly as before. + +```json +{ + "dashboardListener": { + "enabled": true, + "port": 10101, + "hostname": "100.88.9.100" + } +} +``` + +`hostname` is required when the listener is enabled and must be a literal private address: RFC1918 +IPv4, Tailscale CGNAT (`100.64.0.0/10`), or IPv6 ULA (`fc00::/7`). Typically this is the machine's +Tailscale IP (`100.x.y.z`). Wildcard, loopback, public, and DNS addresses are rejected. The key is +absent by default; an `{ "enabled": false }` shape is also accepted. `port` must differ from the +proxy `port` and from `unauthenticatedLoopbackListener`'s port; a collision is a write-time +validation error. + +Every data-plane route is refused on this listener: `/v1/*` (Responses, Chat Completions, models, +Messages, Realtime/live), data-plane WebSocket upgrades, and `/readyz` all return `404`. `GET /healthz` is served because the dashboard itself polls it; it returns the standard health payload — service name, version, uptime, pid, port, and restart/provider-reload capability flags — operational metadata only, with no credentials or provider data. +Management calls still require the existing admin token (`OPENCODEX_ADMIN_AUTH_TOKEN` or the +generated `~/.opencodex/admin-api-token` file) — the same credential the local dashboard uses. The +bind address should be a private or tailnet address; see +[Remote dashboard access (Tailscale)](/guides/web-dashboard/#remote-dashboard-access-tailscale). + +Only the dashboard listener exposes `POST /api/auth/session`. With the admin token in +`X-OpenCodex-API-Key`, it mints a 12-hour opaque session carrying its CSRF token, exact bound origin, +and expiry. The dashboard stores that session only at the exact browser origin; it never persists the +raw admin token. `GET /api/auth/session` is not supported, and the proxy and unauthenticated loopback +listeners return `404` for this endpoint. This feature does not add TLS: use the allowed private bind +only through a trusted transport such as Tailscale, and keep the dashboard free of untrusted scripts +because that origin can read its opaque session. + ### SSH port forwarding Remote use does not require a remote bind. Keep loopback and forward it: diff --git a/gui/src/api.ts b/gui/src/api.ts index 658beac1ff1..bda15c0cfce 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -25,6 +25,8 @@ let requestAdminToken: AdminTokenPrompt = promptForAdminToken; const SESSION_REBOOTSTRAP_PATH = "/opencodex-session"; /** Safe authenticated read used to validate a raw admin token before closing the sign-in form. */ const ADMIN_TOKEN_VALIDATION_PATH = "/api/settings"; +/** Remote-dashboard session endpoint: POST mints an opaque session from an admin token. */ +const AUTH_SESSION_PATH = "/api/auth/session"; /** * The silent re-bootstrap must fail fast: every /api/* request queues behind the @@ -63,11 +65,14 @@ function needsApiAuth(input: RequestInfo | URL): boolean { /** Legacy sessionStorage key from pre-memory auth — wiped once on install, never read. */ const LEGACY_TOKEN_KEY = "opencodex-api-token"; +/** Persistent remote session: origin-scoped and opaque; never contains the raw admin token. */ +const PERSISTENT_GUI_SESSION_KEY = "opencodex-gui-session"; -/** In-memory only — never write tokens to web storage (XSS can read sessionStorage/localStorage). */ +/** The raw admin token stays memory-only. Remote GUI sessions are opaque and process-local. */ let memoryToken: string | null = null; let memoryCsrfToken: string | null = null; let memorySessionOrigin: string | null = null; +let memorySessionPersistent = false; function readToken(): string | null { return memoryToken; @@ -75,12 +80,17 @@ function readToken(): string | null { function storeToken(token: string): void { memoryToken = token; + memoryCsrfToken = null; + memorySessionOrigin = null; + memorySessionPersistent = false; } function clearToken(): void { + if (memorySessionPersistent) clearPersistentGuiSession(); memoryToken = null; memoryCsrfToken = null; memorySessionOrigin = null; + memorySessionPersistent = false; } function takeMetaContent(name: string): string | null { @@ -108,9 +118,81 @@ function storeSession(token: string | null, csrfToken: string | null, origin: st memoryToken = token; memoryCsrfToken = csrfToken; memorySessionOrigin = origin; + memorySessionPersistent = false; return true; } +function clearPersistentGuiSession(): void { + try { + localStorage.removeItem(PERSISTENT_GUI_SESSION_KEY); + } catch { + /* browser storage may be disabled */ + } +} + +function loadPersistentGuiSession(): void { + try { + const raw = localStorage.getItem(PERSISTENT_GUI_SESSION_KEY); + if (!raw) return; + const session = JSON.parse(raw) as { token?: unknown; csrfToken?: unknown; origin?: unknown; expiresAt?: unknown }; + if (typeof session.expiresAt !== "number" + || !Number.isFinite(session.expiresAt) + || session.expiresAt <= Date.now() + || !storeSession( + typeof session.token === "string" ? session.token : null, + typeof session.csrfToken === "string" ? session.csrfToken : null, + typeof session.origin === "string" ? session.origin : null, + )) { + clearPersistentGuiSession(); + return; + } + memorySessionPersistent = true; + } catch { + clearPersistentGuiSession(); + } +} + +function persistGuiSession(token: string, csrfToken: string, origin: string, expiresAt: number): void { + try { + localStorage.setItem(PERSISTENT_GUI_SESSION_KEY, JSON.stringify({ token, csrfToken, origin, expiresAt })); + memorySessionPersistent = true; + } catch { + /* storage is an enhancement; keep the current page session alive */ + } +} + +/** Mint an opaque session alongside a manually entered admin token for future page loads. */ +async function mintPersistentGuiSession(token: string): Promise { + if (!rawFetch) return null; + const bounded = createBoundedFetch(rebootstrapTimeoutMs); + try { + const response = await rawFetch(AUTH_SESSION_PATH, { + method: "POST", + cache: "no-store", + signal: bounded.signal, + headers: { "X-OpenCodex-API-Key": token }, + }); + if (!response.ok) return null; + const session = await response.json().catch(() => null) as { + token?: unknown; csrfToken?: unknown; origin?: unknown; expiresAt?: unknown; + } | null; + if (!session + || typeof session.token !== "string" + || typeof session.csrfToken !== "string" + || typeof session.origin !== "string" + || typeof session.expiresAt !== "number" + || !Number.isFinite(session.expiresAt) + || session.expiresAt <= Date.now() + || !storeSession(session.token, session.csrfToken, session.origin)) return null; + persistGuiSession(session.token, session.csrfToken, session.origin, session.expiresAt); + return session.token; + } catch { + return null; + } finally { + bounded.clear(); + } +} + /** Read one named meta tag out of a served HTML document (attribute order varies). */ function metaContentFromHtml(html: string, name: string): string | null { for (const tag of html.match(/]*>/gi) ?? []) { @@ -185,10 +267,14 @@ function clearLegacySessionToken(): void { } } -function withToken(input: RequestInfo | URL, init: RequestInit | undefined, token: string): [RequestInfo | URL, RequestInit | undefined] { +function withToken(input: RequestInfo | URL, init: RequestInit | undefined, token: string | null): [RequestInfo | URL, RequestInit | undefined] { + // Nothing to attach (no credential or opaque session) — pass through untouched. + if (!token && !(memorySessionOrigin && memoryCsrfToken)) return [input, init]; const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)); - headers.set("X-OpenCodex-API-Key", token); - if (memorySessionOrigin && memoryCsrfToken && token.startsWith("ocx_session_")) { + if (token) headers.set("X-OpenCodex-API-Key", token); + // Opaque sessions carry the GUI binding headers; the server ignores them on the + // raw-admin-token header path. + if (memorySessionOrigin && memoryCsrfToken) { headers.set("X-OpenCodex-GUI-Origin", memorySessionOrigin); const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); if (method !== "GET" && method !== "HEAD") { @@ -234,7 +320,9 @@ async function resolveTokenAfter401(failedToken: string | null, callerSignal?: A const prompted = await requestAdminToken(verifyAdminToken); if (prompted) { storeToken(prompted); - return prompted; + // Bounded and fast; awaiting keeps the retry wave ordered after the mint. + // The raw token remains a current-page fallback if the remote session mint fails. + return await mintPersistentGuiSession(prompted) ?? prompted; } promptCancelled = true; return null; @@ -270,12 +358,14 @@ export function installApiAuthFetch(): void { loadInjectedSession(); const originalFetch = window.fetch.bind(window); rawFetch = originalFetch; + // Remote dashboards restore only an opaque session scoped to this exact browser origin. + if (readToken() === null) loadPersistentGuiSession(); window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { if (!needsApiAuth(input)) return originalFetch(input, init); const callerSignal = init?.signal ?? (input instanceof Request ? input.signal : undefined); const token = readToken(); - const [firstInput, firstInit] = token ? withToken(input, init, token) : [input, init]; + const [firstInput, firstInit] = withToken(input, init, token); const response = await originalFetch(firstInput, firstInit); if (response.status !== 401) return response; @@ -306,6 +396,7 @@ export function resetApiAuthFetchForTests(adminTokenPrompt: AdminTokenPrompt = p memoryToken = null; memoryCsrfToken = null; memorySessionOrigin = null; + memorySessionPersistent = false; resolutionInFlight = null; rawFetch = null; promptCancelled = false; diff --git a/gui/tests/api-auth-deadline.test.ts b/gui/tests/api-auth-deadline.test.ts index de4520ad88c..44763201cf9 100644 --- a/gui/tests/api-auth-deadline.test.ts +++ b/gui/tests/api-auth-deadline.test.ts @@ -178,6 +178,8 @@ test("the retried request carries the caller signal", async () => { const seenSignals: Array = []; const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { if (pathnameOf(input) === "/opencodex-session") return MINTED(); + // The session endpoint is not a data request; keep it out of seenSignals. + if (pathnameOf(input) === "/api/auth/session") return new Response("unauthorized", { status: 401 }); seenSignals.push(init?.signal ?? (input instanceof Request ? input.signal : undefined)); const key = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)).get("X-OpenCodex-API-Key"); if (key === "ocx_session_fresh") return new Response("{}", { status: 200 }); diff --git a/gui/tests/api-auth-memory.test.ts b/gui/tests/api-auth-memory.test.ts index ca70303e263..a951aa8d3c2 100644 --- a/gui/tests/api-auth-memory.test.ts +++ b/gui/tests/api-auth-memory.test.ts @@ -155,9 +155,10 @@ test("concurrent 401s share one token prompt and all retry with the stored token const release401: Array<() => void> = []; const mockFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { const headers = new Headers(init?.headers); - // Session re-bootstrap probe: this fixture never mints sessions, so fail it fast - // instead of letting it join the release queue below. - if (new URL(_input instanceof Request ? _input.url : String(_input), "http://localhost/").pathname === "/opencodex-session") { + // Session bootstrap/mint requests: this fixture never mints + // sessions, so fail them fast instead of letting them join the release queue below. + const path = new URL(_input instanceof Request ? _input.url : String(_input), "http://localhost/").pathname; + if (path === "/opencodex-session" || path === "/api/auth/session") { return new Response("unauthorized", { status: 401 }); } if (headers.get("X-OpenCodex-API-Key") === "shared-token") { @@ -278,7 +279,9 @@ test("canceling the token prompt once does not reopen it for the rest of the 401 const release401: Array<() => void> = []; const mockFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { const headers = new Headers(init?.headers); - if (new URL(_input instanceof Request ? _input.url : String(_input), "http://localhost/").pathname === "/opencodex-session") { + // Session probes fail fast: this fixture queues only /api data requests. + const path = new URL(_input instanceof Request ? _input.url : String(_input), "http://localhost/").pathname; + if (path === "/opencodex-session" || path === "/api/auth/session") { return new Response("unauthorized", { status: 401 }); } if (headers.get("X-OpenCodex-API-Key")) { @@ -325,6 +328,9 @@ test("data-plane requests never receive the management token or prompt", async ( let phase: "seed" | "cross" = "seed"; const seenHeaders: Array = []; const stateful = (async (_input: RequestInfo | URL, init?: RequestInit) => { + // Session endpoints must not count as data-plane probes here. + const path = new URL(_input instanceof Request ? _input.url : String(_input), "http://localhost/").pathname; + if (path === "/api/auth/session") return new Response("unauthorized", { status: 401 }); const headers = new Headers(init?.headers); seenHeaders.push(headers.get("X-OpenCodex-API-Key")); if (phase === "seed") { diff --git a/gui/tests/api-auth-persistent-session.test.ts b/gui/tests/api-auth-persistent-session.test.ts new file mode 100644 index 00000000000..b6095d02efd --- /dev/null +++ b/gui/tests/api-auth-persistent-session.test.ts @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { installApiAuthFetch, resetApiAuthFetchForTests } from "../src/api"; + +const globals = ["document", "window", "navigator", "sessionStorage", "localStorage", "fetch"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +let promptCalls: number; + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map((key) => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://100.88.9.100:10101/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + localStorage: { configurable: true, value: testWindow.localStorage }, + fetch: { configurable: true, value: testWindow.fetch.bind(testWindow) }, + }); + promptCalls = 0; + resetApiAuthFetchForTests(async () => { + promptCalls += 1; + return null; + }); +}); + +afterEach(() => { + resetApiAuthFetchForTests(); + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function installMockAuthFetch(handler: typeof fetch): Promise { + Object.defineProperty(globalThis, "fetch", { configurable: true, value: handler }); + Object.defineProperty(window, "fetch", { configurable: true, value: handler }); + installApiAuthFetch(); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: window.fetch }); +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +function pathnameOf(input: RequestInfo | URL): string { + return new URL(input instanceof Request ? input.url : String(input), window.location.href).pathname; +} + +function headersOf(input: RequestInfo | URL, init?: RequestInit): Headers { + return new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)); +} + +test("a persisted opaque session is origin-scoped and re-arms dashboard requests without a prompt", async () => { + const session = { + token: "ocx_session_saved", + csrfToken: "saved-csrf", + origin: window.location.origin, + expiresAt: Date.now() + 60_000, + }; + localStorage.setItem("opencodex-gui-session", JSON.stringify(session)); + const seen: Array<{ path: string; key: string | null; origin: string | null; csrf: string | null; cookie: string | null }> = []; + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const headers = headersOf(input, init); + seen.push({ + path: pathnameOf(input), + key: headers.get("X-OpenCodex-API-Key"), + origin: headers.get("X-OpenCodex-GUI-Origin"), + csrf: headers.get("X-OpenCodex-CSRF-Token"), + cookie: headers.get("Cookie"), + }); + return jsonResponse({}); + }) as typeof fetch; + await installMockAuthFetch(mockFetch); + + expect((await fetch("/api/config")).status).toBe(200); + expect((await fetch("/api/providers", { method: "POST", body: "{}" })).status).toBe(200); + expect(promptCalls).toBe(0); + expect(seen).toEqual([ + { path: "/api/config", key: "ocx_session_saved", origin: window.location.origin, csrf: null, cookie: null }, + { path: "/api/providers", key: "ocx_session_saved", origin: window.location.origin, csrf: "saved-csrf", cookie: null }, + ]); +}); + +test("a stored session for a different origin is discarded without sending its token", async () => { + localStorage.setItem("opencodex-gui-session", JSON.stringify({ + token: "ocx_session_other_origin", + csrfToken: "csrf", + origin: "http://100.88.9.100:10102", + expiresAt: Date.now() + 60_000, + })); + const seenKeys: Array = []; + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (pathnameOf(input) === "/opencodex-session") return new Response("missing", { status: 404 }); + seenKeys.push(headersOf(input, init).get("X-OpenCodex-API-Key")); + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + await installMockAuthFetch(mockFetch); + + expect((await fetch("/api/config")).status).toBe(401); + expect(seenKeys).toEqual([null]); + expect(localStorage.getItem("opencodex-gui-session")).toBeNull(); +}); + +test("admin-token sign-in persists only the opaque session returned by the remote listener", async () => { + resetApiAuthFetchForTests(async () => { + promptCalls += 1; + return "admin-token"; + }); + const seenKeys: string[] = []; + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const path = pathnameOf(input); + const headers = headersOf(input, init); + const key = headers.get("X-OpenCodex-API-Key"); + if (path === "/opencodex-session") return new Response("missing", { status: 404 }); + if (path === "/api/auth/session") { + expect(init?.method).toBe("POST"); + expect(key).toBe("admin-token"); + return jsonResponse({ + token: "ocx_session_minted", + csrfToken: "minted-csrf", + origin: window.location.origin, + expiresAt: Date.now() + 60_000, + }); + } + seenKeys.push(key ?? ""); + return key === "ocx_session_minted" ? jsonResponse({}) : new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + await installMockAuthFetch(mockFetch); + + expect((await fetch("/api/config")).status).toBe(200); + expect(promptCalls).toBe(1); + expect(seenKeys).toEqual(["", "ocx_session_minted"]); + const persisted = localStorage.getItem("opencodex-gui-session") ?? ""; + expect(persisted).toContain("ocx_session_minted"); + expect(persisted).not.toContain("admin-token"); +}); + +test("a failed session mint keeps the raw admin token in memory only for the current page", async () => { + resetApiAuthFetchForTests(async () => { + promptCalls += 1; + return "admin-token"; + }); + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const path = pathnameOf(input); + const key = headersOf(input, init).get("X-OpenCodex-API-Key"); + if (path === "/opencodex-session") return new Response("missing", { status: 404 }); + if (path === "/api/auth/session") return new Response("mint refused", { status: 403 }); + return key === "admin-token" ? jsonResponse({}) : new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + await installMockAuthFetch(mockFetch); + + expect((await fetch("/api/config")).status).toBe(200); + expect(promptCalls).toBe(1); + expect(localStorage.getItem("opencodex-gui-session")).toBeNull(); +}); + +test("a rejected persisted session is removed before the next prompt", async () => { + localStorage.setItem("opencodex-gui-session", JSON.stringify({ + token: "ocx_session_expired", + csrfToken: "csrf", + origin: window.location.origin, + expiresAt: Date.now() + 60_000, + })); + const mockFetch = (async (input: RequestInfo | URL) => { + if (pathnameOf(input) === "/opencodex-session") return new Response("missing", { status: 404 }); + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + await installMockAuthFetch(mockFetch); + + expect((await fetch("/api/config")).status).toBe(401); + expect(promptCalls).toBe(1); + expect(localStorage.getItem("opencodex-gui-session")).toBeNull(); +}); diff --git a/src/cli/index.ts b/src/cli/index.ts index 40ad1dca3d8..84c69038b9f 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -146,6 +146,12 @@ async function chooseListenPort(requestedPort?: number): Promise { const reservedLoopbackPort = config.unauthenticatedLoopbackListener?.enabled ? config.unauthenticatedLoopbackListener.port : undefined; + const reservedDashboardPort = config.dashboardListener?.enabled + ? config.dashboardListener.port + : undefined; + const reservedPorts = [reservedLoopbackPort, reservedDashboardPort].filter( + (port): port is number => port !== undefined, + ); // Before the reclaim path, not after (#1102). Asking for the port the loopback listener is // configured to bind is a configuration mistake, and reclaim would spend up to 60 seconds // waiting for a socket to free before reporting "port is busy" — the wrong diagnosis for a @@ -155,6 +161,11 @@ async function chooseListenPort(requestedPort?: number): Promise { `Port ${preferred} is reserved for unauthenticatedLoopbackListener; choose a different proxy port.`, ); } + if (reservedDashboardPort !== undefined && preferred === reservedDashboardPort) { + throw new Error( + `Port ${preferred} is reserved for dashboardListener; choose a different proxy port.`, + ); + } // Soft start: brief prefer-retry then ephemeral hop. // Explicit `--port` (service wrappers / update restart): wait for the pinned port // to free without killing any listener (healthy ocx / foreign). Never hop. @@ -182,7 +193,7 @@ async function chooseListenPort(requestedPort?: number): Promise { // bind (#1102). Without this, `--port ` binds the public listener // first and the loopback bind then fails, rolling back a startup that was only // ever a config collision. - ...(reservedLoopbackPort !== undefined ? { reservedPort: reservedLoopbackPort } : {}), + ...(reservedPorts.length > 0 ? { reservedPort: reservedPorts } : {}), }); if (preferred > 0 && selected !== preferred) { console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`); diff --git a/src/config.ts b/src/config.ts index c24bcb4f0e5..d75aecea52e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import { chmodSync, constants as fsConstants, copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; +import { isIP } from "node:net"; import { dirname, join } from "node:path"; import { Database } from "bun:sqlite"; import * as z from "zod/v4"; @@ -849,6 +850,27 @@ const agentTaskRecoverySchema = z.object({ cacheEntries: z.number().int().min(1).max(512).optional(), }).strict(); +/** Whether an address is safe for the authenticated dashboard listener to bind. */ +export function isDashboardListenerBindAddress(hostname: string): boolean { + const ipVersion = isIP(hostname); + if (ipVersion === 4) { + const [first, second] = hostname.split(".").map(Number); + return first === 10 + || (first === 172 && second >= 16 && second <= 31) + || (first === 192 && second === 168) + || (first === 100 && second >= 64 && second <= 127); + } + if (ipVersion === 6) { + const firstHextet = Number.parseInt(hostname.split(":")[0] || "0", 16); + return firstHextet >= 0xfc00 && firstHextet <= 0xfdff; + } + return false; +} + +const dashboardListenerHostnameSchema = z.string().trim().min(1).refine(isDashboardListenerBindAddress, { + message: "must be a literal private or tailnet IP address (RFC1918, 100.64.0.0/10, or IPv6 ULA)", +}); + const configSchema = z.object({ port: z.number().int().min(0).max(65535).default(10100), managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024), @@ -878,6 +900,18 @@ const configSchema = z.object({ z.object({ enabled: z.literal(false) }), z.object({ enabled: z.literal(true), port: z.number().int().min(1).max(65535) }), ]).optional().catch(undefined), + // Same discriminated shape and degradation policy as unauthenticatedLoopbackListener: an + // opt-in surface whose hand-edit typos must never reset providers/apiKeys through the + // backup-and-defaults repair path. The address predicate is enforced here too: malformed or + // unsafe entries degrade to disabled before startup can bind them. + dashboardListener: z.union([ + z.object({ enabled: z.literal(false) }), + z.object({ + enabled: z.literal(true), + port: z.number().int().min(1).max(65535), + hostname: dashboardListenerHostnameSchema, + }), + ]).optional().catch(undefined), providers: z.record(z.string(), providerConfigSchema), defaultProvider: z.string().min(1).default("openai"), defaultModelAliases: z.boolean().optional(), @@ -2188,6 +2222,42 @@ function loopbackListenerPortError(value: unknown): string | null { return null; } +function dashboardListenerError(value: unknown): string | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const listener = (value as Record).dashboardListener; + if (listener === undefined) return null; + if (!listener || typeof listener !== "object" || Array.isArray(listener)) { + return "schema_invalid: dashboardListener: must be an object or omitted"; + } + const entry = listener as Record; + // A string such as `"true"` would otherwise be silently deleted by the schema catch + // while the operator believes the listener is on. + if (typeof entry.enabled !== "boolean") { + return "schema_invalid: dashboardListener.enabled: must be a boolean"; + } + if (entry.enabled !== true) return null; + const listenerPort = entry.port; + if (typeof listenerPort !== "number" || !Number.isInteger(listenerPort) || listenerPort < 1 || listenerPort > 65535) { + return "schema_invalid: dashboardListener.port: must be an integer port when enabled"; + } + const listenerHostname = entry.hostname; + if (typeof listenerHostname !== "string" || !listenerHostname.trim()) { + return "schema_invalid: dashboardListener.hostname: must be a non-blank bind address when enabled"; + } + if (!isDashboardListenerBindAddress(listenerHostname.trim())) { + return "schema_invalid: dashboardListener.hostname: must be a literal private or tailnet IP address (RFC1918, 100.64.0.0/10, or IPv6 ULA); DNS names, wildcard, loopback, and public addresses are not allowed"; + } + const proxyPort = (value as Record).port; + if (typeof proxyPort === "number" && proxyPort === listenerPort) { + return "schema_invalid: dashboardListener.port: must differ from the proxy port"; + } + const loopback = (value as Record).unauthenticatedLoopbackListener as Record | undefined; + if (loopback && loopback.enabled === true && loopback.port === listenerPort) { + return "schema_invalid: dashboardListener.port: must differ from unauthenticatedLoopbackListener.port"; + } + return null; +} + export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } { const boundaryError = blankHostnameError(value) ?? claudeSubagentEffortError(value) @@ -2199,7 +2269,8 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx ?? codexAccountPickerEnabledError(value) ?? emptyCompletionRetryError(value) ?? oauthOpenBrowserError(value) - ?? loopbackListenerPortError(value); + ?? loopbackListenerPortError(value) + ?? dashboardListenerError(value); if (boundaryError) return { ok: false, error: boundaryError }; const result = configSchema.safeParse(value); if (result.success) { diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 9e5bcbb3d36..a8bb18aca9f 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -34,6 +34,8 @@ import { xaiResponsesOptInState } from "../providers/xai-responses-opt-in"; let _corsOrigin = "http://localhost:10100"; export function setCorsOrigin(port: number): void { _corsOrigin = `http://localhost:${port}`; } +/** The config slice management-origin decisions read. Full configs and per-listener policy views are both assignable. */ +export type ManagementPolicyView = Pick; /** The proxy's own listening port. No admission check uses it: both loopback predicates key on hostname alone. */ export function configuredPort(): string { try { return new URL(_corsOrigin).port; } catch { return "10100"; } @@ -117,7 +119,7 @@ function comparableOrigin(value: string): string | null { } } -export function managementRequestOrigin(req: Request, config: OcxConfig): string | null { +export function managementRequestOrigin(req: Request, config: Pick): string | null { const host = req.headers.get("Host"); const parsedHost = parseHttpHost(host); if (!host || !parsedHost) return null; @@ -131,7 +133,10 @@ export function managementRequestOrigin(req: Request, config: OcxConfig): string } } -export function isAllowedManagementOrigin(req: Request, config: OcxConfig): boolean { +// The config parameter is a policy view: the dashboard listener passes its own bind +// hostname so management origin checks reflect the listener that received the request, +// exactly like requestPolicyView does for data-plane admission (#1102 pattern). +export function isAllowedManagementOrigin(req: Request, config: Pick): boolean { const requestOrigin = managementRequestOrigin(req, config); if (!requestOrigin) return false; const origin = req.headers.get("Origin"); @@ -198,7 +203,7 @@ export function corsHeaders(req?: Request, config?: RequestPolicyView): Record { +export function managementCorsHeaders(req?: Request, config?: Pick): Record { const headers = corsHeaders(); const origin = req?.headers.get("Origin"); if (origin && req && config && isAllowedManagementOrigin(req, config)) { @@ -219,7 +224,7 @@ export function withCors(response: Response, req: Request, config: RequestPolicy }); } -export function withManagementCors(response: Response, req: Request, config: OcxConfig): Response { +export function withManagementCors(response: Response, req: Request, config: Pick): Response { const headers = new Headers(response.headers); for (const [name, value] of Object.entries(managementCorsHeaders(req, config))) { headers.set(name, value); diff --git a/src/server/index.ts b/src/server/index.ts index c525dad05ee..9ce09038b92 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -15,6 +15,7 @@ import { DEFAULT_SUBAGENT_MODELS, applyProxyEnv, armClaudeCodeBaseline, + isDashboardListenerBindAddress, loadConfig, saveConfig, getConfigDir, @@ -155,6 +156,7 @@ import { resolveResponsesApiAuth, requestPolicyView, type RequestPolicyView, + type ManagementPolicyView, safeConfigDTO, setCorsOrigin, withCors, @@ -190,7 +192,9 @@ import { handleSearch } from "./search"; import { fetchAllModels, handleManagementAPI, VERSION, type ManagementApiDeps } from "./management-api"; import { initializeManagementAuthState, + GUI_SESSION_ENDPOINT_PATH, issueGuiSession, + handleGuiSessionEndpoint, managementPrincipal, requireManagementAuth, type ManagementAuthState, @@ -689,6 +693,21 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server requestPolicyView(config, "127.0.0.1"); void publicPolicy; + // Authenticated remote dashboard listener. The bind address is operator-named (typically + // a tailnet IP) and the policy view below is what makes management origin checks treat + // that listener's non-loopback Host as legitimate without touching the main listener's + // loopback policy. Data-plane admission never runs here: the route allowlist below + // refuses /v1/* outright, so no data-plane credential is involved at all. + // Config loading already drops invalid entries. Keep the startup guard too so a future + // in-memory config writer cannot turn an unsafe address into a bound dashboard socket. + const dashboardListener = config.dashboardListener?.enabled + && isDashboardListenerBindAddress(config.dashboardListener.hostname) + ? config.dashboardListener + : undefined; + const dashboardListenerPort = dashboardListener?.enabled ? dashboardListener.port : null; + const dashboardBindHostname = dashboardListener?.enabled ? dashboardListener.hostname.trim() : null; + const dashboardPolicy = (): ManagementPolicyView => requestPolicyView(config, dashboardBindHostname ?? "127.0.0.1"); + /** * Routes the unauthenticated loopback listener will serve. Everything else 404s. * @@ -719,6 +738,28 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server; let loopbackServer: Server | null = null; + let dashboardServer: Server | null = null; let backgroundLifecycle: ReturnType | null = null; try { backgroundLifecycle = acquireServerBackgroundLifecycle(applyPolicy); @@ -892,12 +934,23 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server({ + ...serveOptions, + port: dashboardListenerPort, + hostname: dashboardBindHostname, + }); + } catch (error) { + // Each stop is independent: a throw from the first must not skip the second, + // or a failed dashboard bind strands the unauthenticated loopback listener. + try { + void server.stop(true); + } catch { + /* the original bind error is the one worth reporting */ + } + if (loopbackServer) { + try { + void loopbackServer.stop(true); + } catch { + /* the original bind error is the one worth reporting */ + } + } + throw error; + } + } } catch (error) { userCostOverlayReconciler?.stop(); backgroundLifecycle?.releaseAfterFailedStart(); @@ -1920,6 +2016,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server => { @@ -1931,6 +2028,9 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server loopbackListenerRef.stop(closeActiveConnections)] : []), + ...(dashboardListenerRef + ? [() => dashboardListenerRef.stop(closeActiveConnections)] + : []), async () => { userCostOverlayReconciler?.stop(); }, @@ -1965,6 +2065,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server = config, ): Promise { - if (!isAllowedManagementOrigin(req, config)) { - return jsonResponse({ error: "cross-origin request blocked" }, 403, req, config); + if (!isAllowedManagementOrigin(req, policy)) { + return jsonResponse({ error: "cross-origin request blocked" }, 403, req, policy); } // Management bodies are small JSON (provider names, key ids, settings). Reject oversized // payloads before any handler buffers them — the data plane has its own decompression cap. diff --git a/src/server/management-auth.ts b/src/server/management-auth.ts index 83c59d8d06c..9dca880748e 100644 --- a/src/server/management-auth.ts +++ b/src/server/management-auth.ts @@ -46,12 +46,18 @@ import { isApiAuthRequired, isDataPlaneAdmissionSecret, isLoopbackHostname, + type ManagementPolicyView, managementRequestOrigin, parseHttpHost, } from "./auth-cors"; const GUI_SESSION_TTL_MS = 5 * 60_000; +// Persistent remote-dashboard sessions are minted only in exchange for the admin token. +// They remain origin- and CSRF-bound, while the browser keeps the opaque token in +// origin-scoped storage rather than a hostname-scoped HTTP cookie. +const GUI_PERSISTENT_SESSION_TTL_MS = 12 * 60 * 60_000; const GUI_SESSION_LIMIT = 128; +export const GUI_SESSION_ENDPOINT_PATH = "/api/auth/session"; const LOCAL_READ_REPLAY_LIMIT = 256; const consumedLocalReadCapabilities = new Map(); const admittedLocalReadRequests = new WeakSet(); @@ -242,16 +248,11 @@ function randomSessionSecret(prefix: "ocx_session_"): string { return `${prefix}${randomBytes(32).toString("base64url")}`; } -export function issueGuiSession( - req: Request, - config: OcxConfig, - state: ManagementAuthState, -): GuiSessionBootstrap | null { - if (isApiAuthRequired(config) || !state.available || req.method !== "GET" || !isAllowedManagementOrigin(req, config)) return null; - const host = parseHttpHost(req.headers.get("Host")); - if (!host || !isLoopbackHostname(host.hostname)) return null; - const origin = managementRequestOrigin(req, config); - if (!origin) return null; +function mintGuiSession( + state: Extract, + origin: string, + ttlMs: number, +): GuiSessionBootstrap { const now = Date.now(); removeExpiredSessions(state, now); while (state.sessions.size >= GUI_SESSION_LIMIT) { @@ -263,12 +264,99 @@ export function issueGuiSession( const session: GuiSessionRecord = { csrfToken: randomBytes(32).toString("base64url"), origin, - expiresAt: now + GUI_SESSION_TTL_MS, + expiresAt: now + ttlMs, }; state.sessions.set(token, session); return { token, ...session }; } +export function issueGuiSession( + req: Request, + config: OcxConfig, + state: ManagementAuthState, +): GuiSessionBootstrap | null { + if (isApiAuthRequired(config) || !state.available || req.method !== "GET" || !isAllowedManagementOrigin(req, config)) return null; + const host = parseHttpHost(req.headers.get("Host")); + if (!host || !isLoopbackHostname(host.hostname)) return null; + const origin = managementRequestOrigin(req, config); + if (!origin) return null; + return mintGuiSession(state, origin, GUI_SESSION_TTL_MS); +} + +/** The management credential a request presented in a header. */ +function managementCredential(req: Request): string | null { + return req.headers.get("x-opencodex-api-key")?.trim() + || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim() + || null; +} + +/** The origin/CSRF admission a session credential must pass, shared by every gate that reads one. */ +function guiSessionAdmitted(req: Request, session: GuiSessionRecord, config: ManagementPolicyView): boolean { + const requestOrigin = managementRequestOrigin(req, config); + const claimedOrigin = req.headers.get("x-opencodex-gui-origin"); + const browserOrigin = req.headers.get("Origin"); + // Safe methods need no CSRF token. The opaque session credential is carried in a + // header and remains bound to this exact origin; mutations still demand the full arm. + const sameOrigin = requestOrigin === session.origin + && (!claimedOrigin || claimedOrigin === session.origin) + && (!browserOrigin || browserOrigin === session.origin); + const safeMethod = req.method === "GET" || req.method === "HEAD"; + const csrf = req.headers.get("x-opencodex-csrf-token")?.trim(); + return sameOrigin + && (safeMethod + || (claimedOrigin === session.origin + && browserOrigin === session.origin + && !!csrf + && equalSecret(csrf, session.csrfToken))); +} + +/** + * Mint a GUI session in exchange for the raw admin token (POST /api/auth/session). + * + * Unlike issueGuiSession this is not loopback-only: the credential, not the transport, + * carries the trust. The browser stores this opaque, process-local token at its exact + * dashboard origin, avoiding hostname-scoped cookie replay to another HTTP port. + */ +export function issueGuiSessionForAdmin( + req: Request, + config: ManagementPolicyView, + state: ManagementAuthState, +): GuiSessionBootstrap | null { + if (!state.available) return null; + const presented = req.headers.get("x-opencodex-api-key")?.trim() + || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + if (!presented || !equalSecret(presented, state.token)) return null; + if (!isAllowedManagementOrigin(req, config)) return null; + const origin = managementRequestOrigin(req, config); + if (!origin) return null; + return mintGuiSession(state, origin, GUI_PERSISTENT_SESSION_TTL_MS); +} + +/** + * POST /api/auth/session exchanges the admin token for an opaque browser-stored session. + * The route is mounted only on the dedicated remote dashboard listener; ordinary loopback + * dashboards use their injected short-lived session instead. + */ +export function handleGuiSessionEndpoint( + req: Request, + url: URL, + state: ManagementAuthState, + config: ManagementPolicyView, +): Response | null { + if (url.pathname !== GUI_SESSION_ENDPOINT_PATH) return null; + if (req.method === "POST") { + const bootstrap = issueGuiSessionForAdmin(req, config, state); + if (!bootstrap) return Response.json({ error: "opencodex admin token required" }, { status: 401 }); + return Response.json({ + token: bootstrap.token, + csrfToken: bootstrap.csrfToken, + origin: bootstrap.origin, + expiresAt: bootstrap.expiresAt, + }); + } + return Response.json({ error: "method not allowed" }, { status: 405 }); +} + /** * Which credential actually authorized a management request. * @@ -426,15 +514,14 @@ function hasLocalProviderReloadCapability( export function managementPrincipal( req: Request, state: ManagementAuthState, - config?: OcxConfig, + config?: ManagementPolicyView, local?: LocalManagementAuthContext, ): ManagementPrincipal | null { if (hasSystemRestartCapability(req, local)) return "system-restart-capability"; if (hasLocalProviderReloadCapability(req, local)) return "local-provider-reload-capability"; if (hasLocalReadCapability(req, local)) return "local-read-capability"; if (!state.available) return null; - const actual = req.headers.get("x-opencodex-api-key")?.trim() - || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + const actual = managementCredential(req); if (!actual) return null; if (equalSecret(actual, state.token)) return "admin-token"; if (!config) return null; @@ -445,7 +532,7 @@ export function managementPrincipal( export function requireManagementAuth( req: Request, state: ManagementAuthState, - config?: OcxConfig, + config?: ManagementPolicyView, local?: LocalManagementAuthContext, ): Response | null { if (hasSystemRestartCapability(req, local)) return null; @@ -458,25 +545,12 @@ export function requireManagementAuth( hint: "Set OPENCODEX_ADMIN_AUTH_TOKEN to bypass file-backed admin token ACL hardening", }, { status: 503 }); } - const actual = req.headers.get("x-opencodex-api-key")?.trim() - || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + const actual = managementCredential(req); if (actual && equalSecret(actual, state.token)) return null; if (actual && config) { removeExpiredSessions(state); const session = state.sessions.get(actual); - if (session) { - const requestOrigin = managementRequestOrigin(req, config); - const claimedOrigin = req.headers.get("x-opencodex-gui-origin"); - const browserOrigin = req.headers.get("Origin"); - const sameOrigin = requestOrigin === session.origin - && claimedOrigin === session.origin - && (!browserOrigin || browserOrigin === session.origin); - const safeMethod = req.method === "GET" || req.method === "HEAD"; - const csrf = req.headers.get("x-opencodex-csrf-token")?.trim(); - if (sameOrigin && (safeMethod || (browserOrigin === session.origin && !!csrf && equalSecret(csrf, session.csrfToken)))) { - return null; - } - } + if (session && guiSessionAdmitted(req, session, config)) return null; } return Response.json({ error: "opencodex admin token required" }, { status: 401 }); } diff --git a/src/server/ports.ts b/src/server/ports.ts index ae8fe16c1ab..e8f9eb4b6f4 100644 --- a/src/server/ports.ts +++ b/src/server/ports.ts @@ -58,15 +58,16 @@ export type FindAvailablePortOptions = { */ allowEphemeralFallback?: boolean; /** - * A port this selection must never return, even when it is free (#1102). + * A port (or ports) this selection must never return, even when it is free (#1102). * * The unauthenticated loopback listener binds a fixed port from config. If the public * listener took that port first — via an explicit `--port`, a `config.port` of 0, or the * ephemeral fallback happening to land on it — the loopback bind would then fail with * EADDRINUSE, and the startup transaction would roll back a public listener that had - * nothing wrong with it. Excluding the port here fails the right thing at the right time. + * nothing wrong with it. The dashboard listener has the same requirement, so the + * option accepts a list. Excluding the port here fails the right thing at the right time. */ - reservedPort?: number; + reservedPort?: number | number[]; }; export class PortUnavailableError extends Error { @@ -85,10 +86,14 @@ export async function findAvailablePort( ): Promise { const preferRetryMs = opts.preferRetryMs ?? 0; const allowEphemeral = opts.allowEphemeralFallback !== false; - const reserved = opts.reservedPort; + const reserved = opts.reservedPort === undefined + ? [] + : Array.isArray(opts.reservedPort) + ? opts.reservedPort + : [opts.reservedPort]; // An explicit preference for the reserved port is a configuration mistake, not a busy // socket: retrying or hopping would hide it. Refuse before probing anything. - if (reserved !== undefined && preferredPort === reserved) { + if (reserved.includes(preferredPort)) { throw new PortUnavailableError(preferredPort, hostname); } // Port 0 asks the OS to select an ephemeral port. Resolve it to that concrete @@ -113,7 +118,7 @@ export async function findAvailablePort( // async recursion has no way to stop if the assumption is ever wrong. for (let attempt = 0; attempt < EPHEMERAL_REDRAW_LIMIT; attempt += 1) { const port = await allocateEphemeralPort(hostname); - if (port !== reserved) return port; + if (!reserved.includes(port)) return port; } throw new Error("failed to allocate an available port"); } diff --git a/src/types/config.ts b/src/types/config.ts index 0fa86715bda..42b6b3e54ff 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -507,6 +507,30 @@ export interface OcxConfig { unauthenticatedLoopbackListener?: | { enabled: false } | { enabled: true; port: number }; + /** + * Opt-in second listener that serves ONLY the web dashboard and the management API + * behind the existing admin token, for reach-from-another-device setups (typically a + * Tailscale tailnet). + * + * The main proxy listener is untouched: it keeps its own hostname/port and admission + * policy, and Codex/clients keep pointing at it. This listener refuses every data-plane + * route (/v1/*, data-plane WebSocket upgrades, /readyz) so binding it to a tailnet + * address never exposes provider credentials or account quota. GET /healthz is served + * for the dashboard overview poll; it discloses operational metadata only (service, + * version, uptime, pid, port, restart/provider-reload capability), never credentials. + * + * Management requests on this listener authenticate with the admin token once and then + * with its opaque origin-scoped browser session (see /api/auth/session); loopback dashboard + * behavior on the main listener is unchanged. + * + * The port is required when enabled and must differ from the proxy port and from + * unauthenticatedLoopbackListener. The hostname must be a literal RFC1918, tailnet-CGNAT, + * or IPv6 ULA address, so the operator names an exact private interface instead of inheriting + * a wildcard, public address, or DNS name. + */ + dashboardListener?: + | { enabled: false } + | { enabled: true; port: number; hostname: string }; /** * Outbound HTTP(S) proxy URL for provider requests (e.g. "http://user:pass@proxy:8080", or * "${HTTPS_PROXY}"-style env reference). Mirrored into HTTP_PROXY/HTTPS_PROXY at startup when diff --git a/tests/cli-headless-parity.test.ts b/tests/cli-headless-parity.test.ts index 3900af2d7ae..2de02a83a1d 100644 --- a/tests/cli-headless-parity.test.ts +++ b/tests/cli-headless-parity.test.ts @@ -217,6 +217,10 @@ describe("headless GUI parity CLI", () => { } } const coverage: Array<[string, string]> = [ + // GUI-only affordance: the browser dashboard exchanges its once-entered admin + // token for an opaque browser session here. The CLI already authenticates with + // the admin token directly, so a mirror command would duplicate auth it has. + ["/api/auth/session", "(none — GUI-only)"], ["/api/claude-code", "ocx claude config"], ["/api/claude-desktop", "ocx claude desktop"], ["/api/claude/", "ocx observe"], diff --git a/tests/dashboard-listener-admission.test.ts b/tests/dashboard-listener-admission.test.ts new file mode 100644 index 00000000000..777be454d67 --- /dev/null +++ b/tests/dashboard-listener-admission.test.ts @@ -0,0 +1,111 @@ +/** + * Write-time validation for the authenticated dashboard listener, mirroring the + * loopback listener contract: field shape is schema work, but port collisions are + * relationships between fields and belong at the boundary (#1102 pattern). + */ +import { describe, expect, test } from "bun:test"; +import { validateConfigCandidate } from "../src/config"; + +const base = { + port: 10100, + providers: { openai: { adapter: "openai", baseUrl: "https://chatgpt.com/backend-api/codex" } }, + defaultProvider: "openai", +} as const; + +describe("dashboard listener configuration", () => { + test("an enabled listener sharing the proxy port is rejected at write time", () => { + const result = validateConfigCandidate({ + ...base, + dashboardListener: { enabled: true, port: 10100, hostname: "100.88.9.100" }, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("must differ from the proxy port"); + }); + + test("an enabled listener sharing the loopback listener port is rejected", () => { + const result = validateConfigCandidate({ + ...base, + unauthenticatedLoopbackListener: { enabled: true, port: 10200 }, + dashboardListener: { enabled: true, port: 10200, hostname: "100.88.9.100" }, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("must differ from unauthenticatedLoopbackListener.port"); + }); + + test("an enabled listener without a hostname is rejected", () => { + // Unlike the loopback listener, the bind address is the operator's choice; a silent + // default would bind an interface the operator never named. + const result = validateConfigCandidate({ + ...base, + dashboardListener: { enabled: true, port: 10200 }, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("hostname"); + }); + + test("a blank hostname is rejected", () => { + const result = validateConfigCandidate({ + ...base, + dashboardListener: { enabled: true, port: 10200, hostname: " " }, + }); + expect(result.ok).toBe(false); + }); + + test("a disabled listener needs neither port nor hostname", () => { + const result = validateConfigCandidate({ + ...base, + dashboardListener: { enabled: false }, + }); + expect(result.ok).toBe(true); + }); + + test("a string enabled flag is rejected instead of silently dropped", () => { + // The schema would `.catch(undefined)` a bad enabled value away; the explicit + // check is what keeps the operator from believing the listener is on while it is off. + const result = validateConfigCandidate({ + ...base, + dashboardListener: { enabled: "true", port: 10200, hostname: "100.88.9.100" }, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("dashboardListener.enabled"); + }); + + test("a non-object listener is rejected", () => { + const result = validateConfigCandidate({ ...base, dashboardListener: 10200 }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("must be an object or omitted"); + }); + + test("an out-of-range port is rejected", () => { + const result = validateConfigCandidate({ + ...base, + dashboardListener: { enabled: true, port: 70000, hostname: "100.88.9.100" }, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("dashboardListener.port"); + }); + + test("accepts literal private and tailnet bind addresses", () => { + for (const hostname of ["10.1.2.3", "172.16.0.1", "192.168.1.1", "100.88.9.100", "fc00::1", "fdff::1"]) { + const result = validateConfigCandidate({ + ...base, + dashboardListener: { enabled: true, port: 10101, hostname }, + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.config.dashboardListener).toEqual({ enabled: true, port: 10101, hostname }); + } + } + }); + + test("rejects wildcard, loopback, public, and DNS bind addresses", () => { + for (const hostname of ["0.0.0.0", "::", "127.0.0.1", "::1", "8.8.8.8", "dashboard.example"]) { + const result = validateConfigCandidate({ + ...base, + dashboardListener: { enabled: true, port: 10101, hostname }, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("literal private or tailnet IP address"); + } + }); +}); diff --git a/tests/dashboard-listener-integration.test.ts b/tests/dashboard-listener-integration.test.ts new file mode 100644 index 00000000000..78984aa993d --- /dev/null +++ b/tests/dashboard-listener-integration.test.ts @@ -0,0 +1,300 @@ +/** + * Integration coverage for the authenticated dashboard listener. + * + * These tests start real servers and speak HTTP to both sockets. The point under + * test is the split surface: the dashboard port admits GUI + management routes with + * the admin credential, refuses the whole /v1 data plane and the health endpoints, + * and mints an opaque session whose CSRF arm guards mutations. The public listener + * must behave identically with and without the dashboard listener configured. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { networkInterfaces, tmpdir } from "node:os"; +import { join } from "node:path"; +import { isDashboardListenerBindAddress, loadConfig, saveConfig } from "../src/config"; +import { startServer } from "../src/server"; +import { findAvailablePort } from "../src/server/ports"; +import type { OcxConfig } from "../src/types"; + +const ADMIN_TOKEN = "admin-secret-for-dashboard-listener"; +const previousHome = process.env.OPENCODEX_HOME; +const previousDataToken = process.env.OPENCODEX_API_AUTH_TOKEN; +const previousAdminToken = process.env.OPENCODEX_ADMIN_AUTH_TOKEN; +let testHome = ""; + +function baseConfig(dashboardPort: number | null, hostname = dashboardHost ?? "127.0.0.1"): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "test", + providers: { + test: { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + apiKey: "provider-credential-placeholder", + disabled: true, + models: ["gpt-test"], + }, + }, + ...(dashboardPort === null + ? {} + : { dashboardListener: { enabled: true, port: dashboardPort, hostname } }), + } as unknown as OcxConfig; +} + +/** A free port the way production would choose one, so tests cannot collide on a fixed number. */ +async function freePort(hostname = dashboardHost ?? "127.0.0.1"): Promise { + return await findAvailablePort(0, hostname); +} + +function dashboardUrl(port: number, path: string): string { + return `http://${dashboardHost ?? "127.0.0.1"}:${port}${path}`; +} + +/** + * The origin-gate test needs a bind hostname the management gate treats as non-loopback. + * 127.0.0.2 is that on Linux and Windows, but macOS does not bind it (EADDRNOTAVAIL), so + * use the host's real interface address the way tests/loopback-listener-integration.test.ts + * does — the production shape for this listener is a tailnet/LAN bind anyway. + */ +function firstDashboardIPv4(): string | null { + for (const entries of Object.values(networkInterfaces())) { + for (const entry of entries ?? []) { + if (entry.family === "IPv4" && !entry.internal && isDashboardListenerBindAddress(entry.address)) { + return entry.address; + } + } + } + return null; +} + +const dashboardHost = firstDashboardIPv4(); +const dashboardTest = test.skipIf(!dashboardHost); + +beforeEach(() => { + testHome = mkdtempSync(join(tmpdir(), "ocx-dashboard-listener-")); + process.env.OPENCODEX_HOME = testHome; + delete process.env.OPENCODEX_API_AUTH_TOKEN; + process.env.OPENCODEX_ADMIN_AUTH_TOKEN = ADMIN_TOKEN; +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousDataToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; + else process.env.OPENCODEX_API_AUTH_TOKEN = previousDataToken; + if (previousAdminToken === undefined) delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + else process.env.OPENCODEX_ADMIN_AUTH_TOKEN = previousAdminToken; + if (testHome && existsSync(testHome)) rmSync(testHome, { recursive: true, force: true }); + testHome = ""; +}); + +describe("dashboard listener surface", () => { + test("drops unsafe raw listener bind addresses before startup", async () => { + const dashboardPort = await freePort("127.0.0.1"); + for (const hostname of ["0.0.0.0", "127.0.0.1", "8.8.8.8", "dashboard.example"]) { + writeFileSync( + join(testHome, "config.json"), + JSON.stringify({ + ...baseConfig(null), + dashboardListener: { enabled: true, port: dashboardPort, hostname }, + }), + ); + expect(loadConfig().dashboardListener).toBeUndefined(); + } + + // A wildcard used to survive loading and bind this socket. Loading it must leave the + // reserved port untouched even though the rest of the server starts normally. + writeFileSync( + join(testHome, "config.json"), + JSON.stringify({ + ...baseConfig(null), + dashboardListener: { enabled: true, port: dashboardPort, hostname: "0.0.0.0" }, + }), + ); + const server = startServer(0); + try { + const dashboardBound = await fetch(`http://127.0.0.1:${dashboardPort}/healthz`) + .then(() => true) + .catch(() => false); + expect(dashboardBound).toBe(false); + } finally { + await server.stop(true); + } + }); + + dashboardTest("serves the SPA root and the management API, and demands the admin token", async () => { + const dashboardPort = await freePort(); + saveConfig(baseConfig(dashboardPort)); + const server = startServer(0); + try { + const root = await fetch(dashboardUrl(dashboardPort, "/")); + expect(root.status).toBe(200); + + const anon = await fetch(dashboardUrl(dashboardPort, "/api/config")); + expect(anon.status).toBe(401); + + const authorized = await fetch(dashboardUrl(dashboardPort, "/api/config"), { + headers: { "x-opencodex-api-key": ADMIN_TOKEN }, + }); + expect(authorized.status).toBe(200); + } finally { + await server.stop(true); + } + }); + + dashboardTest("refuses the /v1 data plane and /readyz even with the admin token, serves /healthz", async () => { + const dashboardPort = await freePort(); + saveConfig(baseConfig(dashboardPort)); + const server = startServer(0); + try { + for (const path of ["/v1/models", "/v1/responses", "/readyz"]) { + const res = await fetch(dashboardUrl(dashboardPort, path), { + headers: { "x-opencodex-api-key": ADMIN_TOKEN }, + }); + expect(res.status).toBe(404); + } + // Pin the upgrade refusal separately so reordering the allowlist cannot pass silently. + const upgrade = await fetch(dashboardUrl(dashboardPort, "/v1/responses"), { + headers: { + "x-opencodex-api-key": ADMIN_TOKEN, + Upgrade: "websocket", + Connection: "Upgrade", + }, + }); + expect(upgrade.status).toBe(404); + // The dashboard overview polls /healthz; it must work (and discloses no more + // than the already-public SPA shell). + const health = await fetch(dashboardUrl(dashboardPort, "/healthz")); + expect(health.status).toBe(200); + } finally { + await server.stop(true); + } + }); + + dashboardTest("minting an opaque session keeps the dashboard usable across page refreshes", async () => { + const dashboardPort = await freePort(); + saveConfig(baseConfig(dashboardPort)); + const server = startServer(0); + try { + const mint = await fetch(dashboardUrl(dashboardPort, "/api/auth/session"), { + method: "POST", + headers: { "x-opencodex-api-key": ADMIN_TOKEN }, + }); + expect(mint.status).toBe(200); + expect(mint.headers.get("set-cookie")).toBeNull(); + const session = await mint.json() as { token: string; csrfToken: string; origin: string }; + expect(session.token).toMatch(/^ocx_session_/); + expect(session.csrfToken).toBeTruthy(); + expect(session.origin).toContain(String(dashboardPort)); + + // The session endpoint only mints; a refresh uses the opaque browser-stored token. + const sessionGet = await fetch(dashboardUrl(dashboardPort, "/api/auth/session"), { + headers: { "x-opencodex-api-key": session.token }, + }); + expect(sessionGet.status).toBe(405); + + const read = await fetch(dashboardUrl(dashboardPort, "/api/config"), { + headers: { "x-opencodex-api-key": session.token }, + }); + expect(read.status).toBe(200); + + // Mutations need the CSRF arm: the session token alone must not pass the gate. + const mutationHeaders = (csrf?: string) => ({ + "x-opencodex-api-key": session.token, + Origin: session.origin, + "X-OpenCodex-GUI-Origin": session.origin, + ...(csrf === undefined ? {} : { "x-opencodex-csrf-token": csrf }), + }); + const noCsrf = await fetch(dashboardUrl(dashboardPort, "/api/settings"), { + method: "PUT", + headers: mutationHeaders(), + body: "{}", + }); + expect(noCsrf.status).toBe(401); + + const withCsrf = await fetch(dashboardUrl(dashboardPort, "/api/settings"), { + method: "PUT", + headers: mutationHeaders(session.csrfToken), + body: "{}", + }); + // 403 would mean the cross-origin gate regressed; other statuses may reflect body validation. + expect(withCsrf.status).not.toBe(401); + expect(withCsrf.status).not.toBe(403); + } finally { + await server.stop(true); + } + }); + + dashboardTest("rejects a session mint with a wrong admin token", async () => { + const dashboardPort = await freePort(); + saveConfig(baseConfig(dashboardPort)); + const server = startServer(0); + try { + const mint = await fetch(dashboardUrl(dashboardPort, "/api/auth/session"), { + method: "POST", + headers: { "x-opencodex-api-key": "wrong-token" }, + }); + expect(mint.status).toBe(401); + } finally { + await server.stop(true); + } + }); + + dashboardTest("leaves the public listener unchanged: loopback data plane stays token-free", async () => { + const dashboardPort = await freePort(); + saveConfig(baseConfig(dashboardPort)); + const server = startServer(0); + try { + const publicRoot = await fetch(`http://127.0.0.1:${server.port}/`); + expect(publicRoot.status).toBe(200); + // Loopback bind: the data plane admits without a credential (#1102 semantics), + // and the admin token still gates /api on both sockets. + const models = await fetch(`http://127.0.0.1:${server.port}/v1/models`); + expect(models.status).toBe(200); + const anon = await fetch(`http://127.0.0.1:${server.port}/api/config`); + expect(anon.status).toBe(401); + const sessionEndpoint = await fetch(`http://127.0.0.1:${server.port}/api/auth/session`, { + method: "POST", + headers: { "x-opencodex-api-key": ADMIN_TOKEN }, + }); + expect(sessionEndpoint.status).toBe(404); + } finally { + await server.stop(true); + } + }); + + dashboardTest("stop closes the dashboard socket too", async () => { + const dashboardPort = await freePort(); + saveConfig(baseConfig(dashboardPort)); + const server = startServer(0); + await server.stop(true); + let stillServing = false; + try { + const res = await fetch(dashboardUrl(dashboardPort, "/api/config")); + stillServing = res.status > 0; + } catch { + stillServing = false; + } + expect(stillServing).toBe(false); + }); + + dashboardTest("management origin gate follows the listener policy when the proxy stays loopback", async () => { + // The regression this pins: handleManagementAPI re-derived the request origin + // from the shared loopback config, so a non-loopback dashboard Host was rejected + // as cross-origin (403) even after the listener policy had admitted it. The + // listener binds a real private/tailnet interface: non-loopback for the origin + // check and admissible under the public bind contract. + const dashboardPort = await freePort(dashboardHost!); + saveConfig(baseConfig(dashboardPort, dashboardHost)); + const server = startServer(0); + try { + const authorized = await fetch(`http://${dashboardHost}:${dashboardPort}/api/config`, { + headers: { "x-opencodex-api-key": ADMIN_TOKEN }, + }); + expect(authorized.status).toBe(200); + } finally { + await server.stop(true); + } + }); +});