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>
226 lines
9.6 KiB
TypeScript
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;
|