domain routes in the sidecar: nodes with per-route approval done as a read-modify-write (headscale's approve_routes replaces the whole set), users enriched with node counts, and pre-auth keys. pre-auth key secrets are revealed by call path, not by inspecting the value. headscale masks keys created since 0.28, but returns older plaintext ones in full from the list endpoint for backwards compatibility, so listing would otherwise ship live secrets into the browser's query cache. the list always nulls the secret; only creation reveals it, and the ui shows it once with a copy affordance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
143 lines
6.8 KiB
TypeScript
143 lines
6.8 KiB
TypeScript
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
|
import { createSidecarConnector } from '../connect';
|
|
import { handleOfficerRoute } from './routes';
|
|
import { MIN_VERSION_LABEL } from './version';
|
|
|
|
// 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.
|
|
// (Those two vars belong solely to the unrelated /api/vpn/enroll route, which is none of our business.)
|
|
//
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
// 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=<username> 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
|
|
// 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.
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
|
|
|
/** 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<string, unknown>).type}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Connect to API server ──
|
|
|
|
const connection = createSidecarConnector({
|
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
|
name: 'headscale',
|
|
capabilities: ['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'));
|