diff --git a/src/servers/api/vpn/router.ts b/src/servers/api/vpn/router.ts index a3b04535..f7b2540f 100644 --- a/src/servers/api/vpn/router.ts +++ b/src/servers/api/vpn/router.ts @@ -1,109 +1,48 @@ import { createRouter } from '../../create-router'; +import { getHeadscaleServerUrl } from '../headscale/router'; -// Enrollment for OffTail, the in-app Tailscale. The VPN's control and data planes talk DIRECTLY to -// Headscale — never through /api — so this router is not a proxy and must not grow into one. Its whole -// job is to turn an authenticated Officer session into a short-lived Headscale pre-auth key, so the phone -// can register itself without anyone pasting a key by hand. +// Enrollment for OffTail, the in-app Tailscale. Authenticate the owner, forward to officer-headscale, and +// hold no Headscale knowledge whatsoever — no URL, no admin key, no user name. // -// Headscale runs on a separate host, managed manually. The platform only consumes two env vars: -// HEADSCALE_URL e.g. https://headscale.pastilhas.dev — also handed back to the app as controlUrl -// HEADSCALE_API_KEY admin API key (bearer); a secret, so it lives in the host env, never the repo -// Both may be absent (Headscale is being stood up separately), which is a 503, not a crash. - -const HEADSCALE_USER = process.env.HEADSCALE_USER ?? 'officer'; -const KEY_TTL_MS = 10 * 60_000; -const UPSTREAM_TIMEOUT_MS = 10_000; - -type PreAuthKeyResponse = { preAuthKey?: { key?: string } }; -type UserListResponse = { users?: Array<{ id?: string | number; name?: string }> }; - -const config = () => ({ - // Trailing slash would produce //api/v1/... — harmless on most servers, but not worth relying on. - base: (process.env.HEADSCALE_URL ?? '').replace(/\/+$/, ''), - apiKey: process.env.HEADSCALE_API_KEY ?? '', -}); - -const authHeaders = (apiKey: string) => ({ - authorization: `Bearer ${apiKey}`, - 'content-type': 'application/json', -}); - -/** Headscale's user id for `name`, or null if it can't be resolved (old API shape, or no such user). */ -async function resolveUserId(base: string, apiKey: string, name: string): Promise { - try { - const res = await fetch(`${base}/api/v1/user`, { - headers: authHeaders(apiKey), - signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), - }); - if (!res.ok) return null; - const body = (await res.json()) as UserListResponse; - const match = body.users?.find((u) => u.name === name); - return match?.id === undefined || match.id === null ? null : String(match.id); - } catch { - return null; - } -} - -function mintKey(base: string, apiKey: string, user: string): Promise { - return fetch(`${base}/api/v1/preauthkey`, { - method: 'POST', - headers: authHeaders(apiKey), - body: JSON.stringify({ - user, - reusable: false, // single use — one key, one device - ephemeral: false, // the node stays registered after it disconnects - expiration: new Date(Date.now() + KEY_TTL_MS).toISOString(), // RFC3339 - }), - signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), - }); -} +// This file used to mint the pre-auth key itself, from HEADSCALE_URL / HEADSCALE_API_KEY / HEADSCALE_USER +// read out of the host env. Three globals describe exactly one server; Officer keeps a registry of many in +// `headscale_servers`, one active at a time, so the env could contradict the server the owner had selected. +// The two credential vars were later removed and the failure was silent — `if (!base || !apiKey)` returned +// 503 before the rest of the route ever ran, so enrollment had simply stopped working and said nothing. +// The logic now lives in the sidecar that owns the registry (src/servers/sidecar/headscale/enroll.ts). +// +// It stays mounted at /api/vpn rather than moving under /api/headscale because the path is a contract: +// enrollVpn() in the mobile core POSTs exactly /api/vpn/enroll. createSidecarProxy strips its own prefix +// and cannot express that rewrite, so this one forward is spelled out by hand. export const vpnRouter = createRouter(); // POST /api/vpn/enroll → { controlUrl, authKey } // -// That response shape is a contract: enrollVpn() in @officer/core/officer-net.ts reads exactly those two -// fields, so changing it means changing the mobile app too. +// The response shape is the other half of the contract: enrollVpn() in @officer/core destructures exactly +// those two fields, so changing them means changing the mobile app too. vpnRouter.post('/enroll', async (ctx) => { - const { base, apiKey } = config(); - if (!base || !apiKey) { - return ctx.json({ error: 'VPN not configured' }, 503); + const baseUrl = getHeadscaleServerUrl(); + if (!baseUrl) return ctx.json({ error: 'headscale sidecar not available' }, 503); + + // Forwarded verbatim: an optional {userId} picks the owning Headscale user when the server has several. + const body = await ctx.req.arrayBuffer(); + + let upstream: Response; + try { + upstream = await fetch(`${baseUrl}/_officer/enroll`, { + method: 'POST', + headers: { + 'content-type': ctx.req.header('content-type') ?? 'application/json', + // The authenticated owner. The sidecar binds loopback only, so its presence is the trust signal. + 'X-Officer-User': String(ctx.get('user').id), + }, + body: body.byteLength ? body : undefined, + }); + } catch (err) { + console.error('[vpn] headscale sidecar unreachable:', err); + return ctx.json({ error: 'headscale sidecar unreachable' }, 502); } - // The `user` field changed meaning across Headscale versions: a name on <=v0.22, a numeric id on v0.23+. - // Rather than pin a version we can't see from here, try the id when we can resolve one and fall back to - // the name — whichever the running Headscale accepts wins. - const userId = await resolveUserId(base, apiKey, HEADSCALE_USER); - const attempts = userId ? [userId, HEADSCALE_USER] : [HEADSCALE_USER]; - - let lastStatus = 0; - let lastBody = ''; - - for (const user of attempts) { - let res: Response; - try { - res = await mintKey(base, apiKey, user); - } catch (err) { - // Unreachable or timed out — no point trying the other identifier against the same dead host. - console.error('[vpn] headscale unreachable:', err); - return ctx.json({ error: 'headscale unreachable' }, 502); - } - - if (res.ok) { - const body = (await res.json()) as PreAuthKeyResponse; - const authKey = body.preAuthKey?.key; - if (!authKey) { - console.error('[vpn] headscale returned no preAuthKey.key'); - return ctx.json({ error: 'headscale returned no key' }, 502); - } - return ctx.json({ controlUrl: base, authKey }); - } - - lastStatus = res.status; - lastBody = (await res.text().catch(() => '')).slice(0, 300); - } - - // Never echo the upstream body to the client — it is an admin API and its errors can be descriptive. - console.error(`[vpn] headscale preauthkey failed (${lastStatus}): ${lastBody}`); - return ctx.json({ error: 'headscale preauthkey failed' }, 502); + return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) }); }); diff --git a/src/servers/sidecar/headscale/enroll.ts b/src/servers/sidecar/headscale/enroll.ts new file mode 100644 index 00000000..6d8ae143 --- /dev/null +++ b/src/servers/sidecar/headscale/enroll.ts @@ -0,0 +1,96 @@ +import type { OfficerContext } from './routes'; +import type { OfficerUser } from './normalize'; +import { getActiveHeadscaleCredentials } from 'officerdb'; +import { badRequest, methodNotAllowed, readJson } from './routes'; +import { createClient, type HeadscaleClient } from './client'; +import { arrayField, toUser } from './normalize'; + +// Device enrolment — POST /_officer/enroll. The mobile app's one-tap join: it turns an authenticated +// Officer session into a short-lived, single-use pre-auth key, so nobody pastes a key by hand. +// +// THIS USED TO LIVE IN THE PLATFORM. `src/servers/api/vpn/router.ts` read HEADSCALE_URL, HEADSCALE_API_KEY +// and HEADSCALE_USER straight from the host env — three globals that could only ever describe ONE server, +// while this sidecar already kept a registry of many. Worse, the two credential vars were removed at some +// point and nobody noticed: the route had been answering 503 to every enrolment attempt, because it checks +// those two before it gets anywhere near the user name. Enrolment acts on the ACTIVE registered server now, +// like every other domain route here, and the platform holds no Headscale credentials at all. +// +// The response shape `{controlUrl, authKey}` is a CONTRACT: enrollVpn() in the mobile core +// (monorepo-mobile/packages/core/src/services/officer-net.ts) destructures exactly those two fields and +// feeds them to configure()/loginWithAuthKey(). Extra fields are safe; renaming those two is not. + +/** Short by design: the key is redeemed seconds after it is issued, and a leaked one should die quickly. */ +const KEY_TTL_MS = 10 * 60_000; + +/** + * Which Headscale user the joining device is filed under. + * + * An explicit `userId` wins. Otherwise the choice is only made when it is UNAMBIGUOUS — one user on the + * server means there is nothing to choose. Several means the caller has to say, because picking silently + * files someone's phone under the wrong owner and the mistake stays invisible until somebody audits the + * tailnet. The old env var picked one name for every server at once, which is precisely that bug. + */ +async function resolveOwner(client: HeadscaleClient, ctx: OfficerContext): Promise { + const body = await readJson(ctx.req); + const requested = typeof body?.userId === 'string' ? body.userId.trim() : ''; + + const listed = await client.call('/api/v1/user'); + const users = arrayField(listed, 'users') + .map(toUser) + .filter((u): u is OfficerUser => !!u); + + if (requested) { + const match = users.find((u) => u.id === requested); + return match ?? badRequest(`no Headscale user with id ${requested} on the active server`); + } + + if (users.length === 1) return users[0]!; + + if (users.length === 0) { + return Response.json( + { error: 'the active Headscale server has no users — create one before enrolling a device', code: 'no_users' }, + { status: 409 }, + ); + } + + return Response.json( + { + error: 'the active Headscale server has several users — pass userId to say which one owns this device', + code: 'ambiguous_user', + users: users.map((u) => ({ id: u.id, name: u.name })), + }, + { status: 409 }, + ); +} + +export async function handleEnrollRoute(ctx: OfficerContext, segments: string[]): Promise { + if (segments.length > 0) return null; + if (ctx.req.method !== 'POST') return methodNotAllowed(); + + // Not activeClient(): the control URL goes back to the device, and only the credentials carry it. + const creds = await getActiveHeadscaleCredentials(ctx.userId); + if (!creds) { + return Response.json({ error: 'no active Headscale server', code: 'no_active_server' }, { status: 409 }); + } + + const client = createClient(creds); + + const owner = await resolveOwner(client, ctx); + if (owner instanceof Response) return owner; + + const created = await client.call<{ preAuthKey?: { key?: string } }>('/api/v1/preauthkey', { + method: 'POST', + body: { + user: owner.id, + reusable: false, // one key, one device + ephemeral: false, // the node stays registered after it disconnects + expiration: new Date(Date.now() + KEY_TTL_MS).toISOString(), // RFC3339 + }, + }); + + const authKey = created.preAuthKey?.key; + if (!authKey) return Response.json({ error: 'headscale returned no key' }, { status: 502 }); + + // `server` and `user` are advisory — for a UI that wants to say what the device just joined. + return Response.json({ controlUrl: creds.url, authKey, server: creds.name, user: owner.name }); +} diff --git a/src/servers/sidecar/headscale/index.ts b/src/servers/sidecar/headscale/index.ts index e441e3c1..62f5b370 100644 --- a/src/servers/sidecar/headscale/index.ts +++ b/src/servers/sidecar/headscale/index.ts @@ -11,7 +11,8 @@ import { MIN_VERSION_LABEL } from './version'; // 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.) +// 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. @@ -43,6 +44,9 @@ import { MIN_VERSION_LABEL } from './version'; // → 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 diff --git a/src/servers/sidecar/headscale/routes.ts b/src/servers/sidecar/headscale/routes.ts index 98380945..5de94120 100644 --- a/src/servers/sidecar/headscale/routes.ts +++ b/src/servers/sidecar/headscale/routes.ts @@ -3,6 +3,7 @@ import { handleServersRoute } from './servers'; import { handleNodesRoute } from './nodes'; import { handleUsersRoute } from './users'; import { handleKeysRoute } from './keys'; +import { handleEnrollRoute } from './enroll'; // Officer-owned routes for the headscale sidecar — the entire feature surface lives under /_officer/. // @@ -55,6 +56,8 @@ export async function handleOfficerRoute(req: Request, url: URL): Promise