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:
2026-07-30 14:55:32 +00:00
co-authored by Claude Opus 5
parent 1e3dff27f5
commit adf922de30
31 changed files with 1678 additions and 1 deletions
+1
View File
@@ -42,6 +42,7 @@ export function App() {
<Route path="/files" element={<Dashboard.FilesScreen />} />
<Route path="/music" element={<Dashboard.MusicScreen />} />
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
<Route path="/headscale" element={<Dashboard.HeadscaleScreen />} />
<Route path="/system-monitor" element={<Dashboard.SystemMonitorScreen />} />
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
@@ -0,0 +1,46 @@
import { useEffect, useMemo } from 'react';
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /headscale uses the Workspace/Panel system (like /soulseek and /music): a section nav (headscale-nav) on
// the left and a section view (headscale-view) on the right, coordinating via the 'headscale:section'
// channel. Both talk to the officer-headscale sidecar through the /api/headscale auth proxy, which holds no
// Headscale credentials of its own — the registered servers and their keys live in the sidecar.
const ALLOWED_APP_TYPES = new Set<string | null>(['headscale-nav', 'headscale-view', null]);
function normalizeLayout(node: LayoutNode): LayoutNode {
if (node.type === 'panel') {
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'headscale-view' };
}
const children = node.children.map((c) => {
const fixed = normalizeLayout(c.node);
return fixed === c.node ? c : { ...c, node: fixed };
});
const changed = children.some((c, i) => c !== node.children[i]);
return changed ? { ...node, children } : node;
}
export const HeadscaleScreen = () => {
const rawWorkspace = useDashboardState<LayoutNode>('screens/headscale', defaultLayout);
const workspace = useMemo(() => {
const fixed = normalizeLayout(rawWorkspace.value);
if (fixed === rawWorkspace.value) return rawWorkspace;
return { ...rawWorkspace, value: fixed };
}, [rawWorkspace]);
useEffect(() => {
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
rawWorkspace.setValue(workspace.value);
}
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
return (
<div className="h-full w-full pt-2">
<WorkspaceView workspace={workspace} locked />
</div>
);
};
@@ -0,0 +1,11 @@
import type { LayoutNode } from 'officerdev';
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'headscale-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'headscale-nav', appType: 'headscale-nav' }, size: 22 },
{ node: { type: 'panel', id: 'headscale-view', appType: 'headscale-view' }, size: 78 },
],
};
@@ -0,0 +1 @@
export * from './HeadscaleScreen';
@@ -134,6 +134,7 @@ import {
Music,
Activity,
Radio,
Network,
} from 'lucide-react';
export const ALL_DOCK_ITEMS: DockItem[] = [
@@ -143,6 +144,7 @@ export const ALL_DOCK_ITEMS: DockItem[] = [
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
{ label: 'Music', to: '/music', icon: Music, color: '#22c55e' },
{ label: 'Soulseek', to: '/soulseek', image: '/slskd.png', color: '#ffffff' },
{ label: 'Headscale', to: '/headscale', icon: Network, color: '#818cf8' },
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
@@ -12,6 +12,7 @@ export * from './Tasks';
export * from './Files';
export * from './Music';
export * from './Soulseek';
export * from './Headscale';
export * from './SystemMonitor';
export * from './Activity';
export * from './CodeEditor';
@@ -17,6 +17,7 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/files'), title: 'Files' },
{ match: (p) => p.startsWith('/music'), title: 'Music' },
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
{ match: (p) => p.startsWith('/headscale'), title: 'Headscale' },
{ match: (p) => p.startsWith('/system-monitor'), title: 'System Monitor' },
{ match: (p) => p.startsWith('/code-editor'), title: 'Code Editor' },
{ match: (p) => p.startsWith('/task-logs'), title: 'Task Logs' },
+11
View File
@@ -130,6 +130,17 @@ export type {
BrowseTreeSearch,
SoulseekBrowseSnapshot,
} from './queries/soulseek';
export {
listHeadscaleServers,
getActiveHeadscaleCredentials,
getHeadscaleCredentials,
createHeadscaleServer,
updateHeadscaleServer,
setActiveHeadscaleServer,
deleteHeadscaleServer,
recordHeadscaleProbe,
} from './queries/headscale';
export type { HeadscaleServer, HeadscaleServerCredentials } from './queries/headscale';
export {
getVaultTokens,
setVaultTokens,
@@ -0,0 +1,178 @@
import { eq, and, desc } from 'drizzle-orm';
import { db } from '../db';
import { headscaleServers } from '../schema';
import { encryptSecret, decryptSecret } from '../crypto';
// Headscale server registry access for the officer-headscale sidecar. Callers deal in PLAINTEXT —
// encryption to/from at-rest ciphertext happens here, so the sidecar's route handlers never touch crypto.
// See ../crypto.ts and ../schema/headscale.ts.
//
// Two return types on purpose:
// HeadscaleServer — safe to serialize to the browser. Has NO api key field at all.
// HeadscaleServerCredentials — url + decrypted key, for the sidecar's own upstream calls. Never returned
// by a route handler.
// The `serverCols` projection is what enforces that: `select()` without it would leak the ciphertext column
// into every list response the moment someone forgot to strip it.
export type HeadscaleServer = {
id: number;
name: string;
url: string;
version: string | null;
isActive: boolean;
lastSeenAt: Date | null;
createdAt: Date;
};
export type HeadscaleServerCredentials = { id: number; name: string; url: string; apiKey: string };
const serverCols = {
id: headscaleServers.id,
name: headscaleServers.name,
url: headscaleServers.url,
version: headscaleServers.version,
isActive: headscaleServers.isActive,
lastSeenAt: headscaleServers.lastSeenAt,
createdAt: headscaleServers.createdAt,
};
/** Every server the owner has registered, active first then newest. Never includes the API key. */
export async function listHeadscaleServers(userId: number): Promise<HeadscaleServer[]> {
return db
.select(serverCols)
.from(headscaleServers)
.where(eq(headscaleServers.userId, userId))
.orderBy(desc(headscaleServers.isActive), desc(headscaleServers.createdAt));
}
/** The currently selected server with its key decrypted, or null when none is registered/active. */
export async function getActiveHeadscaleCredentials(userId: number): Promise<HeadscaleServerCredentials | null> {
const [row] = await db
.select()
.from(headscaleServers)
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.isActive, true)));
if (!row) return null;
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret(row.apiKey) };
}
/** One server's credentials by id — for probing a specific server rather than the active one. */
export async function getHeadscaleCredentials(userId: number, id: number): Promise<HeadscaleServerCredentials | null> {
const [row] = await db
.select()
.from(headscaleServers)
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)));
if (!row) return null;
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret(row.apiKey) };
}
type CreateHeadscaleServerParams = {
userId: number;
name: string;
url: string;
apiKey: string;
version: string | null;
/** Make it the active server. True for the first registration, so the UI is never left with none selected. */
activate: boolean;
};
/** Register a server. The key is encrypted before write; the returned row carries no key. */
export async function createHeadscaleServer(params: CreateHeadscaleServerParams): Promise<HeadscaleServer> {
const { userId, name, url, apiKey, version, activate } = params;
return db.transaction(async (tx) => {
if (activate) {
await tx
.update(headscaleServers)
.set({ isActive: false, updatedAt: new Date() })
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.isActive, true)));
}
const [row] = await tx
.insert(headscaleServers)
.values({
userId,
name,
url,
apiKey: encryptSecret(apiKey),
version,
isActive: activate,
lastSeenAt: version ? new Date() : null,
})
.returning(serverCols);
return row!;
});
}
type UpdateHeadscaleServerParams = { name?: string; url?: string; apiKey?: string };
/** Edit a registration. Omitted fields are left alone; a supplied key is re-encrypted. */
export async function updateHeadscaleServer(
userId: number,
id: number,
params: UpdateHeadscaleServerParams,
): Promise<HeadscaleServer | null> {
const set: Record<string, unknown> = { updatedAt: new Date() };
if (params.name !== undefined) set.name = params.name;
if (params.url !== undefined) set.url = params.url;
if (params.apiKey !== undefined) set.apiKey = encryptSecret(params.apiKey);
const [row] = await db
.update(headscaleServers)
.set(set)
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)))
.returning(serverCols);
return row ?? null;
}
/** Select a server. Clearing the others first keeps the one-active partial index satisfied. */
export async function setActiveHeadscaleServer(userId: number, id: number): Promise<HeadscaleServer | null> {
return db.transaction(async (tx) => {
await tx
.update(headscaleServers)
.set({ isActive: false, updatedAt: new Date() })
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.isActive, true)));
const [row] = await tx
.update(headscaleServers)
.set({ isActive: true, updatedAt: new Date() })
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)))
.returning(serverCols);
return row ?? null;
});
}
/**
* Drop a registration. If it was the active one, the newest survivor is promoted — otherwise deleting the
* active server would leave the UI with servers registered but none selected, which reads as "not
* configured" and is a confusing place to land.
*/
export async function deleteHeadscaleServer(userId: number, id: number): Promise<boolean> {
return db.transaction(async (tx) => {
const [deleted] = await tx
.delete(headscaleServers)
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)))
.returning({ id: headscaleServers.id, wasActive: headscaleServers.isActive });
if (!deleted) return false;
if (deleted.wasActive) {
const [next] = await tx
.select({ id: headscaleServers.id })
.from(headscaleServers)
.where(eq(headscaleServers.userId, userId))
.orderBy(desc(headscaleServers.createdAt))
.limit(1);
if (next) {
await tx
.update(headscaleServers)
.set({ isActive: true, updatedAt: new Date() })
.where(eq(headscaleServers.id, next.id));
}
}
return true;
});
}
/** Record a successful reachability probe: the version observed and when we last reached the server. */
export async function recordHeadscaleProbe(userId: number, id: number, version: string | null): Promise<void> {
await db
.update(headscaleServers)
.set({ version, lastSeenAt: new Date(), updatedAt: new Date() })
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)));
}
@@ -0,0 +1,49 @@
import { pgTable, serial, integer, text, boolean, timestamp, unique, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from './auth';
// The Headscale servers the owner manages, for the officer-headscale sidecar. Officer targets no single
// Headscale: the owner registers one or more servers (URL + an admin API key generated on that server) and
// toggles between them, so this is configuration the user creates at runtime rather than env vars.
//
// `api_key` is a Headscale *admin* credential — it can delete every node on a tailnet — so it is encrypted
// at rest via ../crypto.ts, exactly like the vault token set. Encryption/decryption is confined to
// queries/headscale.ts; nothing outside that file ever sees ciphertext, and list callers never see the key
// at all. SECURITY_AUDIT.md L2 records plaintext credential storage as an open finding, so the plaintext
// email/integrations tables are debt to avoid copying, not a precedent to follow.
//
// Every table here is `headscale_`-prefixed and this file holds nothing else: when sidecars own their own
// schema it moves wholesale into src/servers/sidecar/headscale/ with no untangling. Only the
// officer-headscale sidecar reads or writes these tables.
export const headscaleServers = pgTable(
'headscale_servers',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
// Normalized without a trailing slash before write, so `${url}/api/v1/...` never doubles the separator.
url: text('url').notNull(),
apiKey: text('api_key').notNull(), // encrypted
// Last version seen from the server's unauthenticated GET /version. Null until first probed; the
// literal 'dev' when the server was built without VCS info, which is unknown rather than too-old.
version: text('version'),
isActive: boolean('is_active').notNull().default(false),
// Last successful probe, so the UI can distinguish "never reached" from "was reachable, now isn't".
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
// One registration per URL — re-registering the same server should be an edit, not a duplicate.
unique('uq_headscale_servers_user_url').on(t.userId, t.url),
// At most one active server per owner, enforced by the DB rather than by convention: a partial unique
// index over the active rows only. setActiveHeadscaleServer still clears the others in a transaction,
// but a bug there fails loudly here instead of silently leaving two servers active.
uniqueIndex('uq_headscale_servers_one_active')
.on(t.userId)
.where(sql`${t.isActive}`),
],
);
@@ -8,4 +8,5 @@ export * from './pipeline-jobs';
export * from './chat-events';
export * from './music';
export * from './soulseek';
export * from './headscale';
export * from './vault';
+50
View File
@@ -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;
}
+3
View File
@@ -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);
+103
View File
@@ -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 };
}
+123
View File
@@ -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'));
+51
View File
@@ -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;
}
}
+189
View File
@@ -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]);
}
+76
View File
@@ -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.230.28 would mean
// carrying several incompatible data models; refusing them at registration time costs one probe.
//
// Detection uses the server's own unauthenticated `GET /version`, which exists in 0.28 and 0.29 and sits at
// the root — NOT under /api/v1, and not behind the bearer middleware. Do not confuse it with the three
// other similarly-named endpoints: `GET /health` (root, unauthenticated, `{status:'pass'}`) and
// `GET /api/v1/health` (authenticated, `{databaseConnectivity:true}`) carry no version at all.
export const MIN_MAJOR = 0;
export const MIN_MINOR = 29;
export const MIN_VERSION_LABEL = '0.29';
const PROBE_TIMEOUT_MS = 8000;
export type VersionProbe =
| { ok: true; version: string; supported: true }
/** Reached the server but can't judge the version — self-built images report the literal 'dev'. */
| { ok: true; version: string; supported: 'unknown' }
| { ok: true; version: string; supported: false }
| { ok: false; error: string };
/** `major.minor` from a Headscale version string, or null when it isn't semver (e.g. the literal 'dev'). */
export function parseVersion(raw: string): { major: number; minor: number } | null {
const m = raw.trim().replace(/^v/, '').match(/^(\d+)\.(\d+)/);
if (!m) return null;
return { major: Number(m[1]), minor: Number(m[2]) };
}
/** Whether a parsed version is at or above the supported floor. */
export function meetsFloor(v: { major: number; minor: number }): boolean {
if (v.major !== MIN_MAJOR) return v.major > MIN_MAJOR;
return v.minor >= MIN_MINOR;
}
/**
* Probe a base URL's `GET /version`. Unauthenticated, so this also doubles as the reachability check during
* registration — it tells us "is there a Headscale here at all" before we bother validating a key.
*
* An unparseable version is reported as `supported: 'unknown'` rather than rejected: a server built without
* VCS build info reports 'dev', and refusing those would lock out legitimately self-built deployments.
*/
export async function probeVersion(baseUrl: string): Promise<VersionProbe> {
let res: Response;
try {
res = await fetch(`${baseUrl}/version`, {
headers: { accept: 'application/json' },
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
});
} catch {
return { ok: false, error: 'server unreachable' };
}
if (!res.ok) {
// A Headscale that answers /version with a non-2xx isn't one we can identify. Most often this is a URL
// pointing at a reverse proxy or an unrelated service rather than at Headscale itself.
return { ok: false, error: `GET /version returned ${res.status} — is this a Headscale server?` };
}
let version: string;
try {
const body = (await res.json()) as { version?: unknown };
if (typeof body.version !== 'string' || !body.version) return { ok: false, error: 'no version in response' };
version = body.version;
} catch {
return { ok: false, error: 'GET /version did not return JSON' };
}
const parsed = parseVersion(version);
if (!parsed) return { ok: true, version, supported: 'unknown' };
return { ok: true, version, supported: meetsFloor(parsed) };
}
+2
View File
@@ -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 };
@@ -11,6 +11,7 @@ import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
import { appRegistryMetas as musicMetas } from '../apps/Music';
import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek';
import { appRegistryMetas as headscaleMetas } from '../apps/Headscale';
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
import { useAppRegistry } from './useAppRegistry';
import { useUserApps } from 'state/useUserApps';
@@ -18,7 +19,22 @@ import { createUserAppPanel } from '../apps/UserApp/UserAppPanel';
import { createUserAppHeader } from '../apps/UserApp/UserAppHeader';
import { resolveIcon } from '../utils/resolve-icon';
const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...dashboardMetas, ...chatHistoryMetas, ...previewMetas, ...widgetMetas, ...desktopMetas, ...musicMetas, ...soulseekMetas, ...monitorMetas];
const apps = [
...fileBrowserMetas,
...terminalMetas,
...codeEditorMetas,
...chatMetas,
...fileViewerMetas,
...dashboardMetas,
...chatHistoryMetas,
...previewMetas,
...widgetMetas,
...desktopMetas,
...musicMetas,
...soulseekMetas,
...headscaleMetas,
...monitorMetas,
];
export const AppRegistry = () => {
const { registerApp } = useAppRegistry(apps);
@@ -0,0 +1,103 @@
import type { ReactNode } from 'react';
// Shared visual language for the /headscale panels, matching the /soulseek grouped views: almost-black
// cards on hairline white borders. Kept local to the app so the look changes in one place.
export const Card = ({ children }: { children: ReactNode }) => (
<div className="overflow-hidden rounded-xl border border-white/10 bg-zinc-950 shadow-sm">{children}</div>
);
export const SectionHeader = ({
title,
subtitle,
action,
}: {
title: string;
subtitle?: string;
action?: ReactNode;
}) => (
<div className="flex items-start justify-between gap-4 px-1 pb-3">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-zinc-100">{title}</h2>
{subtitle && <p className="mt-0.5 text-xs text-zinc-500">{subtitle}</p>}
</div>
{action && <div className="shrink-0">{action}</div>}
</div>
);
type ButtonProps = {
children: ReactNode;
onClick?: () => void;
type?: 'button' | 'submit';
variant?: 'primary' | 'ghost' | 'danger';
disabled?: boolean;
title?: string;
};
const VARIANTS: Record<NonNullable<ButtonProps['variant']>, string> = {
primary: 'border-primary/40 bg-primary/15 text-primary hover:bg-primary/25',
ghost: 'border-white/10 bg-white/[0.02] text-zinc-300 hover:bg-white/10 hover:text-zinc-100',
danger: 'border-red-500/30 bg-red-500/10 text-red-300 hover:bg-red-500/20',
};
export const Button = ({ children, onClick, type = 'button', variant = 'ghost', disabled, title }: ButtonProps) => (
<button
type={type}
onClick={onClick}
disabled={disabled}
title={title}
className={`inline-flex cursor-pointer items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-medium transition-colors disabled:cursor-default disabled:opacity-40 ${VARIANTS[variant]}`}
>
{children}
</button>
);
type FieldProps = {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
hint?: string;
type?: 'text' | 'password';
autoFocus?: boolean;
};
export const Field = ({ label, value, onChange, placeholder, hint, type = 'text', autoFocus }: FieldProps) => (
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-zinc-400">{label}</span>
<input
type={type}
value={value}
onChange={(ev) => onChange(ev.target.value)}
placeholder={placeholder}
autoFocus={autoFocus}
spellCheck={false}
autoComplete="off"
className="rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 focus:border-primary/50"
/>
{hint && <span className="text-[11px] leading-snug text-zinc-600">{hint}</span>}
</label>
);
/** Tiny status light: green healthy, amber unknown/unverified, red failing. */
export const Dot = ({ tone }: { tone: 'ok' | 'warn' | 'bad' | 'idle' }) => {
const color =
tone === 'ok' ? 'bg-emerald-400' : tone === 'warn' ? 'bg-amber-400' : tone === 'bad' ? 'bg-red-400' : 'bg-zinc-600';
return <span className={`inline-block h-2 w-2 shrink-0 rounded-full ${color}`} />;
};
export const Badge = ({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'neutral' | 'active' }) => (
<span
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium ${
tone === 'active' ? 'border-primary/40 bg-primary/10 text-primary' : 'border-white/10 text-zinc-400'
}`}
>
{children}
</span>
);
export const ErrorNote = ({ children }: { children: ReactNode }) => (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs leading-snug text-red-300">
{children}
</div>
);
@@ -0,0 +1,95 @@
import type { LucideIcon } from 'lucide-react';
import { Network, Server, Laptop, Users, KeyRound, Check } from 'lucide-react';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { HEADSCALE_SECTION_CHANNEL, HEADSCALE_SECTIONS, type HeadscaleSectionId } from './shared';
import { useHeadscaleServers } from './useHeadscaleServers';
// Left panel of the /headscale workspace: the active-server switcher on top, sections below. Publishes the
// selected section on 'headscale:section'; HeadscaleView (right) renders the matching UI.
//
// Switching servers is the primary action here rather than a buried setting — the owner runs several
// control servers and every other section is scoped to whichever is active.
const ICONS: Record<HeadscaleSectionId, LucideIcon> = {
servers: Server,
nodes: Laptop,
users: Users,
keys: KeyRound,
};
export const HeadscaleNav = () => {
const [section, setSection] = usePanelChannel<HeadscaleSectionId>(HEADSCALE_SECTION_CHANNEL, 'servers');
const { servers, active, activate } = useHeadscaleServers();
return (
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
<div className="flex items-center gap-3 px-4 py-4">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-indigo-500/15 text-indigo-400 ring-1 ring-black/5">
<Network className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="truncate text-sm font-semibold leading-tight">Headscale</div>
<div className="truncate text-xs text-muted-foreground">{active ? active.name : 'no server'}</div>
</div>
</div>
{servers.length > 1 && (
<div className="px-2 pb-3">
<div className="px-3 pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Server
</div>
<div className="flex flex-col gap-0.5">
{servers.map((server) => {
const isActive = server.isActive;
return (
<button
key={server.id}
type="button"
onClick={() => !isActive && activate.mutate(server.id)}
disabled={activate.isPending}
title={server.url}
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-left text-xs transition-colors ${
isActive ? 'bg-muted font-medium text-foreground' : 'text-muted-foreground hover:bg-muted'
}`}
>
<Check className={`h-3.5 w-3.5 shrink-0 ${isActive ? 'text-primary' : 'opacity-0'}`} />
<span className="min-w-0 flex-1 truncate">{server.name}</span>
</button>
);
})}
</div>
</div>
)}
<nav className="flex flex-col gap-0.5 px-2 pb-3">
{HEADSCALE_SECTIONS.map(({ id, label }) => {
const Icon = ICONS[id];
const selected = section === id;
// Without an active server there is nothing for the domain sections to act on.
const disabled = id !== 'servers' && !active;
return (
<button
key={id}
type="button"
onClick={() => setSection(id)}
disabled={disabled}
className={`group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
selected
? 'bg-primary/10 font-medium text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
} ${disabled ? 'cursor-default opacity-40 hover:bg-transparent hover:text-muted-foreground' : ''}`}
>
{selected && (
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />
)}
<Icon
className={`h-4 w-4 shrink-0 ${selected ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
/>
{label}
</button>
);
})}
</nav>
</div>
);
};
@@ -0,0 +1,31 @@
import { Construction } from 'lucide-react';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { HEADSCALE_SECTION_CHANNEL, HEADSCALE_SECTIONS, type HeadscaleSectionId } from './shared';
import { ServersView } from './ServersView';
// Right panel of the /headscale workspace — renders the section the nav selected.
//
// Only `servers` is implemented. Nodes, users and pre-auth keys need the sidecar's domain routes, which
// don't exist yet; they say so plainly rather than rendering an empty table that looks like a broken fetch.
const Placeholder = ({ id }: { id: HeadscaleSectionId }) => {
const label = HEADSCALE_SECTIONS.find((s) => s.id === id)?.label ?? id;
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<Construction className="h-6 w-6" />
</div>
<div>
<div className="text-base font-semibold">{label}</div>
<div className="text-sm text-muted-foreground">Not built yet</div>
</div>
</div>
);
};
export const HeadscaleView = () => {
const [section] = usePanelChannel<HeadscaleSectionId>(HEADSCALE_SECTION_CHANNEL, 'servers');
if (section === 'servers') return <ServersView />;
return <Placeholder id={section} />;
};
@@ -0,0 +1,23 @@
import { Network } from 'lucide-react';
import { useHeadscaleServers } from './useHeadscaleServers';
import { HEADSCALE_SECTION_CHANNEL, HEADSCALE_SECTIONS, type HeadscaleSectionId } from './shared';
import { usePanelChannel } from 'hooks/usePanelChannel';
// Panel header for the right (headscale-view) panel. Shows the section and, crucially, which server it is
// acting on — with several registered, "delete this node" is only safe if the target is unambiguous.
export const HeadscaleViewHeader = () => {
const [section] = usePanelChannel<HeadscaleSectionId>(HEADSCALE_SECTION_CHANNEL, 'servers');
const { active } = useHeadscaleServers();
const label = HEADSCALE_SECTIONS.find((s) => s.id === section)?.label ?? 'Headscale';
return (
<>
<Network className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate text-xs font-medium">
{label}
{active && <span className="ml-1.5 font-normal text-black/50">· {active.name}</span>}
</span>
</>
);
};
@@ -0,0 +1,104 @@
import { useState } from 'react';
import { Loader2 } from 'lucide-react';
import type { HeadscaleServer } from './shared';
import { MIN_HEADSCALE_VERSION } from './shared';
import { useHeadscaleServers, headscaleErrorMessage } from './useHeadscaleServers';
import { Card, Button, Field, ErrorNote } from './Cards';
// Register / edit one Headscale server. The sidecar validates before it saves — reachable, >= 0.29, and the
// key actually accepted — so this form is genuinely slow on submit and genuinely fails. Both are shown:
// a pending state saying what is being checked, and the server's own reason inline on rejection.
//
// On edit the API key field is intentionally blank rather than pre-filled. Officer cannot pre-fill it (the
// key is encrypted at rest and never leaves the sidecar), and leaving it empty means "keep the current key".
type ServerFormProps = { server?: HeadscaleServer | null; onClose: () => void };
export const ServerForm = ({ server, onClose }: ServerFormProps) => {
const { register, update } = useHeadscaleServers();
const editing = !!server;
const [name, setName] = useState(server?.name ?? '');
const [url, setUrl] = useState(server?.url ?? '');
const [apiKey, setApiKey] = useState('');
const [error, setError] = useState<string | null>(null);
const mutation = editing ? update : register;
const pending = mutation.isPending;
const submit = async () => {
setError(null);
if (!url.trim()) return setError('A server URL is required');
if (!editing && !apiKey.trim()) return setError('An API key is required');
try {
if (editing && server) {
// Send only what changed: an unchanged url+key pair skips the sidecar's re-validation round trips.
await update.mutateAsync({
id: server.id,
name: name.trim() || undefined,
url: url.trim() === server.url ? undefined : url.trim(),
apiKey: apiKey.trim() || undefined,
});
} else {
await register.mutateAsync({ name: name.trim() || undefined, url: url.trim(), apiKey: apiKey.trim() });
}
onClose();
} catch (err) {
setError(headscaleErrorMessage(err));
}
};
return (
<Card>
<form
onSubmit={(ev) => {
ev.preventDefault();
void submit();
}}
className="flex flex-col gap-3 p-4"
>
<div className="text-sm font-semibold text-zinc-100">
{editing ? `Edit ${server?.name}` : 'Register a Headscale server'}
</div>
<Field
label="Server URL"
value={url}
onChange={setUrl}
placeholder="https://headscale.example.com"
hint={`The control server's base URL. Officer requires Headscale ${MIN_HEADSCALE_VERSION} or newer.`}
autoFocus={!editing}
/>
<Field
label={editing ? 'API key (leave blank to keep the current one)' : 'API key'}
value={apiKey}
onChange={setApiKey}
type="password"
placeholder="hskey-api-..."
hint="Generate one on the server with `headscale apikeys create`. It is stored encrypted and never leaves Officer."
/>
<Field
label="Name (optional)"
value={name}
onChange={setName}
placeholder="defaults to the hostname"
hint="A label for switching between servers."
/>
{error && <ErrorNote>{error}</ErrorNote>}
<div className="flex items-center gap-2 pt-1">
<Button type="submit" variant="primary" disabled={pending}>
{pending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{pending ? 'Verifying…' : editing ? 'Save changes' : 'Register server'}
</Button>
<Button onClick={onClose} disabled={pending}>
Cancel
</Button>
{pending && <span className="text-[11px] text-zinc-500">Checking the server and the key</span>}
</div>
</form>
</Card>
);
};
@@ -0,0 +1,230 @@
import { useState } from 'react';
import { Plus, Loader2, Server, Check, Activity, Pencil, Trash2 } from 'lucide-react';
import type { HeadscaleServer, HeadscaleHealth } from './shared';
import { MIN_HEADSCALE_VERSION } from './shared';
import { useHeadscaleServers, useHeadscaleHealth, headscaleErrorMessage } from './useHeadscaleServers';
import { Card, SectionHeader, Button, Dot, Badge, ErrorNote } from './Cards';
import { ServerForm } from './ServerForm';
// The servers section — register Headscale servers and switch between them. Exactly one is active at a
// time (a DB invariant, not a UI convention), and every other section in this workspace reads it.
//
// Health is probed on demand only. It costs two upstream round trips (an unauthenticated /version plus an
// authenticated call to prove the key still works), so polling every registered server would be rude to
// servers the owner isn't currently using.
function timeAgo(iso: string): string {
const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
if (!Number.isFinite(seconds)) return 'unknown';
if (seconds < 60) return 'just now';
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.round(hours / 24)}d ago`;
}
type ServerRowProps = {
server: HeadscaleServer;
health: HeadscaleHealth | undefined;
testing: boolean;
busy: boolean;
onActivate: () => void;
onTest: () => void;
onEdit: () => void;
onRemove: () => void;
};
const ServerRow = ({ server, health, testing, busy, onActivate, onTest, onEdit, onRemove }: ServerRowProps) => {
const [confirming, setConfirming] = useState(false);
// Untested servers get a neutral dot, not a green one: we only know the credentials worked at registration.
const tone = health ? (health.ok ? 'ok' : 'bad') : server.isActive ? 'warn' : 'idle';
return (
<Card>
<div className="flex flex-col gap-3 p-3.5">
<div className="flex items-start gap-2.5">
<span className="mt-1.5">
<Dot tone={tone} />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-zinc-100">{server.name}</span>
{server.isActive && <Badge tone="active">Active</Badge>}
</div>
<div className="mt-0.5 truncate text-xs text-zinc-500" title={server.url}>
{server.url}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-zinc-600">
<span>{server.version ? `Headscale ${server.version}` : 'version unknown'}</span>
{server.lastSeenAt && <span>· reached {timeAgo(server.lastSeenAt)}</span>}
{health?.ok && <span className="text-emerald-400/80">· responded in {health.ms}ms</span>}
</div>
</div>
</div>
{health && !health.ok && <ErrorNote>{health.error ?? 'The server did not respond'}</ErrorNote>}
{health?.ok && health.supported === 'unknown' && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] leading-snug text-amber-300">
This server reports its version as {health.version}, so Officer cannot confirm it is{' '}
{MIN_HEADSCALE_VERSION} or newer. Self-built images do this; features may behave unexpectedly on older
builds.
</div>
)}
<div className="flex flex-wrap items-center gap-2">
{!server.isActive && (
<Button variant="primary" onClick={onActivate} disabled={busy}>
<Check className="h-3.5 w-3.5" />
Use this server
</Button>
)}
<Button onClick={onTest} disabled={testing}>
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Activity className="h-3.5 w-3.5" />}
{testing ? 'Testing…' : 'Test'}
</Button>
<Button onClick={onEdit} disabled={busy}>
<Pencil className="h-3.5 w-3.5" />
Edit
</Button>
{confirming ? (
<>
<Button variant="danger" onClick={onRemove} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
Confirm remove
</Button>
<Button onClick={() => setConfirming(false)} disabled={busy}>
Cancel
</Button>
</>
) : (
<Button variant="danger" onClick={() => setConfirming(true)} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
Remove
</Button>
)}
</div>
</div>
</Card>
);
};
export const ServersView = () => {
const { servers, isLoading, error, activate, remove } = useHeadscaleServers();
const healthProbe = useHeadscaleHealth();
const [formFor, setFormFor] = useState<'new' | HeadscaleServer | null>(null);
const [health, setHealth] = useState<Record<number, HeadscaleHealth>>({});
const [testingId, setTestingId] = useState<number | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const test = async (id: number) => {
setTestingId(id);
setActionError(null);
try {
const result = await healthProbe.mutateAsync(id);
setHealth((prev) => ({ ...prev, [id]: result }));
} catch (err) {
setHealth((prev) => ({ ...prev, [id]: { ok: false, error: headscaleErrorMessage(err), ms: 0 } }));
} finally {
setTestingId(null);
}
};
const run = async (fn: () => Promise<unknown>) => {
setActionError(null);
try {
await fn();
} catch (err) {
setActionError(headscaleErrorMessage(err));
}
};
const busy = activate.isPending || remove.isPending;
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading servers
</div>
);
}
if (error) {
return (
<div className="p-4">
<ErrorNote>
Could not reach the Headscale sidecar: {headscaleErrorMessage(error)}. If it is not running, start it with{' '}
<code className="font-mono">pm2 start ecosystem.config.cjs --only officer-headscale</code>.
</ErrorNote>
</div>
);
}
// Empty state doubles as the registration prompt — there is nothing else to do here without a server.
if (servers.length === 0) {
return (
<div className="mx-auto flex h-full w-full max-w-xl flex-col justify-center gap-4 p-6">
{formFor ? (
<ServerForm onClose={() => setFormFor(null)} />
) : (
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/5 text-zinc-400">
<Server className="h-6 w-6" />
</div>
<div>
<div className="text-base font-semibold text-zinc-100">No Headscale servers yet</div>
<p className="mt-1 text-sm text-zinc-500">
Register a server with its URL and an API key to manage its nodes, users and pre-auth keys from here.
Officer supports Headscale {MIN_HEADSCALE_VERSION} and newer.
</p>
</div>
<Button variant="primary" onClick={() => setFormFor('new')}>
<Plus className="h-3.5 w-3.5" />
Register a server
</Button>
</div>
)}
</div>
);
}
return (
<div className="h-full overflow-y-auto p-4">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-3">
<SectionHeader
title="Servers"
subtitle="One server is active at a time; every other section acts on it."
action={
!formFor && (
<Button variant="primary" onClick={() => setFormFor('new')}>
<Plus className="h-3.5 w-3.5" />
Register a server
</Button>
)
}
/>
{formFor && <ServerForm server={formFor === 'new' ? null : formFor} onClose={() => setFormFor(null)} />}
{actionError && <ErrorNote>{actionError}</ErrorNote>}
{servers.map((server) => (
<ServerRow
key={server.id}
server={server}
health={health[server.id]}
testing={testingId === server.id}
busy={busy}
onActivate={() => void run(() => activate.mutateAsync(server.id))}
onTest={() => void test(server.id)}
onEdit={() => setFormFor(server)}
onRemove={() => void run(() => remove.mutateAsync(server.id))}
/>
))}
</div>
</div>
);
};
@@ -0,0 +1,19 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { PanelLeft, LayoutGrid } from 'lucide-react';
import { HeadscaleNav } from './HeadscaleNav';
import { HeadscaleView } from './HeadscaleView';
import { HeadscaleViewHeader } from './HeadscaleViewHeader';
export { HeadscaleNav, HeadscaleView };
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'headscale-nav', name: 'Headscale', icon: PanelLeft, component: HeadscaleNav, availableOnPanel: false },
{
key: 'headscale-view',
name: 'Headscale',
icon: LayoutGrid,
component: HeadscaleView,
header: HeadscaleViewHeader,
availableOnPanel: false,
},
];
@@ -0,0 +1,42 @@
// Shared types/constants for the /headscale workspace panels. Everything here mirrors the wire shapes the
// officer-headscale sidecar returns under /api/headscale/_officer/* — deliberately NOT Headscale's own API
// shapes. The sidecar absorbs Headscale's quirks (uint64-as-string ids, zero-date sentinels, the version
// floor), so these types are stable across Headscale releases and the browser never learns the upstream
// version. See src/servers/sidecar/headscale/routes.ts.
/** Selected section, published by HeadscaleNav and consumed by HeadscaleView. */
export const HEADSCALE_SECTION_CHANNEL = 'headscale:section';
export const HEADSCALE_SECTIONS = [
{ id: 'servers', label: 'Servers' },
{ id: 'nodes', label: 'Nodes' },
{ id: 'users', label: 'Users' },
{ id: 'keys', label: 'Pre-auth keys' },
] as const;
export type HeadscaleSectionId = (typeof HEADSCALE_SECTIONS)[number]['id'];
/** A registered Headscale server. The API key is never included — it stays encrypted in Postgres. */
export type HeadscaleServer = {
id: number;
name: string;
url: string;
version: string | null;
isActive: boolean;
/** ISO string, or null when we have never successfully probed it. */
lastSeenAt: string | null;
createdAt: string;
};
/** Result of GET /_officer/servers/:id/health — reachable AND the stored key still works. */
export type HeadscaleHealth = {
ok: boolean;
version?: string;
/** `'unknown'` for self-built servers reporting the literal 'dev'. */
supported?: boolean | 'unknown';
error?: string;
ms: number;
};
/** Officer's supported floor, restated for UI copy. The sidecar is the enforcer; this is only a label. */
export const MIN_HEADSCALE_VERSION = '0.29';
@@ -0,0 +1,90 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import type { HeadscaleServer, HeadscaleHealth } from './shared';
// The registered-servers cache. Every panel in the /headscale workspace reads this one query, so switching
// the active server anywhere updates the whole screen at once.
//
// Registration is validated server-side before anything is saved (reachable, >=0.29, key accepted), which
// means a POST can fail for perfectly ordinary reasons — a typo'd URL, a revoked key. Those are not
// exceptional here, so the mutations surface their message rather than swallowing it.
const SERVERS_KEY = ['headscale', 'servers'] as const;
const EMPTY: HeadscaleServer[] = [];
const BASE = '/headscale/_officer/servers';
/**
* Readable message from a useClient rejection. It throws `{status, message}` where `message` is the raw
* body text — JSON `{error}` from our sidecar, but plain text from the platform's own 401/503 paths.
*/
export function headscaleErrorMessage(err: unknown): string {
const raw = (err as { message?: unknown } | null)?.message;
if (typeof raw !== 'string' || !raw) return 'Something went wrong';
try {
const parsed = JSON.parse(raw) as { error?: unknown };
if (typeof parsed.error === 'string' && parsed.error) return parsed.error;
} catch {
/* plain text */
}
return raw.slice(0, 300);
}
export type RegisterServerInput = { name?: string; url: string; apiKey: string };
export type UpdateServerInput = { id: number; name?: string; url?: string; apiKey?: string };
export function useHeadscaleServers() {
const { get, post, patch, delete: del } = useClient();
const qc = useQueryClient();
const query = useQuery({
queryKey: SERVERS_KEY,
queryFn: () => get<{ servers: HeadscaleServer[] }>(BASE),
staleTime: 30_000,
});
const invalidate = () => qc.invalidateQueries({ queryKey: SERVERS_KEY });
const register = useMutation({
mutationFn: (input: RegisterServerInput) => post<{ server: HeadscaleServer }>(BASE, input),
onSuccess: invalidate,
});
const update = useMutation({
mutationFn: ({ id, ...rest }: UpdateServerInput) => patch<{ server: HeadscaleServer }>(`${BASE}/${id}`, rest),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: number) => del(`${BASE}/${id}`),
onSuccess: invalidate,
});
const activate = useMutation({
mutationFn: (id: number) => post<{ server: HeadscaleServer }>(`${BASE}/${id}/activate`),
// Deleting or switching reshuffles which server is active, and every domain query is scoped to it.
onSuccess: () => qc.invalidateQueries({ queryKey: ['headscale'] }),
});
const servers = query.data?.servers ?? EMPTY;
return {
servers,
active: servers.find((s) => s.isActive) ?? null,
isLoading: query.isLoading,
error: query.error,
refetch: query.refetch,
register,
update,
remove,
activate,
};
}
/** On-demand reachability probe for one server. Never automatic — it costs two upstream round trips. */
export function useHeadscaleHealth() {
const { get } = useClient();
return useMutation({
mutationFn: (id: number) => get<HeadscaleHealth>(`${BASE}/${id}/health`),
});
}