import { getActiveHeadscaleCredentials, type HeadscaleServerCredentials } from '../db/queries'; import { badRequest, methodNotAllowed, notFound, type OfficerContext } from './routes'; // The Officer Companion API — a small service deployed NEXT TO each Headscale server that answers what the // admin API structurally cannot: is the container up, what do its logs say, and start/stop/restart it. // Contract: COMMS/HEADSCALE_COMPANION_API.md. // // Three facts shape everything here. // // 1. It lives at `${server.url}/officer-api` and authenticates with the SAME admin API key we already // store, validated locally against Headscale's own key store — so auth keeps working while Headscale // is down, which is exactly when `/restart` matters. Nothing new to register, and the key still never // leaves this sidecar. // // 2. It is OPTIONAL and per-server. Of the four servers registered here today, one has it deployed. So // "no companion" is a normal state, not an error: every route below answers 200 with // `{available: false, reason}` rather than failing, and the UI degrades to what the admin API can do. // Distinguishing the two 502s is the whole trick — nginx returns HTML when the companion is down, // the companion returns JSON when a docker op fails. Branch on whether the body parses. // // 3. `GET /health` is ALWAYS 200, at every verdict. Never key anything off its HTTP status; read // `verdict`. That inversion is deliberate on their side and is preserved on ours. /** * Every route answers `{available: true, ...}` or `{available: false, reason}` at HTTP 200. Not having a * companion is a state to render, not a request that failed — the admin API on the same domain is * independent and may still be working, so this must not surface as an error the UI swallows. */ export const unavailable = (reason: string) => ({ available: false as const, reason }); const DEFAULT_TIMEOUT_MS = 20_000; type CompanionCall = { path: string; method?: string; body?: unknown; timeoutMs?: number; signal?: AbortSignal }; /** * One request to a server's companion. Returns the raw Response, or a reason string when the companion * itself could not be reached — the caller decides how to present that, because for this feature * "unreachable" is information rather than a failure. */ export async function callCompanion( creds: HeadscaleServerCredentials, { path, method = 'GET', body, timeoutMs = DEFAULT_TIMEOUT_MS, signal }: CompanionCall, ): Promise { let res: Response; try { res = await fetch(`${creds.url}/officer-api${path}`, { method, headers: { authorization: `Bearer ${creds.apiKey}`, accept: 'application/json', ...(body === undefined ? {} : { 'content-type': 'application/json' }), }, body: body === undefined ? undefined : JSON.stringify(body), signal: signal ?? AbortSignal.timeout(timeoutMs), }); } catch (err) { if (err instanceof Error && err.name === 'TimeoutError') return 'the companion timed out'; // A TLS failure or DNS miss on the server's own domain: the whole host is unreachable, not just this. return 'could not reach the server'; } if (res.status === 401) return 'the companion rejected the stored API key'; // Both 404 and 502 are ambiguous, and the same test settles both: a JSON body means the companion // answered (no such container / the docker op failed) and that answer belongs to the caller; a // non-JSON body means we never reached it — nginx's own 502 page, or a route that isn't there at all. const isJson = (res.headers.get('content-type') ?? '').includes('json'); if (res.status === 404 && !isJson) return 'this server has no companion at /officer-api'; if (res.status === 502 && !isJson) return 'the companion is not deployed on this server'; if (res.status >= 500 && !isJson) return `the companion returned ${res.status}`; return res; } /** Parse a companion JSON body, or a reason when it isn't JSON after all. */ export async function readBody(res: Response): Promise | string> { const text = await res.text().catch(() => ''); if (!text) return 'the companion returned an empty body'; try { const parsed = JSON.parse(text) as unknown; if (!parsed || typeof parsed !== 'object') return 'the companion returned an unexpected body'; return parsed as Record; } catch { return 'the companion returned a non-JSON body'; } } /** The active server's credentials, or a 409 the UI already knows how to render. */ export async function activeCreds(userId: number): Promise { const creds = await getActiveHeadscaleCredentials(userId); if (!creds) { return Response.json({ error: 'no active Headscale server', code: 'no_active_server' }, { status: 409 }); } return creds; } /** `GET /_officer/companion/health` — verdict, container state and, when unhappy, its own diagnosis. */ async function health(creds: HeadscaleServerCredentials): Promise { const res = await callCompanion(creds, { path: '/health' }); if (typeof res === 'string') return Response.json(unavailable(res)); const body = await readBody(res); if (typeof body === 'string') return Response.json(unavailable(body)); // Passed through as-is. The companion owns this vocabulary and versions it; re-shaping it here would mean // a new verdict or a new likely-cause silently disappearing on the way to the screen. return Response.json({ available: true, health: body }); } /** `GET /_officer/companion/logs?tail=N` — a snapshot of the last N lines. */ async function logs(creds: HeadscaleServerCredentials, url: URL): Promise { const tail = Number(url.searchParams.get('tail') ?? 200); if (!Number.isInteger(tail) || tail < 1 || tail > 2000) return badRequest('tail must be 1–2000'); const res = await callCompanion(creds, { path: `/logs?tail=${tail}` }); if (typeof res === 'string') return Response.json(unavailable(res)); const body = await readBody(res); if (typeof body === 'string') return Response.json(unavailable(body)); const lines = Array.isArray(body.lines) ? body.lines.filter((l): l is string => typeof l === 'string') : []; return Response.json({ available: true, lines }); } /** * `GET /_officer/companion/logs/stream?tail=N` — the live tail, relayed frame for frame. * * The browser cannot open this itself: EventSource sends no Authorization header, and the key it would need * is one this sidecar exists to keep. So the stream is proxied, and the body is returned UNTOUCHED — a * ReadableStream that Bun cancels when the client disconnects, which aborts the upstream fetch in turn. * Buffering it into frames here would break that, and would also mean a log line waiting on our own flush. */ async function logStream(creds: HeadscaleServerCredentials, ctx: OfficerContext): Promise { const tail = Number(ctx.url.searchParams.get('tail') ?? 200); if (!Number.isInteger(tail) || tail < 1 || tail > 2000) return badRequest('tail must be 1–2000'); // No timeout: a quiet log is the normal case and must not look like a dropped connection. The request's // own signal is the lifetime — when the panel closes, this closes. const res = await callCompanion(creds, { path: `/logs?tail=${tail}&follow=1`, signal: ctx.req.signal, }); // An unavailable companion still answers in the stream's own vocabulary, so the client has one parser and // one place to show a problem rather than a second, JSON-shaped failure mode. if (typeof res === 'string') { return new Response(`event: error\ndata: ${res}\n\n`, { headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }, }); } return new Response(res.body, { status: 200, headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', // Belt and braces through our own proxy chain, matching what the companion already sets. 'x-accel-buffering': 'no', }, }); } const ACTIONS = new Set(['restart', 'stop', 'start']); /** * `POST /_officer/companion/:action` — restart / stop / start the Headscale container. * * Every one of these drops every node's control-plane connection for the duration. That is the intended * "kill it" behaviour and the reason the UI asks twice; it is not something to retry automatically. */ async function action(creds: HeadscaleServerCredentials, name: string): Promise { // 60s: docker restart on a busy container is not fast, and a timeout here reads as "did it work?" — the // one question this feature exists to answer. const res = await callCompanion(creds, { path: `/${name}`, method: 'POST', timeoutMs: 60_000 }); if (typeof res === 'string') return Response.json(unavailable(res)); const body = await readBody(res); if (typeof body === 'string') return Response.json(unavailable(body)); return Response.json({ available: true, ...body }); } /** Dispatch `/_officer/companion/...`. Always acts on the ACTIVE server, like every other domain route. */ export async function handleCompanionRoute(ctx: OfficerContext, rest: string[]): Promise { const creds = await activeCreds(ctx.userId); if (creds instanceof Response) return creds; const [head, tail] = rest; if (head === 'health' && !tail) { if (ctx.req.method !== 'GET') return methodNotAllowed(); return health(creds); } if (head === 'logs') { if (ctx.req.method !== 'GET') return methodNotAllowed(); if (!tail) return logs(creds, ctx.url); if (tail === 'stream') return logStream(creds, ctx); return notFound(); } if (head && ACTIONS.has(head) && !tail) { if (ctx.req.method !== 'POST') return methodNotAllowed(); return action(creds, head); } return notFound(); }