domain routes in the sidecar: nodes with per-route approval done as a read-modify-write (headscale's approve_routes replaces the whole set), users enriched with node counts, and pre-auth keys. pre-auth key secrets are revealed by call path, not by inspecting the value. headscale masks keys created since 0.28, but returns older plaintext ones in full from the list endpoint for backwards compatibility, so listing would otherwise ship live secrets into the browser's query cache. the list always nulls the secret; only creation reveals it, and the ui shows it once with a copy affordance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
188 lines
7.3 KiB
TypeScript
188 lines
7.3 KiB
TypeScript
// 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<string, unknown>;
|
|
export type UpstreamNode = Record<string, unknown>;
|
|
export type UpstreamPreAuthKey = Record<string, unknown>;
|
|
|
|
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<string, string> = {
|
|
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-<prefix>-***`.
|
|
* 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<string, unknown>[] {
|
|
const value = (body as Record<string, unknown> | null)?.[field];
|
|
return Array.isArray(value) ? (value.filter((v) => v && typeof v === 'object') as Record<string, unknown>[]) : [];
|
|
}
|