add the officer-headscale sidecar and its server registry ui
officer-headscale owns the whole Headscale contract: the registered servers and their admin api keys, the >=0.29 version floor, and every multi-call composition the ui needs. the platform side is auth+forward only and holds no headscale credentials, so the existing /api/vpn/enroll route and its HEADSCALE_* env vars are untouched and unrelated. officer manages many servers rather than one. the owner registers each with a url and a key generated on that server and switches between them; exactly one is active, enforced by a partial unique index rather than by convention. keys are encrypted at rest and never leave the sidecar — the list projection cannot return one. registration validates before it saves: an unauthenticated GET /version to prove something headscale-shaped is there and meets the floor, then an authenticated call to prove the key works. an edit that moves either half re-validates. there is deliberately no transparent /api/v1/* passthrough. headscale serialises every uint64 as a json string and its rest shape moved repeatedly below 0.29; proxying raw would push all of that into the browser, which is the mistake the soulseek panels made with 37 raw upstream calls. the /headscale workspace is nav + view over the panel system. only the servers section is implemented — nodes, users and pre-auth keys say so plainly rather than rendering an empty table that reads as a failed fetch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -42,6 +42,7 @@ export function App() {
|
||||
<Route path="/files" element={<Dashboard.FilesScreen />} />
|
||||
<Route path="/music" element={<Dashboard.MusicScreen />} />
|
||||
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
|
||||
<Route path="/headscale" element={<Dashboard.HeadscaleScreen />} />
|
||||
<Route path="/system-monitor" element={<Dashboard.SystemMonitorScreen />} />
|
||||
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /headscale uses the Workspace/Panel system (like /soulseek and /music): a section nav (headscale-nav) on
|
||||
// the left and a section view (headscale-view) on the right, coordinating via the 'headscale:section'
|
||||
// channel. Both talk to the officer-headscale sidecar through the /api/headscale auth proxy, which holds no
|
||||
// Headscale credentials of its own — the registered servers and their keys live in the sidecar.
|
||||
|
||||
const ALLOWED_APP_TYPES = new Set<string | null>(['headscale-nav', 'headscale-view', null]);
|
||||
|
||||
function normalizeLayout(node: LayoutNode): LayoutNode {
|
||||
if (node.type === 'panel') {
|
||||
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'headscale-view' };
|
||||
}
|
||||
const children = node.children.map((c) => {
|
||||
const fixed = normalizeLayout(c.node);
|
||||
return fixed === c.node ? c : { ...c, node: fixed };
|
||||
});
|
||||
const changed = children.some((c, i) => c !== node.children[i]);
|
||||
return changed ? { ...node, children } : node;
|
||||
}
|
||||
|
||||
export const HeadscaleScreen = () => {
|
||||
const rawWorkspace = useDashboardState<LayoutNode>('screens/headscale', defaultLayout);
|
||||
|
||||
const workspace = useMemo(() => {
|
||||
const fixed = normalizeLayout(rawWorkspace.value);
|
||||
if (fixed === rawWorkspace.value) return rawWorkspace;
|
||||
return { ...rawWorkspace, value: fixed };
|
||||
}, [rawWorkspace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
|
||||
rawWorkspace.setValue(workspace.value);
|
||||
}
|
||||
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceView workspace={workspace} locked />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'headscale-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'headscale-nav', appType: 'headscale-nav' }, size: 22 },
|
||||
{ node: { type: 'panel', id: 'headscale-view', appType: 'headscale-view' }, size: 78 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './HeadscaleScreen';
|
||||
@@ -134,6 +134,7 @@ import {
|
||||
Music,
|
||||
Activity,
|
||||
Radio,
|
||||
Network,
|
||||
} from 'lucide-react';
|
||||
|
||||
export const ALL_DOCK_ITEMS: DockItem[] = [
|
||||
@@ -143,6 +144,7 @@ export const ALL_DOCK_ITEMS: DockItem[] = [
|
||||
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
|
||||
{ label: 'Music', to: '/music', icon: Music, color: '#22c55e' },
|
||||
{ label: 'Soulseek', to: '/soulseek', image: '/slskd.png', color: '#ffffff' },
|
||||
{ label: 'Headscale', to: '/headscale', icon: Network, color: '#818cf8' },
|
||||
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
|
||||
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
|
||||
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
|
||||
|
||||
@@ -12,6 +12,7 @@ export * from './Tasks';
|
||||
export * from './Files';
|
||||
export * from './Music';
|
||||
export * from './Soulseek';
|
||||
export * from './Headscale';
|
||||
export * from './SystemMonitor';
|
||||
export * from './Activity';
|
||||
export * from './CodeEditor';
|
||||
|
||||
@@ -17,6 +17,7 @@ const RULES: TitleRule[] = [
|
||||
{ match: (p) => p.startsWith('/files'), title: 'Files' },
|
||||
{ match: (p) => p.startsWith('/music'), title: 'Music' },
|
||||
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
|
||||
{ match: (p) => p.startsWith('/headscale'), title: 'Headscale' },
|
||||
{ match: (p) => p.startsWith('/system-monitor'), title: 'System Monitor' },
|
||||
{ match: (p) => p.startsWith('/code-editor'), title: 'Code Editor' },
|
||||
{ match: (p) => p.startsWith('/task-logs'), title: 'Task Logs' },
|
||||
|
||||
@@ -130,6 +130,17 @@ export type {
|
||||
BrowseTreeSearch,
|
||||
SoulseekBrowseSnapshot,
|
||||
} from './queries/soulseek';
|
||||
export {
|
||||
listHeadscaleServers,
|
||||
getActiveHeadscaleCredentials,
|
||||
getHeadscaleCredentials,
|
||||
createHeadscaleServer,
|
||||
updateHeadscaleServer,
|
||||
setActiveHeadscaleServer,
|
||||
deleteHeadscaleServer,
|
||||
recordHeadscaleProbe,
|
||||
} from './queries/headscale';
|
||||
export type { HeadscaleServer, HeadscaleServerCredentials } from './queries/headscale';
|
||||
export {
|
||||
getVaultTokens,
|
||||
setVaultTokens,
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { headscaleServers } from '../schema';
|
||||
import { encryptSecret, decryptSecret } from '../crypto';
|
||||
|
||||
// Headscale server registry access for the officer-headscale sidecar. Callers deal in PLAINTEXT —
|
||||
// encryption to/from at-rest ciphertext happens here, so the sidecar's route handlers never touch crypto.
|
||||
// See ../crypto.ts and ../schema/headscale.ts.
|
||||
//
|
||||
// Two return types on purpose:
|
||||
// HeadscaleServer — safe to serialize to the browser. Has NO api key field at all.
|
||||
// HeadscaleServerCredentials — url + decrypted key, for the sidecar's own upstream calls. Never returned
|
||||
// by a route handler.
|
||||
// The `serverCols` projection is what enforces that: `select()` without it would leak the ciphertext column
|
||||
// into every list response the moment someone forgot to strip it.
|
||||
|
||||
export type HeadscaleServer = {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
version: string | null;
|
||||
isActive: boolean;
|
||||
lastSeenAt: Date | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export type HeadscaleServerCredentials = { id: number; name: string; url: string; apiKey: string };
|
||||
|
||||
const serverCols = {
|
||||
id: headscaleServers.id,
|
||||
name: headscaleServers.name,
|
||||
url: headscaleServers.url,
|
||||
version: headscaleServers.version,
|
||||
isActive: headscaleServers.isActive,
|
||||
lastSeenAt: headscaleServers.lastSeenAt,
|
||||
createdAt: headscaleServers.createdAt,
|
||||
};
|
||||
|
||||
/** Every server the owner has registered, active first then newest. Never includes the API key. */
|
||||
export async function listHeadscaleServers(userId: number): Promise<HeadscaleServer[]> {
|
||||
return db
|
||||
.select(serverCols)
|
||||
.from(headscaleServers)
|
||||
.where(eq(headscaleServers.userId, userId))
|
||||
.orderBy(desc(headscaleServers.isActive), desc(headscaleServers.createdAt));
|
||||
}
|
||||
|
||||
/** The currently selected server with its key decrypted, or null when none is registered/active. */
|
||||
export async function getActiveHeadscaleCredentials(userId: number): Promise<HeadscaleServerCredentials | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(headscaleServers)
|
||||
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.isActive, true)));
|
||||
if (!row) return null;
|
||||
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret(row.apiKey) };
|
||||
}
|
||||
|
||||
/** One server's credentials by id — for probing a specific server rather than the active one. */
|
||||
export async function getHeadscaleCredentials(userId: number, id: number): Promise<HeadscaleServerCredentials | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(headscaleServers)
|
||||
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)));
|
||||
if (!row) return null;
|
||||
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret(row.apiKey) };
|
||||
}
|
||||
|
||||
type CreateHeadscaleServerParams = {
|
||||
userId: number;
|
||||
name: string;
|
||||
url: string;
|
||||
apiKey: string;
|
||||
version: string | null;
|
||||
/** Make it the active server. True for the first registration, so the UI is never left with none selected. */
|
||||
activate: boolean;
|
||||
};
|
||||
|
||||
/** Register a server. The key is encrypted before write; the returned row carries no key. */
|
||||
export async function createHeadscaleServer(params: CreateHeadscaleServerParams): Promise<HeadscaleServer> {
|
||||
const { userId, name, url, apiKey, version, activate } = params;
|
||||
return db.transaction(async (tx) => {
|
||||
if (activate) {
|
||||
await tx
|
||||
.update(headscaleServers)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.isActive, true)));
|
||||
}
|
||||
const [row] = await tx
|
||||
.insert(headscaleServers)
|
||||
.values({
|
||||
userId,
|
||||
name,
|
||||
url,
|
||||
apiKey: encryptSecret(apiKey),
|
||||
version,
|
||||
isActive: activate,
|
||||
lastSeenAt: version ? new Date() : null,
|
||||
})
|
||||
.returning(serverCols);
|
||||
return row!;
|
||||
});
|
||||
}
|
||||
|
||||
type UpdateHeadscaleServerParams = { name?: string; url?: string; apiKey?: string };
|
||||
|
||||
/** Edit a registration. Omitted fields are left alone; a supplied key is re-encrypted. */
|
||||
export async function updateHeadscaleServer(
|
||||
userId: number,
|
||||
id: number,
|
||||
params: UpdateHeadscaleServerParams,
|
||||
): Promise<HeadscaleServer | null> {
|
||||
const set: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (params.name !== undefined) set.name = params.name;
|
||||
if (params.url !== undefined) set.url = params.url;
|
||||
if (params.apiKey !== undefined) set.apiKey = encryptSecret(params.apiKey);
|
||||
|
||||
const [row] = await db
|
||||
.update(headscaleServers)
|
||||
.set(set)
|
||||
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)))
|
||||
.returning(serverCols);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/** Select a server. Clearing the others first keeps the one-active partial index satisfied. */
|
||||
export async function setActiveHeadscaleServer(userId: number, id: number): Promise<HeadscaleServer | null> {
|
||||
return db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(headscaleServers)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.isActive, true)));
|
||||
const [row] = await tx
|
||||
.update(headscaleServers)
|
||||
.set({ isActive: true, updatedAt: new Date() })
|
||||
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)))
|
||||
.returning(serverCols);
|
||||
return row ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a registration. If it was the active one, the newest survivor is promoted — otherwise deleting the
|
||||
* active server would leave the UI with servers registered but none selected, which reads as "not
|
||||
* configured" and is a confusing place to land.
|
||||
*/
|
||||
export async function deleteHeadscaleServer(userId: number, id: number): Promise<boolean> {
|
||||
return db.transaction(async (tx) => {
|
||||
const [deleted] = await tx
|
||||
.delete(headscaleServers)
|
||||
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)))
|
||||
.returning({ id: headscaleServers.id, wasActive: headscaleServers.isActive });
|
||||
if (!deleted) return false;
|
||||
|
||||
if (deleted.wasActive) {
|
||||
const [next] = await tx
|
||||
.select({ id: headscaleServers.id })
|
||||
.from(headscaleServers)
|
||||
.where(eq(headscaleServers.userId, userId))
|
||||
.orderBy(desc(headscaleServers.createdAt))
|
||||
.limit(1);
|
||||
if (next) {
|
||||
await tx
|
||||
.update(headscaleServers)
|
||||
.set({ isActive: true, updatedAt: new Date() })
|
||||
.where(eq(headscaleServers.id, next.id));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** Record a successful reachability probe: the version observed and when we last reached the server. */
|
||||
export async function recordHeadscaleProbe(userId: number, id: number, version: string | null): Promise<void> {
|
||||
await db
|
||||
.update(headscaleServers)
|
||||
.set({ version, lastSeenAt: new Date(), updatedAt: new Date() })
|
||||
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { pgTable, serial, integer, text, boolean, timestamp, unique, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { users } from './auth';
|
||||
|
||||
// The Headscale servers the owner manages, for the officer-headscale sidecar. Officer targets no single
|
||||
// Headscale: the owner registers one or more servers (URL + an admin API key generated on that server) and
|
||||
// toggles between them, so this is configuration the user creates at runtime rather than env vars.
|
||||
//
|
||||
// `api_key` is a Headscale *admin* credential — it can delete every node on a tailnet — so it is encrypted
|
||||
// at rest via ../crypto.ts, exactly like the vault token set. Encryption/decryption is confined to
|
||||
// queries/headscale.ts; nothing outside that file ever sees ciphertext, and list callers never see the key
|
||||
// at all. SECURITY_AUDIT.md L2 records plaintext credential storage as an open finding, so the plaintext
|
||||
// email/integrations tables are debt to avoid copying, not a precedent to follow.
|
||||
//
|
||||
// Every table here is `headscale_`-prefixed and this file holds nothing else: when sidecars own their own
|
||||
// schema it moves wholesale into src/servers/sidecar/headscale/ with no untangling. Only the
|
||||
// officer-headscale sidecar reads or writes these tables.
|
||||
|
||||
export const headscaleServers = pgTable(
|
||||
'headscale_servers',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
// Normalized without a trailing slash before write, so `${url}/api/v1/...` never doubles the separator.
|
||||
url: text('url').notNull(),
|
||||
apiKey: text('api_key').notNull(), // encrypted
|
||||
// Last version seen from the server's unauthenticated GET /version. Null until first probed; the
|
||||
// literal 'dev' when the server was built without VCS info, which is unknown rather than too-old.
|
||||
version: text('version'),
|
||||
isActive: boolean('is_active').notNull().default(false),
|
||||
// Last successful probe, so the UI can distinguish "never reached" from "was reachable, now isn't".
|
||||
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
// One registration per URL — re-registering the same server should be an edit, not a duplicate.
|
||||
unique('uq_headscale_servers_user_url').on(t.userId, t.url),
|
||||
// At most one active server per owner, enforced by the DB rather than by convention: a partial unique
|
||||
// index over the active rows only. setActiveHeadscaleServer still clears the others in a transaction,
|
||||
// but a bug there fails loudly here instead of silently leaving two servers active.
|
||||
uniqueIndex('uq_headscale_servers_one_active')
|
||||
.on(t.userId)
|
||||
.where(sql`${t.isActive}`),
|
||||
],
|
||||
);
|
||||
@@ -8,4 +8,5 @@ export * from './pipeline-jobs';
|
||||
export * from './chat-events';
|
||||
export * from './music';
|
||||
export * from './soulseek';
|
||||
export * from './headscale';
|
||||
export * from './vault';
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { getHeadscaleServerUrl } from './sidecar-server';
|
||||
|
||||
// Thin reverse-proxy for /api/headscale/*. The platform's ONLY job here is AUTH + FORWARDING: this router
|
||||
// mounts under the protected /api tree (userMiddleware upstream authenticates the owner), then forwards the
|
||||
// subpath + query + body to the officer-headscale sidecar, which OWNS the Headscale contract and holds the
|
||||
// admin API key.
|
||||
//
|
||||
// A catch-all with no routes of its own. Unlike /api/slskd this proxies nothing of the upstream's own
|
||||
// surface — the sidecar exposes only Officer-owned routes under /_officer/, because Headscale's REST shape
|
||||
// differs across releases and version handling belongs in the sidecar, not the browser. The full contract
|
||||
// is documented at the top of src/servers/sidecar/headscale/index.ts. It is opaque from here: this file
|
||||
// must never grow Headscale logic.
|
||||
|
||||
export const headscaleRouter = createRouter();
|
||||
|
||||
const PREFIX = '/api/headscale';
|
||||
|
||||
headscaleRouter.all('/*', async (ctx) => {
|
||||
const baseUrl = getHeadscaleServerUrl();
|
||||
if (!baseUrl) return ctx.text('headscale sidecar not available', 503);
|
||||
|
||||
const url = new URL(ctx.req.url);
|
||||
const subpath = url.pathname.slice(PREFIX.length) || '/';
|
||||
const target = `${baseUrl}${subpath}${url.search}`;
|
||||
|
||||
const method = ctx.req.method;
|
||||
const headers: Record<string, string> = {};
|
||||
const contentType = ctx.req.header('content-type');
|
||||
if (contentType) headers['Content-Type'] = contentType;
|
||||
// Forward the authenticated user id so the sidecar can serve its Officer-owned routes. The sidecar binds
|
||||
// loopback only, so this header is trusted.
|
||||
headers['X-Officer-User'] = String(ctx.get('user').id);
|
||||
|
||||
const hasBody = method !== 'GET' && method !== 'HEAD';
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(target, {
|
||||
method,
|
||||
headers,
|
||||
body: hasBody ? await ctx.req.arrayBuffer() : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[headscale] proxy fetch failed', { target, error: String(err) });
|
||||
return ctx.text('headscale sidecar unreachable', 502);
|
||||
}
|
||||
|
||||
return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) });
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
|
||||
// The officer-headscale sidecar starts its HTTP server on a random loopback port and reports it here on
|
||||
// connect. We remember it so `/api/headscale/*` always forwards to the current sidecar. The platform holds
|
||||
// NO knowledge of Headscale itself — not its URL, and emphatically not its admin API key.
|
||||
|
||||
let serverPort: number | null = null;
|
||||
|
||||
sidecar.on('headscale:server', (msg) => {
|
||||
const port = (msg as { port?: number }).port;
|
||||
if (typeof port !== 'number') return;
|
||||
serverPort = port;
|
||||
console.log(`[headscale] sidecar registered on port ${port}`);
|
||||
});
|
||||
|
||||
/** Base URL of the sidecar's HTTP server, or null if the sidecar hasn't reported in yet. */
|
||||
export function getHeadscaleServerUrl(): string | null {
|
||||
return serverPort ? `http://127.0.0.1:${serverPort}` : null;
|
||||
}
|
||||
@@ -22,12 +22,14 @@ import { router as fileBrowserRouter } from './api/file-browser/router';
|
||||
import { musicRouter } from './api/music/router';
|
||||
import { vaultRouter } from './api/vault/router';
|
||||
import { slskdRouter } from './api/slskd/router';
|
||||
import { headscaleRouter } from './api/headscale/router';
|
||||
import { vpnRouter } from './api/vpn/router';
|
||||
import { systemMonitorRouter } from './api/system-monitor/system-monitor';
|
||||
import { activityRouter } from './api/activity/router';
|
||||
import './api/music/sidecar-server'; // side-effect: capture the officer-music audio server port
|
||||
import './api/vault/sidecar-server'; // side-effect: capture the officer-vault reverse-proxy port
|
||||
import './api/slskd/sidecar-server'; // side-effect: capture the officer-slskd reverse-proxy port
|
||||
import './api/headscale/sidecar-server'; // side-effect: capture the officer-headscale server port
|
||||
import { devServerRouter, devServerProxyRouter } from './api/dev-server/router';
|
||||
import { dockRouter } from './api/dock/dock';
|
||||
import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations';
|
||||
@@ -110,6 +112,7 @@ protectedRouter.route('/task-logs', taskLogsRouter);
|
||||
protectedRouter.route('/file-browser', fileBrowserRouter);
|
||||
protectedRouter.route('/music', musicRouter);
|
||||
protectedRouter.route('/slskd', slskdRouter);
|
||||
protectedRouter.route('/headscale', headscaleRouter);
|
||||
protectedRouter.route('/vpn', vpnRouter);
|
||||
protectedRouter.route('/system-monitor', systemMonitorRouter);
|
||||
protectedRouter.route('/activity', activityRouter);
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { HeadscaleServerCredentials } from 'officerdb';
|
||||
|
||||
// The Headscale admin API call layer. Every upstream request in this sidecar goes through here, so the
|
||||
// wire-level quirks are handled once:
|
||||
//
|
||||
// • Auth is `Authorization: Bearer <apiKey>`. Headscale's swagger declares no securityDefinitions at all,
|
||||
// so a generated client would omit it entirely.
|
||||
// • 401/403 bodies are PLAIN TEXT ("Unauthorized"), with no content-type — every other error is
|
||||
// grpc-gateway `{code,message,details}` JSON. Blindly .json()-ing an error body throws on exactly the
|
||||
// auth failure you most want to report clearly.
|
||||
// • Every uint64 is serialized as a JSON STRING, not a number: `node.id` arrives as "7". We keep ids as
|
||||
// strings end to end and never round-trip them through Number, which would silently break above 2^53.
|
||||
// • The gateway marshals with EmitUnpopulated, so absent values come back as [] / null / "" / false rather
|
||||
// than being omitted. You cannot distinguish "unset" from "empty" — don't try.
|
||||
// • It also marshals with DiscardUnknown, so a misspelled request field is IGNORED rather than rejected.
|
||||
// Silent no-ops are the failure mode; mutations here read the object back where the API returns it.
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** An upstream failure carrying the HTTP status to surface, mapped to a response at the route boundary. */
|
||||
export class HeadscaleError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'HeadscaleError';
|
||||
}
|
||||
}
|
||||
|
||||
type CallOptions = { method?: string; body?: unknown; timeoutMs?: number };
|
||||
|
||||
/**
|
||||
* Extract a human-usable message from a Headscale error response, tolerating both of its formats.
|
||||
* Never returned verbatim to the browser for auth failures — see callers.
|
||||
*/
|
||||
async function errorMessage(res: Response): Promise<string> {
|
||||
const text = await res.text().catch(() => '');
|
||||
if (!text) return `upstream returned ${res.status}`;
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { message?: unknown };
|
||||
if (typeof parsed.message === 'string' && parsed.message) return parsed.message;
|
||||
} catch {
|
||||
/* plain text — the 401 case */
|
||||
}
|
||||
return text.slice(0, 300);
|
||||
}
|
||||
|
||||
export type HeadscaleClient = {
|
||||
readonly serverId: number;
|
||||
/** Call an admin API path (e.g. `/api/v1/node`). Throws HeadscaleError on any non-2xx. */
|
||||
call: <T>(path: string, opts?: CallOptions) => Promise<T>;
|
||||
};
|
||||
|
||||
/** Build a client bound to one registered server's credentials. */
|
||||
export function createClient(creds: HeadscaleServerCredentials): HeadscaleClient {
|
||||
async function call<T>(path: string, opts: CallOptions = {}): Promise<T> {
|
||||
const { method = 'GET', body, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
authorization: `Bearer ${creds.apiKey}`,
|
||||
accept: 'application/json',
|
||||
};
|
||||
if (body !== undefined) headers['content-type'] = 'application/json';
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${creds.url}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
} catch (err) {
|
||||
const timedOut = err instanceof Error && err.name === 'TimeoutError';
|
||||
throw new HeadscaleError(504, timedOut ? 'headscale timed out' : 'headscale unreachable');
|
||||
}
|
||||
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
// The stored key is wrong, expired, or was revoked on the server. Actionable, and distinct from an
|
||||
// Officer-side auth problem — the UI should point the owner at re-entering the key.
|
||||
throw new HeadscaleError(502, 'headscale rejected the stored API key');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await errorMessage(res);
|
||||
console.error(`[headscale] ${method} ${path} -> ${res.status}: ${message}`);
|
||||
// 4xx from the admin API is usually a bad argument and safe to relay; 5xx is not, so it's generalized.
|
||||
throw new HeadscaleError(res.status >= 500 ? 502 : res.status, res.status >= 500 ? 'headscale error' : message);
|
||||
}
|
||||
|
||||
// 204 and empty 200s are legitimate: several mutations return `{}` or nothing at all.
|
||||
const text = await res.text();
|
||||
if (!text) return {} as T;
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
throw new HeadscaleError(502, 'headscale returned a non-JSON body');
|
||||
}
|
||||
}
|
||||
|
||||
return { serverId: creds.id, call };
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import { handleOfficerRoute } from './routes';
|
||||
import { MIN_VERSION_LABEL } from './version';
|
||||
|
||||
// The officer-headscale sidecar. Owns the whole Headscale contract for Officer: the registered servers and
|
||||
// their admin API keys, the >=0.29 version floor, and every multi-call composition the UI needs. The platform
|
||||
// API is a thin auth-gated forwarder (src/servers/api/headscale/router.ts) holding no Headscale credentials.
|
||||
//
|
||||
// Officer manages MANY Headscale servers, not one. The owner registers each with a URL and an API key
|
||||
// generated on that server, and switches between them; one is active at a time. So configuration lives in
|
||||
// Postgres (headscale_servers, keys encrypted at rest), NOT in env vars — this sidecar deliberately reads
|
||||
// neither HEADSCALE_URL nor HEADSCALE_API_KEY, so a registered server can never be shadowed by host env.
|
||||
// (Those two vars belong solely to the unrelated /api/vpn/enroll route, which is none of our business.)
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// HTTP CONTRACT — the platform strips its /api/headscale mount prefix before forwarding.
|
||||
//
|
||||
// GET /_health ours. Sidecar liveness only. Per-server reachability is a
|
||||
// different question and needs an owner, so it lives below.
|
||||
// GET /_officer/servers registered servers (never includes API keys)
|
||||
// POST /_officer/servers register {name?,url,apiKey} — validated before it is saved
|
||||
// PATCH /_officer/servers/:id edit; re-validated when url or apiKey changes
|
||||
// DELETE /_officer/servers/:id deregister; promotes the newest survivor if it was active
|
||||
// POST /_officer/servers/:id/activate switch the active server
|
||||
// GET /_officer/servers/:id/health probe: reachable? version? key still accepted?
|
||||
// anything else 404
|
||||
//
|
||||
// There is deliberately NO transparent /api/v1/* passthrough. Headscale's REST shape changed repeatedly
|
||||
// below 0.29 and its ids are uint64-as-JSON-string, so proxying raw would push all of that into the browser
|
||||
// — the mistake the Soulseek panels made with 37 raw upstream calls. Every quirk is absorbed here.
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||
|
||||
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||
function getFreePort(): number {
|
||||
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
||||
const p = probe.port;
|
||||
probe.stop(true);
|
||||
if (p == null) throw new Error('failed to acquire a free port');
|
||||
return p;
|
||||
}
|
||||
|
||||
const port = getFreePort();
|
||||
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
hostname: '127.0.0.1',
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
// Liveness, not upstream health: with many registered servers there is no single upstream to probe, and
|
||||
// choosing one would need an authenticated owner. See /_officer/servers/:id/health for that.
|
||||
if (url.pathname === '/_health') {
|
||||
return Response.json({ ok: true, minHeadscaleVersion: MIN_VERSION_LABEL });
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/_officer/')) {
|
||||
try {
|
||||
const res = await handleOfficerRoute(req, url);
|
||||
return res ?? new Response('not found', { status: 404 });
|
||||
} catch (err) {
|
||||
console.error(`[headscale] ${req.method} ${url.pathname} failed`, err);
|
||||
return Response.json({ error: 'internal error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
return new Response('not found', { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[headscale] listening on 127.0.0.1:${port} (Headscale >=${MIN_VERSION_LABEL})`);
|
||||
|
||||
// ── Command handlers ──
|
||||
|
||||
type ReplyFn = (msg: SidecarEvent) => void;
|
||||
|
||||
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
switch (cmd.type) {
|
||||
case 'ping':
|
||||
reply({ type: 'pong', id: cmd.id });
|
||||
break;
|
||||
default:
|
||||
reply({
|
||||
type: 'error',
|
||||
id: (cmd as SidecarCommand).id,
|
||||
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connect to API server ──
|
||||
|
||||
const connection = createSidecarConnector({
|
||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||
name: 'headscale',
|
||||
capabilities: ['headscale'],
|
||||
onCommand(cmd, reply) {
|
||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||
},
|
||||
onConnected() {
|
||||
// Tell the API where we're listening, so it can forward /api/headscale/* here.
|
||||
connection.send({ type: 'headscale:server', port });
|
||||
console.log(`[headscale] reported port ${port} to API`);
|
||||
},
|
||||
});
|
||||
|
||||
// ── Graceful shutdown ──
|
||||
|
||||
function shutdown(signal: string) {
|
||||
console.log(`[headscale] ${signal} received, shutting down...`);
|
||||
try {
|
||||
server.stop(true);
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
connection.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
@@ -0,0 +1,51 @@
|
||||
import { HeadscaleError } from './client';
|
||||
import { handleServersRoute } from './servers';
|
||||
|
||||
// Officer-owned routes for the headscale sidecar — the entire feature surface lives under /_officer/.
|
||||
//
|
||||
// Nothing here is a passthrough. The shapes the UI receives are stable and Officer-shaped, ids stay strings,
|
||||
// dates are normalized, and anything needing more than one upstream call (device counts per user, pre-auth
|
||||
// keys grouped by user, read-modify-write of a node's approved route set) resolves here rather than in the
|
||||
// browser. That is the whole reason the sidecar exists: see rule 5 in SIDECAR_ARCHITECTURE.md.
|
||||
|
||||
export type OfficerContext = { req: Request; url: URL; userId: number };
|
||||
|
||||
/** 400 with a machine-readable reason. */
|
||||
export const badRequest = (error: string) => Response.json({ error }, { status: 400 });
|
||||
/** 404 for an unknown /_officer/ path or a missing object. */
|
||||
export const notFound = (error = 'not found') => Response.json({ error }, { status: 404 });
|
||||
/** 405 when the path exists but the verb doesn't. */
|
||||
export const methodNotAllowed = () => Response.json({ error: 'method not allowed' }, { status: 405 });
|
||||
|
||||
/**
|
||||
* Dispatch an /_officer/* request. Returns null when nothing matches, which the caller turns into a 404.
|
||||
*
|
||||
* The platform injects X-Officer-User after authenticating the owner. We bind loopback only, so its presence
|
||||
* is the trust signal — a request without it did not come through the platform.
|
||||
*/
|
||||
export async function handleOfficerRoute(req: Request, url: URL): Promise<Response | null> {
|
||||
const officerUser = req.headers.get('X-Officer-User');
|
||||
if (!officerUser) return Response.json({ error: 'missing X-Officer-User' }, { status: 401 });
|
||||
|
||||
const userId = Number(officerUser);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return badRequest('invalid X-Officer-User');
|
||||
|
||||
const segments = url.pathname.slice('/_officer/'.length).split('/').filter(Boolean);
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
const ctx: OfficerContext = { req, url, userId };
|
||||
|
||||
try {
|
||||
switch (segments[0]) {
|
||||
case 'servers':
|
||||
return await handleServersRoute(ctx, segments.slice(1));
|
||||
// Domain routes (nodes, users, preauthkeys) land here, each operating against the active server.
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
// Upstream failures carry their own status; everything else is ours and is a 500 the caller logs.
|
||||
if (err instanceof HeadscaleError) return Response.json({ error: err.message }, { status: err.status });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { OfficerContext } from './routes';
|
||||
import {
|
||||
listHeadscaleServers,
|
||||
createHeadscaleServer,
|
||||
updateHeadscaleServer,
|
||||
setActiveHeadscaleServer,
|
||||
deleteHeadscaleServer,
|
||||
getHeadscaleCredentials,
|
||||
recordHeadscaleProbe,
|
||||
} from 'officerdb';
|
||||
import { createClient, HeadscaleError } from './client';
|
||||
import { probeVersion, MIN_VERSION_LABEL } from './version';
|
||||
import { badRequest, notFound, methodNotAllowed } from './routes';
|
||||
|
||||
// Server registry routes — /_officer/servers/*. Officer manages any number of Headscale servers; the owner
|
||||
// registers each with a URL and an admin API key generated on that server, and one is active at a time.
|
||||
//
|
||||
// Registration VALIDATES before it saves, in two steps, because a bad registration is otherwise only
|
||||
// discovered later as a confusing failure on some unrelated screen:
|
||||
// 1. unauthenticated GET /version — proves something Headscale-shaped is there and enforces the >=0.29 floor
|
||||
// 2. an authenticated call — proves the key actually works
|
||||
// Neither step is skippable, and a rejected registration is never written.
|
||||
|
||||
/** Normalize a user-supplied base URL, or null if it isn't a usable http(s) origin. */
|
||||
function normalizeUrl(raw: unknown): string | null {
|
||||
if (typeof raw !== 'string' || !raw.trim()) return null;
|
||||
let candidate = raw.trim();
|
||||
// Bare host/port is the most common paste; assume https rather than rejecting it.
|
||||
if (!/^https?:\/\//i.test(candidate)) candidate = `https://${candidate}`;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(candidate);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
|
||||
// Trailing slash would produce `//api/v1/...`; query/hash are meaningless on a base URL.
|
||||
return `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`;
|
||||
}
|
||||
|
||||
function requireString(value: unknown, field: string): string | Response {
|
||||
if (typeof value !== 'string' || !value.trim()) return badRequest(`${field} is required`);
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a URL+key pair is a supported, reachable Headscale we can authenticate against.
|
||||
* Returns the observed version on success, or a ready-to-send error Response.
|
||||
*/
|
||||
async function validateServer(url: string, apiKey: string): Promise<string | Response> {
|
||||
const probe = await probeVersion(url);
|
||||
if (!probe.ok) return badRequest(probe.error);
|
||||
if (probe.supported === false) {
|
||||
return badRequest(`Headscale ${probe.version} is not supported — Officer requires ${MIN_VERSION_LABEL} or newer`);
|
||||
}
|
||||
|
||||
// Cheapest authenticated GET whose path is stable across releases, and the same call Headscale's own
|
||||
// clients use to test a key. A wrong key surfaces here as HeadscaleError(502, 'rejected the stored key').
|
||||
const client = createClient({ id: 0, name: 'probe', url, apiKey });
|
||||
try {
|
||||
await client.call('/api/v1/apikey');
|
||||
} catch (err) {
|
||||
if (err instanceof HeadscaleError) {
|
||||
return badRequest(err.status === 502 ? 'the API key was rejected by that server' : err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return probe.version;
|
||||
}
|
||||
|
||||
async function handleCollection(ctx: OfficerContext): Promise<Response> {
|
||||
const { req, userId } = ctx;
|
||||
|
||||
if (req.method === 'GET') {
|
||||
return Response.json({ servers: await listHeadscaleServers(userId) });
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
|
||||
if (!body) return badRequest('expected a JSON body');
|
||||
|
||||
const url = normalizeUrl(body.url);
|
||||
if (!url) return badRequest('url must be a valid http(s) URL');
|
||||
const apiKey = requireString(body.apiKey, 'apiKey');
|
||||
if (apiKey instanceof Response) return apiKey;
|
||||
// The name is a label only; default it to the host so registration needs just a URL and a key.
|
||||
const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim() : new URL(url).host;
|
||||
|
||||
const validated = await validateServer(url, apiKey);
|
||||
if (validated instanceof Response) return validated;
|
||||
|
||||
// First registration becomes active, so the owner is never left with servers but none selected.
|
||||
const existing = await listHeadscaleServers(userId);
|
||||
const server = await createHeadscaleServer({
|
||||
userId,
|
||||
name,
|
||||
url,
|
||||
apiKey,
|
||||
version: validated,
|
||||
activate: existing.length === 0,
|
||||
});
|
||||
return Response.json({ server }, { status: 201 });
|
||||
}
|
||||
|
||||
return methodNotAllowed();
|
||||
}
|
||||
|
||||
async function handleOne(ctx: OfficerContext, id: number, action: string | undefined): Promise<Response> {
|
||||
const { req, userId } = ctx;
|
||||
|
||||
if (action === 'activate') {
|
||||
if (req.method !== 'POST') return methodNotAllowed();
|
||||
const server = await setActiveHeadscaleServer(userId, id);
|
||||
return server ? Response.json({ server }) : notFound('no such server');
|
||||
}
|
||||
|
||||
if (action === 'health') {
|
||||
if (req.method !== 'GET') return methodNotAllowed();
|
||||
const creds = await getHeadscaleCredentials(userId, id);
|
||||
if (!creds) return notFound('no such server');
|
||||
|
||||
const started = Date.now();
|
||||
const probe = await probeVersion(creds.url);
|
||||
if (!probe.ok) return Response.json({ ok: false, error: probe.error, ms: Date.now() - started });
|
||||
|
||||
// Reachable — confirm the key too, so "healthy" means "we can actually use this server".
|
||||
try {
|
||||
await createClient(creds).call('/api/v1/apikey');
|
||||
} catch (err) {
|
||||
const message = err instanceof HeadscaleError ? err.message : 'upstream error';
|
||||
return Response.json({ ok: false, version: probe.version, error: message, ms: Date.now() - started });
|
||||
}
|
||||
|
||||
await recordHeadscaleProbe(userId, id, probe.version);
|
||||
return Response.json({ ok: true, version: probe.version, supported: probe.supported, ms: Date.now() - started });
|
||||
}
|
||||
|
||||
if (action !== undefined) return notFound();
|
||||
|
||||
if (req.method === 'PATCH') {
|
||||
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
|
||||
if (!body) return badRequest('expected a JSON body');
|
||||
|
||||
const current = await getHeadscaleCredentials(userId, id);
|
||||
if (!current) return notFound('no such server');
|
||||
|
||||
let url: string | undefined;
|
||||
if (body.url !== undefined) {
|
||||
const normalized = normalizeUrl(body.url);
|
||||
if (!normalized) return badRequest('url must be a valid http(s) URL');
|
||||
url = normalized;
|
||||
}
|
||||
let apiKey: string | undefined;
|
||||
if (body.apiKey !== undefined) {
|
||||
const parsed = requireString(body.apiKey, 'apiKey');
|
||||
if (parsed instanceof Response) return parsed;
|
||||
apiKey = parsed;
|
||||
}
|
||||
const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim() : undefined;
|
||||
|
||||
// Re-validate whenever either half of the credentials moves — a saved-but-broken server is the exact
|
||||
// state registration works hard to prevent, and an edit can reintroduce it.
|
||||
if (url !== undefined || apiKey !== undefined) {
|
||||
const validated = await validateServer(url ?? current.url, apiKey ?? current.apiKey);
|
||||
if (validated instanceof Response) return validated;
|
||||
}
|
||||
|
||||
const server = await updateHeadscaleServer(userId, id, { name, url, apiKey });
|
||||
return server ? Response.json({ server }) : notFound('no such server');
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
const deleted = await deleteHeadscaleServer(userId, id);
|
||||
return deleted ? new Response(null, { status: 204 }) : notFound('no such server');
|
||||
}
|
||||
|
||||
return methodNotAllowed();
|
||||
}
|
||||
|
||||
/** Dispatch `/_officer/servers/...`. `rest` is the path after `servers`. */
|
||||
export async function handleServersRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
|
||||
if (rest.length === 0) return handleCollection(ctx);
|
||||
|
||||
const id = Number(rest[0]);
|
||||
if (!Number.isInteger(id) || id <= 0) return badRequest('server id must be a positive integer');
|
||||
if (rest.length > 2) return notFound();
|
||||
|
||||
return handleOne(ctx, id, rest[1]);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Headscale version detection and the supported floor.
|
||||
//
|
||||
// Officer targets Headscale >= 0.29 and nothing older. That is a deliberate, narrow floor: the admin API
|
||||
// changed shape repeatedly below it — identifiers went name→numeric at 0.26, `/api/v1/routes` was removed at
|
||||
// 0.26 in favour of node-owned route sets, `forcedTags`/`validTags` collapsed into `tags` at 0.28, pre-auth
|
||||
// key expiry became id-based at 0.28, and MoveNode was removed at 0.28. Supporting 0.23–0.28 would mean
|
||||
// carrying several incompatible data models; refusing them at registration time costs one probe.
|
||||
//
|
||||
// Detection uses the server's own unauthenticated `GET /version`, which exists in 0.28 and 0.29 and sits at
|
||||
// the root — NOT under /api/v1, and not behind the bearer middleware. Do not confuse it with the three
|
||||
// other similarly-named endpoints: `GET /health` (root, unauthenticated, `{status:'pass'}`) and
|
||||
// `GET /api/v1/health` (authenticated, `{databaseConnectivity:true}`) carry no version at all.
|
||||
|
||||
export const MIN_MAJOR = 0;
|
||||
export const MIN_MINOR = 29;
|
||||
export const MIN_VERSION_LABEL = '0.29';
|
||||
|
||||
const PROBE_TIMEOUT_MS = 8000;
|
||||
|
||||
export type VersionProbe =
|
||||
| { ok: true; version: string; supported: true }
|
||||
/** Reached the server but can't judge the version — self-built images report the literal 'dev'. */
|
||||
| { ok: true; version: string; supported: 'unknown' }
|
||||
| { ok: true; version: string; supported: false }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/** `major.minor` from a Headscale version string, or null when it isn't semver (e.g. the literal 'dev'). */
|
||||
export function parseVersion(raw: string): { major: number; minor: number } | null {
|
||||
const m = raw.trim().replace(/^v/, '').match(/^(\d+)\.(\d+)/);
|
||||
if (!m) return null;
|
||||
return { major: Number(m[1]), minor: Number(m[2]) };
|
||||
}
|
||||
|
||||
/** Whether a parsed version is at or above the supported floor. */
|
||||
export function meetsFloor(v: { major: number; minor: number }): boolean {
|
||||
if (v.major !== MIN_MAJOR) return v.major > MIN_MAJOR;
|
||||
return v.minor >= MIN_MINOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe a base URL's `GET /version`. Unauthenticated, so this also doubles as the reachability check during
|
||||
* registration — it tells us "is there a Headscale here at all" before we bother validating a key.
|
||||
*
|
||||
* An unparseable version is reported as `supported: 'unknown'` rather than rejected: a server built without
|
||||
* VCS build info reports 'dev', and refusing those would lock out legitimately self-built deployments.
|
||||
*/
|
||||
export async function probeVersion(baseUrl: string): Promise<VersionProbe> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${baseUrl}/version`, {
|
||||
headers: { accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
||||
});
|
||||
} catch {
|
||||
return { ok: false, error: 'server unreachable' };
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
// A Headscale that answers /version with a non-2xx isn't one we can identify. Most often this is a URL
|
||||
// pointing at a reverse proxy or an unrelated service rather than at Headscale itself.
|
||||
return { ok: false, error: `GET /version returned ${res.status} — is this a Headscale server?` };
|
||||
}
|
||||
|
||||
let version: string;
|
||||
try {
|
||||
const body = (await res.json()) as { version?: unknown };
|
||||
if (typeof body.version !== 'string' || !body.version) return { ok: false, error: 'no version in response' };
|
||||
version = body.version;
|
||||
} catch {
|
||||
return { ok: false, error: 'GET /version did not return JSON' };
|
||||
}
|
||||
|
||||
const parsed = parseVersion(version);
|
||||
if (!parsed) return { ok: true, version, supported: 'unknown' };
|
||||
return { ok: true, version, supported: meetsFloor(parsed) };
|
||||
}
|
||||
@@ -68,6 +68,8 @@ export type SidecarEvent =
|
||||
| { type: 'vault:server'; port: number }
|
||||
// slskd — the sidecar reports where its slskd reverse-proxy HTTP server is listening (random port) on connect
|
||||
| { type: 'slskd:server'; port: number }
|
||||
// Headscale — the sidecar reports where its HTTP server is listening (random port) on connect
|
||||
| { type: 'headscale:server'; port: number }
|
||||
// Generic
|
||||
| { type: 'error'; id?: string; error: string };
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
|
||||
import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
|
||||
import { appRegistryMetas as musicMetas } from '../apps/Music';
|
||||
import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek';
|
||||
import { appRegistryMetas as headscaleMetas } from '../apps/Headscale';
|
||||
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
|
||||
import { useAppRegistry } from './useAppRegistry';
|
||||
import { useUserApps } from 'state/useUserApps';
|
||||
@@ -18,7 +19,22 @@ import { createUserAppPanel } from '../apps/UserApp/UserAppPanel';
|
||||
import { createUserAppHeader } from '../apps/UserApp/UserAppHeader';
|
||||
import { resolveIcon } from '../utils/resolve-icon';
|
||||
|
||||
const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...dashboardMetas, ...chatHistoryMetas, ...previewMetas, ...widgetMetas, ...desktopMetas, ...musicMetas, ...soulseekMetas, ...monitorMetas];
|
||||
const apps = [
|
||||
...fileBrowserMetas,
|
||||
...terminalMetas,
|
||||
...codeEditorMetas,
|
||||
...chatMetas,
|
||||
...fileViewerMetas,
|
||||
...dashboardMetas,
|
||||
...chatHistoryMetas,
|
||||
...previewMetas,
|
||||
...widgetMetas,
|
||||
...desktopMetas,
|
||||
...musicMetas,
|
||||
...soulseekMetas,
|
||||
...headscaleMetas,
|
||||
...monitorMetas,
|
||||
];
|
||||
|
||||
export const AppRegistry = () => {
|
||||
const { registerApp } = useAppRegistry(apps);
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
// Shared visual language for the /headscale panels, matching the /soulseek grouped views: almost-black
|
||||
// cards on hairline white borders. Kept local to the app so the look changes in one place.
|
||||
|
||||
export const Card = ({ children }: { children: ReactNode }) => (
|
||||
<div className="overflow-hidden rounded-xl border border-white/10 bg-zinc-950 shadow-sm">{children}</div>
|
||||
);
|
||||
|
||||
export const SectionHeader = ({
|
||||
title,
|
||||
subtitle,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
action?: ReactNode;
|
||||
}) => (
|
||||
<div className="flex items-start justify-between gap-4 px-1 pb-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-zinc-100">{title}</h2>
|
||||
{subtitle && <p className="mt-0.5 text-xs text-zinc-500">{subtitle}</p>}
|
||||
</div>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
type ButtonProps = {
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
type?: 'button' | 'submit';
|
||||
variant?: 'primary' | 'ghost' | 'danger';
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
const VARIANTS: Record<NonNullable<ButtonProps['variant']>, string> = {
|
||||
primary: 'border-primary/40 bg-primary/15 text-primary hover:bg-primary/25',
|
||||
ghost: 'border-white/10 bg-white/[0.02] text-zinc-300 hover:bg-white/10 hover:text-zinc-100',
|
||||
danger: 'border-red-500/30 bg-red-500/10 text-red-300 hover:bg-red-500/20',
|
||||
};
|
||||
|
||||
export const Button = ({ children, onClick, type = 'button', variant = 'ghost', disabled, title }: ButtonProps) => (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={`inline-flex cursor-pointer items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-medium transition-colors disabled:cursor-default disabled:opacity-40 ${VARIANTS[variant]}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
type FieldProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
type?: 'text' | 'password';
|
||||
autoFocus?: boolean;
|
||||
};
|
||||
|
||||
export const Field = ({ label, value, onChange, placeholder, hint, type = 'text', autoFocus }: FieldProps) => (
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium text-zinc-400">{label}</span>
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(ev) => onChange(ev.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 focus:border-primary/50"
|
||||
/>
|
||||
{hint && <span className="text-[11px] leading-snug text-zinc-600">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
|
||||
/** Tiny status light: green healthy, amber unknown/unverified, red failing. */
|
||||
export const Dot = ({ tone }: { tone: 'ok' | 'warn' | 'bad' | 'idle' }) => {
|
||||
const color =
|
||||
tone === 'ok' ? 'bg-emerald-400' : tone === 'warn' ? 'bg-amber-400' : tone === 'bad' ? 'bg-red-400' : 'bg-zinc-600';
|
||||
return <span className={`inline-block h-2 w-2 shrink-0 rounded-full ${color}`} />;
|
||||
};
|
||||
|
||||
export const Badge = ({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'neutral' | 'active' }) => (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium ${
|
||||
tone === 'active' ? 'border-primary/40 bg-primary/10 text-primary' : 'border-white/10 text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
export const ErrorNote = ({ children }: { children: ReactNode }) => (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs leading-snug text-red-300">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Network, Server, Laptop, Users, KeyRound, Check } from 'lucide-react';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { HEADSCALE_SECTION_CHANNEL, HEADSCALE_SECTIONS, type HeadscaleSectionId } from './shared';
|
||||
import { useHeadscaleServers } from './useHeadscaleServers';
|
||||
|
||||
// Left panel of the /headscale workspace: the active-server switcher on top, sections below. Publishes the
|
||||
// selected section on 'headscale:section'; HeadscaleView (right) renders the matching UI.
|
||||
//
|
||||
// Switching servers is the primary action here rather than a buried setting — the owner runs several
|
||||
// control servers and every other section is scoped to whichever is active.
|
||||
|
||||
const ICONS: Record<HeadscaleSectionId, LucideIcon> = {
|
||||
servers: Server,
|
||||
nodes: Laptop,
|
||||
users: Users,
|
||||
keys: KeyRound,
|
||||
};
|
||||
|
||||
export const HeadscaleNav = () => {
|
||||
const [section, setSection] = usePanelChannel<HeadscaleSectionId>(HEADSCALE_SECTION_CHANNEL, 'servers');
|
||||
const { servers, active, activate } = useHeadscaleServers();
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
||||
<div className="flex items-center gap-3 px-4 py-4">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-indigo-500/15 text-indigo-400 ring-1 ring-black/5">
|
||||
<Network className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold leading-tight">Headscale</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{active ? active.name : 'no server'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{servers.length > 1 && (
|
||||
<div className="px-2 pb-3">
|
||||
<div className="px-3 pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Server
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{servers.map((server) => {
|
||||
const isActive = server.isActive;
|
||||
return (
|
||||
<button
|
||||
key={server.id}
|
||||
type="button"
|
||||
onClick={() => !isActive && activate.mutate(server.id)}
|
||||
disabled={activate.isPending}
|
||||
title={server.url}
|
||||
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-left text-xs transition-colors ${
|
||||
isActive ? 'bg-muted font-medium text-foreground' : 'text-muted-foreground hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
<Check className={`h-3.5 w-3.5 shrink-0 ${isActive ? 'text-primary' : 'opacity-0'}`} />
|
||||
<span className="min-w-0 flex-1 truncate">{server.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<nav className="flex flex-col gap-0.5 px-2 pb-3">
|
||||
{HEADSCALE_SECTIONS.map(({ id, label }) => {
|
||||
const Icon = ICONS[id];
|
||||
const selected = section === id;
|
||||
// Without an active server there is nothing for the domain sections to act on.
|
||||
const disabled = id !== 'servers' && !active;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setSection(id)}
|
||||
disabled={disabled}
|
||||
className={`group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
|
||||
selected
|
||||
? 'bg-primary/10 font-medium text-primary'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
} ${disabled ? 'cursor-default opacity-40 hover:bg-transparent hover:text-muted-foreground' : ''}`}
|
||||
>
|
||||
{selected && (
|
||||
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />
|
||||
)}
|
||||
<Icon
|
||||
className={`h-4 w-4 shrink-0 ${selected ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
|
||||
/>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Construction } from 'lucide-react';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { HEADSCALE_SECTION_CHANNEL, HEADSCALE_SECTIONS, type HeadscaleSectionId } from './shared';
|
||||
import { ServersView } from './ServersView';
|
||||
|
||||
// Right panel of the /headscale workspace — renders the section the nav selected.
|
||||
//
|
||||
// Only `servers` is implemented. Nodes, users and pre-auth keys need the sidecar's domain routes, which
|
||||
// don't exist yet; they say so plainly rather than rendering an empty table that looks like a broken fetch.
|
||||
|
||||
const Placeholder = ({ id }: { id: HeadscaleSectionId }) => {
|
||||
const label = HEADSCALE_SECTIONS.find((s) => s.id === id)?.label ?? id;
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||
<Construction className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold">{label}</div>
|
||||
<div className="text-sm text-muted-foreground">Not built yet</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const HeadscaleView = () => {
|
||||
const [section] = usePanelChannel<HeadscaleSectionId>(HEADSCALE_SECTION_CHANNEL, 'servers');
|
||||
|
||||
if (section === 'servers') return <ServersView />;
|
||||
return <Placeholder id={section} />;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Network } from 'lucide-react';
|
||||
import { useHeadscaleServers } from './useHeadscaleServers';
|
||||
import { HEADSCALE_SECTION_CHANNEL, HEADSCALE_SECTIONS, type HeadscaleSectionId } from './shared';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
|
||||
// Panel header for the right (headscale-view) panel. Shows the section and, crucially, which server it is
|
||||
// acting on — with several registered, "delete this node" is only safe if the target is unambiguous.
|
||||
|
||||
export const HeadscaleViewHeader = () => {
|
||||
const [section] = usePanelChannel<HeadscaleSectionId>(HEADSCALE_SECTION_CHANNEL, 'servers');
|
||||
const { active } = useHeadscaleServers();
|
||||
const label = HEADSCALE_SECTIONS.find((s) => s.id === section)?.label ?? 'Headscale';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Network className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate text-xs font-medium">
|
||||
{label}
|
||||
{active && <span className="ml-1.5 font-normal text-black/50">· {active.name}</span>}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import type { HeadscaleServer } from './shared';
|
||||
import { MIN_HEADSCALE_VERSION } from './shared';
|
||||
import { useHeadscaleServers, headscaleErrorMessage } from './useHeadscaleServers';
|
||||
import { Card, Button, Field, ErrorNote } from './Cards';
|
||||
|
||||
// Register / edit one Headscale server. The sidecar validates before it saves — reachable, >= 0.29, and the
|
||||
// key actually accepted — so this form is genuinely slow on submit and genuinely fails. Both are shown:
|
||||
// a pending state saying what is being checked, and the server's own reason inline on rejection.
|
||||
//
|
||||
// On edit the API key field is intentionally blank rather than pre-filled. Officer cannot pre-fill it (the
|
||||
// key is encrypted at rest and never leaves the sidecar), and leaving it empty means "keep the current key".
|
||||
|
||||
type ServerFormProps = { server?: HeadscaleServer | null; onClose: () => void };
|
||||
|
||||
export const ServerForm = ({ server, onClose }: ServerFormProps) => {
|
||||
const { register, update } = useHeadscaleServers();
|
||||
const editing = !!server;
|
||||
|
||||
const [name, setName] = useState(server?.name ?? '');
|
||||
const [url, setUrl] = useState(server?.url ?? '');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mutation = editing ? update : register;
|
||||
const pending = mutation.isPending;
|
||||
|
||||
const submit = async () => {
|
||||
setError(null);
|
||||
if (!url.trim()) return setError('A server URL is required');
|
||||
if (!editing && !apiKey.trim()) return setError('An API key is required');
|
||||
|
||||
try {
|
||||
if (editing && server) {
|
||||
// Send only what changed: an unchanged url+key pair skips the sidecar's re-validation round trips.
|
||||
await update.mutateAsync({
|
||||
id: server.id,
|
||||
name: name.trim() || undefined,
|
||||
url: url.trim() === server.url ? undefined : url.trim(),
|
||||
apiKey: apiKey.trim() || undefined,
|
||||
});
|
||||
} else {
|
||||
await register.mutateAsync({ name: name.trim() || undefined, url: url.trim(), apiKey: apiKey.trim() });
|
||||
}
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(headscaleErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<form
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
void submit();
|
||||
}}
|
||||
className="flex flex-col gap-3 p-4"
|
||||
>
|
||||
<div className="text-sm font-semibold text-zinc-100">
|
||||
{editing ? `Edit ${server?.name}` : 'Register a Headscale server'}
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Server URL"
|
||||
value={url}
|
||||
onChange={setUrl}
|
||||
placeholder="https://headscale.example.com"
|
||||
hint={`The control server's base URL. Officer requires Headscale ${MIN_HEADSCALE_VERSION} or newer.`}
|
||||
autoFocus={!editing}
|
||||
/>
|
||||
<Field
|
||||
label={editing ? 'API key (leave blank to keep the current one)' : 'API key'}
|
||||
value={apiKey}
|
||||
onChange={setApiKey}
|
||||
type="password"
|
||||
placeholder="hskey-api-..."
|
||||
hint="Generate one on the server with `headscale apikeys create`. It is stored encrypted and never leaves Officer."
|
||||
/>
|
||||
<Field
|
||||
label="Name (optional)"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
placeholder="defaults to the hostname"
|
||||
hint="A label for switching between servers."
|
||||
/>
|
||||
|
||||
{error && <ErrorNote>{error}</ErrorNote>}
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button type="submit" variant="primary" disabled={pending}>
|
||||
{pending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
{pending ? 'Verifying…' : editing ? 'Save changes' : 'Register server'}
|
||||
</Button>
|
||||
<Button onClick={onClose} disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
{pending && <span className="text-[11px] text-zinc-500">Checking the server and the key…</span>}
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,230 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Loader2, Server, Check, Activity, Pencil, Trash2 } from 'lucide-react';
|
||||
import type { HeadscaleServer, HeadscaleHealth } from './shared';
|
||||
import { MIN_HEADSCALE_VERSION } from './shared';
|
||||
import { useHeadscaleServers, useHeadscaleHealth, headscaleErrorMessage } from './useHeadscaleServers';
|
||||
import { Card, SectionHeader, Button, Dot, Badge, ErrorNote } from './Cards';
|
||||
import { ServerForm } from './ServerForm';
|
||||
|
||||
// The servers section — register Headscale servers and switch between them. Exactly one is active at a
|
||||
// time (a DB invariant, not a UI convention), and every other section in this workspace reads it.
|
||||
//
|
||||
// Health is probed on demand only. It costs two upstream round trips (an unauthenticated /version plus an
|
||||
// authenticated call to prove the key still works), so polling every registered server would be rude to
|
||||
// servers the owner isn't currently using.
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (!Number.isFinite(seconds)) return 'unknown';
|
||||
if (seconds < 60) return 'just now';
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.round(hours / 24)}d ago`;
|
||||
}
|
||||
|
||||
type ServerRowProps = {
|
||||
server: HeadscaleServer;
|
||||
health: HeadscaleHealth | undefined;
|
||||
testing: boolean;
|
||||
busy: boolean;
|
||||
onActivate: () => void;
|
||||
onTest: () => void;
|
||||
onEdit: () => void;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
const ServerRow = ({ server, health, testing, busy, onActivate, onTest, onEdit, onRemove }: ServerRowProps) => {
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
// Untested servers get a neutral dot, not a green one: we only know the credentials worked at registration.
|
||||
const tone = health ? (health.ok ? 'ok' : 'bad') : server.isActive ? 'warn' : 'idle';
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex flex-col gap-3 p-3.5">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span className="mt-1.5">
|
||||
<Dot tone={tone} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-zinc-100">{server.name}</span>
|
||||
{server.isActive && <Badge tone="active">Active</Badge>}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-xs text-zinc-500" title={server.url}>
|
||||
{server.url}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-zinc-600">
|
||||
<span>{server.version ? `Headscale ${server.version}` : 'version unknown'}</span>
|
||||
{server.lastSeenAt && <span>· reached {timeAgo(server.lastSeenAt)}</span>}
|
||||
{health?.ok && <span className="text-emerald-400/80">· responded in {health.ms}ms</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{health && !health.ok && <ErrorNote>{health.error ?? 'The server did not respond'}</ErrorNote>}
|
||||
{health?.ok && health.supported === 'unknown' && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] leading-snug text-amber-300">
|
||||
This server reports its version as “{health.version}”, so Officer cannot confirm it is{' '}
|
||||
{MIN_HEADSCALE_VERSION} or newer. Self-built images do this; features may behave unexpectedly on older
|
||||
builds.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!server.isActive && (
|
||||
<Button variant="primary" onClick={onActivate} disabled={busy}>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Use this server
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onTest} disabled={testing}>
|
||||
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Activity className="h-3.5 w-3.5" />}
|
||||
{testing ? 'Testing…' : 'Test'}
|
||||
</Button>
|
||||
<Button onClick={onEdit} disabled={busy}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
{confirming ? (
|
||||
<>
|
||||
<Button variant="danger" onClick={onRemove} disabled={busy}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Confirm remove
|
||||
</Button>
|
||||
<Button onClick={() => setConfirming(false)} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="danger" onClick={() => setConfirming(true)} disabled={busy}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export const ServersView = () => {
|
||||
const { servers, isLoading, error, activate, remove } = useHeadscaleServers();
|
||||
const healthProbe = useHeadscaleHealth();
|
||||
|
||||
const [formFor, setFormFor] = useState<'new' | HeadscaleServer | null>(null);
|
||||
const [health, setHealth] = useState<Record<number, HeadscaleHealth>>({});
|
||||
const [testingId, setTestingId] = useState<number | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const test = async (id: number) => {
|
||||
setTestingId(id);
|
||||
setActionError(null);
|
||||
try {
|
||||
const result = await healthProbe.mutateAsync(id);
|
||||
setHealth((prev) => ({ ...prev, [id]: result }));
|
||||
} catch (err) {
|
||||
setHealth((prev) => ({ ...prev, [id]: { ok: false, error: headscaleErrorMessage(err), ms: 0 } }));
|
||||
} finally {
|
||||
setTestingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const run = async (fn: () => Promise<unknown>) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
setActionError(headscaleErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
const busy = activate.isPending || remove.isPending;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading servers…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<ErrorNote>
|
||||
Could not reach the Headscale sidecar: {headscaleErrorMessage(error)}. If it is not running, start it with{' '}
|
||||
<code className="font-mono">pm2 start ecosystem.config.cjs --only officer-headscale</code>.
|
||||
</ErrorNote>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state doubles as the registration prompt — there is nothing else to do here without a server.
|
||||
if (servers.length === 0) {
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-full max-w-xl flex-col justify-center gap-4 p-6">
|
||||
{formFor ? (
|
||||
<ServerForm onClose={() => setFormFor(null)} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/5 text-zinc-400">
|
||||
<Server className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold text-zinc-100">No Headscale servers yet</div>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
Register a server with its URL and an API key to manage its nodes, users and pre-auth keys from here.
|
||||
Officer supports Headscale {MIN_HEADSCALE_VERSION} and newer.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => setFormFor('new')}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Register a server
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4">
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-3">
|
||||
<SectionHeader
|
||||
title="Servers"
|
||||
subtitle="One server is active at a time; every other section acts on it."
|
||||
action={
|
||||
!formFor && (
|
||||
<Button variant="primary" onClick={() => setFormFor('new')}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Register a server
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{formFor && <ServerForm server={formFor === 'new' ? null : formFor} onClose={() => setFormFor(null)} />}
|
||||
|
||||
{actionError && <ErrorNote>{actionError}</ErrorNote>}
|
||||
|
||||
{servers.map((server) => (
|
||||
<ServerRow
|
||||
key={server.id}
|
||||
server={server}
|
||||
health={health[server.id]}
|
||||
testing={testingId === server.id}
|
||||
busy={busy}
|
||||
onActivate={() => void run(() => activate.mutateAsync(server.id))}
|
||||
onTest={() => void test(server.id)}
|
||||
onEdit={() => setFormFor(server)}
|
||||
onRemove={() => void run(() => remove.mutateAsync(server.id))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { PanelLeft, LayoutGrid } from 'lucide-react';
|
||||
import { HeadscaleNav } from './HeadscaleNav';
|
||||
import { HeadscaleView } from './HeadscaleView';
|
||||
import { HeadscaleViewHeader } from './HeadscaleViewHeader';
|
||||
|
||||
export { HeadscaleNav, HeadscaleView };
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{ key: 'headscale-nav', name: 'Headscale', icon: PanelLeft, component: HeadscaleNav, availableOnPanel: false },
|
||||
{
|
||||
key: 'headscale-view',
|
||||
name: 'Headscale',
|
||||
icon: LayoutGrid,
|
||||
component: HeadscaleView,
|
||||
header: HeadscaleViewHeader,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,42 @@
|
||||
// Shared types/constants for the /headscale workspace panels. Everything here mirrors the wire shapes the
|
||||
// officer-headscale sidecar returns under /api/headscale/_officer/* — deliberately NOT Headscale's own API
|
||||
// shapes. The sidecar absorbs Headscale's quirks (uint64-as-string ids, zero-date sentinels, the version
|
||||
// floor), so these types are stable across Headscale releases and the browser never learns the upstream
|
||||
// version. See src/servers/sidecar/headscale/routes.ts.
|
||||
|
||||
/** Selected section, published by HeadscaleNav and consumed by HeadscaleView. */
|
||||
export const HEADSCALE_SECTION_CHANNEL = 'headscale:section';
|
||||
|
||||
export const HEADSCALE_SECTIONS = [
|
||||
{ id: 'servers', label: 'Servers' },
|
||||
{ id: 'nodes', label: 'Nodes' },
|
||||
{ id: 'users', label: 'Users' },
|
||||
{ id: 'keys', label: 'Pre-auth keys' },
|
||||
] as const;
|
||||
|
||||
export type HeadscaleSectionId = (typeof HEADSCALE_SECTIONS)[number]['id'];
|
||||
|
||||
/** A registered Headscale server. The API key is never included — it stays encrypted in Postgres. */
|
||||
export type HeadscaleServer = {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
version: string | null;
|
||||
isActive: boolean;
|
||||
/** ISO string, or null when we have never successfully probed it. */
|
||||
lastSeenAt: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
/** Result of GET /_officer/servers/:id/health — reachable AND the stored key still works. */
|
||||
export type HeadscaleHealth = {
|
||||
ok: boolean;
|
||||
version?: string;
|
||||
/** `'unknown'` for self-built servers reporting the literal 'dev'. */
|
||||
supported?: boolean | 'unknown';
|
||||
error?: string;
|
||||
ms: number;
|
||||
};
|
||||
|
||||
/** Officer's supported floor, restated for UI copy. The sidecar is the enforcer; this is only a label. */
|
||||
export const MIN_HEADSCALE_VERSION = '0.29';
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import type { HeadscaleServer, HeadscaleHealth } from './shared';
|
||||
|
||||
// The registered-servers cache. Every panel in the /headscale workspace reads this one query, so switching
|
||||
// the active server anywhere updates the whole screen at once.
|
||||
//
|
||||
// Registration is validated server-side before anything is saved (reachable, >=0.29, key accepted), which
|
||||
// means a POST can fail for perfectly ordinary reasons — a typo'd URL, a revoked key. Those are not
|
||||
// exceptional here, so the mutations surface their message rather than swallowing it.
|
||||
|
||||
const SERVERS_KEY = ['headscale', 'servers'] as const;
|
||||
const EMPTY: HeadscaleServer[] = [];
|
||||
|
||||
const BASE = '/headscale/_officer/servers';
|
||||
|
||||
/**
|
||||
* Readable message from a useClient rejection. It throws `{status, message}` where `message` is the raw
|
||||
* body text — JSON `{error}` from our sidecar, but plain text from the platform's own 401/503 paths.
|
||||
*/
|
||||
export function headscaleErrorMessage(err: unknown): string {
|
||||
const raw = (err as { message?: unknown } | null)?.message;
|
||||
if (typeof raw !== 'string' || !raw) return 'Something went wrong';
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { error?: unknown };
|
||||
if (typeof parsed.error === 'string' && parsed.error) return parsed.error;
|
||||
} catch {
|
||||
/* plain text */
|
||||
}
|
||||
return raw.slice(0, 300);
|
||||
}
|
||||
|
||||
export type RegisterServerInput = { name?: string; url: string; apiKey: string };
|
||||
export type UpdateServerInput = { id: number; name?: string; url?: string; apiKey?: string };
|
||||
|
||||
export function useHeadscaleServers() {
|
||||
const { get, post, patch, delete: del } = useClient();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: SERVERS_KEY,
|
||||
queryFn: () => get<{ servers: HeadscaleServer[] }>(BASE),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: SERVERS_KEY });
|
||||
|
||||
const register = useMutation({
|
||||
mutationFn: (input: RegisterServerInput) => post<{ server: HeadscaleServer }>(BASE, input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: ({ id, ...rest }: UpdateServerInput) => patch<{ server: HeadscaleServer }>(`${BASE}/${id}`, rest),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: number) => del(`${BASE}/${id}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const activate = useMutation({
|
||||
mutationFn: (id: number) => post<{ server: HeadscaleServer }>(`${BASE}/${id}/activate`),
|
||||
// Deleting or switching reshuffles which server is active, and every domain query is scoped to it.
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['headscale'] }),
|
||||
});
|
||||
|
||||
const servers = query.data?.servers ?? EMPTY;
|
||||
|
||||
return {
|
||||
servers,
|
||||
active: servers.find((s) => s.isActive) ?? null,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch: query.refetch,
|
||||
register,
|
||||
update,
|
||||
remove,
|
||||
activate,
|
||||
};
|
||||
}
|
||||
|
||||
/** On-demand reachability probe for one server. Never automatic — it costs two upstream round trips. */
|
||||
export function useHeadscaleHealth() {
|
||||
const { get } = useClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => get<HeadscaleHealth>(`${BASE}/${id}/health`),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user