import type { OfficerContext } from './routes'; import { badRequest, notFound, methodNotAllowed, readJson } from './routes'; import { activeClient } from './active'; import { toPreAuthKey, arrayField } from './normalize'; // Pre-auth key routes — /_officer/keys/*. These are the tokens a machine uses to join the tailnet. // // The one thing that matters here: since 0.28 Headscale stores pre-auth keys HASHED and returns the real // secret ONLY in the create response. Every later list returns it masked as `hskey-auth--***`. A // creation response that the UI drops is a key the owner can never recover — it has to be shown once, with // a copy affordance, and the API has to make the difference legible. `key` is non-null exactly once. // // That "exactly once" is enforced by call path, not by inspecting the value: keys created before 0.28 are // still plaintext upstream and Headscale hands them back in full from the LIST endpoint for backwards // compatibility. So listing passes reveal:false and drops the secret unconditionally; only createKey // reveals. A server with history in it would otherwise leak live keys into the browser's query cache. // // Also note the shape of the delete/expire pair: expire takes the id in a POST BODY, delete takes it in a // query STRING, and neither is a REST-shaped path. Both are hidden behind ordinary Officer routes. /** Default lifetime when the caller doesn't pick one; matches Headscale's own CLI default. */ const DEFAULT_EXPIRY_DAYS = 90; const MAX_EXPIRY_DAYS = 3650; async function listKeys(ctx: OfficerContext): Promise { const client = await activeClient(ctx.userId); if (client instanceof Response) return client; // 0.29 lists every user's keys in one call (pre-0.29 required a ?user= filter and one call per user). const body = await client.call('/api/v1/preauthkey'); const keys = arrayField(body, 'preAuthKeys').map((raw) => toPreAuthKey(raw, { reveal: false })); // Usable keys first, then by newest — a spent key is history, an active one is the thing you came for. const rank = { active: 0, used: 1, expired: 2 } as const; keys.sort((a, b) => rank[a.status] - rank[b.status] || (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); return Response.json({ keys }); } async function createKey(ctx: OfficerContext): Promise { const client = await activeClient(ctx.userId); if (client instanceof Response) return client; const body = await readJson(ctx.req); if (!body) return badRequest('expected a JSON body'); // CreatePreAuthKey takes a numeric user ID — unlike the node list filter, which takes a username. The // two are easy to confuse and the failure is a confusing upstream error, so it's validated here. const userId = typeof body.userId === 'string' ? body.userId.trim() : ''; if (!/^\d+$/.test(userId)) return badRequest('userId must be the numeric id of a Headscale user'); const days = body.expirationDays === undefined ? DEFAULT_EXPIRY_DAYS : Number(body.expirationDays); if (!Number.isFinite(days) || days <= 0 || days > MAX_EXPIRY_DAYS) { return badRequest(`expirationDays must be between 1 and ${MAX_EXPIRY_DAYS}`); } const aclTags = Array.isArray(body.aclTags) ? body.aclTags .filter((t): t is string => typeof t === 'string') .map((t) => t.trim()) .filter(Boolean) .map((t) => (t.startsWith('tag:') ? t : `tag:${t}`)) : []; const created = await client.call<{ preAuthKey?: Record }>('/api/v1/preauthkey', { method: 'POST', body: { user: userId, reusable: body.reusable === true, ephemeral: body.ephemeral === true, expiration: new Date(Date.now() + days * 86_400_000).toISOString(), aclTags, }, }); if (!created.preAuthKey) return Response.json({ error: 'headscale returned no key' }, { status: 502 }); const key = toPreAuthKey(created.preAuthKey, { reveal: true }); // Stated explicitly rather than left for the client to infer from `key !== null`: this response is the // only time the secret exists anywhere outside the joining machine. return Response.json({ key, secretShownOnce: true }, { status: 201 }); } type KeyActionParams = { ctx: OfficerContext; id: string; action: string | undefined }; async function handleKeyAction({ ctx, id, action }: KeyActionParams): Promise { const client = await activeClient(ctx.userId); if (client instanceof Response) return client; if (action === 'expire') { if (ctx.req.method !== 'POST') return methodNotAllowed(); await client.call('/api/v1/preauthkey/expire', { method: 'POST', body: { id } }); return new Response(null, { status: 204 }); } if (action !== undefined) return notFound(); if (ctx.req.method === 'DELETE') { await client.call(`/api/v1/preauthkey?id=${encodeURIComponent(id)}`, { method: 'DELETE' }); return new Response(null, { status: 204 }); } return methodNotAllowed(); } /** Dispatch `/_officer/keys/...`. `rest` is the path after `keys`. */ export async function handleKeysRoute(ctx: OfficerContext, rest: string[]): Promise { if (rest.length === 0) { if (ctx.req.method === 'GET') return listKeys(ctx); if (ctx.req.method === 'POST') return createKey(ctx); return methodNotAllowed(); } if (rest.length > 2) return notFound(); const id = rest[0]; if (!id || !/^\d+$/.test(id)) return badRequest('key id must be numeric'); return handleKeyAction({ ctx, id, action: rest[1] }); }