import type { OfficerContext } from './routes'; import type { OfficerUser } from './normalize'; import { getActiveHeadscaleCredentials } from '../db/queries'; import { badRequest, methodNotAllowed, readJson } from './routes'; import { createClient, type HeadscaleClient } from './client'; import { arrayField, toUser } from './normalize'; import { handleInvitesRoute } from './invites'; // 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` (deleted 2026-08-14) 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 { // `/enroll/invites…` is the admin invite surface — a different flow entirely (see invites.ts): the device // is not here and there is no Officer session on it. Same prefix because it is the same feature to the // person using it, and because the spec names it that way. if (segments[0] === 'invites') return handleInvitesRoute(ctx, segments.slice(1)); 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 }); }