// 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; export type UpstreamNode = Record; export type UpstreamPreAuthKey = Record; 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 = { 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--***`. * 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[] { const value = (body as Record | null)?.[field]; return Array.isArray(value) ? (value.filter((v) => v && typeof v === 'object') as Record[]) : []; }