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 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 15:08:02 +00:00
co-authored by Claude Opus 5
parent adf922de30
commit 0d227c46a3
16 changed files with 1769 additions and 23 deletions
+20
View File
@@ -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<HeadscaleClient | Response> {
const creds = await getActiveHeadscaleCredentials(userId);
if (!creds) {
return Response.json({ error: 'no active Headscale server', code: 'no_active_server' }, { status: 409 });
}
return createClient(creds);
}
+19
View File
@@ -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=<username> 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
+119
View File
@@ -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-<prefix>-***`. 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<Response> {
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<Response> {
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<string, unknown> }>('/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<Response> {
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<Response> {
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] });
}
+129
View File
@@ -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<Response> {
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<OfficerNode | null> {
const body = await client.call<{ node?: Record<string, unknown> }>(`/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<Response> {
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<Response> {
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] });
}
+187
View File
@@ -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<string, unknown>;
export type UpstreamNode = Record<string, unknown>;
export type UpstreamPreAuthKey = Record<string, unknown>;
export type OfficerUser = {
id: string;
name: string;
displayName: string | null;
email: string | null;
/** The OIDC provider, when the user came from one. Null for CLI/API-created users. */
provider: string | null;
profilePicUrl: string | null;
createdAt: string | null;
};
export function toUser(raw: UpstreamUser | null | undefined): OfficerUser | null {
if (!raw || typeof raw !== 'object') return null;
const id = str(raw.id);
if (!id) return null;
return {
id,
name: str(raw.name),
displayName: str(raw.displayName) || null,
email: str(raw.email) || null,
provider: str(raw.provider) || null,
profilePicUrl: str(raw.profilePicUrl) || null,
createdAt: isoOrNull(raw.createdAt),
};
}
export type OfficerNode = {
id: string;
/** The name Headscale actually uses in the tailnet — givenName when set, otherwise the reported hostname. */
name: string;
hostname: string;
user: OfficerUser | null;
ipAddresses: string[];
online: boolean;
lastSeen: string | null;
/** When the node's key expires and it must re-authenticate. Null means it never expires. */
expiry: string | null;
createdAt: string | null;
/** How the node joined: 'authkey' | 'cli' | 'oidc' | 'unknown'. */
registerMethod: string;
tags: string[];
/** Routes the node advertises. */
availableRoutes: string[];
/** The subset the admin has approved — the writable one. */
approvedRoutes: string[];
/** Routes actually in effect (approved ∩ available, as Headscale computes it). */
subnetRoutes: string[];
/** True when the node advertises an exit node route. Purely derived, for the UI's badge. */
isExitNode: boolean;
};
const EXIT_ROUTES = new Set(['0.0.0.0/0', '::/0']);
const REGISTER_METHODS: Record<string, string> = {
REGISTER_METHOD_AUTH_KEY: 'authkey',
REGISTER_METHOD_CLI: 'cli',
REGISTER_METHOD_OIDC: 'oidc',
};
export function toNode(raw: UpstreamNode): OfficerNode {
const givenName = str(raw.givenName);
const hostname = str(raw.name);
const availableRoutes = strArray(raw.availableRoutes);
return {
id: str(raw.id),
name: givenName || hostname,
hostname,
user: toUser(raw.user as UpstreamUser),
ipAddresses: strArray(raw.ipAddresses),
online: raw.online === true,
lastSeen: isoOrNull(raw.lastSeen),
expiry: isoOrNull(raw.expiry),
createdAt: isoOrNull(raw.createdAt),
registerMethod: REGISTER_METHODS[str(raw.registerMethod)] ?? 'unknown',
tags: strArray(raw.tags),
availableRoutes,
approvedRoutes: strArray(raw.approvedRoutes),
subnetRoutes: strArray(raw.subnetRoutes),
isExitNode: availableRoutes.some((r) => EXIT_ROUTES.has(r)),
};
}
export type OfficerPreAuthKey = {
id: string;
/**
* The usable secret. Non-null ONLY on the creation response — the list path nulls it unconditionally,
* so a secret can never reach the browser except at the moment it is created and must be shown once.
*/
key: string | null;
/** A never-usable label for identifying a key in a list, e.g. `hskey-auth-a1b2c3-***`. */
keyDisplay: string;
user: OfficerUser | null;
reusable: boolean;
ephemeral: boolean;
used: boolean;
expiration: string | null;
createdAt: string | null;
aclTags: string[];
/** Derived lifecycle, so every surface agrees on what "spent" means. */
status: 'active' | 'used' | 'expired';
};
/**
* A display label that is never a usable secret.
*
* Headscale 0.28+ stores keys bcrypt-hashed and lists them already masked as `hskey-auth-<prefix>-***`.
* But keys created BEFORE 0.28 are still plaintext in its database, and `PreAuthKey.Proto()` returns those
* in full from the list endpoint "for backwards compatibility" — its own source carries a TODO about
* hiding them. So a list response on a server with history in it really does contain live secrets. We mask
* anything that isn't already masked rather than trusting the upstream to have done it.
*/
function displayLabel(key: string): string {
if (!key) return '(no key)';
if (key.endsWith('***')) return key;
return `${key.slice(0, 6)}…-***`;
}
type ToPreAuthKeyOptions = {
/**
* True only on the creation response, where the secret is the entire point and exists nowhere else.
* Everywhere else this is false and the secret is dropped before it can reach a cache or a browser.
*/
reveal: boolean;
};
export function toPreAuthKey(raw: UpstreamPreAuthKey, { reveal }: ToPreAuthKeyOptions): OfficerPreAuthKey {
const expiration = isoOrNull(raw.expiration);
const reusable = raw.reusable === true;
const used = raw.used === true;
const key = str(raw.key);
// A reusable key stays usable after a node has claimed it, so `used` alone doesn't mean spent.
const expired = !!expiration && Date.parse(expiration) < Date.now();
const status: OfficerPreAuthKey['status'] = expired ? 'expired' : used && !reusable ? 'used' : 'active';
return {
id: str(raw.id),
key: reveal ? key || null : null,
keyDisplay: displayLabel(key),
user: toUser(raw.user as UpstreamUser),
reusable,
ephemeral: raw.ephemeral === true,
used,
expiration,
createdAt: isoOrNull(raw.createdAt),
aclTags: strArray(raw.aclTags),
status,
};
}
/** Read an array field out of a gateway response, tolerating the null/absent forms. */
export function arrayField(body: unknown, field: string): Record<string, unknown>[] {
const value = (body as Record<string, unknown> | null)?.[field];
return Array.isArray(value) ? (value.filter((v) => v && typeof v === 'object') as Record<string, unknown>[]) : [];
}
+16 -1
View File
@@ -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<Record<string, unknown> | null> {
const body = await req.json().catch(() => null);
return body && typeof body === 'object' && !Array.isArray(body) ? (body as Record<string, unknown>) : 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<Respon
switch (segments[0]) {
case 'servers':
return await handleServersRoute(ctx, segments.slice(1));
// Domain routes (nodes, users, preauthkeys) land here, each operating against the active server.
// The domain routes below all act on the ACTIVE server — see active.ts for why that isn't a param.
case 'nodes':
return await handleNodesRoute(ctx, segments.slice(1));
case 'users':
return await handleUsersRoute(ctx, segments.slice(1));
case 'keys':
return await handleKeysRoute(ctx, segments.slice(1));
default:
return null;
}
+104
View File
@@ -0,0 +1,104 @@
import type { OfficerContext } from './routes';
import { badRequest, notFound, methodNotAllowed, readJson } from './routes';
import { activeClient } from './active';
import { toUser, toNode, arrayField, type OfficerUser } from './normalize';
// User routes — /_officer/users/*. A Headscale "user" is a namespace that owns nodes and pre-auth keys.
//
// The list is enriched with a node count, which the admin API does not provide: deleting a user takes its
// nodes with it, so "3 nodes" next to the delete button is the difference between an informed action and a
// surprise. That is one extra upstream call for the whole list, not one per user.
export type UserWithCounts = OfficerUser & { nodeCount: number; onlineCount: number };
async function listUsers(ctx: OfficerContext): Promise<Response> {
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<string, { total: number; online: number }>();
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<Response> {
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<string, unknown> }>('/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<Response> {
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<string, unknown> }>(
`/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<Response> {
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] });
}
+4 -1
View File
@@ -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]) };
}
@@ -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 (
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<Construction className="h-6 w-6" />
</div>
<div>
<div className="text-base font-semibold">{label}</div>
<div className="text-sm text-muted-foreground">Not built yet</div>
</div>
</div>
);
};
// 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<HeadscaleSectionId>(HEADSCALE_SECTION_CHANNEL, 'servers');
if (section === 'servers') return <ServersView />;
return <Placeholder id={section} />;
switch (section) {
case 'nodes':
return <NodesView />;
case 'users':
return <UsersView />;
case 'keys':
return <KeysView />;
default:
return <ServersView />;
}
};
@@ -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 (
<Button onClick={copy} variant={done ? 'primary' : 'ghost'}>
{done ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{done ? 'Copied' : label}
</Button>
);
};
type SecretPanelProps = { secret: string; loginServer: string; onDismiss: () => void };
const SecretPanel = ({ secret, loginServer, onDismiss }: SecretPanelProps) => {
const command = `tailscale up --login-server ${loginServer} --authkey ${secret}`;
return (
<div className="overflow-hidden rounded-xl border border-amber-500/30 bg-amber-500/[0.07]">
<div className="flex items-start gap-2.5 border-b border-amber-500/20 px-4 py-3">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-amber-400" />
<div className="min-w-0">
<div className="text-sm font-semibold text-amber-200">Copy this key now</div>
<p className="mt-0.5 text-xs leading-snug text-amber-200/70">
Headscale stores it hashed. Once you dismiss this, nothing not Officer, not the server can show it
again.
</p>
</div>
</div>
<div className="flex flex-col gap-3 p-4">
<div>
<div className="pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-amber-200/60">Key</div>
<div className="rounded-lg border border-white/10 bg-black/50 px-3 py-2 font-mono text-xs break-all text-zinc-100">
{secret}
</div>
</div>
<div>
<div className="pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-amber-200/60">Join command</div>
<div className="rounded-lg border border-white/10 bg-black/50 px-3 py-2 font-mono text-xs break-all text-zinc-400">
{command}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<CopyButton value={secret} label="Copy key" />
<CopyButton value={command} label="Copy command" />
<Button variant="danger" onClick={onDismiss}>
I've saved it
</Button>
</div>
</div>
</div>
);
};
type ToggleProps = { checked: boolean; onChange: (value: boolean) => void; label: string; hint: string };
const Toggle = ({ checked, onChange, label, hint }: ToggleProps) => (
<label className="flex cursor-pointer items-start gap-2.5">
<input
type="checkbox"
checked={checked}
onChange={(ev) => onChange(ev.target.checked)}
className="mt-0.5 h-3.5 w-3.5 cursor-pointer accent-primary"
/>
<span className="min-w-0">
<span className="block text-xs font-medium text-zinc-300">{label}</span>
<span className="block text-[11px] leading-snug text-zinc-600">{hint}</span>
</span>
</label>
);
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<string | null>(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 (
<Card>
<form
onSubmit={(ev) => {
ev.preventDefault();
void submit();
}}
className="flex flex-col gap-3 p-4"
>
<div className="text-sm font-semibold text-zinc-100">New pre-auth key</div>
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-zinc-400">User</span>
<select
value={userId || users[0]?.id || ''}
onChange={(ev) => setUserId(ev.target.value)}
className="rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-primary/50"
>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</label>
<div className="flex flex-col gap-2">
<Toggle
checked={reusable}
onChange={setReusable}
label="Reusable"
hint="Any number of machines can join with it, until it expires."
/>
<Toggle
checked={ephemeral}
onChange={setEphemeral}
label="Ephemeral"
hint="Nodes that join with it are removed when they go offline. For containers and CI."
/>
</div>
<Field label="Expires in (days)" value={days} onChange={setDays} placeholder="90" />
<Field
label="ACL tags (optional)"
value={tags}
onChange={setTags}
placeholder="server, ci"
hint="Comma separated. The tag: prefix is added for you."
/>
{error && <ErrorNote>{error}</ErrorNote>}
<div className="flex items-center gap-2 pt-1">
<Button type="submit" variant="primary" disabled={create.isPending}>
{create.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Create key
</Button>
<Button onClick={onClose} disabled={create.isPending}>
Cancel
</Button>
</div>
</form>
</Card>
);
};
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<unknown>) => {
try {
await fn();
} catch (err) {
onError(headscaleErrorMessage(err));
}
};
return (
<Card>
<div className="flex flex-wrap items-center gap-3 p-3.5">
<Dot tone={STATUS_TONE[entry.status]} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-mono text-xs text-zinc-300">{entry.keyDisplay}</span>
{entry.user && <Badge>{entry.user.name}</Badge>}
{entry.reusable && <Badge>reusable</Badge>}
{entry.ephemeral && <Badge>ephemeral</Badge>}
{entry.aclTags.map((tag) => (
<Badge key={tag} tone="active">
{tag}
</Badge>
))}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-zinc-500">
<span>{entry.status}</span>
<span title={fullDate(entry.expiration)}>· expires {timeUntil(entry.expiration)}</span>
<span>· created {timeAgo(entry.createdAt)}</span>
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
{entry.status === 'active' && (
<Button onClick={() => void run(() => expire.mutateAsync(entry.id))} disabled={busy} title="Expire now">
<TimerOff className="h-3.5 w-3.5" />
Expire
</Button>
)}
{confirming ? (
<>
<Button variant="danger" onClick={() => void run(() => remove.mutateAsync(entry.id))} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
Confirm delete
</Button>
<Button onClick={() => setConfirming(false)} disabled={busy}>
Cancel
</Button>
</>
) : (
<Button variant="danger" onClick={() => setConfirming(true)} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
)}
</div>
</div>
</Card>
);
};
export const KeysView = () => {
const { keys, isLoading, error } = useHeadscaleKeys();
const { active } = useHeadscaleServers();
const [creating, setCreating] = useState(false);
const [secret, setSecret] = useState<string | null>(null);
const [filter, setFilter] = useState<StatusFilter>('active');
const [actionError, setActionError] = useState<string | null>(null);
const visible = filter === 'all' ? keys : keys.filter((k) => k.status === 'active');
const activeCount = keys.filter((k) => k.status === 'active').length;
return (
<ViewShell isLoading={isLoading} error={error} label="pre-auth keys">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-3">
<div className="flex items-start justify-between gap-4 px-1 pb-1">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-zinc-100">Pre-auth keys</h2>
<p className="mt-0.5 text-xs text-zinc-500">
{activeCount} active of {keys.length}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="flex items-center gap-0.5 rounded-lg border border-white/10 p-0.5">
{STATUS_FILTERS.map((option) => (
<button
key={option.id}
type="button"
onClick={() => setFilter(option.id)}
className={`cursor-pointer rounded-md px-2 py-1 text-[11px] transition-colors ${
filter === option.id ? 'bg-white/10 text-zinc-100' : 'text-zinc-500 hover:text-zinc-300'
}`}
>
{option.label}
</button>
))}
</div>
{!creating && (
<Button variant="primary" onClick={() => setCreating(true)}>
<Plus className="h-3.5 w-3.5" />
New key
</Button>
)}
</div>
</div>
{secret && <SecretPanel secret={secret} loginServer={active?.url ?? ''} onDismiss={() => setSecret(null)} />}
{creating && <CreateKeyForm onCreated={setSecret} onClose={() => setCreating(false)} />}
{actionError && <ErrorNote>{actionError}</ErrorNote>}
{keys.length === 0 && !creating && (
<EmptyBody
icon={<KeyRound className="h-6 w-6" />}
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 && (
<div className="py-10 text-center text-sm text-zinc-500">
No active keys. Switch to All to see spent and expired ones.
</div>
)}
{visible.map((entry) => (
<KeyRow key={entry.id} entry={entry} onError={setActionError} />
))}
</div>
</ViewShell>
);
};
@@ -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 (
<div className="flex items-center gap-2 rounded-lg border border-white/5 bg-white/[0.02] px-2.5 py-1.5">
{isExit ? <Globe className="h-3.5 w-3.5 shrink-0 text-amber-400" /> : <Dot tone={approved ? 'ok' : 'idle'} />}
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-zinc-300">{route}</span>
{isExit && <span className="shrink-0 text-[10px] uppercase tracking-wide text-amber-400/80">exit node</span>}
<button
type="button"
disabled={busy}
onClick={() => onToggle(!approved)}
className={`shrink-0 cursor-pointer rounded-md border px-2 py-0.5 text-[11px] transition-colors disabled:opacity-40 ${
approved
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/20'
: 'border-white/10 text-zinc-400 hover:bg-white/10 hover:text-zinc-100'
}`}
>
{approved ? 'Approved' : 'Approve'}
</button>
</div>
);
};
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<unknown>) => {
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 (
<Card>
<div className="flex flex-col">
<div className="flex items-center gap-2.5 px-3.5 py-3">
<Dot tone={node.online ? 'ok' : 'idle'} />
<div className="min-w-0 flex-1">
{renaming ? (
<div className="flex items-center gap-1.5">
<input
value={draftName}
onChange={(ev) => 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"
/>
<button
type="button"
onClick={() => void submitRename()}
className="cursor-pointer p-1 text-emerald-400"
>
<Check className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={() => setRenaming(false)} className="cursor-pointer p-1 text-zinc-500">
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-zinc-100">{node.name}</span>
{node.user && <Badge>{node.user.name}</Badge>}
{node.isExitNode && <Badge>exit</Badge>}
{node.tags.map((tag) => (
<Badge key={tag} tone="active">
{tag}
</Badge>
))}
</div>
)}
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-zinc-500">
<span className="font-mono">{node.ipAddresses[0] ?? 'no address'}</span>
<span>· {node.online ? 'online' : `seen ${timeAgo(node.lastSeen)}`}</span>
{node.subnetRoutes.length > 0 && <span>· {node.subnetRoutes.length} route(s) active</span>}
</div>
</div>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-label={open ? 'Collapse' : 'Expand'}
className="shrink-0 cursor-pointer p-1 text-zinc-500 transition-colors hover:text-zinc-200"
>
<ChevronRight className={`h-4 w-4 transition-transform ${open ? 'rotate-90' : ''}`} />
</button>
</div>
{open && (
<div className="flex flex-col gap-3 border-t border-white/10 bg-black/30 p-3">
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-[11px]">
<div className="text-zinc-500">Addresses</div>
<div className="flex flex-col gap-0.5">
{node.ipAddresses.map((ip) => (
<button
key={ip}
type="button"
onClick={() => copy(ip)}
title="Copy"
className="group flex cursor-pointer items-center gap-1 text-left font-mono text-zinc-300"
>
{ip}
<Copy className="h-3 w-3 opacity-0 transition-opacity group-hover:opacity-60" />
</button>
))}
</div>
<div className="text-zinc-500">Hostname</div>
<div className="truncate font-mono text-zinc-300">{node.hostname}</div>
<div className="text-zinc-500">Registered</div>
<div className="text-zinc-300">
{timeAgo(node.createdAt)} · {node.registerMethod}
</div>
<div className="text-zinc-500">Key expires</div>
<div className="text-zinc-300" title={fullDate(node.expiry)}>
{timeUntil(node.expiry)}
</div>
<div className="text-zinc-500">Last seen</div>
<div className="text-zinc-300" title={fullDate(node.lastSeen)}>
{node.online ? 'now' : timeAgo(node.lastSeen)}
</div>
</div>
<div>
<div className="pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-zinc-500">
Advertised routes
</div>
{node.availableRoutes.length === 0 ? (
<div className="text-[11px] text-zinc-600">This node advertises no routes.</div>
) : (
<div className="flex flex-col gap-1">
{node.availableRoutes.map((route) => (
<RouteRow
key={route}
route={route}
approved={node.approvedRoutes.includes(route)}
busy={busy}
onToggle={(approved) => void run(() => toggleRoute.mutateAsync({ id: node.id, route, approved }))}
/>
))}
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() => {
setDraftName(node.name);
setRenaming(true);
}}
disabled={busy}
>
<Pencil className="h-3.5 w-3.5" />
Rename
</Button>
<Button
onClick={() => void run(() => expire.mutateAsync(node.id))}
disabled={busy}
title="Expire the node's key — it stays registered but must re-authenticate"
>
<TimerReset className="h-3.5 w-3.5" />
Force re-auth
</Button>
{confirming ? (
<>
<Button variant="danger" onClick={() => void run(() => remove.mutateAsync(node.id))} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
Confirm remove
</Button>
<Button onClick={() => setConfirming(false)} disabled={busy}>
Cancel
</Button>
</>
) : (
<Button variant="danger" onClick={() => setConfirming(true)} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
Remove
</Button>
)}
</div>
</div>
)}
</div>
</Card>
);
};
export const NodesView = () => {
const { nodes, isLoading, error } = useHeadscaleNodes();
const [filter, setFilter] = useState('');
const [actionError, setActionError] = useState<string | null>(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 (
<ViewShell isLoading={isLoading} error={error} label="nodes">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-3">
<div className="flex items-center gap-3 px-1 pb-1">
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-zinc-100">Nodes</h2>
<p className="mt-0.5 text-xs text-zinc-500">
{nodes.length} registered · {online} online
</p>
</div>
<div className="relative w-56 shrink-0">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-zinc-600" />
<input
value={filter}
onChange={(ev) => 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"
/>
</div>
</div>
{actionError && <ErrorNote>{actionError}</ErrorNote>}
{nodes.length === 0 && (
<EmptyBody
icon={<Laptop className="h-6 w-6" />}
title="No nodes yet"
hint="Create a pre-auth key and run `tailscale up --login-server <your server> --authkey <key>` on a machine to join it."
/>
)}
{nodes.length > 0 && visible.length === 0 && (
<div className="py-10 text-center text-sm text-zinc-500">Nothing matches {filter}.</div>
)}
{visible.map((node) => (
<NodeCard key={node.id} node={node} onError={setActionError} />
))}
</div>
</ViewShell>
);
};
@@ -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<unknown>) => {
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 (
<Card>
<div className="flex flex-wrap items-center gap-3 p-3.5">
<Dot tone={user.onlineCount > 0 ? 'ok' : 'idle'} />
<div className="min-w-0 flex-1">
{renaming ? (
<div className="flex items-center gap-1.5">
<input
value={draft}
onChange={(ev) => 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"
/>
<button type="button" onClick={() => void submitRename()} className="cursor-pointer p-1 text-emerald-400">
<Check className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={() => setRenaming(false)} className="cursor-pointer p-1 text-zinc-500">
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-zinc-100">{user.name}</span>
{user.provider && <Badge>{user.provider}</Badge>}
</div>
)}
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-zinc-500">
<span>
{user.nodeCount} node{user.nodeCount === 1 ? '' : 's'}
{user.onlineCount > 0 && `, ${user.onlineCount} online`}
</span>
{user.email && <span>· {user.email}</span>}
<span>· created {timeAgo(user.createdAt)}</span>
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
<Button
onClick={() => {
setDraft(user.name);
setRenaming(true);
}}
disabled={busy}
>
<Pencil className="h-3.5 w-3.5" />
Rename
</Button>
{confirming ? (
<>
<Button variant="danger" onClick={() => void run(() => remove.mutateAsync(user.id))} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
{user.nodeCount > 0 ? `Delete with ${user.nodeCount} node(s)` : 'Confirm delete'}
</Button>
<Button onClick={() => setConfirming(false)} disabled={busy}>
Cancel
</Button>
</>
) : (
<Button variant="danger" onClick={() => setConfirming(true)} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
)}
</div>
</div>
</Card>
);
};
const CreateUserForm = ({ onClose }: { onClose: () => void }) => {
const { create } = useHeadscaleUsers();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [error, setError] = useState<string | null>(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 (
<Card>
<form
onSubmit={(ev) => {
ev.preventDefault();
void submit();
}}
className="flex flex-col gap-3 p-4"
>
<div className="text-sm font-semibold text-zinc-100">New user</div>
<Field
label="Name"
value={name}
onChange={setName}
placeholder="laptop-fleet"
hint="Lowercase, no spaces. This is the namespace nodes and keys belong to."
autoFocus
/>
<Field label="Email (optional)" value={email} onChange={setEmail} placeholder="someone@example.com" />
{error && <ErrorNote>{error}</ErrorNote>}
<div className="flex items-center gap-2 pt-1">
<Button type="submit" variant="primary" disabled={create.isPending}>
{create.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Create user
</Button>
<Button onClick={onClose} disabled={create.isPending}>
Cancel
</Button>
</div>
</form>
</Card>
);
};
export const UsersView = () => {
const { users, isLoading, error } = useHeadscaleUsers();
const [creating, setCreating] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
return (
<ViewShell isLoading={isLoading} error={error} label="users">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-3">
<div className="flex items-start justify-between gap-4 px-1 pb-1">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-zinc-100">Users</h2>
<p className="mt-0.5 text-xs text-zinc-500">Namespaces that own nodes and pre-auth keys.</p>
</div>
{!creating && (
<Button variant="primary" onClick={() => setCreating(true)}>
<Plus className="h-3.5 w-3.5" />
New user
</Button>
)}
</div>
{creating && <CreateUserForm onClose={() => setCreating(false)} />}
{actionError && <ErrorNote>{actionError}</ErrorNote>}
{users.length === 0 && !creating && (
<EmptyBody
icon={<Users className="h-6 w-6" />}
title="No users yet"
hint="Every node belongs to a user. Create one before issuing a pre-auth key."
/>
)}
{users.map((user) => (
<UserRow key={user.id} user={user} onError={setActionError} />
))}
</div>
</ViewShell>
);
};
@@ -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 (
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading {label}
</div>
);
}
if (error && isNoActiveServer(error)) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/5 text-zinc-400">
<ServerOff className="h-6 w-6" />
</div>
<div>
<div className="text-base font-semibold text-zinc-100">No server selected</div>
<p className="mt-1 text-sm text-zinc-500">Pick one in the Servers section to see its {label}.</p>
</div>
</div>
);
}
if (error) {
return (
<div className="p-4">
<ErrorNote>
Could not load {label}: {headscaleErrorMessage(error)}
</ErrorNote>
</div>
);
}
return <div className="h-full overflow-y-auto p-4">{children}</div>;
};
/** 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 }) => (
<div className="flex flex-col items-center gap-3 py-16 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/5 text-zinc-400">{icon}</div>
<div>
<div className="text-base font-semibold text-zinc-100">{title}</div>
<p className="mt-1 max-w-sm text-sm text-zinc-500">{hint}</p>
</div>
</div>
);
@@ -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();
}
@@ -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';
@@ -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,
};
}