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