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
+187
View File
@@ -0,0 +1,187 @@
// Officer-shaped views of Headscale's admin API objects, and the quirk handling that gets us there.
//
// Headscale's REST layer is a gRPC gateway marshalling protobuf, which leaks in three ways we normalize
// here so nothing downstream has to know:
//
// 1. Every uint64 is a JSON STRING. Ids stay strings end to end — never Number() them, that breaks
// silently above 2^53 and Headscale's ids are database-assigned, not small by contract.
// 2. Unset timestamps are the protobuf zero value, serialized as '0001-01-01T00:00:00Z' rather than
// omitted. Rendered naively that reads as the year 1 — it means "never", so it becomes null.
// 3. EmitUnpopulated means absent repeated fields arrive as [] and absent messages as null; there is no
// way to distinguish "unset" from "empty", so every accessor tolerates both.
/** Protobuf's zero timestamp. Headscale sends this for "never expires", "never seen", and friends. */
const ZERO_TIME = '0001-01-01T00:00:00Z';
/** An upstream timestamp as an ISO string, or null when it is unset/the protobuf zero value. */
export function isoOrNull(raw: unknown): string | null {
if (typeof raw !== 'string' || !raw || raw === ZERO_TIME) return null;
const ms = Date.parse(raw);
if (Number.isNaN(ms)) return null;
// Some builds emit years far outside anything meaningful; treat pre-1971 as the sentinel too.
return ms < 31_536_000_000 ? null : new Date(ms).toISOString();
}
const str = (raw: unknown): string => (typeof raw === 'string' ? raw : '');
const strArray = (raw: unknown): string[] =>
Array.isArray(raw) ? raw.filter((v): v is string => typeof v === 'string') : [];
export type UpstreamUser = Record<string, unknown>;
export type UpstreamNode = Record<string, unknown>;
export type UpstreamPreAuthKey = Record<string, unknown>;
export type OfficerUser = {
id: string;
name: string;
displayName: string | null;
email: string | null;
/** The OIDC provider, when the user came from one. Null for CLI/API-created users. */
provider: string | null;
profilePicUrl: string | null;
createdAt: string | null;
};
export function toUser(raw: UpstreamUser | null | undefined): OfficerUser | null {
if (!raw || typeof raw !== 'object') return null;
const id = str(raw.id);
if (!id) return null;
return {
id,
name: str(raw.name),
displayName: str(raw.displayName) || null,
email: str(raw.email) || null,
provider: str(raw.provider) || null,
profilePicUrl: str(raw.profilePicUrl) || null,
createdAt: isoOrNull(raw.createdAt),
};
}
export type OfficerNode = {
id: string;
/** The name Headscale actually uses in the tailnet — givenName when set, otherwise the reported hostname. */
name: string;
hostname: string;
user: OfficerUser | null;
ipAddresses: string[];
online: boolean;
lastSeen: string | null;
/** When the node's key expires and it must re-authenticate. Null means it never expires. */
expiry: string | null;
createdAt: string | null;
/** How the node joined: 'authkey' | 'cli' | 'oidc' | 'unknown'. */
registerMethod: string;
tags: string[];
/** Routes the node advertises. */
availableRoutes: string[];
/** The subset the admin has approved — the writable one. */
approvedRoutes: string[];
/** Routes actually in effect (approved ∩ available, as Headscale computes it). */
subnetRoutes: string[];
/** True when the node advertises an exit node route. Purely derived, for the UI's badge. */
isExitNode: boolean;
};
const EXIT_ROUTES = new Set(['0.0.0.0/0', '::/0']);
const REGISTER_METHODS: Record<string, string> = {
REGISTER_METHOD_AUTH_KEY: 'authkey',
REGISTER_METHOD_CLI: 'cli',
REGISTER_METHOD_OIDC: 'oidc',
};
export function toNode(raw: UpstreamNode): OfficerNode {
const givenName = str(raw.givenName);
const hostname = str(raw.name);
const availableRoutes = strArray(raw.availableRoutes);
return {
id: str(raw.id),
name: givenName || hostname,
hostname,
user: toUser(raw.user as UpstreamUser),
ipAddresses: strArray(raw.ipAddresses),
online: raw.online === true,
lastSeen: isoOrNull(raw.lastSeen),
expiry: isoOrNull(raw.expiry),
createdAt: isoOrNull(raw.createdAt),
registerMethod: REGISTER_METHODS[str(raw.registerMethod)] ?? 'unknown',
tags: strArray(raw.tags),
availableRoutes,
approvedRoutes: strArray(raw.approvedRoutes),
subnetRoutes: strArray(raw.subnetRoutes),
isExitNode: availableRoutes.some((r) => EXIT_ROUTES.has(r)),
};
}
export type OfficerPreAuthKey = {
id: string;
/**
* The usable secret. Non-null ONLY on the creation response — the list path nulls it unconditionally,
* so a secret can never reach the browser except at the moment it is created and must be shown once.
*/
key: string | null;
/** A never-usable label for identifying a key in a list, e.g. `hskey-auth-a1b2c3-***`. */
keyDisplay: string;
user: OfficerUser | null;
reusable: boolean;
ephemeral: boolean;
used: boolean;
expiration: string | null;
createdAt: string | null;
aclTags: string[];
/** Derived lifecycle, so every surface agrees on what "spent" means. */
status: 'active' | 'used' | 'expired';
};
/**
* A display label that is never a usable secret.
*
* Headscale 0.28+ stores keys bcrypt-hashed and lists them already masked as `hskey-auth-<prefix>-***`.
* But keys created BEFORE 0.28 are still plaintext in its database, and `PreAuthKey.Proto()` returns those
* in full from the list endpoint "for backwards compatibility" — its own source carries a TODO about
* hiding them. So a list response on a server with history in it really does contain live secrets. We mask
* anything that isn't already masked rather than trusting the upstream to have done it.
*/
function displayLabel(key: string): string {
if (!key) return '(no key)';
if (key.endsWith('***')) return key;
return `${key.slice(0, 6)}…-***`;
}
type ToPreAuthKeyOptions = {
/**
* True only on the creation response, where the secret is the entire point and exists nowhere else.
* Everywhere else this is false and the secret is dropped before it can reach a cache or a browser.
*/
reveal: boolean;
};
export function toPreAuthKey(raw: UpstreamPreAuthKey, { reveal }: ToPreAuthKeyOptions): OfficerPreAuthKey {
const expiration = isoOrNull(raw.expiration);
const reusable = raw.reusable === true;
const used = raw.used === true;
const key = str(raw.key);
// A reusable key stays usable after a node has claimed it, so `used` alone doesn't mean spent.
const expired = !!expiration && Date.parse(expiration) < Date.now();
const status: OfficerPreAuthKey['status'] = expired ? 'expired' : used && !reusable ? 'used' : 'active';
return {
id: str(raw.id),
key: reveal ? key || null : null,
keyDisplay: displayLabel(key),
user: toUser(raw.user as UpstreamUser),
reusable,
ephemeral: raw.ephemeral === true,
used,
expiration,
createdAt: isoOrNull(raw.createdAt),
aclTags: strArray(raw.aclTags),
status,
};
}
/** Read an array field out of a gateway response, tolerating the null/absent forms. */
export function arrayField(body: unknown, field: string): Record<string, unknown>[] {
const value = (body as Record<string, unknown> | null)?.[field];
return Array.isArray(value) ? (value.filter((v) => v && typeof v === 'object') as Record<string, unknown>[]) : [];
}