Files
offscale/sidecar/routes.ts
T
pastilhasandClaude Opus 5 8a446bb4b5 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>
2026-08-15 18:12:57 +00:00

81 lines
3.9 KiB
TypeScript

import { HeadscaleError } from './client';
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';
// Officer-owned routes for the headscale sidecar — the entire feature surface lives under /_officer/.
//
// Nothing here is a passthrough. The shapes the UI receives are stable and Officer-shaped, ids stay strings,
// dates are normalized, and anything needing more than one upstream call (device counts per user, pre-auth
// keys grouped by user, read-modify-write of a node's approved route set) resolves here rather than in the
// browser. That is the whole reason the sidecar exists: see rule 5 in SIDECAR_ARCHITECTURE.md.
export type OfficerContext = { req: Request; url: URL; userId: number };
/** 400 with a machine-readable reason. */
export const badRequest = (error: string) => Response.json({ error }, { status: 400 });
/** 404 for an unknown /_officer/ path or a missing object. */
export const notFound = (error = 'not found') => Response.json({ error }, { status: 404 });
/** 405 when the path exists but the verb doesn't. */
export const methodNotAllowed = () => Response.json({ error: 'method not allowed' }, { status: 405 });
/** Parse a JSON request body, or null when there isn't one / it isn't an object. */
export async function readJson(req: Request): Promise<Record<string, unknown> | null> {
const body = await req.json().catch(() => null);
return body && typeof body === 'object' && !Array.isArray(body) ? (body as Record<string, unknown>) : null;
}
/**
* Dispatch an /_officer/* request. Returns null when nothing matches, which the caller turns into a 404.
*
* The platform injects X-Officer-User after authenticating the owner. We bind loopback only, so its presence
* is the trust signal — a request without it did not come through the platform.
*/
export async function handleOfficerRoute(req: Request, url: URL): Promise<Response | null> {
const officerUser = req.headers.get('X-Officer-User');
if (!officerUser) return Response.json({ error: 'missing X-Officer-User' }, { status: 401 });
const userId = Number(officerUser);
if (!Number.isInteger(userId) || userId <= 0) return badRequest('invalid X-Officer-User');
const segments = url.pathname.slice('/_officer/'.length).split('/').filter(Boolean);
if (segments.length === 0) return null;
const ctx: OfficerContext = { req, url, userId };
try {
switch (segments[0]) {
case 'servers':
return await handleServersRoute(ctx, segments.slice(1));
// The domain routes below all act on the ACTIVE server — see active.ts for why that isn't a param.
case 'nodes':
return await handleNodesRoute(ctx, segments.slice(1));
case 'users':
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.
case 'ssh-test':
return await handleSshTestRoute(ctx, segments.slice(1));
// The active server's Officer Companion: container health, logs and lifecycle. See companion.ts.
case 'companion':
return await handleCompanionRoute(ctx, segments.slice(1));
default:
return null;
}
} catch (err) {
// Upstream failures carry their own status; everything else is ours and is a 500 the caller logs.
if (err instanceof HeadscaleError) return Response.json({ error: err.message }, { status: err.status });
throw err;
}
}