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,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;
|
||||
Reference in New Issue
Block a user