diff --git a/src/servers/sidecar/headscale/active.ts b/src/servers/sidecar/headscale/active.ts new file mode 100644 index 00000000..560d1003 --- /dev/null +++ b/src/servers/sidecar/headscale/active.ts @@ -0,0 +1,20 @@ +import { getActiveHeadscaleCredentials } from 'officerdb'; +import { createClient, type HeadscaleClient } from './client'; + +// Every domain route acts on the ACTIVE server — the one the owner selected in the servers section. That +// choice lives in Postgres (one row, enforced by a partial unique index), not in a request parameter, so +// no client can act on a server the owner isn't currently looking at by guessing an id. + +/** + * The client for the active server, or a ready-to-send 409 when there isn't one. + * + * 409 rather than 404: the route exists and the request was well-formed, the account just has no server + * selected yet. The UI maps it to "pick a server", which is a different message from "that node is gone". + */ +export async function activeClient(userId: number): Promise { + const creds = await getActiveHeadscaleCredentials(userId); + if (!creds) { + return Response.json({ error: 'no active Headscale server', code: 'no_active_server' }, { status: 409 }); + } + return createClient(creds); +} diff --git a/src/servers/sidecar/headscale/index.ts b/src/servers/sidecar/headscale/index.ts index 1480f9e4..e441e3c1 100644 --- a/src/servers/sidecar/headscale/index.ts +++ b/src/servers/sidecar/headscale/index.ts @@ -24,6 +24,25 @@ import { MIN_VERSION_LABEL } from './version'; // DELETE /_officer/servers/:id deregister; promotes the newest survivor if it was active // POST /_officer/servers/:id/activate switch the active server // GET /_officer/servers/:id/health probe: reachable? version? key still accepted? +// +// Everything below acts on the ACTIVE server. 409 when none is selected — see active.ts. +// +// GET /_officer/nodes nodes, normalized; ?user= filters +// GET /_officer/nodes/:id one node +// DELETE /_officer/nodes/:id remove it from the tailnet +// POST /_officer/nodes/:id/rename {name} +// POST /_officer/nodes/:id/tags {tags} — 'tag:' prefix added if missing +// POST /_officer/nodes/:id/routes {routes} whole set, or {route,approved} single toggle (RMW here) +// POST /_officer/nodes/:id/expire expire its key, forcing re-auth (not a delete) +// GET /_officer/users users, each with a node count the admin API doesn't provide +// POST /_officer/users {name, displayName?, email?} +// POST /_officer/users/:id/rename {name} +// DELETE /_officer/users/:id refused upstream while the user still owns nodes +// GET /_officer/keys pre-auth keys, secrets masked, with a derived status +// POST /_officer/keys {userId, reusable?, ephemeral?, expirationDays?, aclTags?} +// → the ONLY response carrying the real secret +// POST /_officer/keys/:id/expire expire without deleting +// DELETE /_officer/keys/:id delete outright // anything else 404 // // There is deliberately NO transparent /api/v1/* passthrough. Headscale's REST shape changed repeatedly diff --git a/src/servers/sidecar/headscale/keys.ts b/src/servers/sidecar/headscale/keys.ts new file mode 100644 index 00000000..dbf3e6d5 --- /dev/null +++ b/src/servers/sidecar/headscale/keys.ts @@ -0,0 +1,119 @@ +import type { OfficerContext } from './routes'; +import { badRequest, notFound, methodNotAllowed, readJson } from './routes'; +import { activeClient } from './active'; +import { toPreAuthKey, arrayField } from './normalize'; + +// Pre-auth key routes — /_officer/keys/*. These are the tokens a machine uses to join the tailnet. +// +// The one thing that matters here: since 0.28 Headscale stores pre-auth keys HASHED and returns the real +// secret ONLY in the create response. Every later list returns it masked as `hskey-auth--***`. A +// creation response that the UI drops is a key the owner can never recover — it has to be shown once, with +// a copy affordance, and the API has to make the difference legible. `key` is non-null exactly once. +// +// That "exactly once" is enforced by call path, not by inspecting the value: keys created before 0.28 are +// still plaintext upstream and Headscale hands them back in full from the LIST endpoint for backwards +// compatibility. So listing passes reveal:false and drops the secret unconditionally; only createKey +// reveals. A server with history in it would otherwise leak live keys into the browser's query cache. +// +// Also note the shape of the delete/expire pair: expire takes the id in a POST BODY, delete takes it in a +// query STRING, and neither is a REST-shaped path. Both are hidden behind ordinary Officer routes. + +/** Default lifetime when the caller doesn't pick one; matches Headscale's own CLI default. */ +const DEFAULT_EXPIRY_DAYS = 90; +const MAX_EXPIRY_DAYS = 3650; + +async function listKeys(ctx: OfficerContext): Promise { + const client = await activeClient(ctx.userId); + if (client instanceof Response) return client; + + // 0.29 lists every user's keys in one call (pre-0.29 required a ?user= filter and one call per user). + const body = await client.call('/api/v1/preauthkey'); + const keys = arrayField(body, 'preAuthKeys').map((raw) => toPreAuthKey(raw, { reveal: false })); + + // Usable keys first, then by newest — a spent key is history, an active one is the thing you came for. + const rank = { active: 0, used: 1, expired: 2 } as const; + keys.sort((a, b) => rank[a.status] - rank[b.status] || (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); + + return Response.json({ keys }); +} + +async function createKey(ctx: OfficerContext): Promise { + const client = await activeClient(ctx.userId); + if (client instanceof Response) return client; + + const body = await readJson(ctx.req); + if (!body) return badRequest('expected a JSON body'); + + // CreatePreAuthKey takes a numeric user ID — unlike the node list filter, which takes a username. The + // two are easy to confuse and the failure is a confusing upstream error, so it's validated here. + const userId = typeof body.userId === 'string' ? body.userId.trim() : ''; + if (!/^\d+$/.test(userId)) return badRequest('userId must be the numeric id of a Headscale user'); + + const days = body.expirationDays === undefined ? DEFAULT_EXPIRY_DAYS : Number(body.expirationDays); + if (!Number.isFinite(days) || days <= 0 || days > MAX_EXPIRY_DAYS) { + return badRequest(`expirationDays must be between 1 and ${MAX_EXPIRY_DAYS}`); + } + + const aclTags = Array.isArray(body.aclTags) + ? body.aclTags + .filter((t): t is string => typeof t === 'string') + .map((t) => t.trim()) + .filter(Boolean) + .map((t) => (t.startsWith('tag:') ? t : `tag:${t}`)) + : []; + + const created = await client.call<{ preAuthKey?: Record }>('/api/v1/preauthkey', { + method: 'POST', + body: { + user: userId, + reusable: body.reusable === true, + ephemeral: body.ephemeral === true, + expiration: new Date(Date.now() + days * 86_400_000).toISOString(), + aclTags, + }, + }); + + if (!created.preAuthKey) return Response.json({ error: 'headscale returned no key' }, { status: 502 }); + + const key = toPreAuthKey(created.preAuthKey, { reveal: true }); + // Stated explicitly rather than left for the client to infer from `key !== null`: this response is the + // only time the secret exists anywhere outside the joining machine. + return Response.json({ key, secretShownOnce: true }, { status: 201 }); +} + +type KeyActionParams = { ctx: OfficerContext; id: string; action: string | undefined }; + +async function handleKeyAction({ ctx, id, action }: KeyActionParams): Promise { + const client = await activeClient(ctx.userId); + if (client instanceof Response) return client; + + if (action === 'expire') { + if (ctx.req.method !== 'POST') return methodNotAllowed(); + await client.call('/api/v1/preauthkey/expire', { method: 'POST', body: { id } }); + return new Response(null, { status: 204 }); + } + + if (action !== undefined) return notFound(); + + if (ctx.req.method === 'DELETE') { + await client.call(`/api/v1/preauthkey?id=${encodeURIComponent(id)}`, { method: 'DELETE' }); + return new Response(null, { status: 204 }); + } + + return methodNotAllowed(); +} + +/** Dispatch `/_officer/keys/...`. `rest` is the path after `keys`. */ +export async function handleKeysRoute(ctx: OfficerContext, rest: string[]): Promise { + if (rest.length === 0) { + if (ctx.req.method === 'GET') return listKeys(ctx); + if (ctx.req.method === 'POST') return createKey(ctx); + return methodNotAllowed(); + } + if (rest.length > 2) return notFound(); + + const id = rest[0]; + if (!id || !/^\d+$/.test(id)) return badRequest('key id must be numeric'); + + return handleKeyAction({ ctx, id, action: rest[1] }); +} diff --git a/src/servers/sidecar/headscale/nodes.ts b/src/servers/sidecar/headscale/nodes.ts new file mode 100644 index 00000000..7fac4c9c --- /dev/null +++ b/src/servers/sidecar/headscale/nodes.ts @@ -0,0 +1,129 @@ +import type { OfficerContext } from './routes'; +import { badRequest, notFound, methodNotAllowed, readJson } from './routes'; +import type { HeadscaleClient } from './client'; +import { activeClient } from './active'; +import { toNode, arrayField, type OfficerNode } from './normalize'; + +// Node routes — /_officer/nodes/*. A "node" is a machine in the tailnet. +// +// Two upstream shapes are worth knowing before reading this: +// +// • Renaming takes the new name in the PATH (`/node/{id}/rename/{newName}`), not a body. It must be +// encodeURIComponent'd or a name with a slash silently becomes a 404 on a different route. +// • Route approval is a whole-SET write (`approve_routes` replaces the approved list), not an +// add/remove. Approving one route means sending every route that should remain approved, so those +// operations are read-modify-write here rather than in the browser — see rule 5 in +// SIDECAR_ARCHITECTURE.md. Doing it client-side would make two admins racing lose each other's edits; +// doing it here still races, but over milliseconds instead of however long a form sits open. + +/** Nodes on the active server, newest-registered first within each user. */ +async function listNodes(ctx: OfficerContext): Promise { + const client = await activeClient(ctx.userId); + if (client instanceof Response) return client; + + // The upstream `user` filter takes a USERNAME, not an id — a trap worth keeping out of the browser. + const user = ctx.url.searchParams.get('user'); + const path = user ? `/api/v1/node?user=${encodeURIComponent(user)}` : '/api/v1/node'; + + const body = await client.call(path); + const nodes = arrayField(body, 'nodes').map(toNode); + nodes.sort((a, b) => Number(b.online) - Number(a.online) || a.name.localeCompare(b.name)); + return Response.json({ nodes }); +} + +/** Re-read one node after a mutation. Headscale's mutation responses are inconsistent; a GET never is. */ +async function getNode(client: HeadscaleClient, id: string): Promise { + const body = await client.call<{ node?: Record }>(`/api/v1/node/${encodeURIComponent(id)}`); + return body.node ? toNode(body.node) : null; +} + +type NodeActionParams = { ctx: OfficerContext; id: string; action: string | undefined }; + +async function handleNodeAction({ ctx, id, action }: NodeActionParams): Promise { + const { req } = ctx; + const client = await activeClient(ctx.userId); + if (client instanceof Response) return client; + + if (action === undefined) { + if (req.method === 'GET') { + const node = await getNode(client, id); + return node ? Response.json({ node }) : notFound('no such node'); + } + if (req.method === 'DELETE') { + await client.call(`/api/v1/node/${encodeURIComponent(id)}`, { method: 'DELETE' }); + return new Response(null, { status: 204 }); + } + return methodNotAllowed(); + } + + if (req.method !== 'POST') return methodNotAllowed(); + + if (action === 'rename') { + const body = await readJson(req); + if (!body) return badRequest('expected a JSON body'); + const name = typeof body.name === 'string' ? body.name.trim() : ''; + if (!name) return badRequest('name is required'); + await client.call(`/api/v1/node/${encodeURIComponent(id)}/rename/${encodeURIComponent(name)}`, { method: 'POST' }); + return Response.json({ node: await getNode(client, id) }); + } + + if (action === 'tags') { + const body = await readJson(req); + if (!body) return badRequest('expected a JSON body'); + if (!Array.isArray(body.tags)) return badRequest('tags must be an array of strings'); + const tags = body.tags.filter((t): t is string => typeof t === 'string').map((t) => t.trim()); + if (tags.some((t) => !t)) return badRequest('tags cannot be empty strings'); + // Headscale requires the `tag:` prefix and rejects anything else with a 500, which we'd surface as a + // useless "headscale error". Normalizing here means the UI can accept either form. + const prefixed = tags.map((t) => (t.startsWith('tag:') ? t : `tag:${t}`)); + await client.call(`/api/v1/node/${encodeURIComponent(id)}/tags`, { method: 'POST', body: { tags: prefixed } }); + return Response.json({ node: await getNode(client, id) }); + } + + if (action === 'routes') { + const body = await readJson(req); + if (!body) return badRequest('expected a JSON body'); + + let routes: string[]; + if (Array.isArray(body.routes)) { + // Whole-set write: the caller states the complete approved list. + routes = body.routes.filter((r): r is string => typeof r === 'string'); + } else if (typeof body.route === 'string' && typeof body.approved === 'boolean') { + // Single-toggle: read the current set, apply one change, write it back. + const current = await getNode(client, id); + if (!current) return notFound('no such node'); + const set = new Set(current.approvedRoutes); + if (body.approved) set.add(body.route); + else set.delete(body.route); + routes = [...set]; + } else { + return badRequest('expected {routes: string[]} or {route: string, approved: boolean}'); + } + + await client.call(`/api/v1/node/${encodeURIComponent(id)}/approve_routes`, { method: 'POST', body: { routes } }); + return Response.json({ node: await getNode(client, id) }); + } + + if (action === 'expire') { + // Expires the node's key, forcing it to re-authenticate. Not a delete: the node stays registered. + await client.call(`/api/v1/node/${encodeURIComponent(id)}/expire`, { method: 'POST' }); + return Response.json({ node: await getNode(client, id) }); + } + + return notFound(); +} + +/** Dispatch `/_officer/nodes/...`. `rest` is the path after `nodes`. */ +export async function handleNodesRoute(ctx: OfficerContext, rest: string[]): Promise { + if (rest.length === 0) { + if (ctx.req.method !== 'GET') return methodNotAllowed(); + return listNodes(ctx); + } + if (rest.length > 2) return notFound(); + + const id = rest[0]; + // Upstream ids are uint64-as-string. Validate the shape without parsing — Number() would lose precision. + if (!id || !/^\d+$/.test(id)) return badRequest('node id must be numeric'); + + return handleNodeAction({ ctx, id, action: rest[1] }); +} diff --git a/src/servers/sidecar/headscale/normalize.ts b/src/servers/sidecar/headscale/normalize.ts new file mode 100644 index 00000000..fc96f8ab --- /dev/null +++ b/src/servers/sidecar/headscale/normalize.ts @@ -0,0 +1,187 @@ +// 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[]) : []; +} diff --git a/src/servers/sidecar/headscale/routes.ts b/src/servers/sidecar/headscale/routes.ts index c33102d5..98380945 100644 --- a/src/servers/sidecar/headscale/routes.ts +++ b/src/servers/sidecar/headscale/routes.ts @@ -1,5 +1,8 @@ import { HeadscaleError } from './client'; import { handleServersRoute } from './servers'; +import { handleNodesRoute } from './nodes'; +import { handleUsersRoute } from './users'; +import { handleKeysRoute } from './keys'; // Officer-owned routes for the headscale sidecar — the entire feature surface lives under /_officer/. // @@ -17,6 +20,12 @@ export const notFound = (error = 'not found') => Response.json({ error }, { stat /** 405 when the path exists but the verb doesn't. */ export const methodNotAllowed = () => Response.json({ error: 'method not allowed' }, { status: 405 }); +/** Parse a JSON request body, or null when there isn't one / it isn't an object. */ +export async function readJson(req: Request): Promise | null> { + const body = await req.json().catch(() => null); + return body && typeof body === 'object' && !Array.isArray(body) ? (body as Record) : null; +} + /** * Dispatch an /_officer/* request. Returns null when nothing matches, which the caller turns into a 404. * @@ -39,7 +48,13 @@ export async function handleOfficerRoute(req: Request, url: URL): Promise { + const client = await activeClient(ctx.userId); + if (client instanceof Response) return client; + + const [userBody, nodeBody] = await Promise.all([client.call('/api/v1/user'), client.call('/api/v1/node')]); + + const nodes = arrayField(nodeBody, 'nodes').map(toNode); + const counts = new Map(); + for (const node of nodes) { + const id = node.user?.id; + if (!id) continue; + const entry = counts.get(id) ?? { total: 0, online: 0 }; + entry.total += 1; + if (node.online) entry.online += 1; + counts.set(id, entry); + } + + const users: UserWithCounts[] = arrayField(userBody, 'users') + .map(toUser) + .filter((u): u is OfficerUser => !!u) + .map((u) => ({ ...u, nodeCount: counts.get(u.id)?.total ?? 0, onlineCount: counts.get(u.id)?.online ?? 0 })) + .sort((a, b) => a.name.localeCompare(b.name)); + + return Response.json({ users }); +} + +async function createUser(ctx: OfficerContext): Promise { + const client = await activeClient(ctx.userId); + if (client instanceof Response) return client; + + const body = await readJson(ctx.req); + if (!body) return badRequest('expected a JSON body'); + const name = typeof body.name === 'string' ? body.name.trim() : ''; + if (!name) return badRequest('name is required'); + + const created = await client.call<{ user?: Record }>('/api/v1/user', { + method: 'POST', + body: { + name, + displayName: typeof body.displayName === 'string' ? body.displayName.trim() : undefined, + email: typeof body.email === 'string' ? body.email.trim() : undefined, + }, + }); + return Response.json({ user: toUser(created.user) }, { status: 201 }); +} + +type UserActionParams = { ctx: OfficerContext; id: string; action: string | undefined }; + +async function handleUserAction({ ctx, id, action }: UserActionParams): Promise { + const client = await activeClient(ctx.userId); + if (client instanceof Response) return client; + + if (action === 'rename') { + if (ctx.req.method !== 'POST') return methodNotAllowed(); + const body = await readJson(ctx.req); + if (!body) return badRequest('expected a JSON body'); + const name = typeof body.name === 'string' ? body.name.trim() : ''; + if (!name) return badRequest('name is required'); + // Rename takes both the id and the new name in the path — encode or a '/' becomes a routing accident. + const renamed = await client.call<{ user?: Record }>( + `/api/v1/user/${encodeURIComponent(id)}/rename/${encodeURIComponent(name)}`, + { method: 'POST' }, + ); + return Response.json({ user: toUser(renamed.user) }); + } + + if (action !== undefined) return notFound(); + + if (ctx.req.method === 'DELETE') { + // Headscale refuses to delete a user that still owns nodes, with a message the UI relays verbatim. + await client.call(`/api/v1/user/${encodeURIComponent(id)}`, { method: 'DELETE' }); + return new Response(null, { status: 204 }); + } + + return methodNotAllowed(); +} + +/** Dispatch `/_officer/users/...`. `rest` is the path after `users`. */ +export async function handleUsersRoute(ctx: OfficerContext, rest: string[]): Promise { + if (rest.length === 0) { + if (ctx.req.method === 'GET') return listUsers(ctx); + if (ctx.req.method === 'POST') return createUser(ctx); + return methodNotAllowed(); + } + if (rest.length > 2) return notFound(); + + const id = rest[0]; + if (!id || !/^\d+$/.test(id)) return badRequest('user id must be numeric'); + + return handleUserAction({ ctx, id, action: rest[1] }); +} diff --git a/src/servers/sidecar/headscale/version.ts b/src/servers/sidecar/headscale/version.ts index 53123669..cd902153 100644 --- a/src/servers/sidecar/headscale/version.ts +++ b/src/servers/sidecar/headscale/version.ts @@ -26,7 +26,10 @@ export type VersionProbe = /** `major.minor` from a Headscale version string, or null when it isn't semver (e.g. the literal 'dev'). */ export function parseVersion(raw: string): { major: number; minor: number } | null { - const m = raw.trim().replace(/^v/, '').match(/^(\d+)\.(\d+)/); + const m = raw + .trim() + .replace(/^v/, '') + .match(/^(\d+)\.(\d+)/); if (!m) return null; return { major: Number(m[1]), minor: Number(m[2]) }; } diff --git a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleView.tsx b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleView.tsx index 347d3f3f..bf3b4c4d 100644 --- a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleView.tsx +++ b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleView.tsx @@ -1,31 +1,26 @@ -import { Construction } from 'lucide-react'; import { usePanelChannel } from 'hooks/usePanelChannel'; -import { HEADSCALE_SECTION_CHANNEL, HEADSCALE_SECTIONS, type HeadscaleSectionId } from './shared'; +import { HEADSCALE_SECTION_CHANNEL, type HeadscaleSectionId } from './shared'; import { ServersView } from './ServersView'; +import { NodesView } from './NodesView'; +import { UsersView } from './UsersView'; +import { KeysView } from './KeysView'; // Right panel of the /headscale workspace — renders the section the nav selected. // -// Only `servers` is implemented. Nodes, users and pre-auth keys need the sidecar's domain routes, which -// don't exist yet; they say so plainly rather than rendering an empty table that looks like a broken fetch. - -const Placeholder = ({ id }: { id: HeadscaleSectionId }) => { - const label = HEADSCALE_SECTIONS.find((s) => s.id === id)?.label ?? id; - return ( -
-
- -
-
-
{label}
-
Not built yet
-
-
- ); -}; +// Every section except `servers` acts on whichever server is active; each handles the "none selected" case +// itself through ViewShell, so there is no gating to do here. export const HeadscaleView = () => { const [section] = usePanelChannel(HEADSCALE_SECTION_CHANNEL, 'servers'); - if (section === 'servers') return ; - return ; + switch (section) { + case 'nodes': + return ; + case 'users': + return ; + case 'keys': + return ; + default: + return ; + } }; diff --git a/src/workspaces/officerdev/src/apps/Headscale/KeysView.tsx b/src/workspaces/officerdev/src/apps/Headscale/KeysView.tsx new file mode 100644 index 00000000..8062de00 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Headscale/KeysView.tsx @@ -0,0 +1,340 @@ +import { useState } from 'react'; +import { KeyRound, Plus, Trash2, TimerOff, Copy, Check, Loader2, ShieldAlert } from 'lucide-react'; +import type { HeadscalePreAuthKey } from './shared'; +import { useHeadscaleKeys, useHeadscaleUsers } from './useHeadscaleData'; +import { useHeadscaleServers, headscaleErrorMessage } from './useHeadscaleServers'; +import { timeAgo, timeUntil, fullDate } from './format'; +import { Card, Button, Field, Badge, Dot, ErrorNote } from './Cards'; +import { ViewShell, EmptyBody } from './ViewShell'; + +// Pre-auth keys — the tokens a machine presents to join the tailnet. +// +// The whole screen is shaped by one fact: Headscale hashes keys, so the secret exists exactly once, in the +// create response. If this view renders it into a list, or lets a re-render drop it, the key is gone and the +// owner has to make another. Hence the show-once panel: it holds the secret in local state, offers the copy +// and the ready-to-paste join command, and only clears on an explicit dismiss. +// +// The list defaults to active keys because a long-lived server accumulates hundreds of spent ones. + +const STATUS_FILTERS = [ + { id: 'active', label: 'Active' }, + { id: 'all', label: 'All' }, +] as const; + +type StatusFilter = (typeof STATUS_FILTERS)[number]['id']; + +const STATUS_TONE = { active: 'ok', used: 'idle', expired: 'bad' } as const; + +const CopyButton = ({ value, label }: { value: string; label: string }) => { + const [done, setDone] = useState(false); + const copy = () => { + void navigator.clipboard?.writeText(value); + setDone(true); + window.setTimeout(() => setDone(false), 1500); + }; + return ( + + ); +}; + +type SecretPanelProps = { secret: string; loginServer: string; onDismiss: () => void }; + +const SecretPanel = ({ secret, loginServer, onDismiss }: SecretPanelProps) => { + const command = `tailscale up --login-server ${loginServer} --authkey ${secret}`; + return ( +
+
+ +
+
Copy this key now
+

+ Headscale stores it hashed. Once you dismiss this, nothing — not Officer, not the server — can show it + again. +

+
+
+
+
+
Key
+
+ {secret} +
+
+
+
Join command
+
+ {command} +
+
+
+ + + +
+
+
+ ); +}; + +type ToggleProps = { checked: boolean; onChange: (value: boolean) => void; label: string; hint: string }; + +const Toggle = ({ checked, onChange, label, hint }: ToggleProps) => ( + +); + +type CreateKeyFormProps = { onCreated: (secret: string) => void; onClose: () => void }; + +const CreateKeyForm = ({ onCreated, onClose }: CreateKeyFormProps) => { + const { users } = useHeadscaleUsers(); + const { create } = useHeadscaleKeys(); + const [userId, setUserId] = useState(''); + const [reusable, setReusable] = useState(false); + const [ephemeral, setEphemeral] = useState(false); + const [days, setDays] = useState('90'); + const [tags, setTags] = useState(''); + const [error, setError] = useState(null); + + const submit = async () => { + setError(null); + const chosen = userId || users[0]?.id; + if (!chosen) return setError('Create a user first — every key belongs to one.'); + const expirationDays = Number(days); + if (!Number.isFinite(expirationDays) || expirationDays <= 0) + return setError('Expiry must be a positive number of days'); + + try { + const result = await create.mutateAsync({ + userId: chosen, + reusable, + ephemeral, + expirationDays, + aclTags: tags + .split(',') + .map((t) => t.trim()) + .filter(Boolean), + }); + if (result.key.key) onCreated(result.key.key); + onClose(); + } catch (err) { + setError(headscaleErrorMessage(err)); + } + }; + + return ( + +
{ + ev.preventDefault(); + void submit(); + }} + className="flex flex-col gap-3 p-4" + > +
New pre-auth key
+ + + +
+ + +
+ + + + + {error && {error}} + +
+ + +
+ +
+ ); +}; + +type KeyRowProps = { entry: HeadscalePreAuthKey; onError: (message: string) => void }; + +const KeyRow = ({ entry, onError }: KeyRowProps) => { + const { expire, remove } = useHeadscaleKeys(); + const [confirming, setConfirming] = useState(false); + const busy = expire.isPending || remove.isPending; + + const run = async (fn: () => Promise) => { + try { + await fn(); + } catch (err) { + onError(headscaleErrorMessage(err)); + } + }; + + return ( + +
+ +
+
+ {entry.keyDisplay} + {entry.user && {entry.user.name}} + {entry.reusable && reusable} + {entry.ephemeral && ephemeral} + {entry.aclTags.map((tag) => ( + + {tag} + + ))} +
+
+ {entry.status} + · expires {timeUntil(entry.expiration)} + · created {timeAgo(entry.createdAt)} +
+
+ +
+ {entry.status === 'active' && ( + + )} + {confirming ? ( + <> + + + + ) : ( + + )} +
+
+
+ ); +}; + +export const KeysView = () => { + const { keys, isLoading, error } = useHeadscaleKeys(); + const { active } = useHeadscaleServers(); + const [creating, setCreating] = useState(false); + const [secret, setSecret] = useState(null); + const [filter, setFilter] = useState('active'); + const [actionError, setActionError] = useState(null); + + const visible = filter === 'all' ? keys : keys.filter((k) => k.status === 'active'); + const activeCount = keys.filter((k) => k.status === 'active').length; + + return ( + +
+
+
+

Pre-auth keys

+

+ {activeCount} active of {keys.length} +

+
+
+
+ {STATUS_FILTERS.map((option) => ( + + ))} +
+ {!creating && ( + + )} +
+
+ + {secret && setSecret(null)} />} + {creating && setCreating(false)} />} + {actionError && {actionError}} + + {keys.length === 0 && !creating && ( + } + title="No pre-auth keys" + hint="A key lets a machine join without an interactive login. Create one, then run the join command it gives you." + /> + )} + {keys.length > 0 && visible.length === 0 && ( +
+ No active keys. Switch to “All” to see spent and expired ones. +
+ )} + + {visible.map((entry) => ( + + ))} +
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Headscale/NodesView.tsx b/src/workspaces/officerdev/src/apps/Headscale/NodesView.tsx new file mode 100644 index 00000000..754981e0 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Headscale/NodesView.tsx @@ -0,0 +1,286 @@ +import { useState } from 'react'; +import { Laptop, Globe, Trash2, Pencil, TimerReset, Check, X, Search, Copy, ChevronRight } from 'lucide-react'; +import type { HeadscaleNode } from './shared'; +import { useHeadscaleNodes } from './useHeadscaleData'; +import { headscaleErrorMessage } from './useHeadscaleServers'; +import { timeAgo, timeUntil, fullDate } from './format'; +import { Card, Button, Dot, Badge, ErrorNote } from './Cards'; +import { ViewShell, EmptyBody } from './ViewShell'; + +// The nodes section — the machines in the tailnet. +// +// Route approval is the only genuinely dangerous control here, so it is explicit: every route the node +// ADVERTISES is listed, each with its own approve/revoke toggle, and an exit node is called what it is +// rather than shown as the bare 0.0.0.0/0 that it advertises. Approving one route sends the whole approved +// set upstream; the sidecar does that read-modify-write so two panels can't clobber each other's sets. + +const copy = (text: string) => void navigator.clipboard?.writeText(text); + +type RouteRowProps = { route: string; approved: boolean; busy: boolean; onToggle: (approved: boolean) => void }; + +const RouteRow = ({ route, approved, busy, onToggle }: RouteRowProps) => { + const isExit = route === '0.0.0.0/0' || route === '::/0'; + return ( +
+ {isExit ? : } + {route} + {isExit && exit node} + +
+ ); +}; + +type NodeCardProps = { node: HeadscaleNode; onError: (message: string) => void }; + +const NodeCard = ({ node, onError }: NodeCardProps) => { + const { rename, toggleRoute, expire, remove } = useHeadscaleNodes(); + const [open, setOpen] = useState(false); + const [renaming, setRenaming] = useState(false); + const [draftName, setDraftName] = useState(node.name); + const [confirming, setConfirming] = useState(false); + + const busy = rename.isPending || toggleRoute.isPending || expire.isPending || remove.isPending; + + const run = async (fn: () => Promise) => { + try { + await fn(); + } catch (err) { + onError(headscaleErrorMessage(err)); + } + }; + + const submitRename = async () => { + const name = draftName.trim(); + setRenaming(false); + if (!name || name === node.name) return; + await run(() => rename.mutateAsync({ id: node.id, name })); + }; + + return ( + +
+
+ +
+ {renaming ? ( +
+ setDraftName(ev.target.value)} + onKeyDown={(ev) => { + if (ev.key === 'Enter') void submitRename(); + if (ev.key === 'Escape') setRenaming(false); + }} + autoFocus + spellCheck={false} + className="min-w-0 flex-1 rounded-md border border-white/10 bg-black/40 px-2 py-1 text-sm text-zinc-100 outline-none focus:border-primary/50" + /> + + +
+ ) : ( +
+ {node.name} + {node.user && {node.user.name}} + {node.isExitNode && exit} + {node.tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+ {node.ipAddresses[0] ?? 'no address'} + · {node.online ? 'online' : `seen ${timeAgo(node.lastSeen)}`} + {node.subnetRoutes.length > 0 && · {node.subnetRoutes.length} route(s) active} +
+
+ +
+ + {open && ( +
+
+
Addresses
+
+ {node.ipAddresses.map((ip) => ( + + ))} +
+
Hostname
+
{node.hostname}
+
Registered
+
+ {timeAgo(node.createdAt)} · {node.registerMethod} +
+
Key expires
+
+ {timeUntil(node.expiry)} +
+
Last seen
+
+ {node.online ? 'now' : timeAgo(node.lastSeen)} +
+
+ +
+
+ Advertised routes +
+ {node.availableRoutes.length === 0 ? ( +
This node advertises no routes.
+ ) : ( +
+ {node.availableRoutes.map((route) => ( + void run(() => toggleRoute.mutateAsync({ id: node.id, route, approved }))} + /> + ))} +
+ )} +
+ +
+ + + {confirming ? ( + <> + + + + ) : ( + + )} +
+
+ )} +
+
+ ); +}; + +export const NodesView = () => { + const { nodes, isLoading, error } = useHeadscaleNodes(); + const [filter, setFilter] = useState(''); + const [actionError, setActionError] = useState(null); + + const needle = filter.trim().toLowerCase(); + const visible = needle + ? nodes.filter( + (n) => + n.name.toLowerCase().includes(needle) || + n.hostname.toLowerCase().includes(needle) || + n.user?.name.toLowerCase().includes(needle) || + n.ipAddresses.some((ip) => ip.includes(needle)) || + n.tags.some((t) => t.toLowerCase().includes(needle)), + ) + : nodes; + + const online = nodes.filter((n) => n.online).length; + + return ( + +
+
+
+

Nodes

+

+ {nodes.length} registered · {online} online +

+
+
+ + setFilter(ev.target.value)} + placeholder="Filter by name, user, IP, tag" + spellCheck={false} + className="w-full rounded-lg border border-white/10 bg-black/40 py-1.5 pl-8 pr-2.5 text-xs text-zinc-100 outline-none placeholder:text-zinc-600 focus:border-primary/50" + /> +
+
+ + {actionError && {actionError}} + + {nodes.length === 0 && ( + } + title="No nodes yet" + hint="Create a pre-auth key and run `tailscale up --login-server --authkey ` on a machine to join it." + /> + )} + {nodes.length > 0 && visible.length === 0 && ( +
Nothing matches “{filter}”.
+ )} + + {visible.map((node) => ( + + ))} +
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Headscale/UsersView.tsx b/src/workspaces/officerdev/src/apps/Headscale/UsersView.tsx new file mode 100644 index 00000000..31276743 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Headscale/UsersView.tsx @@ -0,0 +1,203 @@ +import { useState } from 'react'; +import { Users, Plus, Pencil, Trash2, Check, X, Loader2 } from 'lucide-react'; +import type { HeadscaleUserWithCounts } from './shared'; +import { useHeadscaleUsers } from './useHeadscaleData'; +import { headscaleErrorMessage } from './useHeadscaleServers'; +import { timeAgo } from './format'; +import { Card, Button, Field, Badge, ErrorNote, Dot } from './Cards'; +import { ViewShell, EmptyBody } from './ViewShell'; + +// The users section. A Headscale user is a namespace that owns nodes and pre-auth keys — not a login. +// +// The node count next to each user is the point of this screen: Headscale refuses to delete a user that +// still owns nodes, and without the count that refusal arrives as a surprise after the confirm click. + +type UserRowProps = { user: HeadscaleUserWithCounts; onError: (message: string) => void }; + +const UserRow = ({ user, onError }: UserRowProps) => { + const { rename, remove } = useHeadscaleUsers(); + const [renaming, setRenaming] = useState(false); + const [draft, setDraft] = useState(user.name); + const [confirming, setConfirming] = useState(false); + + const busy = rename.isPending || remove.isPending; + + const run = async (fn: () => Promise) => { + try { + await fn(); + } catch (err) { + onError(headscaleErrorMessage(err)); + } + }; + + const submitRename = async () => { + const name = draft.trim(); + setRenaming(false); + if (!name || name === user.name) return; + await run(() => rename.mutateAsync({ id: user.id, name })); + }; + + return ( + +
+ 0 ? 'ok' : 'idle'} /> +
+ {renaming ? ( +
+ setDraft(ev.target.value)} + onKeyDown={(ev) => { + if (ev.key === 'Enter') void submitRename(); + if (ev.key === 'Escape') setRenaming(false); + }} + autoFocus + spellCheck={false} + className="min-w-0 flex-1 rounded-md border border-white/10 bg-black/40 px-2 py-1 text-sm text-zinc-100 outline-none focus:border-primary/50" + /> + + +
+ ) : ( +
+ {user.name} + {user.provider && {user.provider}} +
+ )} +
+ + {user.nodeCount} node{user.nodeCount === 1 ? '' : 's'} + {user.onlineCount > 0 && `, ${user.onlineCount} online`} + + {user.email && · {user.email}} + · created {timeAgo(user.createdAt)} +
+
+ +
+ + {confirming ? ( + <> + + + + ) : ( + + )} +
+
+
+ ); +}; + +const CreateUserForm = ({ onClose }: { onClose: () => void }) => { + const { create } = useHeadscaleUsers(); + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [error, setError] = useState(null); + + const submit = async () => { + setError(null); + if (!name.trim()) return setError('A name is required'); + try { + await create.mutateAsync({ name: name.trim(), email: email.trim() || undefined }); + onClose(); + } catch (err) { + setError(headscaleErrorMessage(err)); + } + }; + + return ( + +
{ + ev.preventDefault(); + void submit(); + }} + className="flex flex-col gap-3 p-4" + > +
New user
+ + + {error && {error}} +
+ + +
+ +
+ ); +}; + +export const UsersView = () => { + const { users, isLoading, error } = useHeadscaleUsers(); + const [creating, setCreating] = useState(false); + const [actionError, setActionError] = useState(null); + + return ( + +
+
+
+

Users

+

Namespaces that own nodes and pre-auth keys.

+
+ {!creating && ( + + )} +
+ + {creating && setCreating(false)} />} + {actionError && {actionError}} + + {users.length === 0 && !creating && ( + } + title="No users yet" + hint="Every node belongs to a user. Create one before issuing a pre-auth key." + /> + )} + + {users.map((user) => ( + + ))} +
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Headscale/ViewShell.tsx b/src/workspaces/officerdev/src/apps/Headscale/ViewShell.tsx new file mode 100644 index 00000000..f678d603 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Headscale/ViewShell.tsx @@ -0,0 +1,77 @@ +import type { ReactNode } from 'react'; +import { Loader2, ServerOff } from 'lucide-react'; +import { NO_ACTIVE_SERVER } from './shared'; +import { headscaleErrorMessage } from './useHeadscaleServers'; +import { ErrorNote } from './Cards'; + +// The loading / no-server / failed states every domain section shares. +// +// "No active server" is a 409 carrying a `code`, deliberately not a 404 and deliberately not an empty +// list — an empty node table would read as "your tailnet is empty", which is a very different and much +// more alarming statement than "you haven't picked a server". + +function isNoActiveServer(err: unknown): boolean { + const raw = (err as { message?: unknown } | null)?.message; + if (typeof raw !== 'string') return false; + try { + return (JSON.parse(raw) as { code?: unknown }).code === NO_ACTIVE_SERVER; + } catch { + return false; + } +} + +type ViewShellProps = { + isLoading: boolean; + error: unknown; + /** What this section is called, for the loading and empty copy. */ + label: string; + children: ReactNode; +}; + +export const ViewShell = ({ isLoading, error, label, children }: ViewShellProps) => { + if (isLoading) { + return ( +
+ + Loading {label}… +
+ ); + } + + if (error && isNoActiveServer(error)) { + return ( +
+
+ +
+
+
No server selected
+

Pick one in the Servers section to see its {label}.

+
+
+ ); + } + + if (error) { + return ( +
+ + Could not load {label}: {headscaleErrorMessage(error)} + +
+ ); + } + + return
{children}
; +}; + +/** Centred "nothing here yet" body for a section whose fetch succeeded but returned nothing. */ +export const EmptyBody = ({ icon, title, hint }: { icon: ReactNode; title: string; hint: string }) => ( +
+
{icon}
+
+
{title}
+

{hint}

+
+
+); diff --git a/src/workspaces/officerdev/src/apps/Headscale/format.ts b/src/workspaces/officerdev/src/apps/Headscale/format.ts new file mode 100644 index 00000000..9b965880 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Headscale/format.ts @@ -0,0 +1,35 @@ +// Date formatting for the Headscale views. The sidecar already turned protobuf's zero timestamp into null, +// so null genuinely means "never" here and every helper says so rather than printing a fake date. + +export function timeAgo(iso: string | null): string { + if (!iso) return 'never'; + const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000); + if (!Number.isFinite(seconds)) return 'unknown'; + if (seconds < 0) return 'just now'; + if (seconds < 60) return 'just now'; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.round(hours / 24); + if (days < 365) return `${days}d ago`; + return `${Math.round(days / 365)}y ago`; +} + +/** "in 3d" / "5h ago" — signed, for expiry dates that may be either side of now. */ +export function timeUntil(iso: string | null): string { + if (!iso) return 'never'; + const seconds = Math.round((new Date(iso).getTime() - Date.now()) / 1000); + if (!Number.isFinite(seconds)) return 'unknown'; + if (seconds < 0) return timeAgo(iso); + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `in ${Math.max(1, minutes)}m`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `in ${hours}h`; + return `in ${Math.round(hours / 24)}d`; +} + +export function fullDate(iso: string | null): string { + if (!iso) return 'never'; + return new Date(iso).toLocaleString(); +} diff --git a/src/workspaces/officerdev/src/apps/Headscale/shared.ts b/src/workspaces/officerdev/src/apps/Headscale/shared.ts index d0921f18..2e138d5d 100644 --- a/src/workspaces/officerdev/src/apps/Headscale/shared.ts +++ b/src/workspaces/officerdev/src/apps/Headscale/shared.ts @@ -40,3 +40,59 @@ export type HeadscaleHealth = { /** 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'; + +// ── 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'; diff --git a/src/workspaces/officerdev/src/apps/Headscale/useHeadscaleData.ts b/src/workspaces/officerdev/src/apps/Headscale/useHeadscaleData.ts new file mode 100644 index 00000000..d8f66e95 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Headscale/useHeadscaleData.ts @@ -0,0 +1,158 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useClient } from 'hooks/useClient'; +import type { HeadscaleNode, HeadscaleUserWithCounts, HeadscalePreAuthKey } from './shared'; + +// Queries for the domain sections. All three act on whichever server is active, so they live under the +// same ['headscale'] key prefix that switching servers invalidates wholesale (see useHeadscaleServers). +// +// Mutations invalidate broadly rather than patching caches: deleting a user changes node counts, approving +// a route changes subnetRoutes, expiring a key changes nothing else but costs one cheap refetch. The lists +// are small and the correctness is worth more than the round trip. + +const NODES_KEY = ['headscale', 'nodes'] as const; +const USERS_KEY = ['headscale', 'users'] as const; +const KEYS_KEY = ['headscale', 'keys'] as const; + +const EMPTY_NODES: HeadscaleNode[] = []; +const EMPTY_USERS: HeadscaleUserWithCounts[] = []; +const EMPTY_KEYS: HeadscalePreAuthKey[] = []; + +export function useHeadscaleNodes() { + const { get, post, delete: del } = useClient(); + const qc = useQueryClient(); + const invalidate = () => qc.invalidateQueries({ queryKey: ['headscale'] }); + + const query = useQuery({ + queryKey: NODES_KEY, + queryFn: () => get<{ nodes: HeadscaleNode[] }>('/headscale/_officer/nodes'), + // Online/lastSeen go stale fast, and this is a screen you sit on while waiting for a machine to join. + refetchInterval: 20_000, + staleTime: 10_000, + }); + + const rename = useMutation({ + mutationFn: ({ id, name }: { id: string; name: string }) => + post(`/headscale/_officer/nodes/${id}/rename`, { name }), + onSuccess: invalidate, + }); + + const setTags = useMutation({ + mutationFn: ({ id, tags }: { id: string; tags: string[] }) => + post(`/headscale/_officer/nodes/${id}/tags`, { tags }), + onSuccess: invalidate, + }); + + // Single-route toggle: the sidecar reads the current approved set and writes it back with one change, + // because Headscale's approve_routes replaces the whole set. + const toggleRoute = useMutation({ + mutationFn: ({ id, route, approved }: { id: string; route: string; approved: boolean }) => + post(`/headscale/_officer/nodes/${id}/routes`, { route, approved }), + onSuccess: invalidate, + }); + + const expire = useMutation({ + mutationFn: (id: string) => post(`/headscale/_officer/nodes/${id}/expire`), + onSuccess: invalidate, + }); + + const remove = useMutation({ + mutationFn: (id: string) => del(`/headscale/_officer/nodes/${id}`), + onSuccess: invalidate, + }); + + return { + nodes: query.data?.nodes ?? EMPTY_NODES, + isLoading: query.isLoading, + error: query.error, + rename, + setTags, + toggleRoute, + expire, + remove, + }; +} + +export function useHeadscaleUsers() { + const { get, post, delete: del } = useClient(); + const qc = useQueryClient(); + const invalidate = () => qc.invalidateQueries({ queryKey: ['headscale'] }); + + const query = useQuery({ + queryKey: USERS_KEY, + queryFn: () => get<{ users: HeadscaleUserWithCounts[] }>('/headscale/_officer/users'), + staleTime: 30_000, + }); + + const create = useMutation({ + mutationFn: (input: { name: string; displayName?: string; email?: string }) => + post('/headscale/_officer/users', input), + onSuccess: invalidate, + }); + + const rename = useMutation({ + mutationFn: ({ id, name }: { id: string; name: string }) => + post(`/headscale/_officer/users/${id}/rename`, { name }), + onSuccess: invalidate, + }); + + const remove = useMutation({ + mutationFn: (id: string) => del(`/headscale/_officer/users/${id}`), + onSuccess: invalidate, + }); + + return { + users: query.data?.users ?? EMPTY_USERS, + isLoading: query.isLoading, + error: query.error, + create, + rename, + remove, + }; +} + +export type CreateKeyInput = { + userId: string; + reusable: boolean; + ephemeral: boolean; + expirationDays: number; + aclTags: string[]; +}; + +export function useHeadscaleKeys() { + const { get, post, delete: del } = useClient(); + const qc = useQueryClient(); + const invalidate = () => qc.invalidateQueries({ queryKey: KEYS_KEY }); + + const query = useQuery({ + queryKey: KEYS_KEY, + queryFn: () => get<{ keys: HeadscalePreAuthKey[] }>('/headscale/_officer/keys'), + staleTime: 30_000, + }); + + // The response carries the only copy of the secret that will ever exist. It is returned to the caller + // (not merged into the list cache) so the view can show it once and deliberately drop it. + const create = useMutation({ + mutationFn: (input: CreateKeyInput) => + post<{ key: HeadscalePreAuthKey; secretShownOnce: boolean }>('/headscale/_officer/keys', input), + onSuccess: invalidate, + }); + + const expire = useMutation({ + mutationFn: (id: string) => post(`/headscale/_officer/keys/${id}/expire`), + onSuccess: invalidate, + }); + + const remove = useMutation({ + mutationFn: (id: string) => del(`/headscale/_officer/keys/${id}`), + onSuccess: invalidate, + }); + + return { + keys: query.data?.keys ?? EMPTY_KEYS, + isLoading: query.isLoading, + error: query.error, + create, + expire, + remove, + }; +}