The tailnet plugin — machines, users, pre-auth keys, access policy and device invites. Moved out of officerdev/platform, where it had lived in plugins/ since the plugin system was built. Until now this code existed in exactly one place: the platform repository. That made "gitignore the plugins directory" impossible to do safely, because untracking it would have left 49 files on a single disk with no remote. This repository is what makes that move safe. Same extraction as plugins/music before it: source only, no history. The platform's history still holds every commit that shaped this, and the SHAs cited across the codebase keep resolving — replaying it here would have created a second, divergent account of the same work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
143 lines
6.9 KiB
TypeScript
143 lines
6.9 KiB
TypeScript
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 === '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<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] });
|
|
}
|