headscale nodes, users and pre-auth key management
domain routes in the sidecar: nodes with per-route approval done as a read-modify-write (headscale's approve_routes replaces the whole set), users enriched with node counts, and pre-auth keys. pre-auth key secrets are revealed by call path, not by inspecting the value. headscale masks keys created since 0.28, but returns older plaintext ones in full from the list endpoint for backwards compatibility, so listing would otherwise ship live secrets into the browser's query cache. the list always nulls the secret; only creation reveals it, and the ui shows it once with a copy affordance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
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<Response> {
|
||||
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<OfficerNode | null> {
|
||||
const body = await client.call<{ node?: Record<string, unknown> }>(`/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<Response> {
|
||||
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 === '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<Response> {
|
||||
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] });
|
||||
}
|
||||
Reference in New Issue
Block a user