diff --git a/TODO.md b/TODO.md index e9aec6c2..d00597aa 100644 --- a/TODO.md +++ b/TODO.md @@ -100,22 +100,24 @@ it exists in the reference app, so none of it was in scope for parity. ## Headscale -Gaps against headscale's own API, found while reading the app on 2026-08-05. None is urgent; the -first is the only one you would otherwise SSH in to do. +Gaps against headscale's own API, found while reading the app on 2026-08-05 — all closed the same +day. Kept here for what the policy work turned up, which is not obvious from the code. -- [ ] **ACL policy is not wired at all.** `/api/v1/policy` (GET/PUT) has no sidecar route and no - screen. Editing the policy means SSHing to the host, which is the one thing this app exists to - avoid. Wants a text editor with the server's own validation error surfaced on save, not a - form — the policy is HuJSON and headscale is the authority on whether it parses. +- [x] **ACL policy.** `/api/v1/policy` GET/PUT behind `/_officer/policy`, with a plain HuJSON textarea + that sends the text byte for byte and shows headscale's verdict verbatim (line and column + included). Officer does not pre-validate: headscale owns the only parser that resolves groups, + tags and hosts, and a second weaker one would disagree with the thing that actually enforces. + **The mode cannot be read.** A file-backed policy is still served over GET; only a PUT refuses, + with "update is disabled for modes other than 'database'". So the first save is what discovers + writability, and a refusal becomes a persistent read-only banner. Verified live, not inferred. -- [ ] **A node cannot be moved between users.** `/api/v1/node/{id}/user` is missing, so a node can be - renamed, tagged, route-approved and expired, but not re-owned. +- [x] **A node can be moved between users.** `/api/v1/node/{id}/user`, in the expanded node card next + to the tag editor — both being the things that decide which policy rules apply to a node. -- [ ] **Users are create/delete/list only.** No rename (`/api/v1/user/{id}/rename/{newName}`). +- [x] **User rename** — was already shipped end to end (`/users/:id/rename`, `UsersView`); this entry + was stale when it was written. -- [ ] **Nothing refreshes.** No query in `useHeadscaleData.ts` polls, so a node going offline (or - coming back) only appears on a manual reload. A modest `refetchInterval` on the nodes list is - probably the whole fix. +- [x] **Polling** — `useHeadscaleNodes` has had `refetchInterval: 20_000` all along; also stale. ## Known bugs diff --git a/src/servers/sidecar/headscale/client.ts b/src/servers/sidecar/headscale/client.ts index 94dc7c70..8f357ea8 100644 --- a/src/servers/sidecar/headscale/client.ts +++ b/src/servers/sidecar/headscale/client.ts @@ -22,6 +22,15 @@ export class HeadscaleError extends Error { constructor( readonly status: number, message: string, + /** + * Headscale's own words, kept even when `message` generalizes them. + * + * A 5xx is normally not safe to relay — it leaks internals and rarely helps. The policy endpoints are + * the exception: Headscale answers "policy is read from a file" and reports a HuJSON syntax error's + * line and column with the same 500, and there the message IS the feature. Callers that know their + * endpoint's 5xx is a real answer read this; everyone else keeps getting "headscale error". + */ + readonly detail?: string, ) { super(message); this.name = 'HeadscaleError'; @@ -86,7 +95,8 @@ export function createClient(creds: HeadscaleServerCredentials): HeadscaleClient const message = await errorMessage(res); console.error(`[headscale] ${method} ${path} -> ${res.status}: ${message}`); // 4xx from the admin API is usually a bad argument and safe to relay; 5xx is not, so it's generalized. - throw new HeadscaleError(res.status >= 500 ? 502 : res.status, res.status >= 500 ? 'headscale error' : message); + const serverSide = res.status >= 500; + throw new HeadscaleError(serverSide ? 502 : res.status, serverSide ? 'headscale error' : message, message); } // 204 and empty 200s are legitimate: several mutations return `{}` or nothing at all. diff --git a/src/servers/sidecar/headscale/nodes.ts b/src/servers/sidecar/headscale/nodes.ts index 7fac4c9c..dc34a2c9 100644 --- a/src/servers/sidecar/headscale/nodes.ts +++ b/src/servers/sidecar/headscale/nodes.ts @@ -104,6 +104,19 @@ async function handleNodeAction({ ctx, id, action }: NodeActionParams): Promise< return Response.json({ node: await getNode(client, id) }); } + if (action === 'user') { + const body = await readJson(req); + if (!body) return badRequest('expected a JSON body'); + // Upstream takes the target user's numeric id, not its name — and uint64-as-string, so it is validated + // by shape and passed through as a string rather than parsed. + const userId = typeof body.userId === 'string' ? body.userId.trim() : ''; + if (!/^\d+$/.test(userId)) return badRequest('userId must be numeric'); + // Moving a node changes which ACL rules and tag ownership apply to it — the routes it advertises and + // the tags it carries stay put, but what they now MEAN can differ. The UI says so before asking. + await client.call(`/api/v1/node/${encodeURIComponent(id)}/user`, { method: 'POST', body: { user: userId } }); + 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' }); diff --git a/src/servers/sidecar/headscale/policy.ts b/src/servers/sidecar/headscale/policy.ts new file mode 100644 index 00000000..38dde6da --- /dev/null +++ b/src/servers/sidecar/headscale/policy.ts @@ -0,0 +1,112 @@ +import type { OfficerContext } from './routes'; +import { badRequest, methodNotAllowed, readJson } from './routes'; +import { HeadscaleError } from './client'; +import { activeClient } from './active'; + +// The ACL policy — /_officer/policy. One HuJSON document that decides which node may reach which, so it is +// the highest-consequence thing this app can write and the only place a typo silently partitions a network. +// +// Three upstream behaviours drive the shape of this file. +// +// 1. **Readable always, writable sometimes.** Headscale can keep its policy in a file (`policy.mode: file`) +// instead of the database, and then the API still SERVES it — a GET returns the file's contents quite +// happily — but a PUT is refused with "update is disabled for modes other than 'database'". Verified +// against a live server, and it means the mode CANNOT be inferred from a read. There is no endpoint +// that reports it either. So this route makes no claim about writability up front; the first save is +// what finds out, and a refusal is a 409 the UI turns into a persistent read-only banner. +// +// 2. **Validation happens on PUT, in Headscale, and its message is the whole value.** It parses the +// HuJSON, resolves every group and tag reference, and rejects the write with a line and column or a +// "group not defined" naming the offender. Officer must not pre-validate: a second, weaker parser here +// would reject documents Headscale accepts and — worse — accept ones it rejects, and its opinion would +// be the one shown. So the text goes up untouched and Headscale's verdict comes back verbatim. +// +// 3. **Both of those arrive as HTTP 500** from grpc-gateway, which the client layer normally generalizes +// to "headscale error". `HeadscaleError.detail` is how the real message survives that; see client.ts. + +/** + * Does this failure mean "writing is turned off here", as opposed to "your document is wrong"? + * + * Matched on the message because Headscale gives no code to match on. Deliberately broad: a false positive + * costs a slightly-wrong banner over a message the owner can still read, while a false negative would tell + * someone their perfectly good ACL was rejected and send them hunting for a syntax error that isn't there. + */ +function isWriteDisabled(detail: string): boolean { + const text = detail.toLowerCase(); + if (text.includes('disabled')) return true; + return text.includes('file') && (text.includes('policy') || text.includes('mode')); +} + +type PolicyBody = { policy?: unknown; updatedAt?: unknown }; + +const asText = (value: unknown) => (typeof value === 'string' ? value : ''); +const asDate = (value: unknown) => (typeof value === 'string' && value && !value.startsWith('0001-') ? value : null); + +/** + * `GET /_officer/policy`. + * + * Answers 200 for every state a running server can be in, including "there is no policy yet" — a fresh + * Headscale has none, and an empty editor is both the honest rendering of that and the thing the owner + * needs to start typing into. Only an unreachable server is an error, because only that leaves nothing + * to say. Note there is no `mode` here on purpose: see the header. + */ +async function getPolicy(ctx: OfficerContext): Promise { + const client = await activeClient(ctx.userId); + if (client instanceof Response) return client; + + try { + const body = await client.call('/api/v1/policy'); + return Response.json({ policy: asText(body.policy), updatedAt: asDate(body.updatedAt) }); + } catch (err) { + if (!(err instanceof HeadscaleError)) throw err; + const detail = err.detail ?? err.message; + if (err.status === 404 || detail.toLowerCase().includes('not found')) { + return Response.json({ policy: '', updatedAt: null }); + } + throw err; + } +} + +/** + * `PUT /_officer/policy {policy}`. + * + * The body is sent up byte for byte — no trimming, no reformatting, no parse. Comments and layout are load + * bearing in a hand-maintained ACL, and re-serializing would destroy both. + */ +async function putPolicy(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'); + if (typeof body.policy !== 'string') return badRequest('policy must be a string'); + // An empty document would be accepted by some Headscale versions and lock every node out of every other + // one. Deleting a policy is not something to do by leaving a textarea blank and pressing save. + if (!body.policy.trim()) return badRequest('the policy is empty — that would deny every connection'); + + try { + const saved = await client.call('/api/v1/policy', { method: 'PUT', body: { policy: body.policy } }); + // Headscale echoes what it stored; fall back to what we sent if it echoes nothing, so a successful save + // never blanks the editor. + return Response.json({ policy: asText(saved.policy) || body.policy, updatedAt: asDate(saved.updatedAt) }); + } catch (err) { + if (!(err instanceof HeadscaleError)) throw err; + const detail = err.detail ?? err.message; + + if (isWriteDisabled(detail)) { + return Response.json({ error: detail, code: 'policy_read_only' }, { status: 409 }); + } + // Everything else on a PUT is Headscale rejecting this document: a syntax error with a position, an + // unresolvable group, an unknown tag owner. 422 rather than 502 — the request is the problem, and the + // message is the one thing that will fix it. + return Response.json({ error: detail, code: 'policy_rejected' }, { status: 422 }); + } +} + +/** Dispatch `/_officer/policy`. No sub-paths: there is exactly one policy per server. */ +export async function handlePolicyRoute(ctx: OfficerContext, rest: string[]): Promise { + if (rest.length > 0) return badRequest('unexpected path'); + if (ctx.req.method === 'GET') return getPolicy(ctx); + if (ctx.req.method === 'PUT') return putPolicy(ctx); + return methodNotAllowed(); +} diff --git a/src/servers/sidecar/headscale/routes.ts b/src/servers/sidecar/headscale/routes.ts index bc9f55d7..c939de7f 100644 --- a/src/servers/sidecar/headscale/routes.ts +++ b/src/servers/sidecar/headscale/routes.ts @@ -3,6 +3,7 @@ import { handleServersRoute } from './servers'; import { handleNodesRoute } from './nodes'; import { handleUsersRoute } from './users'; import { handleKeysRoute } from './keys'; +import { handlePolicyRoute } from './policy'; import { handleEnrollRoute } from './enroll'; import { handleSshTestRoute } from './ssh'; import { handleCompanionRoute } from './companion'; @@ -58,6 +59,8 @@ export async function handleOfficerRoute(req: Request, url: URL): Promise = { nodes: Laptop, users: Users, keys: KeyRound, + policy: ShieldCheck, diagnostics: Activity, console: TerminalSquare, }; diff --git a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleView.tsx b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleView.tsx index 6d9bdb87..ecbc237e 100644 --- a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleView.tsx +++ b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleView.tsx @@ -3,6 +3,7 @@ import { ServersView } from './ServersView'; import { NodesView } from './NodesView'; import { UsersView } from './UsersView'; import { KeysView } from './KeysView'; +import { PolicyView } from './PolicyView'; import { DiagnosticsView } from './DiagnosticsView'; import { ConsoleView } from './ConsoleView'; @@ -21,6 +22,8 @@ export const HeadscaleView = () => { return ; case 'keys': return ; + case 'policy': + return ; case 'diagnostics': return ; case 'console': diff --git a/src/workspaces/officerdev/src/apps/Headscale/NodesView.tsx b/src/workspaces/officerdev/src/apps/Headscale/NodesView.tsx index 6768c52b..b6586ea4 100644 --- a/src/workspaces/officerdev/src/apps/Headscale/NodesView.tsx +++ b/src/workspaces/officerdev/src/apps/Headscale/NodesView.tsx @@ -1,7 +1,21 @@ import { useState } from 'react'; -import { Laptop, Globe, Trash2, Pencil, TimerReset, Check, X, Search, Copy, ChevronRight } from 'lucide-react'; +import { + Laptop, + Globe, + Trash2, + Pencil, + TimerReset, + Check, + X, + Search, + Copy, + ChevronRight, + UserRound, + ArrowRightLeft, + Tag as TagIcon, +} from 'lucide-react'; import type { HeadscaleNode } from './shared'; -import { useHeadscaleNodes } from './useHeadscaleData'; +import { useHeadscaleNodes, useHeadscaleUsers } from './useHeadscaleData'; import { headscaleErrorMessage } from './useHeadscaleServers'; import { timeAgo, timeUntil, fullDate } from './format'; import { Card, Button, Dot, Badge, ErrorNote } from './Cards'; @@ -41,6 +55,134 @@ const RouteRow = ({ route, approved, busy, onToggle }: RouteRowProps) => { ); }; +/** + * Tags as Headscale stores them: every one prefixed `tag:`. Typing the prefix every time is noise, so the + * editor accepts either form and normalizes here — which is also how the dirty check stays honest, since + * `web` and `tag:web` are the same tag and neither should look like an edit. + */ +const parseTags = (text: string): string[] => { + const parts = text + .split(/[\s,]+/) + .map((t) => t.trim()) + .filter(Boolean); + return [...new Set(parts.map((t) => (t.startsWith('tag:') ? t : `tag:${t}`)))]; +}; + +const sameTags = (a: string[], b: string[]) => a.length === b.length && a.every((t, i) => t === b[i]); + +type OwnershipProps = { node: HeadscaleNode; busy: boolean; onError: (message: string) => void }; + +/** + * Owner and tags — the two things that decide which ACL rules apply to a node, which is why they sit + * together behind the disclosure rather than next to Rename. + * + * Mounted only while the card is expanded: it needs the user list, and fetching every user to render a + * collapsed row would be a request per screenful for a control nobody is looking at. The query key is + * shared with the Users section, so an expanded card is usually a cache hit anyway. + */ +const Ownership = ({ node, busy, onError }: OwnershipProps) => { + const { setTags, moveToUser } = useHeadscaleNodes(); + const { users } = useHeadscaleUsers(); + + const [owner, setOwner] = useState(node.user?.id ?? ''); + const [draftTags, setDraftTags] = useState(node.tags.join(' ')); + + const pending = setTags.isPending || moveToUser.isPending; + const nextTags = parseTags(draftTags); + const tagsDirty = !sameTags(nextTags, node.tags); + const ownerDirty = !!owner && owner !== node.user?.id; + const target = users.find((u) => u.id === owner); + + const run = async (fn: () => Promise) => { + try { + await fn(); + } catch (err) { + onError(headscaleErrorMessage(err)); + } + }; + + return ( +
+
Owner and tags
+ +
+ + + {ownerDirty && ( + <> + + + + )} +
+ + {/* Said before the move, not after: the node keeps its address and its tags, but the rules that let + anything reach it are written per user, so it can go dark to everything that used to see it. */} + {ownerDirty && ( +

+ Moving this node to {target?.name ?? 'another user'} changes which policy + rules apply to it. Its addresses and tags stay, but anything reaching it through a rule written for{' '} + {node.user?.name ?? 'its current owner'} will stop. +

+ )} + +
+ + setDraftTags(ev.target.value)} + onKeyDown={(ev) => { + if (ev.key === 'Enter' && tagsDirty) void run(() => setTags.mutateAsync({ id: node.id, tags: nextTags })); + if (ev.key === 'Escape') setDraftTags(node.tags.join(' ')); + }} + placeholder="tag:server tag:eu — space separated" + spellCheck={false} + autoComplete="off" + className="min-w-0 flex-1 rounded-md border border-white/10 bg-black/40 px-2 py-1 font-mono text-[11px] text-zinc-200 outline-none placeholder:text-zinc-600 focus:border-primary/50" + /> + {tagsDirty && ( + <> + + + + )} +
+

+ Tags are what the access policy targets. A tag no rule mentions does nothing; removing one a rule depends on + cuts the node off from it. The tag: prefix is added for you. +

+
+ ); +}; + type NodeCardProps = { node: HeadscaleNode; onError: (message: string) => void }; const NodeCard = ({ node, onError }: NodeCardProps) => { @@ -183,6 +325,8 @@ const NodeCard = ({ node, onError }: NodeCardProps) => { )} + +
+ )} + +
+ } + /> + + {readOnly && } + {failure?.kind === 'rejected' && } + {failure?.kind === 'unknown' && {failure.message}} + + +