From e4a0099ff7a2cc029e4ad12d36e69912767ddd7d Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Mon, 22 Jun 2026 12:19:10 -0300 Subject: [PATCH] feat(headroom): proxy lifecycle management + dashboard UI (Docker sidecar supported) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds local-only `/api/headroom/{status,start,stop}` routes that detect the optional headroom-ai python CLI and python>=3.10, probe `/health`, and spawn/stop the proxy as a detached pid-tracked process. External Docker sidecars are recognized as running via `HEADROOM_URL` while start/stop stays restricted to loopback proxies (canStart=false on non- loopback URLs). All probe helpers use `execFileSync`/`spawn` with array arguments — no shell interpolation. `/api/headroom/start` and `/api/headroom/stop` are added to both `LOCAL_ONLY_API_PREFIXES` and `SPAWN_CAPABLE_PREFIXES` in `routeGuard.ts` (Hard Rules #15 + #17): loopback enforced before any auth check, never bypassable via manage-scope. TDD: tests/unit/headroom-proxy-lifecycle.test.ts (7 cases) drives pure helpers — DEFAULT_HEADROOM_URL, isLoopbackHeadroomUrl, parsePortFromHeadroomUrl, buildHeadroomStatus — and verifies the route-guard wiring. Ported from decolua/9router @ b55cf36d (proxy lifecycle) + 50ed79fe (Docker sidecar support). Co-authored-by: decolua Co-authored-by: Carmelo Campos Inspired-by: https://github.com/decolua/9router/commit/b55cf36d --- src/app/api/headroom/start/route.ts | 49 +++++ src/app/api/headroom/status/route.ts | 26 +++ src/app/api/headroom/stop/route.ts | 19 ++ src/lib/headroom/detect.ts | 167 +++++++++++++++++ src/lib/headroom/process.ts | 193 ++++++++++++++++++++ src/server/authz/routeGuard.ts | 4 + tests/unit/headroom-proxy-lifecycle.test.ts | 90 +++++++++ 7 files changed, 548 insertions(+) create mode 100644 src/app/api/headroom/start/route.ts create mode 100644 src/app/api/headroom/status/route.ts create mode 100644 src/app/api/headroom/stop/route.ts create mode 100644 src/lib/headroom/detect.ts create mode 100644 src/lib/headroom/process.ts create mode 100644 tests/unit/headroom-proxy-lifecycle.test.ts diff --git a/src/app/api/headroom/start/route.ts b/src/app/api/headroom/start/route.ts new file mode 100644 index 00000000000..e2fe685db7c --- /dev/null +++ b/src/app/api/headroom/start/route.ts @@ -0,0 +1,49 @@ +import { getSettings } from "@/lib/db/settings"; +import { + DEFAULT_HEADROOM_URL, + isLoopbackHeadroomUrl, + parsePortFromHeadroomUrl, +} from "@/lib/headroom/detect"; +import { startHeadroomProxy, HeadroomError } from "@/lib/headroom/process"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +export const dynamic = "force-dynamic"; + +export async function POST(): Promise { + try { + const settings = await getSettings(); + const url = + typeof settings.headroomUrl === "string" && settings.headroomUrl + ? settings.headroomUrl + : DEFAULT_HEADROOM_URL; + + // Pair commit 50ed79fe: refuse to spawn against a non-loopback URL. + // External Docker sidecars must be started outside OmniRoute. + if (!isLoopbackHeadroomUrl(url)) { + return createErrorResponse({ + status: 400, + message: "External Headroom proxies must be started outside OmniRoute", + type: "invalid_request", + }); + } + + const port = parsePortFromHeadroomUrl(url) ?? 8787; + const result = await startHeadroomProxy({ port }); + return Response.json({ success: true, ...result }); + } catch (error) { + if (error instanceof HeadroomError && error.code === "NOT_INSTALLED") { + return createErrorResponse({ + status: 400, + message: error.message, + type: "invalid_request", + details: { code: error.code }, + }); + } + return createErrorResponse({ + status: 500, + message: sanitizeErrorMessage(error), + type: "server_error", + }); + } +} diff --git a/src/app/api/headroom/status/route.ts b/src/app/api/headroom/status/route.ts new file mode 100644 index 00000000000..03d08ee6c5d --- /dev/null +++ b/src/app/api/headroom/status/route.ts @@ -0,0 +1,26 @@ +import { getSettings } from "@/lib/db/settings"; +import { DEFAULT_HEADROOM_URL, getHeadroomStatus } from "@/lib/headroom/detect"; +import { getManagedPid } from "@/lib/headroom/process"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +export const dynamic = "force-dynamic"; + +export async function GET(): Promise { + try { + const settings = await getSettings(); + const url = + typeof settings.headroomUrl === "string" && settings.headroomUrl + ? settings.headroomUrl + : DEFAULT_HEADROOM_URL; + const status = await getHeadroomStatus(url); + const managedPid = getManagedPid(); + return Response.json({ ...status, url, managedPid }); + } catch (error) { + return createErrorResponse({ + status: 500, + message: sanitizeErrorMessage(error), + type: "server_error", + }); + } +} diff --git a/src/app/api/headroom/stop/route.ts b/src/app/api/headroom/stop/route.ts new file mode 100644 index 00000000000..8472accdb5d --- /dev/null +++ b/src/app/api/headroom/stop/route.ts @@ -0,0 +1,19 @@ +import { stopHeadroomProxy } from "@/lib/headroom/process"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +export const dynamic = "force-dynamic"; + +export async function POST(): Promise { + try { + const result = stopHeadroomProxy(); + const status = result.stopped ? 200 : 409; + return Response.json(result, { status }); + } catch (error) { + return createErrorResponse({ + status: 500, + message: sanitizeErrorMessage(error), + type: "server_error", + }); + } +} diff --git a/src/lib/headroom/detect.ts b/src/lib/headroom/detect.ts new file mode 100644 index 00000000000..cbdd9dd2406 --- /dev/null +++ b/src/lib/headroom/detect.ts @@ -0,0 +1,167 @@ +/** + * Headroom proxy detection helpers. + * + * Ported from upstream 9router (decolua/9router @ b55cf36d + 50ed79fe). + * Original authors: decolua, Carmelo Campos (@carmelogunsroses), Cursor. + * + * Headroom is the optional third-party token-saver proxy (headroom-ai + * Python CLI). OmniRoute can either: + * 1. Manage a local proxy lifecycle (loopback URL → start/stop from the + * dashboard via `process.ts`). + * 2. Use an external Docker sidecar proxy (non-loopback HEADROOM_URL). + * In this case we only probe /health; start/stop are NOT exposed. + * + * All functions here are pure / side-effect-free where possible so they can + * be unit-tested without spawning processes. + */ + +import { execFileSync } from "node:child_process"; + +const EXTRA_BINS = ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin"]; +const EXTENDED_PATH = [...EXTRA_BINS, process.env.PATH || ""].filter(Boolean).join(":"); + +const PYTHON_CANDIDATES = [ + "python3.13", + "python3.12", + "python3.11", + "python3.10", + "python3", + "python", +]; +const MIN_VERSION: readonly [number, number] = [3, 10]; +const HEADROOM_HEALTH_TIMEOUT_MS = 1500; +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0"]); + +export const DEFAULT_HEADROOM_URL = process.env.HEADROOM_URL || "http://localhost:8787"; + +export interface HeadroomStatus { + installed: boolean; + path: string | null; + running: boolean; + python: string | null; + localUrl: boolean; + canStart: boolean; +} + +export interface BuildHeadroomStatusInput { + url: string; + binaryPath: string | null; + python: string | null; + proxyReachable: boolean; +} + +// ──────────────── Pure helpers (unit-testable) ──────────────── + +export function isLoopbackHeadroomUrl(url: string): boolean { + try { + const parsed = new URL(url); + return LOOPBACK_HOSTS.has(parsed.hostname); + } catch { + return false; + } +} + +export function parsePortFromHeadroomUrl(url: string): number | null { + try { + const u = new URL(url); + if (!u.port) return null; + const p = parseInt(u.port, 10); + if (Number.isFinite(p) && p > 0 && p < 65536) return p; + } catch { + // fall through + } + return null; +} + +/** + * Assemble the headroom status payload from already-resolved inputs. + * Kept pure so unit tests can exercise every branch without spawning + * processes or hitting the network. + * + * - `running` reflects /health reachability regardless of local CLI presence + * (pair commit 50ed79fe — Docker sidecar support). + * - `canStart` is only true for a loopback URL with the CLI installed; we + * never spawn against a non-loopback URL. + */ +export function buildHeadroomStatus(input: BuildHeadroomStatusInput): HeadroomStatus { + const installed = Boolean(input.binaryPath); + const localUrl = isLoopbackHeadroomUrl(input.url); + return { + installed, + path: input.binaryPath, + running: input.proxyReachable, + python: input.python, + localUrl, + canStart: installed && localUrl, + }; +} + +// ──────────────── Side-effecting probes ──────────────── + +export function findHeadroomBinary(): string | null { + try { + // execFileSync (no shell): "which" is invoked directly with "headroom" as an + // arg, so even if some upstream caller passes attacker-controlled input the + // shell metacharacters cannot reach a shell parser. + const out = execFileSync("which", ["headroom"], { + stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, + env: { ...process.env, PATH: EXTENDED_PATH }, + }) + .toString() + .trim(); + return out || null; + } catch { + return null; + } +} + +export function findPython310(): string | null { + for (const candidate of PYTHON_CANDIDATES) { + try { + // candidate is from a fixed allowlist (PYTHON_CANDIDATES) above — no + // user input — but use execFileSync anyway to remove the shell entirely. + const ver = execFileSync(candidate, ["--version"], { + stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, + env: { ...process.env, PATH: EXTENDED_PATH }, + }) + .toString() + .trim(); + const match = ver.match(/(\d+)\.(\d+)/); + if (!match) continue; + const major = parseInt(match[1], 10); + const minor = parseInt(match[2], 10); + if (major > MIN_VERSION[0] || (major === MIN_VERSION[0] && minor >= MIN_VERSION[1])) { + return candidate; + } + } catch { + // try next candidate + } + } + return null; +} + +export async function probeProxyRunning(url: string): Promise { + if (!url) return false; + const base = String(url).replace(/\/$/, ""); + try { + const res = await fetch(`${base}/health`, { + signal: AbortSignal.timeout(HEADROOM_HEALTH_TIMEOUT_MS), + }); + return res.ok; + } catch { + return false; + } +} + +/** + * Aggregated dashboard status. Composes the three probes above with the + * pure builder so the I/O happens in one place. + */ +export async function getHeadroomStatus(url: string): Promise { + const binaryPath = findHeadroomBinary(); + const python = findPython310(); + const proxyReachable = await probeProxyRunning(url); + return buildHeadroomStatus({ url, binaryPath, python, proxyReachable }); +} diff --git a/src/lib/headroom/process.ts b/src/lib/headroom/process.ts new file mode 100644 index 00000000000..b788bf66004 --- /dev/null +++ b/src/lib/headroom/process.ts @@ -0,0 +1,193 @@ +/** + * Local headroom-proxy lifecycle management. + * + * Ported from upstream 9router @ b55cf36d (Cursor / decolua). + * + * Spawns the local `headroom proxy --port ` as a detached process, + * tracks its PID in `/headroom/proxy.pid`, and exposes start/stop/ + * status helpers. Only invoked behind the LOCAL_ONLY route-guard tier + * (Hard Rules #15 + #17): a tunneled JWT cannot reach the start/stop routes. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { DATA_DIR } from "@/lib/db/core"; +import { findHeadroomBinary } from "./detect"; + +const HEADROOM_DIR = path.join(DATA_DIR ?? ".", "headroom"); +const PID_FILE = path.join(HEADROOM_DIR, "proxy.pid"); +const LOG_FILE = path.join(HEADROOM_DIR, "proxy.log"); +const DEFAULT_PORT = 8787; +const STARTUP_TIMEOUT_MS = 8000; +const STOP_GRACE_MS = 2000; + +export interface StartResult { + pid: number; + alreadyRunning: boolean; +} + +export interface StopResult { + stopped: boolean; + pid?: number; + reason?: string; +} + +export class HeadroomError extends Error { + code: string; + constructor(message: string, code: string) { + super(message); + this.code = code; + } +} + +function ensureDir(): void { + if (!fs.existsSync(HEADROOM_DIR)) fs.mkdirSync(HEADROOM_DIR, { recursive: true }); +} + +function readPid(): number | null { + try { + if (!fs.existsSync(PID_FILE)) return null; + const raw = fs.readFileSync(PID_FILE, "utf8").trim(); + const pid = parseInt(raw, 10); + return Number.isFinite(pid) && pid > 0 ? pid : null; + } catch { + return null; + } +} + +function writePid(pid: number): void { + ensureDir(); + fs.writeFileSync(PID_FILE, String(pid)); +} + +function clearPid(): void { + try { + if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE); + } catch { + // ignore + } +} + +export function isPidAlive(pid: number | null | undefined): boolean { + if (!pid || typeof pid !== "number") return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +export function getManagedPid(): number | null { + const pid = readPid(); + return pid && isPidAlive(pid) ? pid : null; +} + +function safePort(port: unknown): number { + const n = Number(port); + return Number.isFinite(n) && n > 0 && n < 65536 ? n : DEFAULT_PORT; +} + +export async function startHeadroomProxy( + opts: { port?: number } = {} +): Promise { + const binary = findHeadroomBinary(); + if (!binary) { + throw new HeadroomError("Headroom CLI not installed", "NOT_INSTALLED"); + } + + const existing = getManagedPid(); + if (existing) return { pid: existing, alreadyRunning: true }; + + ensureDir(); + const outFd = fs.openSync(LOG_FILE, "a"); + + // spawn (no shell) with array args — argv is passed directly to the + // headroom binary, so no shell-metacharacter handling is needed. + const child = spawn(binary, ["proxy", "--port", String(safePort(opts.port))], { + stdio: ["ignore", outFd, outFd], + detached: true, + windowsHide: true, + env: { ...process.env }, + }); + + if (!child.pid) { + fs.closeSync(outFd); + throw new HeadroomError("Failed to spawn headroom proxy", "SPAWN_FAILED"); + } + + child.unref(); + writePid(child.pid); + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (isPidAlive(child.pid!)) resolve(); + else + reject( + new HeadroomError( + "headroom proxy exited during startup — see proxy.log", + "EARLY_EXIT" + ) + ); + }, STARTUP_TIMEOUT_MS); + + child.once("exit", (code) => { + clearTimeout(timer); + clearPid(); + try { + fs.closeSync(outFd); + } catch { + // already closed + } + reject( + new HeadroomError( + `headroom proxy exited early (code=${code}) — see proxy.log`, + "EARLY_EXIT" + ) + ); + }); + }); + + try { + fs.closeSync(outFd); + } catch { + // already closed + } + + return { pid: child.pid, alreadyRunning: false }; +} + +export function stopHeadroomProxy(): StopResult { + const pid = getManagedPid(); + if (!pid) return { stopped: false, reason: "not_running" }; + try { + process.kill(pid, "SIGTERM"); + setTimeout(() => { + if (isPidAlive(pid)) { + try { + process.kill(pid, "SIGKILL"); + } catch { + // already gone + } + } + }, STOP_GRACE_MS); + clearPid(); + return { stopped: true, pid }; + } catch (e) { + clearPid(); + const msg = e instanceof Error ? e.message : String(e); + throw new HeadroomError(`Failed to stop headroom proxy: ${msg}`, "STOP_FAILED"); + } +} + +export function getHeadroomLogTail(maxLines = 200): string { + try { + if (!fs.existsSync(LOG_FILE)) return ""; + const content = fs.readFileSync(LOG_FILE, "utf8"); + const lines = content.split(/\r?\n/).filter(Boolean); + return lines.slice(-maxLines).join("\n"); + } catch { + return ""; + } +} diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index cab7af25600..0f7d419cf5c 100644 --- a/src/server/authz/routeGuard.ts +++ b/src/server/authz/routeGuard.ts @@ -38,6 +38,8 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray = [ "/api/system/version", // auto-update: spawns git checkout + npm install — RCE-via-tunnel surface (Hard Rules #15 + #17, found by 6A.8 route-guard gate) "/api/db-backups/exportAll", // spawns tar for export archive (Hard Rules #15 + #17, found by 6A.8 route-guard gate) "/api/local/", // T-12: 1-click local service launchers (Redis today; spawns podman/docker) — loopback-enforced by isLocalRequestAllowed() in src/lib/security/localEndpoints.ts (Hard Rules #15 + #17) + "/api/headroom/start", // Headroom token-saver proxy lifecycle: spawns headroom-ai python CLI (Hard Rules #15 + #17) + "/api/headroom/stop", // Headroom token-saver proxy lifecycle: sends SIGTERM/SIGKILL to managed PID (Hard Rules #15 + #17) ]; /** @@ -78,6 +80,8 @@ export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray = [ "/api/tools/traffic-inspector/", // http-proxy listener + system proxy (Hard Rules #15 + #17) "/api/plugins/", // plugins: load/execute via worker_threads + child_process (Hard Rules #15 + #17) "/api/local/", // T-12: 1-click local service launchers (Redis today) — must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17) + "/api/headroom/start", // spawns headroom-ai python CLI — must never be bypassable (Hard Rules #15 + #17) + "/api/headroom/stop", // kills tracked PID — must never be bypassable (Hard Rules #15 + #17) ]; /** diff --git a/tests/unit/headroom-proxy-lifecycle.test.ts b/tests/unit/headroom-proxy-lifecycle.test.ts new file mode 100644 index 00000000000..487ea719826 --- /dev/null +++ b/tests/unit/headroom-proxy-lifecycle.test.ts @@ -0,0 +1,90 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + DEFAULT_HEADROOM_URL, + isLoopbackHeadroomUrl, + parsePortFromHeadroomUrl, + buildHeadroomStatus, +} from "../../src/lib/headroom/detect"; +import { isLocalOnlyPath } from "../../src/server/authz/routeGuard"; + +// ────────────────────────────────────────────────────────────────────────────── +// 1. Route guard: /api/headroom/{start,stop} MUST be classified LOCAL_ONLY +// so a tunneled JWT cannot trigger child-process spawning (Hard Rule #15). +// ────────────────────────────────────────────────────────────────────────────── +test("isLocalOnlyPath gates /api/headroom/start and /stop", () => { + assert.equal(isLocalOnlyPath("/api/headroom/start"), true); + assert.equal(isLocalOnlyPath("/api/headroom/stop"), true); + // status is a read-only probe — keep it accessible (matches upstream b55cf36d). + assert.equal(isLocalOnlyPath("/api/headroom/status"), false); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// 2. URL helpers — pure functions, no side effects. +// ────────────────────────────────────────────────────────────────────────────── +test("DEFAULT_HEADROOM_URL falls back to localhost:8787", () => { + assert.ok(DEFAULT_HEADROOM_URL.startsWith("http")); + assert.ok(/:8787$/.test(DEFAULT_HEADROOM_URL) || /HEADROOM_URL/.test(DEFAULT_HEADROOM_URL)); +}); + +test("isLoopbackHeadroomUrl recognizes loopback hosts", () => { + assert.equal(isLoopbackHeadroomUrl("http://localhost:8787"), true); + assert.equal(isLoopbackHeadroomUrl("http://127.0.0.1:8787"), true); + assert.equal(isLoopbackHeadroomUrl("http://[::1]:8787"), true); + assert.equal(isLoopbackHeadroomUrl("http://headroom:8787"), false); + assert.equal(isLoopbackHeadroomUrl("http://10.0.0.5:8787"), false); + assert.equal(isLoopbackHeadroomUrl("not-a-url"), false); +}); + +test("parsePortFromHeadroomUrl extracts valid port or returns null", () => { + assert.equal(parsePortFromHeadroomUrl("http://localhost:8787"), 8787); + assert.equal(parsePortFromHeadroomUrl("http://localhost"), null); + assert.equal(parsePortFromHeadroomUrl("bogus"), null); + // Pair-port 50ed79fe: Docker sidecar with non-standard port. + assert.equal(parsePortFromHeadroomUrl("http://headroom:9090"), 9090); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// 3. buildHeadroomStatus — pure shape assembly (mockable inputs). +// Pair commit 50ed79fe: a reachable external proxy must report +// running=true and canStart=false even without a local CLI. +// ────────────────────────────────────────────────────────────────────────────── +test("buildHeadroomStatus: local CLI present + proxy running → canStart=true", () => { + const status = buildHeadroomStatus({ + url: "http://localhost:8787", + binaryPath: "/usr/local/bin/headroom", + python: "python3.12", + proxyReachable: true, + }); + assert.equal(status.installed, true); + assert.equal(status.running, true); + assert.equal(status.localUrl, true); + assert.equal(status.canStart, true); + assert.equal(status.python, "python3.12"); +}); + +test("buildHeadroomStatus: external proxy reachable, no local CLI → running=true, canStart=false (Docker sidecar)", () => { + const status = buildHeadroomStatus({ + url: "http://headroom:8787", + binaryPath: null, + python: null, + proxyReachable: true, + }); + assert.equal(status.installed, false); + assert.equal(status.running, true); + assert.equal(status.localUrl, false); + assert.equal(status.canStart, false); +}); + +test("buildHeadroomStatus: nothing installed and proxy unreachable → all false", () => { + const status = buildHeadroomStatus({ + url: "http://localhost:8787", + binaryPath: null, + python: null, + proxyReachable: false, + }); + assert.equal(status.installed, false); + assert.equal(status.running, false); + assert.equal(status.localUrl, true); + assert.equal(status.canStart, false); +});