offscale is a plugin
headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.
api/router.ts the thin auth-gated proxy, now at /api/offscale
sidecar/ 18 files, the whole headscale contract and its admin keys
db/ schema + queries, offscale_servers
web/ 26 files as panels and a layout — no screen, per the rule
removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.
the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.
AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.
install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.
verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.
757 pass, same 10 pre-existing failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
|
||||
import { createSidecarConnector } from '@@/sidecar/connect';
|
||||
import { handleOfficerRoute } from './routes';
|
||||
import { MIN_VERSION_LABEL } from './version';
|
||||
import { API_URL } from '@@/officer-url.mjs';
|
||||
|
||||
// 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.
|
||||
// Device enrollment used to be the exception, minting keys in the platform from those two vars plus
|
||||
// HEADSCALE_USER; it moved here (enroll.ts) and now acts on the active server like everything else.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// 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?
|
||||
//
|
||||
// Everything below acts on the ACTIVE server. 409 when none is selected — see active.ts.
|
||||
//
|
||||
// GET /_officer/nodes nodes, normalized; ?user=<username> filters
|
||||
// GET /_officer/nodes/:id one node
|
||||
// DELETE /_officer/nodes/:id remove it from the tailnet
|
||||
// POST /_officer/nodes/:id/rename {name}
|
||||
// POST /_officer/nodes/:id/tags {tags} — 'tag:' prefix added if missing
|
||||
// POST /_officer/nodes/:id/routes {routes} whole set, or {route,approved} single toggle (RMW here)
|
||||
// POST /_officer/nodes/:id/expire expire its key, forcing re-auth (not a delete)
|
||||
// GET /_officer/users users, each with a node count the admin API doesn't provide
|
||||
// POST /_officer/users {name, displayName?, email?}
|
||||
// POST /_officer/users/:id/rename {name}
|
||||
// DELETE /_officer/users/:id refused upstream while the user still owns nodes
|
||||
// GET /_officer/keys pre-auth keys, secrets masked, with a derived status
|
||||
// POST /_officer/keys {userId, reusable?, ephemeral?, expirationDays?, aclTags?}
|
||||
// → the ONLY response carrying the real secret
|
||||
// POST /_officer/keys/:id/expire expire without deleting
|
||||
// DELETE /_officer/keys/:id delete outright
|
||||
// POST /_officer/enroll {userId?} → {controlUrl, authKey} — a single-use 10-minute key
|
||||
// for a joining device. userId is only required when the server
|
||||
// has more than one user.
|
||||
// NO CALLER since 2026-08-14: its only door was /api/vpn/enroll,
|
||||
// which is deleted. Kept because it is the handler a route under
|
||||
// /api/offscale would reuse, and because `/enroll/invites` — which
|
||||
// IS live — dispatches through the same function.
|
||||
// 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.
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 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'));
|
||||
Reference in New Issue
Block a user