headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.
api/router.ts the thin auth-gated proxy, now at /api/offscale
sidecar/ 18 files, the whole headscale contract and its admin keys
db/ schema + queries, offscale_servers
web/ 26 files as panels and a layout — no screen, per the rule
removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.
the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.
AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.
install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.
verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.
757 pass, same 10 pre-existing failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
81 lines
3.9 KiB
TypeScript
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;
|
|
}
|
|
}
|