From adf922de3022a990ce707352d56dec9ff60f658b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 30 Jul 2026 14:55:32 +0000 Subject: [PATCH] add the officer-headscale sidecar and its server registry ui MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ecosystem.config.cjs | 6 + src/apps/officer-web/App.tsx | 1 + .../Dashboard/Headscale/HeadscaleScreen.tsx | 46 ++++ .../Dashboard/Headscale/defaultLayout.ts | 11 + .../Screens/Dashboard/Headscale/index.tsx | 1 + .../Screens/Dashboard/Layout/Dock.tsx | 2 + .../officer-web/Screens/Dashboard/index.tsx | 1 + src/apps/officer-web/state/usePageTitle.ts | 1 + src/databases/officer_db/src/index.ts | 11 + .../officer_db/src/queries/headscale.ts | 178 ++++++++++++++ .../officer_db/src/schema/headscale.ts | 49 ++++ src/databases/officer_db/src/schema/index.ts | 1 + src/servers/api/headscale/router.ts | 50 ++++ src/servers/api/headscale/sidecar-server.ts | 19 ++ src/servers/hono.ts | 3 + src/servers/sidecar/headscale/client.ts | 103 ++++++++ src/servers/sidecar/headscale/index.ts | 123 ++++++++++ src/servers/sidecar/headscale/routes.ts | 51 ++++ src/servers/sidecar/headscale/servers.ts | 189 ++++++++++++++ src/servers/sidecar/headscale/version.ts | 76 ++++++ src/servers/sidecar/protocol.ts | 2 + .../src/AppRegistry/AppRegistry.tsx | 18 +- .../officerdev/src/apps/Headscale/Cards.tsx | 103 ++++++++ .../src/apps/Headscale/HeadscaleNav.tsx | 95 ++++++++ .../src/apps/Headscale/HeadscaleView.tsx | 31 +++ .../apps/Headscale/HeadscaleViewHeader.tsx | 23 ++ .../src/apps/Headscale/ServerForm.tsx | 104 ++++++++ .../src/apps/Headscale/ServersView.tsx | 230 ++++++++++++++++++ .../officerdev/src/apps/Headscale/index.ts | 19 ++ .../officerdev/src/apps/Headscale/shared.ts | 42 ++++ .../src/apps/Headscale/useHeadscaleServers.ts | 90 +++++++ 31 files changed, 1678 insertions(+), 1 deletion(-) create mode 100644 src/apps/officer-web/Screens/Dashboard/Headscale/HeadscaleScreen.tsx create mode 100644 src/apps/officer-web/Screens/Dashboard/Headscale/defaultLayout.ts create mode 100644 src/apps/officer-web/Screens/Dashboard/Headscale/index.tsx create mode 100644 src/databases/officer_db/src/queries/headscale.ts create mode 100644 src/databases/officer_db/src/schema/headscale.ts create mode 100644 src/servers/api/headscale/router.ts create mode 100644 src/servers/api/headscale/sidecar-server.ts create mode 100644 src/servers/sidecar/headscale/client.ts create mode 100644 src/servers/sidecar/headscale/index.ts create mode 100644 src/servers/sidecar/headscale/routes.ts create mode 100644 src/servers/sidecar/headscale/servers.ts create mode 100644 src/servers/sidecar/headscale/version.ts create mode 100644 src/workspaces/officerdev/src/apps/Headscale/Cards.tsx create mode 100644 src/workspaces/officerdev/src/apps/Headscale/HeadscaleNav.tsx create mode 100644 src/workspaces/officerdev/src/apps/Headscale/HeadscaleView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Headscale/HeadscaleViewHeader.tsx create mode 100644 src/workspaces/officerdev/src/apps/Headscale/ServerForm.tsx create mode 100644 src/workspaces/officerdev/src/apps/Headscale/ServersView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Headscale/index.ts create mode 100644 src/workspaces/officerdev/src/apps/Headscale/shared.ts create mode 100644 src/workspaces/officerdev/src/apps/Headscale/useHeadscaleServers.ts 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 }) => ( +
{children}
+); + +export const SectionHeader = ({ + title, + subtitle, + action, +}: { + title: string; + subtitle?: string; + action?: ReactNode; +}) => ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {action &&
{action}
} +
+); + +type ButtonProps = { + children: ReactNode; + onClick?: () => void; + type?: 'button' | 'submit'; + variant?: 'primary' | 'ghost' | 'danger'; + disabled?: boolean; + title?: string; +}; + +const VARIANTS: Record, 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) => ( + +); + +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) => ( + +); + +/** 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 ; +}; + +export const Badge = ({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'neutral' | 'active' }) => ( + + {children} + +); + +export const ErrorNote = ({ children }: { children: ReactNode }) => ( +
+ {children} +
+); diff --git a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleNav.tsx b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleNav.tsx new file mode 100644 index 00000000..e613b675 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleNav.tsx @@ -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 = { + servers: Server, + nodes: Laptop, + users: Users, + keys: KeyRound, +}; + +export const HeadscaleNav = () => { + const [section, setSection] = usePanelChannel(HEADSCALE_SECTION_CHANNEL, 'servers'); + const { servers, active, activate } = useHeadscaleServers(); + + return ( +
+
+
+ +
+
+
Headscale
+
{active ? active.name : 'no server'}
+
+
+ + {servers.length > 1 && ( +
+
+ Server +
+
+ {servers.map((server) => { + const isActive = server.isActive; + return ( + + ); + })} +
+
+ )} + + +
+ ); +}; 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 ( + +
{ + ev.preventDefault(); + void submit(); + }} + className="flex flex-col gap-3 p-4" + > +
+ {editing ? `Edit ${server?.name}` : 'Register a Headscale server'} +
+ + + + + + {error && {error}} + +
+ + + {pending && Checking the server and the key…} +
+ +
+ ); +}; 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 ( + +
+
+ + + +
+
+ {server.name} + {server.isActive && Active} +
+
+ {server.url} +
+
+ {server.version ? `Headscale ${server.version}` : 'version unknown'} + {server.lastSeenAt && · reached {timeAgo(server.lastSeenAt)}} + {health?.ok && · responded in {health.ms}ms} +
+
+
+ + {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. +
+ )} + +
+ {!server.isActive && ( + + )} + + + {confirming ? ( + <> + + + + ) : ( + + )} +
+
+
+ ); +}; + +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>({}); + const [testingId, setTestingId] = useState(null); + const [actionError, setActionError] = useState(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) => { + setActionError(null); + try { + await fn(); + } catch (err) { + setActionError(headscaleErrorMessage(err)); + } + }; + + const busy = activate.isPending || remove.isPending; + + if (isLoading) { + return ( +
+ + Loading servers… +
+ ); + } + + if (error) { + return ( +
+ + 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. +

+
+ +
+ )} +
+ ); + } + + return ( +
+
+ setFormFor('new')}> + + Register a server + + ) + } + /> + + {formFor && setFormFor(null)} />} + + {actionError && {actionError}} + + {servers.map((server) => ( + void run(() => activate.mutateAsync(server.id))} + onTest={() => void test(server.id)} + onEdit={() => setFormFor(server)} + onRemove={() => void run(() => remove.mutateAsync(server.id))} + /> + ))} +
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Headscale/index.ts b/src/workspaces/officerdev/src/apps/Headscale/index.ts new file mode 100644 index 00000000..341849c5 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Headscale/index.ts @@ -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, + }, +]; diff --git a/src/workspaces/officerdev/src/apps/Headscale/shared.ts b/src/workspaces/officerdev/src/apps/Headscale/shared.ts new file mode 100644 index 00000000..d0921f18 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Headscale/shared.ts @@ -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'; diff --git a/src/workspaces/officerdev/src/apps/Headscale/useHeadscaleServers.ts b/src/workspaces/officerdev/src/apps/Headscale/useHeadscaleServers.ts new file mode 100644 index 00000000..a74149e5 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Headscale/useHeadscaleServers.ts @@ -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(`${BASE}/${id}/health`), + }); +}