import { HeadscaleError } from './client'; import { handleServersRoute } from './servers'; import { handleNodesRoute } from './nodes'; import { handleUsersRoute } from './users'; import { handleKeysRoute } from './keys'; import { handlePolicyRoute } from './policy'; import { handleEnrollRoute } from './enroll'; import { handleSshTestRoute } from './ssh'; import { handleCompanionRoute } from './companion'; // Officer-owned routes for the headscale sidecar — the entire feature surface lives under /_officer/. // // Nothing here is a passthrough. The shapes the UI receives are stable and Officer-shaped, ids stay strings, // dates are normalized, and anything needing more than one upstream call (device counts per user, pre-auth // keys grouped by user, read-modify-write of a node's approved route set) resolves here rather than in the // browser. That is the whole reason the sidecar exists: see rule 5 in SIDECAR_ARCHITECTURE.md. export type OfficerContext = { req: Request; url: URL; userId: number }; /** 400 with a machine-readable reason. */ export const badRequest = (error: string) => Response.json({ error }, { status: 400 }); /** 404 for an unknown /_officer/ path or a missing object. */ export const notFound = (error = 'not found') => Response.json({ error }, { status: 404 }); /** 405 when the path exists but the verb doesn't. */ export const methodNotAllowed = () => Response.json({ error: 'method not allowed' }, { status: 405 }); /** Parse a JSON request body, or null when there isn't one / it isn't an object. */ export async function readJson(req: Request): Promise | null> { const body = await req.json().catch(() => null); return body && typeof body === 'object' && !Array.isArray(body) ? (body as Record) : null; } /** * Dispatch an /_officer/* request. Returns null when nothing matches, which the caller turns into a 404. * * The platform injects X-Officer-User after authenticating the owner. We bind loopback only, so its presence * is the trust signal — a request without it did not come through the platform. */ export async function handleOfficerRoute(req: Request, url: URL): Promise { const officerUser = req.headers.get('X-Officer-User'); if (!officerUser) return Response.json({ error: 'missing X-Officer-User' }, { status: 401 }); const userId = Number(officerUser); if (!Number.isInteger(userId) || userId <= 0) return badRequest('invalid X-Officer-User'); const segments = url.pathname.slice('/_officer/'.length).split('/').filter(Boolean); if (segments.length === 0) return null; const ctx: OfficerContext = { req, url, userId }; try { switch (segments[0]) { case 'servers': return await handleServersRoute(ctx, segments.slice(1)); // The domain routes below all act on the ACTIVE server — see active.ts for why that isn't a param. case 'nodes': return await handleNodesRoute(ctx, segments.slice(1)); case 'users': return await handleUsersRoute(ctx, segments.slice(1)); case 'keys': return await handleKeysRoute(ctx, segments.slice(1)); case 'policy': return await handlePolicyRoute(ctx, segments.slice(1)); case 'enroll': return await handleEnrollRoute(ctx, segments.slice(1)); // Not a Headscale call at all — a local `ssh` reachability probe for the console. See ssh.ts. case 'ssh-test': return await handleSshTestRoute(ctx, segments.slice(1)); // The active server's Officer Companion: container health, logs and lifecycle. See companion.ts. case 'companion': return await handleCompanionRoute(ctx, segments.slice(1)); default: return null; } } catch (err) { // Upstream failures carry their own status; everything else is ours and is a 500 the caller logs. if (err instanceof HeadscaleError) return Response.json({ error: err.message }, { status: err.status }); throw err; } }