import type { OfficerContext } from './routes'; import { badRequest, methodNotAllowed, readJson } from './routes'; import { HeadscaleError } from './client'; import { activeClient } from './active'; import { handlePolicyAssistRoute } from './assist'; // The ACL policy — /_officer/policy. One HuJSON document that decides which node may reach which, so it is // the highest-consequence thing this app can write and the only place a typo silently partitions a network. // // Three upstream behaviours drive the shape of this file. // // 1. **Readable always, writable sometimes.** Headscale can keep its policy in a file (`policy.mode: file`) // instead of the database, and then the API still SERVES it — a GET returns the file's contents quite // happily — but a PUT is refused with "update is disabled for modes other than 'database'". Verified // against a live server, and it means the mode CANNOT be inferred from a read. There is no endpoint // that reports it either. So this route makes no claim about writability up front; the first save is // what finds out, and a refusal is a 409 the UI turns into a persistent read-only banner. // // 2. **Validation happens on PUT, in Headscale, and its message is the whole value.** It parses the // HuJSON, resolves every group and tag reference, and rejects the write with a line and column or a // "group not defined" naming the offender. Officer must not pre-validate: a second, weaker parser here // would reject documents Headscale accepts and — worse — accept ones it rejects, and its opinion would // be the one shown. So the text goes up untouched and Headscale's verdict comes back verbatim. // // 3. **Both of those arrive as HTTP 500** from grpc-gateway, which the client layer normally generalizes // to "headscale error". `HeadscaleError.detail` is how the real message survives that; see client.ts. /** * Does this failure mean "writing is turned off here", as opposed to "your document is wrong"? * * Matched on the message because Headscale gives no code to match on. Deliberately broad: a false positive * costs a slightly-wrong banner over a message the owner can still read, while a false negative would tell * someone their perfectly good ACL was rejected and send them hunting for a syntax error that isn't there. */ function isWriteDisabled(detail: string): boolean { const text = detail.toLowerCase(); if (text.includes('disabled')) return true; return text.includes('file') && (text.includes('policy') || text.includes('mode')); } type PolicyBody = { policy?: unknown; updatedAt?: unknown }; const asText = (value: unknown) => (typeof value === 'string' ? value : ''); const asDate = (value: unknown) => (typeof value === 'string' && value && !value.startsWith('0001-') ? value : null); /** * `GET /_officer/policy`. * * Answers 200 for every state a running server can be in, including "there is no policy yet" — a fresh * Headscale has none, and an empty editor is both the honest rendering of that and the thing the owner * needs to start typing into. Only an unreachable server is an error, because only that leaves nothing * to say. Note there is no `mode` here on purpose: see the header. */ async function getPolicy(ctx: OfficerContext): Promise { const client = await activeClient(ctx.userId); if (client instanceof Response) return client; try { const body = await client.call('/api/v1/policy'); return Response.json({ policy: asText(body.policy), updatedAt: asDate(body.updatedAt) }); } catch (err) { if (!(err instanceof HeadscaleError)) throw err; const detail = err.detail ?? err.message; if (err.status === 404 || detail.toLowerCase().includes('not found')) { return Response.json({ policy: '', updatedAt: null }); } throw err; } } /** * `PUT /_officer/policy {policy}`. * * The body is sent up byte for byte — no trimming, no reformatting, no parse. Comments and layout are load * bearing in a hand-maintained ACL, and re-serializing would destroy both. */ async function putPolicy(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'); if (typeof body.policy !== 'string') return badRequest('policy must be a string'); // An empty document would be accepted by some Headscale versions and lock every node out of every other // one. Deleting a policy is not something to do by leaving a textarea blank and pressing save. if (!body.policy.trim()) return badRequest('the policy is empty — that would deny every connection'); try { const saved = await client.call('/api/v1/policy', { method: 'PUT', body: { policy: body.policy } }); // Headscale echoes what it stored; fall back to what we sent if it echoes nothing, so a successful save // never blanks the editor. return Response.json({ policy: asText(saved.policy) || body.policy, updatedAt: asDate(saved.updatedAt) }); } catch (err) { if (!(err instanceof HeadscaleError)) throw err; const detail = err.detail ?? err.message; if (isWriteDisabled(detail)) { return Response.json({ error: detail, code: 'policy_read_only' }, { status: 409 }); } // Everything else on a PUT is Headscale rejecting this document: a syntax error with a position, an // unresolvable group, an unknown tag owner. 422 rather than 502 — the request is the problem, and the // message is the one thing that will fix it. return Response.json({ error: detail, code: 'policy_rejected' }, { status: 422 }); } } /** Dispatch `/_officer/policy`. One policy per server, plus the drafting assistant beside it. */ export async function handlePolicyRoute(ctx: OfficerContext, rest: string[]): Promise { // `/policy/assist` proposes a document; it never writes one. See assist.ts. if (rest[0] === 'assist') return handlePolicyAssistRoute(ctx, rest.slice(1)); if (rest.length > 0) return badRequest('unexpected path'); if (ctx.req.method === 'GET') return getPolicy(ctx); if (ctx.req.method === 'PUT') return putPolicy(ctx); return methodNotAllowed(); }