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:
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 1–2000');
|
||||
|
||||
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 1–2000');
|
||||
|
||||
// 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();
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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'));
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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] });
|
||||
}
|
||||
@@ -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] });
|
||||
}
|
||||
@@ -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>[]) : [];
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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] });
|
||||
}
|
||||
@@ -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.23–0.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) };
|
||||
}
|
||||
Reference in New Issue
Block a user