diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs
index c32430d6..d37f49e6 100644
--- a/ecosystem.config.cjs
+++ b/ecosystem.config.cjs
@@ -70,5 +70,11 @@ module.exports = {
args: 'run src/servers/sidecar/slskd/index.ts',
watch: false,
},
+ {
+ name: 'officer-headscale',
+ script: 'bun',
+ args: 'run src/servers/sidecar/headscale/index.ts',
+ watch: false,
+ },
],
};
diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx
index 7e148735..4fc4c917 100644
--- a/src/apps/officer-web/App.tsx
+++ b/src/apps/officer-web/App.tsx
@@ -42,6 +42,7 @@ export function App() {
} />
} />
} />
+ } />
} />
} />
diff --git a/src/apps/officer-web/Screens/Dashboard/Headscale/HeadscaleScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Headscale/HeadscaleScreen.tsx
new file mode 100644
index 00000000..a1ec0821
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Headscale/HeadscaleScreen.tsx
@@ -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(['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('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 (
+
+
+
+ );
+};
diff --git a/src/apps/officer-web/Screens/Dashboard/Headscale/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Headscale/defaultLayout.ts
new file mode 100644
index 00000000..ac8932b9
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Headscale/defaultLayout.ts
@@ -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 },
+ ],
+};
diff --git a/src/apps/officer-web/Screens/Dashboard/Headscale/index.tsx b/src/apps/officer-web/Screens/Dashboard/Headscale/index.tsx
new file mode 100644
index 00000000..a73ee431
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Headscale/index.tsx
@@ -0,0 +1 @@
+export * from './HeadscaleScreen';
diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx
index 32553afb..cf210bff 100644
--- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx
@@ -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' },
diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx
index cb68a88c..2469eb7f 100644
--- a/src/apps/officer-web/Screens/Dashboard/index.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/index.tsx
@@ -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';
diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts
index 66c78e83..0765b87d 100644
--- a/src/apps/officer-web/state/usePageTitle.ts
+++ b/src/apps/officer-web/state/usePageTitle.ts
@@ -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' },
diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts
index 73b5190a..71bb94c6 100644
--- a/src/databases/officer_db/src/index.ts
+++ b/src/databases/officer_db/src/index.ts
@@ -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,
diff --git a/src/databases/officer_db/src/queries/headscale.ts b/src/databases/officer_db/src/queries/headscale.ts
new file mode 100644
index 00000000..5e750496
--- /dev/null
+++ b/src/databases/officer_db/src/queries/headscale.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ const set: Record = { 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 {
+ 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 {
+ 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 {
+ await db
+ .update(headscaleServers)
+ .set({ version, lastSeenAt: new Date(), updatedAt: new Date() })
+ .where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)));
+}
diff --git a/src/databases/officer_db/src/schema/headscale.ts b/src/databases/officer_db/src/schema/headscale.ts
new file mode 100644
index 00000000..ce333771
--- /dev/null
+++ b/src/databases/officer_db/src/schema/headscale.ts
@@ -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}`),
+ ],
+);
diff --git a/src/databases/officer_db/src/schema/index.ts b/src/databases/officer_db/src/schema/index.ts
index 5c6c2704..9fe2ebd4 100644
--- a/src/databases/officer_db/src/schema/index.ts
+++ b/src/databases/officer_db/src/schema/index.ts
@@ -8,4 +8,5 @@ export * from './pipeline-jobs';
export * from './chat-events';
export * from './music';
export * from './soulseek';
+export * from './headscale';
export * from './vault';
diff --git a/src/servers/api/headscale/router.ts b/src/servers/api/headscale/router.ts
new file mode 100644
index 00000000..09e30623
--- /dev/null
+++ b/src/servers/api/headscale/router.ts
@@ -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 = {};
+ 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) });
+});
diff --git a/src/servers/api/headscale/sidecar-server.ts b/src/servers/api/headscale/sidecar-server.ts
new file mode 100644
index 00000000..8fd9258d
--- /dev/null
+++ b/src/servers/api/headscale/sidecar-server.ts
@@ -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;
+}
diff --git a/src/servers/hono.ts b/src/servers/hono.ts
index 6f2d4322..a647dd74 100644
--- a/src/servers/hono.ts
+++ b/src/servers/hono.ts
@@ -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);
diff --git a/src/servers/sidecar/headscale/client.ts b/src/servers/sidecar/headscale/client.ts
new file mode 100644
index 00000000..94dc7c70
--- /dev/null
+++ b/src/servers/sidecar/headscale/client.ts
@@ -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 `. 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 {
+ 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: (path: string, opts?: CallOptions) => Promise;
+};
+
+/** Build a client bound to one registered server's credentials. */
+export function createClient(creds: HeadscaleServerCredentials): HeadscaleClient {
+ async function call(path: string, opts: CallOptions = {}): Promise {
+ const { method = 'GET', body, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
+
+ const headers: Record = {
+ 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 };
+}
diff --git a/src/servers/sidecar/headscale/index.ts b/src/servers/sidecar/headscale/index.ts
new file mode 100644
index 00000000..1480f9e4
--- /dev/null
+++ b/src/servers/sidecar/headscale/index.ts
@@ -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).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'));
diff --git a/src/servers/sidecar/headscale/routes.ts b/src/servers/sidecar/headscale/routes.ts
new file mode 100644
index 00000000..c33102d5
--- /dev/null
+++ b/src/servers/sidecar/headscale/routes.ts
@@ -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 {
+ 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;
+ }
+}
diff --git a/src/servers/sidecar/headscale/servers.ts b/src/servers/sidecar/headscale/servers.ts
new file mode 100644
index 00000000..cb514206
--- /dev/null
+++ b/src/servers/sidecar/headscale/servers.ts
@@ -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 {
+ 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 {
+ 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 | 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 {
+ 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 | 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 {
+ 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]);
+}
diff --git a/src/servers/sidecar/headscale/version.ts b/src/servers/sidecar/headscale/version.ts
new file mode 100644
index 00000000..53123669
--- /dev/null
+++ b/src/servers/sidecar/headscale/version.ts
@@ -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 {
+ 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) };
+}
diff --git a/src/servers/sidecar/protocol.ts b/src/servers/sidecar/protocol.ts
index 21d94757..82b9dfeb 100644
--- a/src/servers/sidecar/protocol.ts
+++ b/src/servers/sidecar/protocol.ts
@@ -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 };
diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx
index 68d0e55b..0556bad0 100644
--- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx
+++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx
@@ -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);
diff --git a/src/workspaces/officerdev/src/apps/Headscale/Cards.tsx b/src/workspaces/officerdev/src/apps/Headscale/Cards.tsx
new file mode 100644
index 00000000..a9f9fe52
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/Headscale/Cards.tsx
@@ -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 }) => (
+
+ );
+};
diff --git a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleView.tsx b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleView.tsx
new file mode 100644
index 00000000..347d3f3f
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleView.tsx
@@ -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 (
+
+
+
+
+
+
{label}
+
Not built yet
+
+
+ );
+};
+
+export const HeadscaleView = () => {
+ const [section] = usePanelChannel(HEADSCALE_SECTION_CHANNEL, 'servers');
+
+ if (section === 'servers') return ;
+ return ;
+};
diff --git a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleViewHeader.tsx b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleViewHeader.tsx
new file mode 100644
index 00000000..ac57c2a2
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleViewHeader.tsx
@@ -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(HEADSCALE_SECTION_CHANNEL, 'servers');
+ const { active } = useHeadscaleServers();
+ const label = HEADSCALE_SECTIONS.find((s) => s.id === section)?.label ?? 'Headscale';
+
+ return (
+ <>
+
+
+ {label}
+ {active && · {active.name}}
+
+ >
+ );
+};
diff --git a/src/workspaces/officerdev/src/apps/Headscale/ServerForm.tsx b/src/workspaces/officerdev/src/apps/Headscale/ServerForm.tsx
new file mode 100644
index 00000000..07b1e5a5
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/Headscale/ServerForm.tsx
@@ -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(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 (
+
+
+
+ );
+};
diff --git a/src/workspaces/officerdev/src/apps/Headscale/ServersView.tsx b/src/workspaces/officerdev/src/apps/Headscale/ServersView.tsx
new file mode 100644
index 00000000..adca325e
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/Headscale/ServersView.tsx
@@ -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 (
+
+
+
+ {health && !health.ok && {health.error ?? 'The server did not respond'}}
+ {health?.ok && health.supported === 'unknown' && (
+
+ 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.
+
+
+ Could not reach the Headscale sidecar: {headscaleErrorMessage(error)}. If it is not running, start it with{' '}
+ pm2 start ecosystem.config.cjs --only officer-headscale.
+
+
+ );
+ }
+
+ // Empty state doubles as the registration prompt — there is nothing else to do here without a server.
+ if (servers.length === 0) {
+ return (
+
+ {formFor ? (
+ setFormFor(null)} />
+ ) : (
+
+
+
+
+
+
No Headscale servers yet
+
+ 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.
+