From 0d227c46a3cc90ce28dfcdd5912ce339a77abe1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 30 Jul 2026 15:08:02 +0000 Subject: [PATCH] headscale nodes, users and pre-auth key management 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 --- src/servers/sidecar/headscale/active.ts | 20 ++ src/servers/sidecar/headscale/index.ts | 19 + src/servers/sidecar/headscale/keys.ts | 119 ++++++ src/servers/sidecar/headscale/nodes.ts | 129 +++++++ src/servers/sidecar/headscale/normalize.ts | 187 ++++++++++ src/servers/sidecar/headscale/routes.ts | 17 +- src/servers/sidecar/headscale/users.ts | 104 ++++++ src/servers/sidecar/headscale/version.ts | 5 +- .../src/apps/Headscale/HeadscaleView.tsx | 37 +- .../src/apps/Headscale/KeysView.tsx | 340 ++++++++++++++++++ .../src/apps/Headscale/NodesView.tsx | 286 +++++++++++++++ .../src/apps/Headscale/UsersView.tsx | 203 +++++++++++ .../src/apps/Headscale/ViewShell.tsx | 77 ++++ .../officerdev/src/apps/Headscale/format.ts | 35 ++ .../officerdev/src/apps/Headscale/shared.ts | 56 +++ .../src/apps/Headscale/useHeadscaleData.ts | 158 ++++++++ 16 files changed, 1769 insertions(+), 23 deletions(-) create mode 100644 src/servers/sidecar/headscale/active.ts create mode 100644 src/servers/sidecar/headscale/keys.ts create mode 100644 src/servers/sidecar/headscale/nodes.ts create mode 100644 src/servers/sidecar/headscale/normalize.ts create mode 100644 src/servers/sidecar/headscale/users.ts create mode 100644 src/workspaces/officerdev/src/apps/Headscale/KeysView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Headscale/NodesView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Headscale/UsersView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Headscale/ViewShell.tsx create mode 100644 src/workspaces/officerdev/src/apps/Headscale/format.ts create mode 100644 src/workspaces/officerdev/src/apps/Headscale/useHeadscaleData.ts 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, + }; +}