headscale: acl policy editor, node owner and tags

policy: /_officer/policy GET/PUT. the text goes up byte for byte and
headscale's verdict comes back verbatim — it owns the only parser that
resolves groups, tags and hosts, so a second one here would disagree with
what actually enforces. a file-backed policy is still served over GET and
only refuses on PUT, so writability cannot be read: the first save finds
out, and a refusal becomes a persistent read-only banner rather than a
rejection the owner would go hunting for a syntax error over.

nodes: move between users, and a tag editor for the setTags route that had
no ui. both sit together in the expanded card because both decide which
policy rules apply to a node, and a move says so before it happens.
This commit is contained in:
2026-08-05 16:48:27 +00:00
parent d6d405100c
commit e8b19229cb
12 changed files with 550 additions and 16 deletions
+11 -1
View File
@@ -22,6 +22,15 @@ export class HeadscaleError extends Error {
constructor(
readonly status: number,
message: string,
/**
* Headscale's own words, kept even when `message` generalizes them.
*
* A 5xx is normally not safe to relay — it leaks internals and rarely helps. The policy endpoints are
* the exception: Headscale answers "policy is read from a file" and reports a HuJSON syntax error's
* line and column with the same 500, and there the message IS the feature. Callers that know their
* endpoint's 5xx is a real answer read this; everyone else keeps getting "headscale error".
*/
readonly detail?: string,
) {
super(message);
this.name = 'HeadscaleError';
@@ -86,7 +95,8 @@ export function createClient(creds: HeadscaleServerCredentials): HeadscaleClient
const message = await errorMessage(res);
console.error(`[headscale] ${method} ${path} -> ${res.status}: ${message}`);
// 4xx from the admin API is usually a bad argument and safe to relay; 5xx is not, so it's generalized.
throw new HeadscaleError(res.status >= 500 ? 502 : res.status, res.status >= 500 ? 'headscale error' : message);
const serverSide = res.status >= 500;
throw new HeadscaleError(serverSide ? 502 : res.status, serverSide ? 'headscale error' : message, message);
}
// 204 and empty 200s are legitimate: several mutations return `{}` or nothing at all.
+13
View File
@@ -104,6 +104,19 @@ async function handleNodeAction({ ctx, id, action }: NodeActionParams): Promise<
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' });
+112
View File
@@ -0,0 +1,112 @@
import type { OfficerContext } from './routes';
import { badRequest, methodNotAllowed, readJson } from './routes';
import { HeadscaleError } from './client';
import { activeClient } from './active';
// 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<Response> {
const client = await activeClient(ctx.userId);
if (client instanceof Response) return client;
try {
const body = await client.call<PolicyBody>('/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<Response> {
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<PolicyBody>('/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`. No sub-paths: there is exactly one policy per server. */
export async function handlePolicyRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
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();
}
+3
View File
@@ -3,6 +3,7 @@ import { handleServersRoute } from './servers';
import { handleNodesRoute } from './nodes';
import { handleUsersRoute } from './users';
import { handleKeysRoute } from './keys';
import { handlePolicyRoute } from './policy';
import { handleEnrollRoute } from './enroll';
import { handleSshTestRoute } from './ssh';
import { handleCompanionRoute } from './companion';
@@ -58,6 +59,8 @@ export async function handleOfficerRoute(req: Request, url: URL): Promise<Respon
return await handleUsersRoute(ctx, segments.slice(1));
case 'keys':
return await handleKeysRoute(ctx, segments.slice(1));
case 'policy':
return await handlePolicyRoute(ctx, segments.slice(1));
case 'enroll':
return await handleEnrollRoute(ctx, segments.slice(1));
// Not a Headscale call at all — a local `ssh` reachability probe for the console. See ssh.ts.