mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-15 03:32:21 +03:00
Release v3.8.42 — full CHANGELOG in CHANGELOG.md. CI: 103 checks green incl. CodeQL (all languages), Semgrep, all 8 unit shards, coverage, Node 24 compat, and integration tests. Full unit suite validated locally: 19437 pass / 0 fail. The 3 red checks are advisory and do not gate main (no required status checks): SonarCloud/SonarQube new-code coverage gate, and PR Test Policy (test-masking detector flagging the legitimate dead-Phind provider removal in #5530 — reviewed, correct). Includes cycle-close reconciliation + repair of inherited base-red tests from #5480/#5527/#5427/#5521 that the PR->release fast-path did not exercise.
178 lines
4.5 KiB
TypeScript
178 lines
4.5 KiB
TypeScript
/**
|
|
* Local headroom-proxy lifecycle management.
|
|
*
|
|
* Ported from upstream 9router @ b55cf36d (Cursor / decolua).
|
|
*
|
|
* Spawns the local `headroom proxy --port <port>` as a detached process,
|
|
* tracks its PID in `<DATA_DIR>/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<StartResult> {
|
|
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<void>((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");
|
|
}
|
|
}
|