import type { OfficerContext } from './routes'; import { badRequest, notFound, methodNotAllowed, readJson } from './routes'; import type { HeadscaleClient } from './client'; import { activeClient } from './active'; import { toNode, arrayField, type OfficerNode } from './normalize'; // Node routes — /_officer/nodes/*. A "node" is a machine in the tailnet. // // Two upstream shapes are worth knowing before reading this: // // • Renaming takes the new name in the PATH (`/node/{id}/rename/{newName}`), not a body. It must be // encodeURIComponent'd or a name with a slash silently becomes a 404 on a different route. // • Route approval is a whole-SET write (`approve_routes` replaces the approved list), not an // add/remove. Approving one route means sending every route that should remain approved, so those // operations are read-modify-write here rather than in the browser — see rule 5 in // SIDECAR_ARCHITECTURE.md. Doing it client-side would make two admins racing lose each other's edits; // doing it here still races, but over milliseconds instead of however long a form sits open. /** Nodes on the active server, newest-registered first within each user. */ async function listNodes(ctx: OfficerContext): Promise { const client = await activeClient(ctx.userId); if (client instanceof Response) return client; // The upstream `user` filter takes a USERNAME, not an id — a trap worth keeping out of the browser. const user = ctx.url.searchParams.get('user'); const path = user ? `/api/v1/node?user=${encodeURIComponent(user)}` : '/api/v1/node'; const body = await client.call(path); const nodes = arrayField(body, 'nodes').map(toNode); nodes.sort((a, b) => Number(b.online) - Number(a.online) || a.name.localeCompare(b.name)); return Response.json({ nodes }); } /** Re-read one node after a mutation. Headscale's mutation responses are inconsistent; a GET never is. */ async function getNode(client: HeadscaleClient, id: string): Promise { const body = await client.call<{ node?: Record }>(`/api/v1/node/${encodeURIComponent(id)}`); return body.node ? toNode(body.node) : null; } type NodeActionParams = { ctx: OfficerContext; id: string; action: string | undefined }; async function handleNodeAction({ ctx, id, action }: NodeActionParams): Promise { const { req } = ctx; const client = await activeClient(ctx.userId); if (client instanceof Response) return client; if (action === undefined) { if (req.method === 'GET') { const node = await getNode(client, id); return node ? Response.json({ node }) : notFound('no such node'); } if (req.method === 'DELETE') { await client.call(`/api/v1/node/${encodeURIComponent(id)}`, { method: 'DELETE' }); return new Response(null, { status: 204 }); } return methodNotAllowed(); } if (req.method !== 'POST') return methodNotAllowed(); if (action === 'rename') { const body = await readJson(req); if (!body) return badRequest('expected a JSON body'); const name = typeof body.name === 'string' ? body.name.trim() : ''; if (!name) return badRequest('name is required'); await client.call(`/api/v1/node/${encodeURIComponent(id)}/rename/${encodeURIComponent(name)}`, { method: 'POST' }); return Response.json({ node: await getNode(client, id) }); } if (action === 'tags') { const body = await readJson(req); if (!body) return badRequest('expected a JSON body'); if (!Array.isArray(body.tags)) return badRequest('tags must be an array of strings'); const tags = body.tags.filter((t): t is string => typeof t === 'string').map((t) => t.trim()); if (tags.some((t) => !t)) return badRequest('tags cannot be empty strings'); // Headscale requires the `tag:` prefix and rejects anything else with a 500, which we'd surface as a // useless "headscale error". Normalizing here means the UI can accept either form. const prefixed = tags.map((t) => (t.startsWith('tag:') ? t : `tag:${t}`)); await client.call(`/api/v1/node/${encodeURIComponent(id)}/tags`, { method: 'POST', body: { tags: prefixed } }); return Response.json({ node: await getNode(client, id) }); } if (action === 'routes') { const body = await readJson(req); if (!body) return badRequest('expected a JSON body'); let routes: string[]; if (Array.isArray(body.routes)) { // Whole-set write: the caller states the complete approved list. routes = body.routes.filter((r): r is string => typeof r === 'string'); } else if (typeof body.route === 'string' && typeof body.approved === 'boolean') { // Single-toggle: read the current set, apply one change, write it back. const current = await getNode(client, id); if (!current) return notFound('no such node'); const set = new Set(current.approvedRoutes); if (body.approved) set.add(body.route); else set.delete(body.route); routes = [...set]; } else { return badRequest('expected {routes: string[]} or {route: string, approved: boolean}'); } await client.call(`/api/v1/node/${encodeURIComponent(id)}/approve_routes`, { method: 'POST', body: { routes } }); return Response.json({ node: await getNode(client, id) }); } if (action === 'user') { const body = await readJson(req); if (!body) return badRequest('expected a JSON body'); // Upstream takes the target user's numeric id, not its name — and uint64-as-string, so it is validated // by shape and passed through as a string rather than parsed. const userId = typeof body.userId === 'string' ? body.userId.trim() : ''; if (!/^\d+$/.test(userId)) return badRequest('userId must be numeric'); // Moving a node changes which ACL rules and tag ownership apply to it — the routes it advertises and // the tags it carries stay put, but what they now MEAN can differ. The UI says so before asking. await client.call(`/api/v1/node/${encodeURIComponent(id)}/user`, { method: 'POST', body: { user: userId } }); return Response.json({ node: await getNode(client, id) }); } if (action === 'expire') { // Expires the node's key, forcing it to re-authenticate. Not a delete: the node stays registered. await client.call(`/api/v1/node/${encodeURIComponent(id)}/expire`, { method: 'POST' }); return Response.json({ node: await getNode(client, id) }); } return notFound(); } /** Dispatch `/_officer/nodes/...`. `rest` is the path after `nodes`. */ export async function handleNodesRoute(ctx: OfficerContext, rest: string[]): Promise { if (rest.length === 0) { if (ctx.req.method !== 'GET') return methodNotAllowed(); return listNodes(ctx); } if (rest.length > 2) return notFound(); const id = rest[0]; // Upstream ids are uint64-as-string. Validate the shape without parsing — Number() would lose precision. if (!id || !/^\d+$/.test(id)) return badRequest('node id must be numeric'); return handleNodeAction({ ctx, id, action: rest[1] }); }