offscale, extracted from the platform into its own repository
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>
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
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] });
|
||||
}
|
||||
Reference in New Issue
Block a user