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>
105 lines
4.3 KiB
TypeScript
105 lines
4.3 KiB
TypeScript
import type { OfficerContext } from './routes';
|
|
import { badRequest, notFound, methodNotAllowed, readJson } from './routes';
|
|
import { activeClient } from './active';
|
|
import { toUser, toNode, arrayField, type OfficerUser } from './normalize';
|
|
|
|
// User routes — /_officer/users/*. A Headscale "user" is a namespace that owns nodes and pre-auth keys.
|
|
//
|
|
// The list is enriched with a node count, which the admin API does not provide: deleting a user takes its
|
|
// nodes with it, so "3 nodes" next to the delete button is the difference between an informed action and a
|
|
// surprise. That is one extra upstream call for the whole list, not one per user.
|
|
|
|
export type UserWithCounts = OfficerUser & { nodeCount: number; onlineCount: number };
|
|
|
|
async function listUsers(ctx: OfficerContext): Promise<Response> {
|
|
const client = await activeClient(ctx.userId);
|
|
if (client instanceof Response) return client;
|
|
|
|
const [userBody, nodeBody] = await Promise.all([client.call('/api/v1/user'), client.call('/api/v1/node')]);
|
|
|
|
const nodes = arrayField(nodeBody, 'nodes').map(toNode);
|
|
const counts = new Map<string, { total: number; online: number }>();
|
|
for (const node of nodes) {
|
|
const id = node.user?.id;
|
|
if (!id) continue;
|
|
const entry = counts.get(id) ?? { total: 0, online: 0 };
|
|
entry.total += 1;
|
|
if (node.online) entry.online += 1;
|
|
counts.set(id, entry);
|
|
}
|
|
|
|
const users: UserWithCounts[] = arrayField(userBody, 'users')
|
|
.map(toUser)
|
|
.filter((u): u is OfficerUser => !!u)
|
|
.map((u) => ({ ...u, nodeCount: counts.get(u.id)?.total ?? 0, onlineCount: counts.get(u.id)?.online ?? 0 }))
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
return Response.json({ users });
|
|
}
|
|
|
|
async function createUser(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');
|
|
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
|
if (!name) return badRequest('name is required');
|
|
|
|
const created = await client.call<{ user?: Record<string, unknown> }>('/api/v1/user', {
|
|
method: 'POST',
|
|
body: {
|
|
name,
|
|
displayName: typeof body.displayName === 'string' ? body.displayName.trim() : undefined,
|
|
email: typeof body.email === 'string' ? body.email.trim() : undefined,
|
|
},
|
|
});
|
|
return Response.json({ user: toUser(created.user) }, { status: 201 });
|
|
}
|
|
|
|
type UserActionParams = { ctx: OfficerContext; id: string; action: string | undefined };
|
|
|
|
async function handleUserAction({ ctx, id, action }: UserActionParams): Promise<Response> {
|
|
const client = await activeClient(ctx.userId);
|
|
if (client instanceof Response) return client;
|
|
|
|
if (action === 'rename') {
|
|
if (ctx.req.method !== 'POST') return methodNotAllowed();
|
|
const body = await readJson(ctx.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');
|
|
// Rename takes both the id and the new name in the path — encode or a '/' becomes a routing accident.
|
|
const renamed = await client.call<{ user?: Record<string, unknown> }>(
|
|
`/api/v1/user/${encodeURIComponent(id)}/rename/${encodeURIComponent(name)}`,
|
|
{ method: 'POST' },
|
|
);
|
|
return Response.json({ user: toUser(renamed.user) });
|
|
}
|
|
|
|
if (action !== undefined) return notFound();
|
|
|
|
if (ctx.req.method === 'DELETE') {
|
|
// Headscale refuses to delete a user that still owns nodes, with a message the UI relays verbatim.
|
|
await client.call(`/api/v1/user/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
return new Response(null, { status: 204 });
|
|
}
|
|
|
|
return methodNotAllowed();
|
|
}
|
|
|
|
/** Dispatch `/_officer/users/...`. `rest` is the path after `users`. */
|
|
export async function handleUsersRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
|
|
if (rest.length === 0) {
|
|
if (ctx.req.method === 'GET') return listUsers(ctx);
|
|
if (ctx.req.method === 'POST') return createUser(ctx);
|
|
return methodNotAllowed();
|
|
}
|
|
if (rest.length > 2) return notFound();
|
|
|
|
const id = rest[0];
|
|
if (!id || !/^\d+$/.test(id)) return badRequest('user id must be numeric');
|
|
|
|
return handleUserAction({ ctx, id, action: rest[1] });
|
|
}
|