import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol'; import { createSidecarConnector } from '@@/sidecar/connect'; import { handleOfficerRoute } from './routes'; import { MIN_VERSION_LABEL } from './version'; import { API_URL } from '@@/officer-url.mjs'; // The officer-headscale sidecar. Owns the whole Headscale contract for Officer: the registered servers and // their admin API keys, the >=0.29 version floor, and every multi-call composition the UI needs. The platform // API is a thin auth-gated forwarder (src/servers/api/headscale/router.ts) holding no Headscale credentials. // // Officer manages MANY Headscale servers, not one. The owner registers each with a URL and an API key // generated on that server, and switches between them; one is active at a time. So configuration lives in // Postgres (headscale_servers, keys encrypted at rest), NOT in env vars — this sidecar deliberately reads // neither HEADSCALE_URL nor HEADSCALE_API_KEY, so a registered server can never be shadowed by host env. // Device enrollment used to be the exception, minting keys in the platform from those two vars plus // HEADSCALE_USER; it moved here (enroll.ts) and now acts on the active server like everything else. // // ───────────────────────────────────────────────────────────────────────────────────────────────── // HTTP CONTRACT — the platform strips its /api/headscale mount prefix before forwarding. // // GET /_health ours. Sidecar liveness only. Per-server reachability is a // different question and needs an owner, so it lives below. // GET /_officer/servers registered servers (never includes API keys) // POST /_officer/servers register {name?,url,apiKey} — validated before it is saved // PATCH /_officer/servers/:id edit; re-validated when url or apiKey changes // DELETE /_officer/servers/:id deregister; promotes the newest survivor if it was active // POST /_officer/servers/:id/activate switch the active server // GET /_officer/servers/:id/health probe: reachable? version? key still accepted? // // Everything below acts on the ACTIVE server. 409 when none is selected — see active.ts. // // GET /_officer/nodes nodes, normalized; ?user= filters // GET /_officer/nodes/:id one node // DELETE /_officer/nodes/:id remove it from the tailnet // POST /_officer/nodes/:id/rename {name} // POST /_officer/nodes/:id/tags {tags} — 'tag:' prefix added if missing // POST /_officer/nodes/:id/routes {routes} whole set, or {route,approved} single toggle (RMW here) // POST /_officer/nodes/:id/expire expire its key, forcing re-auth (not a delete) // GET /_officer/users users, each with a node count the admin API doesn't provide // POST /_officer/users {name, displayName?, email?} // POST /_officer/users/:id/rename {name} // DELETE /_officer/users/:id refused upstream while the user still owns nodes // GET /_officer/keys pre-auth keys, secrets masked, with a derived status // POST /_officer/keys {userId, reusable?, ephemeral?, expirationDays?, aclTags?} // → the ONLY response carrying the real secret // POST /_officer/keys/:id/expire expire without deleting // DELETE /_officer/keys/:id delete outright // POST /_officer/enroll {userId?} → {controlUrl, authKey} — a single-use 10-minute key // for a joining device. userId is only required when the server // has more than one user. // NO CALLER since 2026-08-14: its only door was /api/vpn/enroll, // which is deleted. Kept because it is the handler a route under // /api/offscale would reuse, and because `/enroll/invites` — which // IS live — dispatches through the same function. // anything else 404 // // There is deliberately NO transparent /api/v1/* passthrough. Headscale's REST shape changed repeatedly // below 0.29 and its ids are uint64-as-JSON-string, so proxying raw would push all of that into the browser // — the mistake the Soulseek panels made with 37 raw upstream calls. Every quirk is absorbed here. // ───────────────────────────────────────────────────────────────────────────────────────────────── /** Grab an ephemeral free port by briefly binding one and releasing it. */ function getFreePort(): number { const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); const p = probe.port; probe.stop(true); if (p == null) throw new Error('failed to acquire a free port'); return p; } const port = getFreePort(); const server = Bun.serve({ port, hostname: '127.0.0.1', async fetch(req) { const url = new URL(req.url); // Liveness, not upstream health: with many registered servers there is no single upstream to probe, and // choosing one would need an authenticated owner. See /_officer/servers/:id/health for that. if (url.pathname === '/_health') { return Response.json({ ok: true, minHeadscaleVersion: MIN_VERSION_LABEL }); } if (url.pathname.startsWith('/_officer/')) { try { const res = await handleOfficerRoute(req, url); return res ?? new Response('not found', { status: 404 }); } catch (err) { console.error(`[headscale] ${req.method} ${url.pathname} failed`, err); return Response.json({ error: 'internal error' }, { status: 500 }); } } return new Response('not found', { status: 404 }); }, }); console.log(`[headscale] listening on 127.0.0.1:${port} (Headscale >=${MIN_VERSION_LABEL})`); // ── Command handlers ── type ReplyFn = (msg: SidecarEvent) => void; function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { switch (cmd.type) { case 'ping': reply({ type: 'pong', id: cmd.id }); break; default: reply({ type: 'error', id: (cmd as SidecarCommand).id, error: `Unknown command type: ${(cmd as Record).type}`, }); } } // ── Connect to API server ── const connection = createSidecarConnector({ apiUrl: `${API_URL}/api/sidecar/register`, name: 'headscale', handles: ['headscale'], onCommand(cmd, reply) { handleCommand(cmd as SidecarCommand, reply as ReplyFn); }, onConnected() { // Tell the API where we're listening, so it can forward /api/headscale/* here. connection.send({ type: 'headscale:server', port }); console.log(`[headscale] reported port ${port} to API`); }, }); // ── Graceful shutdown ── function shutdown(signal: string) { console.log(`[headscale] ${signal} received, shutting down...`); try { server.stop(true); } catch { /* already stopped */ } connection.destroy(); process.exit(0); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT'));