Repository navigation
feat(headroom): proxy lifecycle management + dashboard UI (Docker sidecar supported) #4649
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Response> { | ||
| 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", | ||
| }); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Response> { | ||
| 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", | ||
| }); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Response> { | ||
| 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", | ||
| }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| } | ||
|
Comment on lines
+101
to
+117
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The export function findHeadroomBinary(): string | null {
try {
const cmd = process.platform === "win32" ? "where" : "which";
const out = execFileSync(cmd, ["headroom"], {
stdio: ["ignore", "pipe", "ignore"],
windowsHide: true,
env: { ...process.env, PATH: EXTENDED_PATH },
})
.toString()
.trim();
return out.split(/\r?\n/)[0] || 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<boolean> { | ||
| 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<HeadroomStatus> { | ||
| const binaryPath = findHeadroomBinary(); | ||
| const python = findPython310(); | ||
| const proxyReachable = await probeProxyRunning(url); | ||
| return buildHeadroomStatus({ url, binaryPath, python, proxyReachable }); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since
stopHeadroomProxyshould be asynchronous to prevent port collision race conditions, we need toawaitits result here.