add the officer-headscale sidecar and its server registry ui
officer-headscale owns the whole Headscale contract: the registered servers and their admin api keys, the >=0.29 version floor, and every multi-call composition the ui needs. the platform side is auth+forward only and holds no headscale credentials, so the existing /api/vpn/enroll route and its HEADSCALE_* env vars are untouched and unrelated. officer manages many servers rather than one. the owner registers each with a url and a key generated on that server and switches between them; exactly one is active, enforced by a partial unique index rather than by convention. keys are encrypted at rest and never leave the sidecar — the list projection cannot return one. registration validates before it saves: an unauthenticated GET /version to prove something headscale-shaped is there and meets the floor, then an authenticated call to prove the key works. an edit that moves either half re-validates. there is deliberately no transparent /api/v1/* passthrough. headscale serialises every uint64 as a json string and its rest shape moved repeatedly below 0.29; proxying raw would push all of that into the browser, which is the mistake the soulseek panels made with 37 raw upstream calls. the /headscale workspace is nav + view over the panel system. only the servers section is implemented — nodes, users and pre-auth keys say so plainly rather than rendering an empty table that reads as a failed fetch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { getHeadscaleServerUrl } from './sidecar-server';
|
||||
|
||||
// Thin reverse-proxy for /api/headscale/*. The platform's ONLY job here is AUTH + FORWARDING: this router
|
||||
// mounts under the protected /api tree (userMiddleware upstream authenticates the owner), then forwards the
|
||||
// subpath + query + body to the officer-headscale sidecar, which OWNS the Headscale contract and holds the
|
||||
// admin API key.
|
||||
//
|
||||
// A catch-all with no routes of its own. Unlike /api/slskd this proxies nothing of the upstream's own
|
||||
// surface — the sidecar exposes only Officer-owned routes under /_officer/, because Headscale's REST shape
|
||||
// differs across releases and version handling belongs in the sidecar, not the browser. The full contract
|
||||
// is documented at the top of src/servers/sidecar/headscale/index.ts. It is opaque from here: this file
|
||||
// must never grow Headscale logic.
|
||||
|
||||
export const headscaleRouter = createRouter();
|
||||
|
||||
const PREFIX = '/api/headscale';
|
||||
|
||||
headscaleRouter.all('/*', async (ctx) => {
|
||||
const baseUrl = getHeadscaleServerUrl();
|
||||
if (!baseUrl) return ctx.text('headscale sidecar not available', 503);
|
||||
|
||||
const url = new URL(ctx.req.url);
|
||||
const subpath = url.pathname.slice(PREFIX.length) || '/';
|
||||
const target = `${baseUrl}${subpath}${url.search}`;
|
||||
|
||||
const method = ctx.req.method;
|
||||
const headers: Record<string, string> = {};
|
||||
const contentType = ctx.req.header('content-type');
|
||||
if (contentType) headers['Content-Type'] = contentType;
|
||||
// Forward the authenticated user id so the sidecar can serve its Officer-owned routes. The sidecar binds
|
||||
// loopback only, so this header is trusted.
|
||||
headers['X-Officer-User'] = String(ctx.get('user').id);
|
||||
|
||||
const hasBody = method !== 'GET' && method !== 'HEAD';
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(target, {
|
||||
method,
|
||||
headers,
|
||||
body: hasBody ? await ctx.req.arrayBuffer() : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[headscale] proxy fetch failed', { target, error: String(err) });
|
||||
return ctx.text('headscale sidecar unreachable', 502);
|
||||
}
|
||||
|
||||
return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) });
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
|
||||
// The officer-headscale sidecar starts its HTTP server on a random loopback port and reports it here on
|
||||
// connect. We remember it so `/api/headscale/*` always forwards to the current sidecar. The platform holds
|
||||
// NO knowledge of Headscale itself — not its URL, and emphatically not its admin API key.
|
||||
|
||||
let serverPort: number | null = null;
|
||||
|
||||
sidecar.on('headscale:server', (msg) => {
|
||||
const port = (msg as { port?: number }).port;
|
||||
if (typeof port !== 'number') return;
|
||||
serverPort = port;
|
||||
console.log(`[headscale] sidecar registered on port ${port}`);
|
||||
});
|
||||
|
||||
/** Base URL of the sidecar's HTTP server, or null if the sidecar hasn't reported in yet. */
|
||||
export function getHeadscaleServerUrl(): string | null {
|
||||
return serverPort ? `http://127.0.0.1:${serverPort}` : null;
|
||||
}
|
||||
Reference in New Issue
Block a user