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:
2026-08-15 00:15:38 +00:00
co-authored by Claude Opus 5
parent 0e24aa3d52
commit e13128846b
111 changed files with 351 additions and 302 deletions
+225
View File
@@ -0,0 +1,225 @@
// 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.
export const HEADSCALE_SECTIONS = [
{ id: 'servers', label: 'Servers' },
{ id: 'nodes', label: 'Nodes' },
{ id: 'users', label: 'Users' },
{ id: 'keys', label: 'Pre-auth keys' },
{ id: 'invites', label: 'Device invites' },
{ id: 'policy', label: 'Access policy' },
{ id: 'diagnostics', label: 'Diagnostics' },
{ id: 'console', label: 'Console' },
] as const;
export type HeadscaleSectionId = (typeof HEADSCALE_SECTIONS)[number]['id'];
/** Where /headscale lands, and where an unrecognised section redirects to. */
export const DEFAULT_HEADSCALE_SECTION: HeadscaleSectionId = 'servers';
export const isHeadscaleSection = (value: string | undefined): value is HeadscaleSectionId =>
HEADSCALE_SECTIONS.some((s) => s.id === value);
/** The one place the section URL is spelled, so the nav, the guard and any deep link cannot drift apart. */
export const headscaleSectionPath = (id: HeadscaleSectionId) => `/headscale/${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;
/**
* Where the Console section SSHes. Null when unset. Not derived from `url` on purpose — it exists to reach
* the machine when the control plane's own hostname has stopped answering.
*/
sshHost: 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;
};
/** Result of POST /_officer/ssh-test — can we open a shell there with the keys already on this box. */
export type HeadscaleSshTest = { ok: boolean; 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';
// ── Access policy ─────────────────────────────────────────────────────────────────────────────────
/**
* The tailnet's ACL document, in HuJSON (JSON with comments and trailing commas). Headscale serves it
* whether it is stored in the database or read from a file — so this carries no "is it editable" flag,
* because there is nothing on the server that reports one. Only an attempted save finds out.
*/
export type HeadscalePolicy = {
policy: string;
/** Null when Headscale has never recorded one, which includes every file-backed policy. */
updatedAt: string | null;
};
/** Headscale refused the write outright — this server's policy is read-only over the API. */
export const POLICY_READ_ONLY = 'policy_read_only';
/** Headscale parsed the document and rejected it. The message is a syntax position or a bad reference. */
export const POLICY_REJECTED = 'policy_rejected';
// ── Companion API ─────────────────────────────────────────────────────────────────────────────────
// The Officer Companion is a service deployed next to a Headscale server that can see the container the
// admin API is served from: whether it is running, what it logged, and start/stop/restart. It is optional
// and per-server, so `available: false` is a first-class state rather than an error — the admin API on the
// same domain is independent and may still work. Contract: COMMS/HEADSCALE_COMPANION_API.md.
/** Never available for a companion that is missing — the reason says which flavour of missing. */
type Unavailable = { available: false; reason: string };
export type CompanionVerdict = 'ok' | 'degraded' | 'down' | 'unknown';
export type CompanionContainer = {
status: string;
running: boolean;
exitCode: number;
restartCount: number;
startedAt: string;
/** The RFC3339 zero date (`0001-…`) while the container is running. */
finishedAt: string;
/** Null when the image defines no healthcheck. */
healthcheck: string | null;
};
export type CompanionHealthBody = {
verdict: CompanionVerdict;
/** Whether Headscale's own HTTP is answering — the "is the control plane serving?" signal. */
connected: boolean;
container?: CompanionContainer;
/** Human string for the probe outcome, e.g. `GET /health -> 200`, `unreachable`, `container not running`. */
probe?: string;
/** Only on `unknown`: docker has no container by that name. */
reason?: string;
/** Only when not ok — best-effort guesses read out of the logs. May be empty. */
likelyCauses?: string[];
healthcheckOutput?: string | null;
recentLogs?: string[];
};
export type CompanionHealthResult = ({ available: true } & { health: CompanionHealthBody }) | Unavailable;
export type CompanionLogsResult = { available: true; lines: string[] } | Unavailable;
/** The three lifecycle verbs. `stop`/`start` are what the companion calls `disconnect`/`reconnect`. */
export type CompanionAction = 'restart' | 'stop' | 'start';
export type CompanionActionResult =
| { available: true; ok: boolean; action?: string; result?: string; error?: string }
| Unavailable;
// ── Domain objects ────────────────────────────────────────────────────────────────────────────────
// Ids are strings because Headscale's are uint64 — never parse them to numbers.
export type HeadscaleUser = {
id: string;
name: string;
displayName: string | null;
email: string | null;
provider: string | null;
profilePicUrl: string | null;
createdAt: string | null;
};
export type HeadscaleUserWithCounts = HeadscaleUser & { nodeCount: number; onlineCount: number };
export type HeadscaleNode = {
id: string;
name: string;
hostname: string;
user: HeadscaleUser | null;
ipAddresses: string[];
online: boolean;
lastSeen: string | null;
/** Null means the node's key never expires. */
expiry: string | null;
createdAt: string | null;
registerMethod: string;
tags: string[];
/** What the node advertises. */
availableRoutes: string[];
/** What the admin has approved — the writable set. */
approvedRoutes: string[];
/** What is actually in effect. */
subnetRoutes: string[];
isExitNode: boolean;
};
export type HeadscalePreAuthKey = {
id: string;
/** Non-null ONLY on the creation response — the sidecar strips the secret from every list. */
key: string | null;
/** A never-usable label for telling keys apart in a list, e.g. `hskey-auth-a1b2c3-***`. */
keyDisplay: string;
user: HeadscaleUser | null;
reusable: boolean;
ephemeral: boolean;
used: boolean;
expiration: string | null;
createdAt: string | null;
aclTags: string[];
status: 'active' | 'used' | 'expired';
};
/** The sidecar's 409 when no server is selected, distinguished from a genuine 404. */
export const NO_ACTIVE_SERVER = 'no_active_server';
// ── Device invites ────────────────────────────────────────────────────────────────────────────────
// An invite is a link the admin sends to whoever needs to join. The pre-auth key is minted when the link is
// claimed, not when it is created, so an unused invite never has a credential attached to it. Contract:
// COMMS/OFFSCALE_INVITE_ENROLLMENT.md; the records live on the server's companion, never in Officer.
export type InviteStatus = 'pending' | 'claimed' | 'expired' | 'revoked';
/** What the admin list returns. It carries no claim token and no key — by design, at every status. */
export type HeadscaleInvite = {
id: string;
user: string;
note?: string | null;
status: InviteStatus;
ephemeral?: boolean;
tags?: string[];
createdAt?: string | null;
expiresAt?: string | null;
claimedAt?: string | null;
claimedFromIp?: string | null;
};
/**
* The create response — the ONLY time the link exists. Its fragment holds the claim token, so it is held in
* component state, shown once, and never written to a cache, a query key or a log.
*/
export type HeadscaleInviteCreated = HeadscaleInvite & { url: string };
export type InviteCreateInput = {
user: string;
ttlSeconds: number;
ephemeral: boolean;
tags: string[];
note: string;
};
export type InvitesListResult = { available: true; invites: HeadscaleInvite[] } | Unavailable;
export type InviteCreateResult = { available: true; invite: HeadscaleInviteCreated } | Unavailable;
/** Spec §4.1. The floor is Officer's: a sub-minute invite cannot be sent to anybody in time. */
export const INVITE_TTL_MIN_SECONDS = 60;
export const INVITE_TTL_DEFAULT_SECONDS = 900;
export const INVITE_TTL_MAX_SECONDS = 86_400;