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:
@@ -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.
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
Reference in New Issue
Block a user