add the officer-headscale sidecar and its server registry ui
officer-headscale owns the whole Headscale contract: the registered servers and their admin api keys, the >=0.29 version floor, and every multi-call composition the ui needs. the platform side is auth+forward only and holds no headscale credentials, so the existing /api/vpn/enroll route and its HEADSCALE_* env vars are untouched and unrelated. officer manages many servers rather than one. the owner registers each with a url and a key generated on that server and switches between them; exactly one is active, enforced by a partial unique index rather than by convention. keys are encrypted at rest and never leave the sidecar — the list projection cannot return one. registration validates before it saves: an unauthenticated GET /version to prove something headscale-shaped is there and meets the floor, then an authenticated call to prove the key works. an edit that moves either half re-validates. there is deliberately no transparent /api/v1/* passthrough. headscale serialises every uint64 as a json string and its rest shape moved repeatedly below 0.29; proxying raw would push all of that into the browser, which is the mistake the soulseek panels made with 37 raw upstream calls. the /headscale workspace is nav + view over the panel system. only the servers section is implemented — nodes, users and pre-auth keys say so plainly rather than rendering an empty table that reads as a failed fetch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { getHeadscaleServerUrl } from './sidecar-server';
|
||||
|
||||
// Thin reverse-proxy for /api/headscale/*. The platform's ONLY job here is AUTH + FORWARDING: this router
|
||||
// mounts under the protected /api tree (userMiddleware upstream authenticates the owner), then forwards the
|
||||
// subpath + query + body to the officer-headscale sidecar, which OWNS the Headscale contract and holds the
|
||||
// admin API key.
|
||||
//
|
||||
// A catch-all with no routes of its own. Unlike /api/slskd this proxies nothing of the upstream's own
|
||||
// surface — the sidecar exposes only Officer-owned routes under /_officer/, because Headscale's REST shape
|
||||
// differs across releases and version handling belongs in the sidecar, not the browser. The full contract
|
||||
// is documented at the top of src/servers/sidecar/headscale/index.ts. It is opaque from here: this file
|
||||
// must never grow Headscale logic.
|
||||
|
||||
export const headscaleRouter = createRouter();
|
||||
|
||||
const PREFIX = '/api/headscale';
|
||||
|
||||
headscaleRouter.all('/*', async (ctx) => {
|
||||
const baseUrl = getHeadscaleServerUrl();
|
||||
if (!baseUrl) return ctx.text('headscale sidecar not available', 503);
|
||||
|
||||
const url = new URL(ctx.req.url);
|
||||
const subpath = url.pathname.slice(PREFIX.length) || '/';
|
||||
const target = `${baseUrl}${subpath}${url.search}`;
|
||||
|
||||
const method = ctx.req.method;
|
||||
const headers: Record<string, string> = {};
|
||||
const contentType = ctx.req.header('content-type');
|
||||
if (contentType) headers['Content-Type'] = contentType;
|
||||
// Forward the authenticated user id so the sidecar can serve its Officer-owned routes. The sidecar binds
|
||||
// loopback only, so this header is trusted.
|
||||
headers['X-Officer-User'] = String(ctx.get('user').id);
|
||||
|
||||
const hasBody = method !== 'GET' && method !== 'HEAD';
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(target, {
|
||||
method,
|
||||
headers,
|
||||
body: hasBody ? await ctx.req.arrayBuffer() : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[headscale] proxy fetch failed', { target, error: String(err) });
|
||||
return ctx.text('headscale sidecar unreachable', 502);
|
||||
}
|
||||
|
||||
return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) });
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
|
||||
// The officer-headscale sidecar starts its HTTP server on a random loopback port and reports it here on
|
||||
// connect. We remember it so `/api/headscale/*` always forwards to the current sidecar. The platform holds
|
||||
// NO knowledge of Headscale itself — not its URL, and emphatically not its admin API key.
|
||||
|
||||
let serverPort: number | null = null;
|
||||
|
||||
sidecar.on('headscale:server', (msg) => {
|
||||
const port = (msg as { port?: number }).port;
|
||||
if (typeof port !== 'number') return;
|
||||
serverPort = port;
|
||||
console.log(`[headscale] sidecar registered on port ${port}`);
|
||||
});
|
||||
|
||||
/** Base URL of the sidecar's HTTP server, or null if the sidecar hasn't reported in yet. */
|
||||
export function getHeadscaleServerUrl(): string | null {
|
||||
return serverPort ? `http://127.0.0.1:${serverPort}` : null;
|
||||
}
|
||||
@@ -22,12 +22,14 @@ import { router as fileBrowserRouter } from './api/file-browser/router';
|
||||
import { musicRouter } from './api/music/router';
|
||||
import { vaultRouter } from './api/vault/router';
|
||||
import { slskdRouter } from './api/slskd/router';
|
||||
import { headscaleRouter } from './api/headscale/router';
|
||||
import { vpnRouter } from './api/vpn/router';
|
||||
import { systemMonitorRouter } from './api/system-monitor/system-monitor';
|
||||
import { activityRouter } from './api/activity/router';
|
||||
import './api/music/sidecar-server'; // side-effect: capture the officer-music audio server port
|
||||
import './api/vault/sidecar-server'; // side-effect: capture the officer-vault reverse-proxy port
|
||||
import './api/slskd/sidecar-server'; // side-effect: capture the officer-slskd reverse-proxy port
|
||||
import './api/headscale/sidecar-server'; // side-effect: capture the officer-headscale server port
|
||||
import { devServerRouter, devServerProxyRouter } from './api/dev-server/router';
|
||||
import { dockRouter } from './api/dock/dock';
|
||||
import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations';
|
||||
@@ -110,6 +112,7 @@ protectedRouter.route('/task-logs', taskLogsRouter);
|
||||
protectedRouter.route('/file-browser', fileBrowserRouter);
|
||||
protectedRouter.route('/music', musicRouter);
|
||||
protectedRouter.route('/slskd', slskdRouter);
|
||||
protectedRouter.route('/headscale', headscaleRouter);
|
||||
protectedRouter.route('/vpn', vpnRouter);
|
||||
protectedRouter.route('/system-monitor', systemMonitorRouter);
|
||||
protectedRouter.route('/activity', activityRouter);
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { HeadscaleServerCredentials } from 'officerdb';
|
||||
|
||||
// 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,
|
||||
) {
|
||||
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.
|
||||
throw new HeadscaleError(res.status >= 500 ? 502 : res.status, res.status >= 500 ? 'headscale error' : 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,123 @@
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import { handleOfficerRoute } from './routes';
|
||||
import { MIN_VERSION_LABEL } from './version';
|
||||
|
||||
// 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.
|
||||
// (Those two vars belong solely to the unrelated /api/vpn/enroll route, which is none of our business.)
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// 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?
|
||||
// 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.
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||
|
||||
/** 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,51 @@
|
||||
import { HeadscaleError } from './client';
|
||||
import { handleServersRoute } from './servers';
|
||||
|
||||
// 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 });
|
||||
|
||||
/**
|
||||
* 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));
|
||||
// Domain routes (nodes, users, preauthkeys) land here, each operating against the active server.
|
||||
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,189 @@
|
||||
import type { OfficerContext } from './routes';
|
||||
import {
|
||||
listHeadscaleServers,
|
||||
createHeadscaleServer,
|
||||
updateHeadscaleServer,
|
||||
setActiveHeadscaleServer,
|
||||
deleteHeadscaleServer,
|
||||
getHeadscaleCredentials,
|
||||
recordHeadscaleProbe,
|
||||
} from 'officerdb';
|
||||
import { createClient, HeadscaleError } from './client';
|
||||
import { probeVersion, MIN_VERSION_LABEL } from './version';
|
||||
import { badRequest, notFound, methodNotAllowed } from './routes';
|
||||
|
||||
// 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;
|
||||
|
||||
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,
|
||||
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;
|
||||
|
||||
// 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 });
|
||||
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,76 @@
|
||||
// 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) };
|
||||
}
|
||||
@@ -68,6 +68,8 @@ export type SidecarEvent =
|
||||
| { type: 'vault:server'; port: number }
|
||||
// slskd — the sidecar reports where its slskd reverse-proxy HTTP server is listening (random port) on connect
|
||||
| { type: 'slskd:server'; port: number }
|
||||
// Headscale — the sidecar reports where its HTTP server is listening (random port) on connect
|
||||
| { type: 'headscale:server'; port: number }
|
||||
// Generic
|
||||
| { type: 'error'; id?: string; error: string };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user