offscale is a plugin

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>
This commit is contained in:
2026-08-15 00:15:38 +00:00
co-authored by Claude Opus 5
parent 0e24aa3d52
commit e13128846b
111 changed files with 351 additions and 302 deletions
+20
View File
@@ -0,0 +1,20 @@
import { getActiveHeadscaleCredentials } from '../db/queries';
import { createClient, type HeadscaleClient } from './client';
// Every domain route acts on the ACTIVE server — the one the owner selected in the servers section. That
// choice lives in Postgres (one row, enforced by a partial unique index), not in a request parameter, so
// no client can act on a server the owner isn't currently looking at by guessing an id.
/**
* The client for the active server, or a ready-to-send 409 when there isn't one.
*
* 409 rather than 404: the route exists and the request was well-formed, the account just has no server
* selected yet. The UI maps it to "pick a server", which is a different message from "that node is gone".
*/
export async function activeClient(userId: number): Promise<HeadscaleClient | Response> {
const creds = await getActiveHeadscaleCredentials(userId);
if (!creds) {
return Response.json({ error: 'no active Headscale server', code: 'no_active_server' }, { status: 409 });
}
return createClient(creds);
}
+153
View File
@@ -0,0 +1,153 @@
import type { OfficerContext } from './routes';
import { badRequest, methodNotAllowed, readJson } from './routes';
import { activeClient } from './active';
import { arrayField, toNode, toUser } from './normalize';
import { askClaude, ProxyUnavailable } from './claude-proxy';
// `POST /_officer/policy/assist` — describe a change in English, get a complete revised policy back.
//
// The ACL is the one document in this app that nobody can write from memory: HuJSON, Tailscale's grammar,
// and every rule keyed to user, tag and host names that only this server knows. The gap this closes is not
// "typing is slow", it is "I do not know what the file is supposed to look like".
//
// THREE THINGS THIS DELIBERATELY DOES NOT DO.
//
// 1. **It never saves.** The proposal comes back as text and lands in the editor as a draft. Headscale is
// written to by exactly one thing, the Save button, and it is pressed by a person who has read the diff.
// A model that could write the ACL directly is a model that can partition the network the owner is
// connected through — including the SSH route back in to fix it.
// 2. **It never validates.** Same argument as policy.ts: Headscale owns the only parser that counts, and a
// proposal that looks fine here and is refused on save is a normal, visible outcome.
// 3. **It sends no credentials.** The prompt carries user names, node names and tags — the vocabulary the
// rules must reference — and nothing else. No API keys, no pre-auth keys, no node addresses.
//
// The current document is sent in full and the reply must be the full replacement, not a patch. Patches
// against a hand-formatted HuJSON file are where comments and alignment get silently destroyed, and this
// file's whole premise is that those are worth keeping.
const MODEL = 'claude-sonnet-5';
const MAX_TOKENS = 8_000;
/** A prompt long enough to be an essay is a prompt that should be a conversation. Cheap guard, not a limit. */
const MAX_PROMPT_CHARS = 2_000;
/** Enough context to write rules against without pasting an entire large tailnet into the request. */
const MAX_NODES = 60;
const SYSTEM = `You are helping the owner of a self-hosted Headscale server edit their tailnet's ACL policy.
The policy is a HuJSON document (JSON with // comments and trailing commas) in Tailscale's ACL format:
groups, tagOwners, hosts, acls, ssh, autoApprovers. Headscale implements a subset — it has no Tailscale SaaS
features such as nodeAttrs postures, and grants are supported only in recent versions, so prefer classic
"acls" entries unless the existing document already uses grants.
Rules for your reply, in this order:
1. First, one short paragraph of plain English: what you changed and, where it matters, what it now allows or
denies. No preamble, no restating the request.
2. Then the COMPLETE new policy document inside a single fenced code block tagged hujson. Not a patch, not an
excerpt — the whole file, ready to replace what is there.
Preserve the existing document's comments, key order and indentation wherever your change does not touch
them; they are hand-maintained and the owner reads this file. Only reference users, tags and hosts that exist
in the context given to you, or that you also define in the same document. If the request is ambiguous enough
that you would have to guess at something consequential, say so in the paragraph and make the narrower,
safer choice rather than asking a question — the owner reviews a diff before anything is saved.
If the request cannot be expressed in this policy at all, say why in the paragraph and return the document
unchanged in the code block.`;
type TailnetContext = { users: string[]; tags: string[]; nodes: string[] };
/**
* The vocabulary a usable rule has to be written in: who exists, what tags are in use, what the machines are
* called. Best-effort — a server that will not answer these still gets an assistant, just a less informed
* one, which beats failing the request over context that is an optimisation.
*/
async function readContext(userId: number): Promise<TailnetContext> {
const client = await activeClient(userId);
if (client instanceof Response) return { users: [], tags: [], nodes: [] };
try {
const [userBody, nodeBody] = await Promise.all([client.call('/api/v1/user'), client.call('/api/v1/node')]);
const users = arrayField(userBody, 'users')
.map((raw) => toUser(raw)?.name)
.filter((name): name is string => !!name);
const nodes = arrayField(nodeBody, 'nodes').map(toNode);
const tags = [...new Set(nodes.flatMap((node) => node.tags))].sort();
const named = nodes.slice(0, MAX_NODES).map((node) => {
const owner = node.user?.name ?? 'unknown';
const tagged = node.tags.length ? ` [${node.tags.join(' ')}]` : '';
return `${node.name} (user: ${owner})${tagged}`;
});
return { users, tags, nodes: named };
} catch {
return { users: [], tags: [], nodes: [] };
}
}
function buildPrompt(policy: string, request: string, context: TailnetContext): string {
const lines = [
'Current policy document:',
'```hujson',
policy.trim() || '// (this server has no policy yet)',
'```',
'',
'This tailnet:',
`- users: ${context.users.length ? context.users.join(', ') : '(none)'}`,
`- tags in use: ${context.tags.length ? context.tags.join(', ') : '(none)'}`,
`- machines: ${context.nodes.length ? context.nodes.join('; ') : '(none)'}`,
];
if (context.nodes.length === MAX_NODES) lines.push(` (first ${MAX_NODES} shown)`);
lines.push('', 'Requested change:', request.trim());
return lines.join('\n');
}
/**
* Split the reply into the explanation and the document.
*
* The fence is the contract, so a reply without one is a failure to report rather than something to salvage:
* feeding half an answer into the editor as if it were a policy is worse than saying the model didn't comply.
*/
function splitReply(text: string): { explanation: string; policy: string } | null {
const match = text.match(/```(?:hujson|json|jsonc)?\s*\n([\s\S]*?)```/);
if (!match || !match[1]?.trim()) return null;
return { explanation: text.slice(0, match.index).trim(), policy: match[1].replace(/\s+$/, '') };
}
/** `POST /_officer/policy/assist {prompt, policy}` → `{explanation, policy}`. Nothing is written upstream. */
export async function handlePolicyAssistRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
if (rest.length > 0) return badRequest('unexpected path');
if (ctx.req.method !== 'POST') return methodNotAllowed();
const body = await readJson(ctx.req);
const request = typeof body?.prompt === 'string' ? body.prompt.trim() : '';
if (!request) return badRequest('prompt is required');
if (request.length > MAX_PROMPT_CHARS) return badRequest(`prompt must be under ${MAX_PROMPT_CHARS} characters`);
// The draft on screen, not the saved document: the owner may have edited it, and a proposal built against
// a version they cannot see would come back as a diff full of changes they never asked for.
const policy = typeof body?.policy === 'string' ? body.policy : '';
const context = await readContext(ctx.userId);
try {
const reply = await askClaude({
model: MODEL,
maxTokens: MAX_TOKENS,
system: SYSTEM,
prompt: buildPrompt(policy, request, context),
});
const split = splitReply(reply);
if (!split) {
return Response.json({ error: 'the model did not return a policy document — try rephrasing' }, { status: 502 });
}
return Response.json({ explanation: split.explanation, policy: split.policy });
} catch (err) {
if (err instanceof ProxyUnavailable) {
return Response.json({ error: err.message, code: 'assistant_unavailable' }, { status: 503 });
}
throw err;
}
}
+97
View File
@@ -0,0 +1,97 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { DATA_PATH } from '@@/data-path';
import { ANTHROPIC_PROXY_URL } from '@@/officer-url.mjs';
// One-shot model calls, for sidecar features that need a sentence of reasoning rather than an agent.
//
// The target is `officer-anthropic-proxy` on loopback — the same process Claude Code itself talks to. It
// holds the owner's OAuth credential and refreshes it; callers hold nothing. Its `x-api-key` is a locally
// generated secret it writes to its own state file, so authenticating is a file read, not a credential this
// sidecar is given. That file is written by the proxy and read by everyone else; see claude/state.ts
// (`readProxySecretFromDisk`), which does the same thing for the agent.
//
// Not imported from claude/state.ts on purpose: that module initialises paths and a lock for a sidecar this
// one is not. Twenty lines of file read is a better dependency than another sidecar's lifecycle.
//
// This is a REQUEST-SCOPED call with a timeout, not a session. Anything conversational belongs in the chat
// surface, which already exists and already persists.
const DEFAULT_TIMEOUT_MS = 120_000;
/** The proxy is not running, has no token, or refused us. Distinct from the model declining to answer. */
export class ProxyUnavailable extends Error {}
/**
* The proxy's own generated secret. Empty means "not on disk yet" — it persists on a debounce, so a
* freshly installed machine has a window where the file exists without it.
*/
function readProxySecret(): string {
try {
const file = join(DATA_PATH, 'sidecar', 'claude-state.json');
if (!existsSync(file)) return '';
const parsed = JSON.parse(readFileSync(file, 'utf-8')) as { proxySecret?: unknown };
return typeof parsed.proxySecret === 'string' ? parsed.proxySecret : '';
} catch {
return '';
}
}
type AskParams = { model: string; system: string; prompt: string; maxTokens: number; timeoutMs?: number };
type MessagesResponse = { content?: { type?: string; text?: string }[]; error?: { message?: string } };
/**
* One user turn, one reply, as plain text.
*
* The system prompt is sent as two blocks with Claude Code's own identity first. The proxy authenticates
* with a Claude Pro/Max OAuth token, and that credential is issued to the CLI — asking it to be something
* else is a request the upstream is entitled to refuse. (Measured 2026-08-06: a plain assistant prompt is
* currently accepted too. Keeping the block costs ~14 tokens and removes the question.)
*/
export async function askClaude({ model, system, prompt, maxTokens, timeoutMs }: AskParams): Promise<string> {
const secret = readProxySecret();
if (!secret) throw new ProxyUnavailable('the Claude proxy has not started yet — try again in a moment');
let res: Response;
try {
res = await fetch(`${ANTHROPIC_PROXY_URL}/v1/messages`, {
method: 'POST',
headers: { 'x-api-key': secret, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
body: JSON.stringify({
model,
max_tokens: maxTokens,
system: [
{ type: 'text', text: "You are Claude Code, Anthropic's official CLI for Claude." },
{ type: 'text', text: system },
],
messages: [{ role: 'user', content: prompt }],
}),
signal: AbortSignal.timeout(timeoutMs ?? DEFAULT_TIMEOUT_MS),
});
} catch (err) {
if (err instanceof Error && err.name === 'TimeoutError') throw new ProxyUnavailable('the model took too long');
throw new ProxyUnavailable('the Claude proxy is not reachable');
}
const body = (await res.json().catch(() => null)) as MessagesResponse | null;
if (!res.ok) {
const detail = body?.error?.message;
// 401/429 are the proxy's own credential problems and read as "unavailable"; anything else is upstream
// saying something specific about this request, which is worth passing through.
if (res.status === 401 || res.status === 429) {
throw new ProxyUnavailable(detail ?? `the Claude proxy returned ${res.status}`);
}
throw new Error(detail ?? `the model returned ${res.status}`);
}
const text = (body?.content ?? [])
.filter((block) => block.type === 'text' && typeof block.text === 'string')
.map((block) => block.text)
.join('')
.trim();
if (!text) throw new Error('the model returned an empty reply');
return text;
}
+113
View File
@@ -0,0 +1,113 @@
import type { HeadscaleServerCredentials } from '../db/queries';
// The Headscale admin API call layer. Every upstream request in this sidecar goes through here, so the
// wire-level quirks are handled once:
//
// • Auth is `Authorization: Bearer <apiKey>`. Headscale's swagger declares no securityDefinitions at all,
// so a generated client would omit it entirely.
// • 401/403 bodies are PLAIN TEXT ("Unauthorized"), with no content-type — every other error is
// grpc-gateway `{code,message,details}` JSON. Blindly .json()-ing an error body throws on exactly the
// auth failure you most want to report clearly.
// • Every uint64 is serialized as a JSON STRING, not a number: `node.id` arrives as "7". We keep ids as
// strings end to end and never round-trip them through Number, which would silently break above 2^53.
// • The gateway marshals with EmitUnpopulated, so absent values come back as [] / null / "" / false rather
// than being omitted. You cannot distinguish "unset" from "empty" — don't try.
// • It also marshals with DiscardUnknown, so a misspelled request field is IGNORED rather than rejected.
// Silent no-ops are the failure mode; mutations here read the object back where the API returns it.
const DEFAULT_TIMEOUT_MS = 15_000;
/** An upstream failure carrying the HTTP status to surface, mapped to a response at the route boundary. */
export class HeadscaleError extends Error {
constructor(
readonly status: number,
message: string,
/**
* Headscale's own words, kept even when `message` generalizes them.
*
* A 5xx is normally not safe to relay — it leaks internals and rarely helps. The policy endpoints are
* the exception: Headscale answers "policy is read from a file" and reports a HuJSON syntax error's
* line and column with the same 500, and there the message IS the feature. Callers that know their
* endpoint's 5xx is a real answer read this; everyone else keeps getting "headscale error".
*/
readonly detail?: string,
) {
super(message);
this.name = 'HeadscaleError';
}
}
type CallOptions = { method?: string; body?: unknown; timeoutMs?: number };
/**
* Extract a human-usable message from a Headscale error response, tolerating both of its formats.
* Never returned verbatim to the browser for auth failures — see callers.
*/
async function errorMessage(res: Response): Promise<string> {
const text = await res.text().catch(() => '');
if (!text) return `upstream returned ${res.status}`;
try {
const parsed = JSON.parse(text) as { message?: unknown };
if (typeof parsed.message === 'string' && parsed.message) return parsed.message;
} catch {
/* plain text — the 401 case */
}
return text.slice(0, 300);
}
export type HeadscaleClient = {
readonly serverId: number;
/** Call an admin API path (e.g. `/api/v1/node`). Throws HeadscaleError on any non-2xx. */
call: <T>(path: string, opts?: CallOptions) => Promise<T>;
};
/** Build a client bound to one registered server's credentials. */
export function createClient(creds: HeadscaleServerCredentials): HeadscaleClient {
async function call<T>(path: string, opts: CallOptions = {}): Promise<T> {
const { method = 'GET', body, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
const headers: Record<string, string> = {
authorization: `Bearer ${creds.apiKey}`,
accept: 'application/json',
};
if (body !== undefined) headers['content-type'] = 'application/json';
let res: Response;
try {
res = await fetch(`${creds.url}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
});
} catch (err) {
const timedOut = err instanceof Error && err.name === 'TimeoutError';
throw new HeadscaleError(504, timedOut ? 'headscale timed out' : 'headscale unreachable');
}
if (res.status === 401 || res.status === 403) {
// The stored key is wrong, expired, or was revoked on the server. Actionable, and distinct from an
// Officer-side auth problem — the UI should point the owner at re-entering the key.
throw new HeadscaleError(502, 'headscale rejected the stored API key');
}
if (!res.ok) {
const message = await errorMessage(res);
console.error(`[headscale] ${method} ${path} -> ${res.status}: ${message}`);
// 4xx from the admin API is usually a bad argument and safe to relay; 5xx is not, so it's generalized.
const serverSide = res.status >= 500;
throw new HeadscaleError(serverSide ? 502 : res.status, serverSide ? 'headscale error' : message, message);
}
// 204 and empty 200s are legitimate: several mutations return `{}` or nothing at all.
const text = await res.text();
if (!text) return {} as T;
try {
return JSON.parse(text) as T;
} catch {
throw new HeadscaleError(502, 'headscale returned a non-JSON body');
}
}
return { serverId: creds.id, call };
}
+204
View File
@@ -0,0 +1,204 @@
import { getActiveHeadscaleCredentials, type HeadscaleServerCredentials } from '../db/queries';
import { badRequest, methodNotAllowed, notFound, type OfficerContext } from './routes';
// The Officer Companion API — a small service deployed NEXT TO each Headscale server that answers what the
// admin API structurally cannot: is the container up, what do its logs say, and start/stop/restart it.
// Contract: COMMS/HEADSCALE_COMPANION_API.md.
//
// Three facts shape everything here.
//
// 1. It lives at `${server.url}/officer-api` and authenticates with the SAME admin API key we already
// store, validated locally against Headscale's own key store — so auth keeps working while Headscale
// is down, which is exactly when `/restart` matters. Nothing new to register, and the key still never
// leaves this sidecar.
//
// 2. It is OPTIONAL and per-server. Of the four servers registered here today, one has it deployed. So
// "no companion" is a normal state, not an error: every route below answers 200 with
// `{available: false, reason}` rather than failing, and the UI degrades to what the admin API can do.
// Distinguishing the two 502s is the whole trick — nginx returns HTML when the companion is down,
// the companion returns JSON when a docker op fails. Branch on whether the body parses.
//
// 3. `GET /health` is ALWAYS 200, at every verdict. Never key anything off its HTTP status; read
// `verdict`. That inversion is deliberate on their side and is preserved on ours.
/**
* Every route answers `{available: true, ...}` or `{available: false, reason}` at HTTP 200. Not having a
* companion is a state to render, not a request that failed — the admin API on the same domain is
* independent and may still be working, so this must not surface as an error the UI swallows.
*/
export const unavailable = (reason: string) => ({ available: false as const, reason });
const DEFAULT_TIMEOUT_MS = 20_000;
type CompanionCall = { path: string; method?: string; body?: unknown; timeoutMs?: number; signal?: AbortSignal };
/**
* One request to a server's companion. Returns the raw Response, or a reason string when the companion
* itself could not be reached — the caller decides how to present that, because for this feature
* "unreachable" is information rather than a failure.
*/
export async function callCompanion(
creds: HeadscaleServerCredentials,
{ path, method = 'GET', body, timeoutMs = DEFAULT_TIMEOUT_MS, signal }: CompanionCall,
): Promise<Response | string> {
let res: Response;
try {
res = await fetch(`${creds.url}/officer-api${path}`, {
method,
headers: {
authorization: `Bearer ${creds.apiKey}`,
accept: 'application/json',
...(body === undefined ? {} : { 'content-type': 'application/json' }),
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: signal ?? AbortSignal.timeout(timeoutMs),
});
} catch (err) {
if (err instanceof Error && err.name === 'TimeoutError') return 'the companion timed out';
// A TLS failure or DNS miss on the server's own domain: the whole host is unreachable, not just this.
return 'could not reach the server';
}
if (res.status === 401) return 'the companion rejected the stored API key';
// Both 404 and 502 are ambiguous, and the same test settles both: a JSON body means the companion
// answered (no such container / the docker op failed) and that answer belongs to the caller; a
// non-JSON body means we never reached it — nginx's own 502 page, or a route that isn't there at all.
const isJson = (res.headers.get('content-type') ?? '').includes('json');
if (res.status === 404 && !isJson) return 'this server has no companion at /officer-api';
if (res.status === 502 && !isJson) return 'the companion is not deployed on this server';
if (res.status >= 500 && !isJson) return `the companion returned ${res.status}`;
return res;
}
/** Parse a companion JSON body, or a reason when it isn't JSON after all. */
export async function readBody(res: Response): Promise<Record<string, unknown> | string> {
const text = await res.text().catch(() => '');
if (!text) return 'the companion returned an empty body';
try {
const parsed = JSON.parse(text) as unknown;
if (!parsed || typeof parsed !== 'object') return 'the companion returned an unexpected body';
return parsed as Record<string, unknown>;
} catch {
return 'the companion returned a non-JSON body';
}
}
/** The active server's credentials, or a 409 the UI already knows how to render. */
export async function activeCreds(userId: number): Promise<HeadscaleServerCredentials | Response> {
const creds = await getActiveHeadscaleCredentials(userId);
if (!creds) {
return Response.json({ error: 'no active Headscale server', code: 'no_active_server' }, { status: 409 });
}
return creds;
}
/** `GET /_officer/companion/health` — verdict, container state and, when unhappy, its own diagnosis. */
async function health(creds: HeadscaleServerCredentials): Promise<Response> {
const res = await callCompanion(creds, { path: '/health' });
if (typeof res === 'string') return Response.json(unavailable(res));
const body = await readBody(res);
if (typeof body === 'string') return Response.json(unavailable(body));
// Passed through as-is. The companion owns this vocabulary and versions it; re-shaping it here would mean
// a new verdict or a new likely-cause silently disappearing on the way to the screen.
return Response.json({ available: true, health: body });
}
/** `GET /_officer/companion/logs?tail=N` — a snapshot of the last N lines. */
async function logs(creds: HeadscaleServerCredentials, url: URL): Promise<Response> {
const tail = Number(url.searchParams.get('tail') ?? 200);
if (!Number.isInteger(tail) || tail < 1 || tail > 2000) return badRequest('tail must be 12000');
const res = await callCompanion(creds, { path: `/logs?tail=${tail}` });
if (typeof res === 'string') return Response.json(unavailable(res));
const body = await readBody(res);
if (typeof body === 'string') return Response.json(unavailable(body));
const lines = Array.isArray(body.lines) ? body.lines.filter((l): l is string => typeof l === 'string') : [];
return Response.json({ available: true, lines });
}
/**
* `GET /_officer/companion/logs/stream?tail=N` — the live tail, relayed frame for frame.
*
* The browser cannot open this itself: EventSource sends no Authorization header, and the key it would need
* is one this sidecar exists to keep. So the stream is proxied, and the body is returned UNTOUCHED — a
* ReadableStream that Bun cancels when the client disconnects, which aborts the upstream fetch in turn.
* Buffering it into frames here would break that, and would also mean a log line waiting on our own flush.
*/
async function logStream(creds: HeadscaleServerCredentials, ctx: OfficerContext): Promise<Response> {
const tail = Number(ctx.url.searchParams.get('tail') ?? 200);
if (!Number.isInteger(tail) || tail < 1 || tail > 2000) return badRequest('tail must be 12000');
// No timeout: a quiet log is the normal case and must not look like a dropped connection. The request's
// own signal is the lifetime — when the panel closes, this closes.
const res = await callCompanion(creds, {
path: `/logs?tail=${tail}&follow=1`,
signal: ctx.req.signal,
});
// An unavailable companion still answers in the stream's own vocabulary, so the client has one parser and
// one place to show a problem rather than a second, JSON-shaped failure mode.
if (typeof res === 'string') {
return new Response(`event: error\ndata: ${res}\n\n`, {
headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' },
});
}
return new Response(res.body, {
status: 200,
headers: {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
// Belt and braces through our own proxy chain, matching what the companion already sets.
'x-accel-buffering': 'no',
},
});
}
const ACTIONS = new Set(['restart', 'stop', 'start']);
/**
* `POST /_officer/companion/:action` — restart / stop / start the Headscale container.
*
* Every one of these drops every node's control-plane connection for the duration. That is the intended
* "kill it" behaviour and the reason the UI asks twice; it is not something to retry automatically.
*/
async function action(creds: HeadscaleServerCredentials, name: string): Promise<Response> {
// 60s: docker restart on a busy container is not fast, and a timeout here reads as "did it work?" — the
// one question this feature exists to answer.
const res = await callCompanion(creds, { path: `/${name}`, method: 'POST', timeoutMs: 60_000 });
if (typeof res === 'string') return Response.json(unavailable(res));
const body = await readBody(res);
if (typeof body === 'string') return Response.json(unavailable(body));
return Response.json({ available: true, ...body });
}
/** Dispatch `/_officer/companion/...`. Always acts on the ACTIVE server, like every other domain route. */
export async function handleCompanionRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
const creds = await activeCreds(ctx.userId);
if (creds instanceof Response) return creds;
const [head, tail] = rest;
if (head === 'health' && !tail) {
if (ctx.req.method !== 'GET') return methodNotAllowed();
return health(creds);
}
if (head === 'logs') {
if (ctx.req.method !== 'GET') return methodNotAllowed();
if (!tail) return logs(creds, ctx.url);
if (tail === 'stream') return logStream(creds, ctx);
return notFound();
}
if (head && ACTIONS.has(head) && !tail) {
if (ctx.req.method !== 'POST') return methodNotAllowed();
return action(creds, head);
}
return notFound();
}
+103
View File
@@ -0,0 +1,103 @@
import type { OfficerContext } from './routes';
import type { OfficerUser } from './normalize';
import { getActiveHeadscaleCredentials } from '../db/queries';
import { badRequest, methodNotAllowed, readJson } from './routes';
import { createClient, type HeadscaleClient } from './client';
import { arrayField, toUser } from './normalize';
import { handleInvitesRoute } from './invites';
// Device enrolment — POST /_officer/enroll. The mobile app's one-tap join: it turns an authenticated
// Officer session into a short-lived, single-use pre-auth key, so nobody pastes a key by hand.
//
// THIS USED TO LIVE IN THE PLATFORM. `src/servers/api/vpn/router.ts` (deleted 2026-08-14) read
// HEADSCALE_URL, HEADSCALE_API_KEY
// and HEADSCALE_USER straight from the host env — three globals that could only ever describe ONE server,
// while this sidecar already kept a registry of many. Worse, the two credential vars were removed at some
// point and nobody noticed: the route had been answering 503 to every enrolment attempt, because it checks
// those two before it gets anywhere near the user name. Enrolment acts on the ACTIVE registered server now,
// like every other domain route here, and the platform holds no Headscale credentials at all.
//
// The response shape `{controlUrl, authKey}` is a CONTRACT: enrollVpn() in the mobile core
// (monorepo-mobile/packages/core/src/services/officer-net.ts) destructures exactly those two fields and
// feeds them to configure()/loginWithAuthKey(). Extra fields are safe; renaming those two is not.
/** Short by design: the key is redeemed seconds after it is issued, and a leaked one should die quickly. */
const KEY_TTL_MS = 10 * 60_000;
/**
* Which Headscale user the joining device is filed under.
*
* An explicit `userId` wins. Otherwise the choice is only made when it is UNAMBIGUOUS — one user on the
* server means there is nothing to choose. Several means the caller has to say, because picking silently
* files someone's phone under the wrong owner and the mistake stays invisible until somebody audits the
* tailnet. The old env var picked one name for every server at once, which is precisely that bug.
*/
async function resolveOwner(client: HeadscaleClient, ctx: OfficerContext): Promise<OfficerUser | Response> {
const body = await readJson(ctx.req);
const requested = typeof body?.userId === 'string' ? body.userId.trim() : '';
const listed = await client.call('/api/v1/user');
const users = arrayField(listed, 'users')
.map(toUser)
.filter((u): u is OfficerUser => !!u);
if (requested) {
const match = users.find((u) => u.id === requested);
return match ?? badRequest(`no Headscale user with id ${requested} on the active server`);
}
if (users.length === 1) return users[0]!;
if (users.length === 0) {
return Response.json(
{ error: 'the active Headscale server has no users — create one before enrolling a device', code: 'no_users' },
{ status: 409 },
);
}
return Response.json(
{
error: 'the active Headscale server has several users — pass userId to say which one owns this device',
code: 'ambiguous_user',
users: users.map((u) => ({ id: u.id, name: u.name })),
},
{ status: 409 },
);
}
export async function handleEnrollRoute(ctx: OfficerContext, segments: string[]): Promise<Response | null> {
// `/enroll/invites…` is the admin invite surface — a different flow entirely (see invites.ts): the device
// is not here and there is no Officer session on it. Same prefix because it is the same feature to the
// person using it, and because the spec names it that way.
if (segments[0] === 'invites') return handleInvitesRoute(ctx, segments.slice(1));
if (segments.length > 0) return null;
if (ctx.req.method !== 'POST') return methodNotAllowed();
// Not activeClient(): the control URL goes back to the device, and only the credentials carry it.
const creds = await getActiveHeadscaleCredentials(ctx.userId);
if (!creds) {
return Response.json({ error: 'no active Headscale server', code: 'no_active_server' }, { status: 409 });
}
const client = createClient(creds);
const owner = await resolveOwner(client, ctx);
if (owner instanceof Response) return owner;
const created = await client.call<{ preAuthKey?: { key?: string } }>('/api/v1/preauthkey', {
method: 'POST',
body: {
user: owner.id,
reusable: false, // one key, one device
ephemeral: false, // the node stays registered after it disconnects
expiration: new Date(Date.now() + KEY_TTL_MS).toISOString(), // RFC3339
},
});
const authKey = created.preAuthKey?.key;
if (!authKey) return Response.json({ error: 'headscale returned no key' }, { status: 502 });
// `server` and `user` are advisory — for a UI that wants to say what the device just joined.
return Response.json({ controlUrl: creds.url, authKey, server: creds.name, user: owner.name });
}
+149
View File
@@ -0,0 +1,149 @@
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
import { createSidecarConnector } from '@@/sidecar/connect';
import { handleOfficerRoute } from './routes';
import { MIN_VERSION_LABEL } from './version';
import { API_URL } from '@@/officer-url.mjs';
// The officer-headscale sidecar. Owns the whole Headscale contract for Officer: the registered servers and
// their admin API keys, the >=0.29 version floor, and every multi-call composition the UI needs. The platform
// API is a thin auth-gated forwarder (src/servers/api/headscale/router.ts) holding no Headscale credentials.
//
// Officer manages MANY Headscale servers, not one. The owner registers each with a URL and an API key
// generated on that server, and switches between them; one is active at a time. So configuration lives in
// Postgres (headscale_servers, keys encrypted at rest), NOT in env vars — this sidecar deliberately reads
// neither HEADSCALE_URL nor HEADSCALE_API_KEY, so a registered server can never be shadowed by host env.
// Device enrollment used to be the exception, minting keys in the platform from those two vars plus
// HEADSCALE_USER; it moved here (enroll.ts) and now acts on the active server like everything else.
//
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// HTTP CONTRACT — the platform strips its /api/headscale mount prefix before forwarding.
//
// GET /_health ours. Sidecar liveness only. Per-server reachability is a
// different question and needs an owner, so it lives below.
// GET /_officer/servers registered servers (never includes API keys)
// POST /_officer/servers register {name?,url,apiKey} — validated before it is saved
// PATCH /_officer/servers/:id edit; re-validated when url or apiKey changes
// DELETE /_officer/servers/:id deregister; promotes the newest survivor if it was active
// POST /_officer/servers/:id/activate switch the active server
// GET /_officer/servers/:id/health probe: reachable? version? key still accepted?
//
// Everything below acts on the ACTIVE server. 409 when none is selected — see active.ts.
//
// GET /_officer/nodes nodes, normalized; ?user=<username> filters
// GET /_officer/nodes/:id one node
// DELETE /_officer/nodes/:id remove it from the tailnet
// POST /_officer/nodes/:id/rename {name}
// POST /_officer/nodes/:id/tags {tags} — 'tag:' prefix added if missing
// POST /_officer/nodes/:id/routes {routes} whole set, or {route,approved} single toggle (RMW here)
// POST /_officer/nodes/:id/expire expire its key, forcing re-auth (not a delete)
// GET /_officer/users users, each with a node count the admin API doesn't provide
// POST /_officer/users {name, displayName?, email?}
// POST /_officer/users/:id/rename {name}
// DELETE /_officer/users/:id refused upstream while the user still owns nodes
// GET /_officer/keys pre-auth keys, secrets masked, with a derived status
// POST /_officer/keys {userId, reusable?, ephemeral?, expirationDays?, aclTags?}
// → the ONLY response carrying the real secret
// POST /_officer/keys/:id/expire expire without deleting
// DELETE /_officer/keys/:id delete outright
// POST /_officer/enroll {userId?} → {controlUrl, authKey} — a single-use 10-minute key
// for a joining device. userId is only required when the server
// has more than one user.
// NO CALLER since 2026-08-14: its only door was /api/vpn/enroll,
// which is deleted. Kept because it is the handler a route under
// /api/offscale would reuse, and because `/enroll/invites` — which
// IS live — dispatches through the same function.
// anything else 404
//
// There is deliberately NO transparent /api/v1/* passthrough. Headscale's REST shape changed repeatedly
// below 0.29 and its ids are uint64-as-JSON-string, so proxying raw would push all of that into the browser
// — the mistake the Soulseek panels made with 37 raw upstream calls. Every quirk is absorbed here.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
const p = probe.port;
probe.stop(true);
if (p == null) throw new Error('failed to acquire a free port');
return p;
}
const port = getFreePort();
const server = Bun.serve({
port,
hostname: '127.0.0.1',
async fetch(req) {
const url = new URL(req.url);
// Liveness, not upstream health: with many registered servers there is no single upstream to probe, and
// choosing one would need an authenticated owner. See /_officer/servers/:id/health for that.
if (url.pathname === '/_health') {
return Response.json({ ok: true, minHeadscaleVersion: MIN_VERSION_LABEL });
}
if (url.pathname.startsWith('/_officer/')) {
try {
const res = await handleOfficerRoute(req, url);
return res ?? new Response('not found', { status: 404 });
} catch (err) {
console.error(`[headscale] ${req.method} ${url.pathname} failed`, err);
return Response.json({ error: 'internal error' }, { status: 500 });
}
}
return new Response('not found', { status: 404 });
},
});
console.log(`[headscale] listening on 127.0.0.1:${port} (Headscale >=${MIN_VERSION_LABEL})`);
// ── Command handlers ──
type ReplyFn = (msg: SidecarEvent) => void;
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
switch (cmd.type) {
case 'ping':
reply({ type: 'pong', id: cmd.id });
break;
default:
reply({
type: 'error',
id: (cmd as SidecarCommand).id,
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
});
}
}
// ── Connect to API server ──
const connection = createSidecarConnector({
apiUrl: `${API_URL}/api/sidecar/register`,
name: 'headscale',
capabilities: ['headscale'],
onCommand(cmd, reply) {
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
},
onConnected() {
// Tell the API where we're listening, so it can forward /api/headscale/* here.
connection.send({ type: 'headscale:server', port });
console.log(`[headscale] reported port ${port} to API`);
},
});
// ── Graceful shutdown ──
function shutdown(signal: string) {
console.log(`[headscale] ${signal} received, shutting down...`);
try {
server.stop(true);
} catch {
/* already stopped */
}
connection.destroy();
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
+183
View File
@@ -0,0 +1,183 @@
import type { HeadscaleServerCredentials } from '../db/queries';
import { badRequest, methodNotAllowed, notFound, readJson, type OfficerContext } from './routes';
import { activeCreds, callCompanion, readBody, unavailable } from './companion';
// Enrolment invites — the admin half of COMMS/OFFSCALE_INVITE_ENROLLMENT.md. An admin mints a single-use
// invite, sends the link to whoever needs to join, and their phone exchanges the claim token for a pre-auth
// key it never had to be told.
//
// WHY THESE PROXY THE COMPANION RATHER THAN LIVING HERE. The invite store has to sit somewhere the joining
// phone can reach without an Officer account, and this sidecar is not that: it binds loopback on an
// ephemeral port behind Officer's auth. The spec's own argument settles it — an invite must still work when
// the platform is down, because the tailnet is often how you reach the platform. So the invite records, the
// token hashing and the claim endpoint belong next to Headscale, on its public origin, which is exactly what
// the Officer Companion already is. Officer is the admin surface and nothing more: create, list, revoke.
//
// Officer therefore stores no invite and no token. §5: "Never display, log or store the claim token beyond
// the moment it is handed to the admin." The create response passes through this process once, in memory,
// on its way to the browser — that is the whole of its life here.
//
// A server without the enrolment API answers `{available: false, reason}` at HTTP 200, like every other
// companion route: most registered servers have no companion at all, and that is a state to render rather
// than a request that failed.
/**
* Where the invite API sits on the companion, under its own `/officer-api` mount — so the full URL is
* `${server.url}/officer-api/api/v1/enroll/invites`. Versioned separately from the companion's container
* routes (`/health`, `/logs`, `/restart`), which are unversioned; one constant so the two cannot drift.
*/
const INVITES_PATH = '/api/v1/enroll/invites';
/** Spec §4.1: default 900, max 86400. The floor is ours — a sub-minute invite cannot be sent to anyone. */
const DEFAULT_TTL_SECONDS = 900;
const MIN_TTL_SECONDS = 60;
const MAX_TTL_SECONDS = 86_400;
type CreateInput = {
user: string;
ttlSeconds: number;
ephemeral: boolean;
tags: string[];
note?: string;
};
/** Validate the admin's form into the companion's request body, or a 400 saying which field was wrong. */
function parseCreate(body: Record<string, unknown> | null): CreateInput | Response {
const user = typeof body?.user === 'string' ? body.user.trim() : '';
if (!user) return badRequest('user is required — an invite files the joining device under one Headscale user');
const raw = body?.ttlSeconds;
const ttlSeconds = raw === undefined || raw === null ? DEFAULT_TTL_SECONDS : Number(raw);
if (!Number.isInteger(ttlSeconds) || ttlSeconds < MIN_TTL_SECONDS || ttlSeconds > MAX_TTL_SECONDS) {
return badRequest(`ttlSeconds must be an integer between ${MIN_TTL_SECONDS} and ${MAX_TTL_SECONDS}`);
}
// Tags are admin-set and passed through opaquely (spec §9.2). The `tag:` prefix is Headscale's, and
// adding it here means the admin can type either form without minting a key that silently has no tag.
const tags = Array.isArray(body?.tags)
? [
...new Set(
body.tags
.filter((t): t is string => typeof t === 'string')
.map((t) => t.trim())
.filter(Boolean)
.map((t) => (t.startsWith('tag:') ? t : `tag:${t}`)),
),
]
: [];
const note = typeof body?.note === 'string' ? body.note.trim().slice(0, 200) : '';
return { user, ttlSeconds, ephemeral: body?.ephemeral === true, tags, ...(note ? { note } : {}) };
}
/**
* Turn a companion answer into ours.
*
* The three cases are distinct and the UI needs them to stay that way: unreachable is `available: false`
* (render an explanation), a companion refusal keeps its own status and message (the admin typed something
* the server rejected), and success is the body with `available: true` on it.
*/
async function relay(res: Response | string, wrap: (body: Record<string, unknown>) => unknown): Promise<Response> {
if (typeof res === 'string') return Response.json(unavailable(res));
const body = await readBody(res);
if (typeof body === 'string') return Response.json(unavailable(body));
if (!res.ok) {
const error = typeof body.error === 'string' ? body.error : `the companion returned ${res.status}`;
return Response.json(
{ error, code: typeof body.code === 'string' ? body.code : undefined },
{ status: res.status },
);
}
return Response.json(wrap(body));
}
/**
* Carry the admin's device name in the link's fragment, as `n=<percent-encoded>`.
*
* The companion already knows the name — it stores the note and hands it back as `suggestedHostname` on
* claim — but a claim only happens when the person taps Join, which is one step AFTER the screen that asks
* them to name the device. So the name has to arrive with the link if the field is to be prefilled, and the
* link is the last thing that passes through here.
*
* Safe at every hop: the fragment is never sent to a server, the companion's /join page copies it verbatim
* into the `officer-offscale://` deep link, and a build of the app that predates this ignores an unknown
* parameter and still gets the name from `suggestedHostname` at claim time. Percent-encoded rather than
* base64url (which `s` uses) because the app's fragment parser already decodeURIComponent()s every value,
* and because base64url of a non-ASCII name would decode to mojibake on Hermes.
*/
function withNameHint(url: unknown, name: string | undefined): unknown {
if (typeof url !== 'string' || !name || !url.includes('#')) return url;
return `${url}&n=${encodeURIComponent(name)}`;
}
/**
* `POST /_officer/enroll/invites` — mint an invite. The response carries the link, and only this once.
*
* `url` is an ordinary HTTPS link to a page on the server's own domain, which bounces into the app; the
* companion also returns `deepLink`, the `officer-offscale://` scheme that page redirects to. That one is
* dropped here rather than passed on: it carries the same claim token in its fragment, and a second copy of
* a single-use credential in the browser is a second chance to leak it. Nothing on our side opens it.
*/
async function create(creds: HeadscaleServerCredentials, ctx: OfficerContext): Promise<Response> {
const input = parseCreate(await readJson(ctx.req));
if (input instanceof Response) return input;
const res = await callCompanion(creds, { path: INVITES_PATH, method: 'POST', body: input });
return relay(res, (body) => {
const raw = body.invite ?? body;
const invite = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
const { deepLink: _deepLink, ...rest } = invite;
return { available: true, invite: { ...rest, url: withNameHint(rest.url, input.note) } };
});
}
/**
* Pull the invite array out of whatever envelope the companion used.
*
* §4.3 specifies the fields but not the wrapper, and the create response came back flat (no `invite` key),
* so the list may equally be a bare array or sit under `invites`/`items`/`data`. Taking the first
* array-valued property is shape-agnostic without being credulous: the body has exactly one array in it.
*/
function pickInvites(body: Record<string, unknown>): unknown[] {
if (Array.isArray(body)) return body;
for (const key of ['invites', 'items', 'data', 'results']) {
const value = body[key];
if (Array.isArray(value)) return value;
}
const found = Object.values(body).find(Array.isArray);
return Array.isArray(found) ? found : [];
}
/** `GET /_officer/enroll/invites` — the admin's audit list. Never carries a token or a key. */
async function list(creds: HeadscaleServerCredentials): Promise<Response> {
const res = await callCompanion(creds, { path: INVITES_PATH });
return relay(res, (body) => ({ available: true, invites: pickInvites(body) }));
}
/** `DELETE /_officer/enroll/invites/:id` — revoke an unclaimed invite. A no-op on a claimed one. */
async function revoke(creds: HeadscaleServerCredentials, id: string): Promise<Response> {
const res = await callCompanion(creds, { path: `${INVITES_PATH}/${encodeURIComponent(id)}`, method: 'DELETE' });
return relay(res, (body) => ({ available: true, ...body }));
}
/** Dispatch `/_officer/enroll/invites...`. Acts on the ACTIVE server, like every other domain route. */
export async function handleInvitesRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
const creds = await activeCreds(ctx.userId);
if (creds instanceof Response) return creds;
const [id, extra] = rest;
if (extra) return notFound();
if (!id) {
if (ctx.req.method === 'POST') return create(creds, ctx);
if (ctx.req.method === 'GET') return list(creds);
return methodNotAllowed();
}
if (ctx.req.method !== 'DELETE') return methodNotAllowed();
return revoke(creds, id);
}
+119
View File
@@ -0,0 +1,119 @@
import type { OfficerContext } from './routes';
import { badRequest, notFound, methodNotAllowed, readJson } from './routes';
import { activeClient } from './active';
import { toPreAuthKey, arrayField } from './normalize';
// Pre-auth key routes — /_officer/keys/*. These are the tokens a machine uses to join the tailnet.
//
// The one thing that matters here: since 0.28 Headscale stores pre-auth keys HASHED and returns the real
// secret ONLY in the create response. Every later list returns it masked as `hskey-auth-<prefix>-***`. A
// creation response that the UI drops is a key the owner can never recover — it has to be shown once, with
// a copy affordance, and the API has to make the difference legible. `key` is non-null exactly once.
//
// That "exactly once" is enforced by call path, not by inspecting the value: keys created before 0.28 are
// still plaintext upstream and Headscale hands them back in full from the LIST endpoint for backwards
// compatibility. So listing passes reveal:false and drops the secret unconditionally; only createKey
// reveals. A server with history in it would otherwise leak live keys into the browser's query cache.
//
// Also note the shape of the delete/expire pair: expire takes the id in a POST BODY, delete takes it in a
// query STRING, and neither is a REST-shaped path. Both are hidden behind ordinary Officer routes.
/** Default lifetime when the caller doesn't pick one; matches Headscale's own CLI default. */
const DEFAULT_EXPIRY_DAYS = 90;
const MAX_EXPIRY_DAYS = 3650;
async function listKeys(ctx: OfficerContext): Promise<Response> {
const client = await activeClient(ctx.userId);
if (client instanceof Response) return client;
// 0.29 lists every user's keys in one call (pre-0.29 required a ?user= filter and one call per user).
const body = await client.call('/api/v1/preauthkey');
const keys = arrayField(body, 'preAuthKeys').map((raw) => toPreAuthKey(raw, { reveal: false }));
// Usable keys first, then by newest — a spent key is history, an active one is the thing you came for.
const rank = { active: 0, used: 1, expired: 2 } as const;
keys.sort((a, b) => rank[a.status] - rank[b.status] || (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
return Response.json({ keys });
}
async function createKey(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');
// CreatePreAuthKey takes a numeric user ID — unlike the node list filter, which takes a username. The
// two are easy to confuse and the failure is a confusing upstream error, so it's validated here.
const userId = typeof body.userId === 'string' ? body.userId.trim() : '';
if (!/^\d+$/.test(userId)) return badRequest('userId must be the numeric id of a Headscale user');
const days = body.expirationDays === undefined ? DEFAULT_EXPIRY_DAYS : Number(body.expirationDays);
if (!Number.isFinite(days) || days <= 0 || days > MAX_EXPIRY_DAYS) {
return badRequest(`expirationDays must be between 1 and ${MAX_EXPIRY_DAYS}`);
}
const aclTags = Array.isArray(body.aclTags)
? body.aclTags
.filter((t): t is string => typeof t === 'string')
.map((t) => t.trim())
.filter(Boolean)
.map((t) => (t.startsWith('tag:') ? t : `tag:${t}`))
: [];
const created = await client.call<{ preAuthKey?: Record<string, unknown> }>('/api/v1/preauthkey', {
method: 'POST',
body: {
user: userId,
reusable: body.reusable === true,
ephemeral: body.ephemeral === true,
expiration: new Date(Date.now() + days * 86_400_000).toISOString(),
aclTags,
},
});
if (!created.preAuthKey) return Response.json({ error: 'headscale returned no key' }, { status: 502 });
const key = toPreAuthKey(created.preAuthKey, { reveal: true });
// Stated explicitly rather than left for the client to infer from `key !== null`: this response is the
// only time the secret exists anywhere outside the joining machine.
return Response.json({ key, secretShownOnce: true }, { status: 201 });
}
type KeyActionParams = { ctx: OfficerContext; id: string; action: string | undefined };
async function handleKeyAction({ ctx, id, action }: KeyActionParams): Promise<Response> {
const client = await activeClient(ctx.userId);
if (client instanceof Response) return client;
if (action === 'expire') {
if (ctx.req.method !== 'POST') return methodNotAllowed();
await client.call('/api/v1/preauthkey/expire', { method: 'POST', body: { id } });
return new Response(null, { status: 204 });
}
if (action !== undefined) return notFound();
if (ctx.req.method === 'DELETE') {
await client.call(`/api/v1/preauthkey?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
return new Response(null, { status: 204 });
}
return methodNotAllowed();
}
/** Dispatch `/_officer/keys/...`. `rest` is the path after `keys`. */
export async function handleKeysRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
if (rest.length === 0) {
if (ctx.req.method === 'GET') return listKeys(ctx);
if (ctx.req.method === 'POST') return createKey(ctx);
return methodNotAllowed();
}
if (rest.length > 2) return notFound();
const id = rest[0];
if (!id || !/^\d+$/.test(id)) return badRequest('key id must be numeric');
return handleKeyAction({ ctx, id, action: rest[1] });
}
+142
View File
@@ -0,0 +1,142 @@
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] });
}
+187
View File
@@ -0,0 +1,187 @@
// Officer-shaped views of Headscale's admin API objects, and the quirk handling that gets us there.
//
// Headscale's REST layer is a gRPC gateway marshalling protobuf, which leaks in three ways we normalize
// here so nothing downstream has to know:
//
// 1. Every uint64 is a JSON STRING. Ids stay strings end to end — never Number() them, that breaks
// silently above 2^53 and Headscale's ids are database-assigned, not small by contract.
// 2. Unset timestamps are the protobuf zero value, serialized as '0001-01-01T00:00:00Z' rather than
// omitted. Rendered naively that reads as the year 1 — it means "never", so it becomes null.
// 3. EmitUnpopulated means absent repeated fields arrive as [] and absent messages as null; there is no
// way to distinguish "unset" from "empty", so every accessor tolerates both.
/** Protobuf's zero timestamp. Headscale sends this for "never expires", "never seen", and friends. */
const ZERO_TIME = '0001-01-01T00:00:00Z';
/** An upstream timestamp as an ISO string, or null when it is unset/the protobuf zero value. */
export function isoOrNull(raw: unknown): string | null {
if (typeof raw !== 'string' || !raw || raw === ZERO_TIME) return null;
const ms = Date.parse(raw);
if (Number.isNaN(ms)) return null;
// Some builds emit years far outside anything meaningful; treat pre-1971 as the sentinel too.
return ms < 31_536_000_000 ? null : new Date(ms).toISOString();
}
const str = (raw: unknown): string => (typeof raw === 'string' ? raw : '');
const strArray = (raw: unknown): string[] =>
Array.isArray(raw) ? raw.filter((v): v is string => typeof v === 'string') : [];
export type UpstreamUser = Record<string, unknown>;
export type UpstreamNode = Record<string, unknown>;
export type UpstreamPreAuthKey = Record<string, unknown>;
export type OfficerUser = {
id: string;
name: string;
displayName: string | null;
email: string | null;
/** The OIDC provider, when the user came from one. Null for CLI/API-created users. */
provider: string | null;
profilePicUrl: string | null;
createdAt: string | null;
};
export function toUser(raw: UpstreamUser | null | undefined): OfficerUser | null {
if (!raw || typeof raw !== 'object') return null;
const id = str(raw.id);
if (!id) return null;
return {
id,
name: str(raw.name),
displayName: str(raw.displayName) || null,
email: str(raw.email) || null,
provider: str(raw.provider) || null,
profilePicUrl: str(raw.profilePicUrl) || null,
createdAt: isoOrNull(raw.createdAt),
};
}
export type OfficerNode = {
id: string;
/** The name Headscale actually uses in the tailnet — givenName when set, otherwise the reported hostname. */
name: string;
hostname: string;
user: OfficerUser | null;
ipAddresses: string[];
online: boolean;
lastSeen: string | null;
/** When the node's key expires and it must re-authenticate. Null means it never expires. */
expiry: string | null;
createdAt: string | null;
/** How the node joined: 'authkey' | 'cli' | 'oidc' | 'unknown'. */
registerMethod: string;
tags: string[];
/** Routes the node advertises. */
availableRoutes: string[];
/** The subset the admin has approved — the writable one. */
approvedRoutes: string[];
/** Routes actually in effect (approved ∩ available, as Headscale computes it). */
subnetRoutes: string[];
/** True when the node advertises an exit node route. Purely derived, for the UI's badge. */
isExitNode: boolean;
};
const EXIT_ROUTES = new Set(['0.0.0.0/0', '::/0']);
const REGISTER_METHODS: Record<string, string> = {
REGISTER_METHOD_AUTH_KEY: 'authkey',
REGISTER_METHOD_CLI: 'cli',
REGISTER_METHOD_OIDC: 'oidc',
};
export function toNode(raw: UpstreamNode): OfficerNode {
const givenName = str(raw.givenName);
const hostname = str(raw.name);
const availableRoutes = strArray(raw.availableRoutes);
return {
id: str(raw.id),
name: givenName || hostname,
hostname,
user: toUser(raw.user as UpstreamUser),
ipAddresses: strArray(raw.ipAddresses),
online: raw.online === true,
lastSeen: isoOrNull(raw.lastSeen),
expiry: isoOrNull(raw.expiry),
createdAt: isoOrNull(raw.createdAt),
registerMethod: REGISTER_METHODS[str(raw.registerMethod)] ?? 'unknown',
tags: strArray(raw.tags),
availableRoutes,
approvedRoutes: strArray(raw.approvedRoutes),
subnetRoutes: strArray(raw.subnetRoutes),
isExitNode: availableRoutes.some((r) => EXIT_ROUTES.has(r)),
};
}
export type OfficerPreAuthKey = {
id: string;
/**
* The usable secret. Non-null ONLY on the creation response — the list path nulls it unconditionally,
* so a secret can never reach the browser except at the moment it is created and must be shown once.
*/
key: string | null;
/** A never-usable label for identifying a key in a list, e.g. `hskey-auth-a1b2c3-***`. */
keyDisplay: string;
user: OfficerUser | null;
reusable: boolean;
ephemeral: boolean;
used: boolean;
expiration: string | null;
createdAt: string | null;
aclTags: string[];
/** Derived lifecycle, so every surface agrees on what "spent" means. */
status: 'active' | 'used' | 'expired';
};
/**
* A display label that is never a usable secret.
*
* Headscale 0.28+ stores keys bcrypt-hashed and lists them already masked as `hskey-auth-<prefix>-***`.
* But keys created BEFORE 0.28 are still plaintext in its database, and `PreAuthKey.Proto()` returns those
* in full from the list endpoint "for backwards compatibility" — its own source carries a TODO about
* hiding them. So a list response on a server with history in it really does contain live secrets. We mask
* anything that isn't already masked rather than trusting the upstream to have done it.
*/
function displayLabel(key: string): string {
if (!key) return '(no key)';
if (key.endsWith('***')) return key;
return `${key.slice(0, 6)}…-***`;
}
type ToPreAuthKeyOptions = {
/**
* True only on the creation response, where the secret is the entire point and exists nowhere else.
* Everywhere else this is false and the secret is dropped before it can reach a cache or a browser.
*/
reveal: boolean;
};
export function toPreAuthKey(raw: UpstreamPreAuthKey, { reveal }: ToPreAuthKeyOptions): OfficerPreAuthKey {
const expiration = isoOrNull(raw.expiration);
const reusable = raw.reusable === true;
const used = raw.used === true;
const key = str(raw.key);
// A reusable key stays usable after a node has claimed it, so `used` alone doesn't mean spent.
const expired = !!expiration && Date.parse(expiration) < Date.now();
const status: OfficerPreAuthKey['status'] = expired ? 'expired' : used && !reusable ? 'used' : 'active';
return {
id: str(raw.id),
key: reveal ? key || null : null,
keyDisplay: displayLabel(key),
user: toUser(raw.user as UpstreamUser),
reusable,
ephemeral: raw.ephemeral === true,
used,
expiration,
createdAt: isoOrNull(raw.createdAt),
aclTags: strArray(raw.aclTags),
status,
};
}
/** Read an array field out of a gateway response, tolerating the null/absent forms. */
export function arrayField(body: unknown, field: string): Record<string, unknown>[] {
const value = (body as Record<string, unknown> | null)?.[field];
return Array.isArray(value) ? (value.filter((v) => v && typeof v === 'object') as Record<string, unknown>[]) : [];
}
+115
View File
@@ -0,0 +1,115 @@
import type { OfficerContext } from './routes';
import { badRequest, methodNotAllowed, readJson } from './routes';
import { HeadscaleError } from './client';
import { activeClient } from './active';
import { handlePolicyAssistRoute } from './assist';
// The ACL policy — /_officer/policy. One HuJSON document that decides which node may reach which, so it is
// the highest-consequence thing this app can write and the only place a typo silently partitions a network.
//
// Three upstream behaviours drive the shape of this file.
//
// 1. **Readable always, writable sometimes.** Headscale can keep its policy in a file (`policy.mode: file`)
// instead of the database, and then the API still SERVES it — a GET returns the file's contents quite
// happily — but a PUT is refused with "update is disabled for modes other than 'database'". Verified
// against a live server, and it means the mode CANNOT be inferred from a read. There is no endpoint
// that reports it either. So this route makes no claim about writability up front; the first save is
// what finds out, and a refusal is a 409 the UI turns into a persistent read-only banner.
//
// 2. **Validation happens on PUT, in Headscale, and its message is the whole value.** It parses the
// HuJSON, resolves every group and tag reference, and rejects the write with a line and column or a
// "group not defined" naming the offender. Officer must not pre-validate: a second, weaker parser here
// would reject documents Headscale accepts and — worse — accept ones it rejects, and its opinion would
// be the one shown. So the text goes up untouched and Headscale's verdict comes back verbatim.
//
// 3. **Both of those arrive as HTTP 500** from grpc-gateway, which the client layer normally generalizes
// to "headscale error". `HeadscaleError.detail` is how the real message survives that; see client.ts.
/**
* Does this failure mean "writing is turned off here", as opposed to "your document is wrong"?
*
* Matched on the message because Headscale gives no code to match on. Deliberately broad: a false positive
* costs a slightly-wrong banner over a message the owner can still read, while a false negative would tell
* someone their perfectly good ACL was rejected and send them hunting for a syntax error that isn't there.
*/
function isWriteDisabled(detail: string): boolean {
const text = detail.toLowerCase();
if (text.includes('disabled')) return true;
return text.includes('file') && (text.includes('policy') || text.includes('mode'));
}
type PolicyBody = { policy?: unknown; updatedAt?: unknown };
const asText = (value: unknown) => (typeof value === 'string' ? value : '');
const asDate = (value: unknown) => (typeof value === 'string' && value && !value.startsWith('0001-') ? value : null);
/**
* `GET /_officer/policy`.
*
* Answers 200 for every state a running server can be in, including "there is no policy yet" — a fresh
* Headscale has none, and an empty editor is both the honest rendering of that and the thing the owner
* needs to start typing into. Only an unreachable server is an error, because only that leaves nothing
* to say. Note there is no `mode` here on purpose: see the header.
*/
async function getPolicy(ctx: OfficerContext): Promise<Response> {
const client = await activeClient(ctx.userId);
if (client instanceof Response) return client;
try {
const body = await client.call<PolicyBody>('/api/v1/policy');
return Response.json({ policy: asText(body.policy), updatedAt: asDate(body.updatedAt) });
} catch (err) {
if (!(err instanceof HeadscaleError)) throw err;
const detail = err.detail ?? err.message;
if (err.status === 404 || detail.toLowerCase().includes('not found')) {
return Response.json({ policy: '', updatedAt: null });
}
throw err;
}
}
/**
* `PUT /_officer/policy {policy}`.
*
* The body is sent up byte for byte — no trimming, no reformatting, no parse. Comments and layout are load
* bearing in a hand-maintained ACL, and re-serializing would destroy both.
*/
async function putPolicy(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');
if (typeof body.policy !== 'string') return badRequest('policy must be a string');
// An empty document would be accepted by some Headscale versions and lock every node out of every other
// one. Deleting a policy is not something to do by leaving a textarea blank and pressing save.
if (!body.policy.trim()) return badRequest('the policy is empty — that would deny every connection');
try {
const saved = await client.call<PolicyBody>('/api/v1/policy', { method: 'PUT', body: { policy: body.policy } });
// Headscale echoes what it stored; fall back to what we sent if it echoes nothing, so a successful save
// never blanks the editor.
return Response.json({ policy: asText(saved.policy) || body.policy, updatedAt: asDate(saved.updatedAt) });
} catch (err) {
if (!(err instanceof HeadscaleError)) throw err;
const detail = err.detail ?? err.message;
if (isWriteDisabled(detail)) {
return Response.json({ error: detail, code: 'policy_read_only' }, { status: 409 });
}
// Everything else on a PUT is Headscale rejecting this document: a syntax error with a position, an
// unresolvable group, an unknown tag owner. 422 rather than 502 — the request is the problem, and the
// message is the one thing that will fix it.
return Response.json({ error: detail, code: 'policy_rejected' }, { status: 422 });
}
}
/** Dispatch `/_officer/policy`. One policy per server, plus the drafting assistant beside it. */
export async function handlePolicyRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
// `/policy/assist` proposes a document; it never writes one. See assist.ts.
if (rest[0] === 'assist') return handlePolicyAssistRoute(ctx, rest.slice(1));
if (rest.length > 0) return badRequest('unexpected path');
if (ctx.req.method === 'GET') return getPolicy(ctx);
if (ctx.req.method === 'PUT') return putPolicy(ctx);
return methodNotAllowed();
}
+80
View File
@@ -0,0 +1,80 @@
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;
}
}
+201
View File
@@ -0,0 +1,201 @@
import type { OfficerContext } from './routes';
import {
listHeadscaleServers,
createHeadscaleServer,
updateHeadscaleServer,
setActiveHeadscaleServer,
deleteHeadscaleServer,
getHeadscaleCredentials,
recordHeadscaleProbe,
} from '../db/queries';
import { createClient, HeadscaleError } from './client';
import { probeVersion, MIN_VERSION_LABEL } from './version';
import { badRequest, notFound, methodNotAllowed } from './routes';
import { normalizeSshHost } from './ssh';
// Server registry routes — /_officer/servers/*. Officer manages any number of Headscale servers; the owner
// registers each with a URL and an admin API key generated on that server, and one is active at a time.
//
// Registration VALIDATES before it saves, in two steps, because a bad registration is otherwise only
// discovered later as a confusing failure on some unrelated screen:
// 1. unauthenticated GET /version — proves something Headscale-shaped is there and enforces the >=0.29 floor
// 2. an authenticated call — proves the key actually works
// Neither step is skippable, and a rejected registration is never written.
/** Normalize a user-supplied base URL, or null if it isn't a usable http(s) origin. */
function normalizeUrl(raw: unknown): string | null {
if (typeof raw !== 'string' || !raw.trim()) return null;
let candidate = raw.trim();
// Bare host/port is the most common paste; assume https rather than rejecting it.
if (!/^https?:\/\//i.test(candidate)) candidate = `https://${candidate}`;
let parsed: URL;
try {
parsed = new URL(candidate);
} catch {
return null;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
// Trailing slash would produce `//api/v1/...`; query/hash are meaningless on a base URL.
return `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`;
}
function requireString(value: unknown, field: string): string | Response {
if (typeof value !== 'string' || !value.trim()) return badRequest(`${field} is required`);
return value.trim();
}
/**
* Confirm a URL+key pair is a supported, reachable Headscale we can authenticate against.
* Returns the observed version on success, or a ready-to-send error Response.
*/
async function validateServer(url: string, apiKey: string): Promise<string | Response> {
const probe = await probeVersion(url);
if (!probe.ok) return badRequest(probe.error);
if (probe.supported === false) {
return badRequest(`Headscale ${probe.version} is not supported — Officer requires ${MIN_VERSION_LABEL} or newer`);
}
// Cheapest authenticated GET whose path is stable across releases, and the same call Headscale's own
// clients use to test a key. A wrong key surfaces here as HeadscaleError(502, 'rejected the stored key').
const client = createClient({ id: 0, name: 'probe', url, apiKey });
try {
await client.call('/api/v1/apikey');
} catch (err) {
if (err instanceof HeadscaleError) {
return badRequest(err.status === 502 ? 'the API key was rejected by that server' : err.message);
}
throw err;
}
return probe.version;
}
async function handleCollection(ctx: OfficerContext): Promise<Response> {
const { req, userId } = ctx;
if (req.method === 'GET') {
return Response.json({ servers: await listHeadscaleServers(userId) });
}
if (req.method === 'POST') {
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
if (!body) return badRequest('expected a JSON body');
const url = normalizeUrl(body.url);
if (!url) return badRequest('url must be a valid http(s) URL');
const apiKey = requireString(body.apiKey, 'apiKey');
if (apiKey instanceof Response) return apiKey;
// The name is a label only; default it to the host so registration needs just a URL and a key.
const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim() : new URL(url).host;
// Optional, and never validated by connecting: registration should not fail because a box is rebooting.
const sshHost = normalizeSshHost(body.sshHost);
if (sshHost instanceof Response) return sshHost;
const validated = await validateServer(url, apiKey);
if (validated instanceof Response) return validated;
// First registration becomes active, so the owner is never left with servers but none selected.
const existing = await listHeadscaleServers(userId);
const server = await createHeadscaleServer({
userId,
name,
url,
apiKey,
version: validated,
sshHost,
activate: existing.length === 0,
});
return Response.json({ server }, { status: 201 });
}
return methodNotAllowed();
}
async function handleOne(ctx: OfficerContext, id: number, action: string | undefined): Promise<Response> {
const { req, userId } = ctx;
if (action === 'activate') {
if (req.method !== 'POST') return methodNotAllowed();
const server = await setActiveHeadscaleServer(userId, id);
return server ? Response.json({ server }) : notFound('no such server');
}
if (action === 'health') {
if (req.method !== 'GET') return methodNotAllowed();
const creds = await getHeadscaleCredentials(userId, id);
if (!creds) return notFound('no such server');
const started = Date.now();
const probe = await probeVersion(creds.url);
if (!probe.ok) return Response.json({ ok: false, error: probe.error, ms: Date.now() - started });
// Reachable — confirm the key too, so "healthy" means "we can actually use this server".
try {
await createClient(creds).call('/api/v1/apikey');
} catch (err) {
const message = err instanceof HeadscaleError ? err.message : 'upstream error';
return Response.json({ ok: false, version: probe.version, error: message, ms: Date.now() - started });
}
await recordHeadscaleProbe(userId, id, probe.version);
return Response.json({ ok: true, version: probe.version, supported: probe.supported, ms: Date.now() - started });
}
if (action !== undefined) return notFound();
if (req.method === 'PATCH') {
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
if (!body) return badRequest('expected a JSON body');
const current = await getHeadscaleCredentials(userId, id);
if (!current) return notFound('no such server');
let url: string | undefined;
if (body.url !== undefined) {
const normalized = normalizeUrl(body.url);
if (!normalized) return badRequest('url must be a valid http(s) URL');
url = normalized;
}
let apiKey: string | undefined;
if (body.apiKey !== undefined) {
const parsed = requireString(body.apiKey, 'apiKey');
if (parsed instanceof Response) return parsed;
apiKey = parsed;
}
const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim() : undefined;
// Absent = leave it; '' or null = clear the console target. normalizeSshHost collapses both to null.
let sshHost: string | null | undefined;
if (body.sshHost !== undefined) {
const parsed = normalizeSshHost(body.sshHost);
if (parsed instanceof Response) return parsed;
sshHost = parsed;
}
// Re-validate whenever either half of the credentials moves — a saved-but-broken server is the exact
// state registration works hard to prevent, and an edit can reintroduce it.
if (url !== undefined || apiKey !== undefined) {
const validated = await validateServer(url ?? current.url, apiKey ?? current.apiKey);
if (validated instanceof Response) return validated;
}
const server = await updateHeadscaleServer(userId, id, { name, url, apiKey, sshHost });
return server ? Response.json({ server }) : notFound('no such server');
}
if (req.method === 'DELETE') {
const deleted = await deleteHeadscaleServer(userId, id);
return deleted ? new Response(null, { status: 204 }) : notFound('no such server');
}
return methodNotAllowed();
}
/** Dispatch `/_officer/servers/...`. `rest` is the path after `servers`. */
export async function handleServersRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
if (rest.length === 0) return handleCollection(ctx);
const id = Number(rest[0]);
if (!Number.isInteger(id) || id <= 0) return badRequest('server id must be a positive integer');
if (rest.length > 2) return notFound();
return handleOne(ctx, id, rest[1]);
}
+86
View File
@@ -0,0 +1,86 @@
import { badRequest, methodNotAllowed, readJson, type OfficerContext } from './routes';
// SSH console support — the escape hatch for when the Headscale API cannot answer.
//
// Officer never handles a password, a key or a port here. The console runs `ssh <host>` in the owner's own
// shell, so it authenticates with whatever `~/.ssh` already knows; the only thing stored is where to point it.
// That is why this file has no credential handling at all, and why it must never grow any: the moment Officer
// starts holding a private key or a password, this stops being "run the command you would have run yourself".
//
// The host string is typed into an interactive shell, so it is validated to a conservative charset rather than
// quoted. Quoting would let a plausible-looking value survive to the shell and be someone else's problem;
// rejecting it says which character is wrong while the form is still open.
/** `user@` plus a hostname or IP. Deliberately no spaces, no flags, no shell metacharacters. */
const SSH_HOST_RE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*)?(?:@[A-Za-z0-9](?:[A-Za-z0-9._:-]*)?)?$/;
/**
* Validate a console target. Returns the trimmed host, null when the field was blank (meaning "no console"),
* or an error Response.
*/
export function normalizeSshHost(raw: unknown): string | null | Response {
if (raw === null) return null;
if (typeof raw !== 'string') return badRequest('sshHost must be a string');
const host = raw.trim();
if (!host) return null;
if (host.length > 255) return badRequest('sshHost is too long');
if (!SSH_HOST_RE.test(host)) {
return badRequest('sshHost must be a plain host, IP or user@host — no ports, flags or spaces');
}
return host;
}
type SshProbe = { ok: boolean; error?: string; ms: number };
/**
* Prove the machine is reachable with the keys already on this box, without opening a session.
*
* `BatchMode=yes` is what makes this a test rather than a hang: ssh fails instead of prompting for a password
* or a passphrase, which is exactly the outcome the owner needs to see. `accept-new` records an unknown host
* key here rather than leaving the console to open on an interactive "are you sure" prompt the first time —
* it still refuses a CHANGED key, which is the check worth keeping.
*/
export async function probeSsh(host: string): Promise<SshProbe> {
const started = Date.now();
const proc = Bun.spawn(
['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', '-o', 'StrictHostKeyChecking=accept-new', host, 'true'],
{ stdout: 'ignore', stderr: 'pipe' },
);
// ConnectTimeout only bounds the TCP connect; a server that accepts and then stalls would hang forever.
const timer = setTimeout(() => proc.kill(), 15_000);
let stderr = '';
try {
[stderr] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
} finally {
clearTimeout(timer);
}
const ms = Date.now() - started;
if (proc.exitCode === 0) return { ok: true, ms };
// ssh's own first line is the useful one ("Permission denied", "Connection timed out"); the rest is noise.
const first = stderr
.split('\n')
.map((line) => line.trim())
.find((line) => line && !line.startsWith('Warning: Permanently added'));
return { ok: false, error: first || `ssh exited ${proc.exitCode ?? 'on a signal'}`, ms };
}
/**
* `POST /_officer/ssh-test {host}`. Takes the host in the body rather than a server id on purpose: the form
* needs to test a value the owner has typed but not yet saved, which is the case where a typo is still cheap.
*/
export async function handleSshTestRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
if (rest.length > 0) return badRequest('unexpected path');
if (ctx.req.method !== 'POST') return methodNotAllowed();
const body = await readJson(ctx.req);
if (!body) return badRequest('expected a JSON body');
const host = normalizeSshHost(body.host);
if (host instanceof Response) return host;
if (!host) return badRequest('host is required');
return Response.json(await probeSsh(host));
}
+104
View File
@@ -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] });
}
+79
View File
@@ -0,0 +1,79 @@
// Headscale version detection and the supported floor.
//
// Officer targets Headscale >= 0.29 and nothing older. That is a deliberate, narrow floor: the admin API
// changed shape repeatedly below it — identifiers went name→numeric at 0.26, `/api/v1/routes` was removed at
// 0.26 in favour of node-owned route sets, `forcedTags`/`validTags` collapsed into `tags` at 0.28, pre-auth
// key expiry became id-based at 0.28, and MoveNode was removed at 0.28. Supporting 0.230.28 would mean
// carrying several incompatible data models; refusing them at registration time costs one probe.
//
// Detection uses the server's own unauthenticated `GET /version`, which exists in 0.28 and 0.29 and sits at
// the root — NOT under /api/v1, and not behind the bearer middleware. Do not confuse it with the three
// other similarly-named endpoints: `GET /health` (root, unauthenticated, `{status:'pass'}`) and
// `GET /api/v1/health` (authenticated, `{databaseConnectivity:true}`) carry no version at all.
export const MIN_MAJOR = 0;
export const MIN_MINOR = 29;
export const MIN_VERSION_LABEL = '0.29';
const PROBE_TIMEOUT_MS = 8000;
export type VersionProbe =
| { ok: true; version: string; supported: true }
/** Reached the server but can't judge the version — self-built images report the literal 'dev'. */
| { ok: true; version: string; supported: 'unknown' }
| { ok: true; version: string; supported: false }
| { ok: false; error: string };
/** `major.minor` from a Headscale version string, or null when it isn't semver (e.g. the literal 'dev'). */
export function parseVersion(raw: string): { major: number; minor: number } | null {
const m = raw
.trim()
.replace(/^v/, '')
.match(/^(\d+)\.(\d+)/);
if (!m) return null;
return { major: Number(m[1]), minor: Number(m[2]) };
}
/** Whether a parsed version is at or above the supported floor. */
export function meetsFloor(v: { major: number; minor: number }): boolean {
if (v.major !== MIN_MAJOR) return v.major > MIN_MAJOR;
return v.minor >= MIN_MINOR;
}
/**
* Probe a base URL's `GET /version`. Unauthenticated, so this also doubles as the reachability check during
* registration — it tells us "is there a Headscale here at all" before we bother validating a key.
*
* An unparseable version is reported as `supported: 'unknown'` rather than rejected: a server built without
* VCS build info reports 'dev', and refusing those would lock out legitimately self-built deployments.
*/
export async function probeVersion(baseUrl: string): Promise<VersionProbe> {
let res: Response;
try {
res = await fetch(`${baseUrl}/version`, {
headers: { accept: 'application/json' },
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
});
} catch {
return { ok: false, error: 'server unreachable' };
}
if (!res.ok) {
// A Headscale that answers /version with a non-2xx isn't one we can identify. Most often this is a URL
// pointing at a reverse proxy or an unrelated service rather than at Headscale itself.
return { ok: false, error: `GET /version returned ${res.status} — is this a Headscale server?` };
}
let version: string;
try {
const body = (await res.json()) as { version?: unknown };
if (typeof body.version !== 'string' || !body.version) return { ok: false, error: 'no version in response' };
version = body.version;
} catch {
return { ok: false, error: 'GET /version did not return JSON' };
}
const parsed = parseVersion(version);
if (!parsed) return { ok: true, version, supported: 'unknown' };
return { ok: true, version, supported: meetsFloor(parsed) };
}