Files
offscale/web/shared.ts
T
pastilhasandClaude Opus 5 8a446bb4b5 offscale, extracted from the platform into its own repository
The tailnet plugin — machines, users, pre-auth keys, access policy and device
invites. Moved out of officerdev/platform, where it had lived in plugins/ since
the plugin system was built.

Until now this code existed in exactly one place: the platform repository. That
made "gitignore the plugins directory" impossible to do safely, because
untracking it would have left 49 files on a single disk with no remote. This
repository is what makes that move safe.

Same extraction as plugins/music before it: source only, no history. The
platform's history still holds every commit that shaped this, and the SHAs cited
across the codebase keep resolving — replaying it here would have created a
second, divergent account of the same work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 18:12:57 +00:00

226 lines
9.6 KiB
TypeScript

// 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;