Files
platform/src/servers/sidecar/headscale/index.ts
T
pastilhasandClaude Opus 5 69961a52cd enroll devices against the headscale server the owner picked
/api/vpn/enroll minted pre-auth keys itself, from HEADSCALE_URL, HEADSCALE_API_KEY
and HEADSCALE_USER in the host env. Three globals describe one server; Officer keeps
a registry of many in headscale_servers with one active, so the env could contradict
the server the owner had selected — and HEADSCALE_USER filed every joining device
under the same name on all of them.

The two credential vars had already been removed from the environment and nothing
noticed: the route checks `if (!base || !apiKey)` first, so it had been answering
503 to every enrollment attempt, silently. HEADSCALE_USER was read but never reached.

Enrollment moves into the sidecar that owns the registry and acts on the active
server. The owning user is resolved rather than hardcoded: an explicit userId wins,
one user on the server needs no choice, several is a 409 listing them instead of a
silent guess. The platform route keeps its path and response shape — both are a
contract with enrollVpn() in the mobile core — and is now a bare forward holding no
Headscale URL, key or user name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:07:56 +00:00

147 lines
7.2 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.
// 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=<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
// 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; reached via /api/vpn/enroll.
// 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'));