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>
188 lines
7.3 KiB
TypeScript
188 lines
7.3 KiB
TypeScript
// 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>[]) : [];
|
|
}
|