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>
116 lines
6.2 KiB
TypeScript
116 lines
6.2 KiB
TypeScript
import type { OfficerContext } from './routes';
|
|
import { badRequest, methodNotAllowed, readJson } from './routes';
|
|
import { HeadscaleError } from './client';
|
|
import { activeClient } from './active';
|
|
import { handlePolicyAssistRoute } from './assist';
|
|
|
|
// The ACL policy — /_officer/policy. One HuJSON document that decides which node may reach which, so it is
|
|
// the highest-consequence thing this app can write and the only place a typo silently partitions a network.
|
|
//
|
|
// Three upstream behaviours drive the shape of this file.
|
|
//
|
|
// 1. **Readable always, writable sometimes.** Headscale can keep its policy in a file (`policy.mode: file`)
|
|
// instead of the database, and then the API still SERVES it — a GET returns the file's contents quite
|
|
// happily — but a PUT is refused with "update is disabled for modes other than 'database'". Verified
|
|
// against a live server, and it means the mode CANNOT be inferred from a read. There is no endpoint
|
|
// that reports it either. So this route makes no claim about writability up front; the first save is
|
|
// what finds out, and a refusal is a 409 the UI turns into a persistent read-only banner.
|
|
//
|
|
// 2. **Validation happens on PUT, in Headscale, and its message is the whole value.** It parses the
|
|
// HuJSON, resolves every group and tag reference, and rejects the write with a line and column or a
|
|
// "group not defined" naming the offender. Officer must not pre-validate: a second, weaker parser here
|
|
// would reject documents Headscale accepts and — worse — accept ones it rejects, and its opinion would
|
|
// be the one shown. So the text goes up untouched and Headscale's verdict comes back verbatim.
|
|
//
|
|
// 3. **Both of those arrive as HTTP 500** from grpc-gateway, which the client layer normally generalizes
|
|
// to "headscale error". `HeadscaleError.detail` is how the real message survives that; see client.ts.
|
|
|
|
/**
|
|
* Does this failure mean "writing is turned off here", as opposed to "your document is wrong"?
|
|
*
|
|
* Matched on the message because Headscale gives no code to match on. Deliberately broad: a false positive
|
|
* costs a slightly-wrong banner over a message the owner can still read, while a false negative would tell
|
|
* someone their perfectly good ACL was rejected and send them hunting for a syntax error that isn't there.
|
|
*/
|
|
function isWriteDisabled(detail: string): boolean {
|
|
const text = detail.toLowerCase();
|
|
if (text.includes('disabled')) return true;
|
|
return text.includes('file') && (text.includes('policy') || text.includes('mode'));
|
|
}
|
|
|
|
type PolicyBody = { policy?: unknown; updatedAt?: unknown };
|
|
|
|
const asText = (value: unknown) => (typeof value === 'string' ? value : '');
|
|
const asDate = (value: unknown) => (typeof value === 'string' && value && !value.startsWith('0001-') ? value : null);
|
|
|
|
/**
|
|
* `GET /_officer/policy`.
|
|
*
|
|
* Answers 200 for every state a running server can be in, including "there is no policy yet" — a fresh
|
|
* Headscale has none, and an empty editor is both the honest rendering of that and the thing the owner
|
|
* needs to start typing into. Only an unreachable server is an error, because only that leaves nothing
|
|
* to say. Note there is no `mode` here on purpose: see the header.
|
|
*/
|
|
async function getPolicy(ctx: OfficerContext): Promise<Response> {
|
|
const client = await activeClient(ctx.userId);
|
|
if (client instanceof Response) return client;
|
|
|
|
try {
|
|
const body = await client.call<PolicyBody>('/api/v1/policy');
|
|
return Response.json({ policy: asText(body.policy), updatedAt: asDate(body.updatedAt) });
|
|
} catch (err) {
|
|
if (!(err instanceof HeadscaleError)) throw err;
|
|
const detail = err.detail ?? err.message;
|
|
if (err.status === 404 || detail.toLowerCase().includes('not found')) {
|
|
return Response.json({ policy: '', updatedAt: null });
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `PUT /_officer/policy {policy}`.
|
|
*
|
|
* The body is sent up byte for byte — no trimming, no reformatting, no parse. Comments and layout are load
|
|
* bearing in a hand-maintained ACL, and re-serializing would destroy both.
|
|
*/
|
|
async function putPolicy(ctx: OfficerContext): Promise<Response> {
|
|
const client = await activeClient(ctx.userId);
|
|
if (client instanceof Response) return client;
|
|
|
|
const body = await readJson(ctx.req);
|
|
if (!body) return badRequest('expected a JSON body');
|
|
if (typeof body.policy !== 'string') return badRequest('policy must be a string');
|
|
// An empty document would be accepted by some Headscale versions and lock every node out of every other
|
|
// one. Deleting a policy is not something to do by leaving a textarea blank and pressing save.
|
|
if (!body.policy.trim()) return badRequest('the policy is empty — that would deny every connection');
|
|
|
|
try {
|
|
const saved = await client.call<PolicyBody>('/api/v1/policy', { method: 'PUT', body: { policy: body.policy } });
|
|
// Headscale echoes what it stored; fall back to what we sent if it echoes nothing, so a successful save
|
|
// never blanks the editor.
|
|
return Response.json({ policy: asText(saved.policy) || body.policy, updatedAt: asDate(saved.updatedAt) });
|
|
} catch (err) {
|
|
if (!(err instanceof HeadscaleError)) throw err;
|
|
const detail = err.detail ?? err.message;
|
|
|
|
if (isWriteDisabled(detail)) {
|
|
return Response.json({ error: detail, code: 'policy_read_only' }, { status: 409 });
|
|
}
|
|
// Everything else on a PUT is Headscale rejecting this document: a syntax error with a position, an
|
|
// unresolvable group, an unknown tag owner. 422 rather than 502 — the request is the problem, and the
|
|
// message is the one thing that will fix it.
|
|
return Response.json({ error: detail, code: 'policy_rejected' }, { status: 422 });
|
|
}
|
|
}
|
|
|
|
/** Dispatch `/_officer/policy`. One policy per server, plus the drafting assistant beside it. */
|
|
export async function handlePolicyRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
|
|
// `/policy/assist` proposes a document; it never writes one. See assist.ts.
|
|
if (rest[0] === 'assist') return handlePolicyAssistRoute(ctx, rest.slice(1));
|
|
if (rest.length > 0) return badRequest('unexpected path');
|
|
if (ctx.req.method === 'GET') return getPolicy(ctx);
|
|
if (ctx.req.method === 'PUT') return putPolicy(ctx);
|
|
return methodNotAllowed();
|
|
}
|