headscale: acl policy editor, node owner and tags

policy: /_officer/policy GET/PUT. the text goes up byte for byte and
headscale's verdict comes back verbatim — it owns the only parser that
resolves groups, tags and hosts, so a second one here would disagree with
what actually enforces. a file-backed policy is still served over GET and
only refuses on PUT, so writability cannot be read: the first save finds
out, and a refusal becomes a persistent read-only banner rather than a
rejection the owner would go hunting for a syntax error over.

nodes: move between users, and a tag editor for the setTags route that had
no ui. both sit together in the expanded card because both decide which
policy rules apply to a node, and a move says so before it happens.
This commit is contained in:
2026-08-05 16:48:27 +00:00
parent d6d405100c
commit e8b19229cb
12 changed files with 550 additions and 16 deletions
+11 -1
View File
@@ -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.
+13
View File
@@ -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' });
+112
View File
@@ -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<Response> {
const client = await activeClient(ctx.userId);
if (client instanceof Response) return client;
try {
const body = await client.call<PolicyBody>('/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<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');
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<PolicyBody>('/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<Response> {
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();
}
+3
View File
@@ -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<Respon
return await handleUsersRoute(ctx, segments.slice(1));
case 'keys':
return await handleKeysRoute(ctx, segments.slice(1));
case 'policy':
return await handlePolicyRoute(ctx, segments.slice(1));
case 'enroll':
return await handleEnrollRoute(ctx, segments.slice(1));
// Not a Headscale call at all — a local `ssh` reachability probe for the console. See ssh.ts.
@@ -1,6 +1,6 @@
import type { LucideIcon } from 'lucide-react';
import { NavLink } from 'react-router';
import { Network, Server, Laptop, Users, KeyRound, Activity, TerminalSquare, Check } from 'lucide-react';
import { Network, Server, Laptop, Users, KeyRound, ShieldCheck, Activity, TerminalSquare, Check } from 'lucide-react';
import { HEADSCALE_SECTIONS, headscaleSectionPath, type HeadscaleSectionId } from './shared';
import { useHeadscaleServers } from './useHeadscaleServers';
@@ -18,6 +18,7 @@ const ICONS: Record<HeadscaleSectionId, LucideIcon> = {
nodes: Laptop,
users: Users,
keys: KeyRound,
policy: ShieldCheck,
diagnostics: Activity,
console: TerminalSquare,
};
@@ -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 <UsersView />;
case 'keys':
return <KeysView />;
case 'policy':
return <PolicyView />;
case 'diagnostics':
return <DiagnosticsView />;
case 'console':
@@ -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<unknown>) => {
try {
await fn();
} catch (err) {
onError(headscaleErrorMessage(err));
}
};
return (
<div className="flex flex-col gap-2.5">
<div className="text-[10px] font-semibold uppercase tracking-wide text-zinc-500">Owner and tags</div>
<div className="flex flex-wrap items-center gap-2">
<UserRound className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
<select
value={owner}
onChange={(ev) => setOwner(ev.target.value)}
disabled={busy || pending}
className="min-w-0 flex-1 cursor-pointer rounded-md border border-white/10 bg-black/40 px-2 py-1 text-xs text-zinc-200 outline-none focus:border-primary/50 disabled:opacity-40"
>
{!node.user && <option value="">no owner</option>}
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
{ownerDirty && (
<>
<Button
onClick={() => void run(() => moveToUser.mutateAsync({ id: node.id, userId: owner }))}
disabled={busy || pending}
>
<ArrowRightLeft className="h-3.5 w-3.5" />
Move
</Button>
<Button onClick={() => setOwner(node.user?.id ?? '')} disabled={busy || pending}>
Cancel
</Button>
</>
)}
</div>
{/* 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 && (
<p className="text-[11px] leading-snug text-amber-400/90">
Moving this node to <span className="font-medium">{target?.name ?? 'another user'}</span> 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.
</p>
)}
<div className="flex flex-wrap items-center gap-2">
<TagIcon className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
<input
value={draftTags}
onChange={(ev) => 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 && (
<>
<Button
onClick={() => void run(() => setTags.mutateAsync({ id: node.id, tags: nextTags }))}
disabled={busy || pending}
>
<Check className="h-3.5 w-3.5" />
Apply tags
</Button>
<Button onClick={() => setDraftTags(node.tags.join(' '))} disabled={busy || pending}>
Revert
</Button>
</>
)}
</div>
<p className="text-[11px] leading-snug text-zinc-600">
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 <span className="text-zinc-500">tag:</span> prefix is added for you.
</p>
</div>
);
};
type NodeCardProps = { node: HeadscaleNode; onError: (message: string) => void };
const NodeCard = ({ node, onError }: NodeCardProps) => {
@@ -183,6 +325,8 @@ const NodeCard = ({ node, onError }: NodeCardProps) => {
)}
</div>
<Ownership node={node} busy={busy} onError={onError} />
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() => {
@@ -0,0 +1,166 @@
import { useEffect, useRef, useState } from 'react';
import { AlertTriangle, FileLock2, Loader2, RotateCcw, Save, ShieldCheck } from 'lucide-react';
import { timeAgo } from './format';
import { useHeadscalePolicy, policySaveFailure, type PolicySaveFailure } from './useHeadscalePolicy';
import { Card, SectionHeader, Button, ErrorNote } from './Cards';
import { ViewShell } from './ViewShell';
// The tailnet's ACL document. A plain textarea on purpose — this is HuJSON, where the comments and the
// hand-kept alignment are half the document's value to whoever maintains it, and a rich editor that
// reformats or a client-side parser that disagrees with Headscale would both destroy more than they add.
//
// Validation is entirely Headscale's. It has the only parser that counts: it resolves groups, tags and
// host aliases, and it is what will actually enforce the result. Officer sends the text up untouched and
// shows the verdict verbatim — including the line and column, which is the whole reason to show it at all.
//
// Two failures, deliberately styled differently. A REJECTED document is a normal part of editing and stays
// inline next to the save button. A READ-ONLY server means this screen cannot do its job at all and says so
// at the top, permanently, because the owner needs to go and edit a file on the server instead.
/** Ctrl/Cmd-S while the textarea has focus. An ACL is long enough that reaching for the button breaks flow. */
function useSaveShortcut(onSave: () => void, enabled: boolean) {
const handler = useRef(onSave);
handler.current = onSave;
useEffect(() => {
if (!enabled) return;
const onKeyDown = (ev: KeyboardEvent) => {
if ((ev.metaKey || ev.ctrlKey) && ev.key.toLowerCase() === 's') {
ev.preventDefault();
handler.current();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [enabled]);
}
const ReadOnlyBanner = ({ message }: { message: string }) => (
<div className="flex items-start gap-2.5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs leading-relaxed text-amber-200">
<FileLock2 className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<div className="font-medium text-amber-100">This server's policy is read-only</div>
<p className="mt-1 text-amber-200/80">
Headscale said: <span className="font-mono">{message}</span>
</p>
<p className="mt-1.5 text-amber-200/70">
It is reading its policy from a file on disk rather than from its database, so the API refuses writes — a save
here would be overwritten on the next restart anyway. Edit the file on the server (the Console section is one
way in) and reload it there. Everything below is still the live document, and still readable.
</p>
</div>
</div>
);
const Rejected = ({ message }: { message: string }) => (
<div className="flex items-start gap-2.5 rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-xs leading-relaxed text-red-300">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<div className="font-medium text-red-200">Headscale rejected this policy</div>
{/* Verbatim, monospaced: it usually carries a line and column, and re-wording it would throw that away. */}
<pre className="mt-1 font-mono text-[11px] whitespace-pre-wrap text-red-300/90">{message}</pre>
<p className="mt-1.5 text-red-300/70">Nothing was saved — the tailnet is still running the previous policy.</p>
</div>
</div>
);
export const PolicyView = () => {
const { policy, isLoading, error, save } = useHeadscalePolicy();
const [draft, setDraft] = useState<string | null>(null);
const [failure, setFailure] = useState<PolicySaveFailure | null>(null);
// Sticky for the session: once a server has refused a write, every later save would refuse identically,
// and re-discovering that by pressing save again is not information.
const [readOnly, setReadOnly] = useState<string | null>(null);
const [savedAt, setSavedAt] = useState<number | null>(null);
// The fetched document seeds the editor once. After that the draft owns the text — a refetch must never
// reach in and replace what someone is typing.
const text = draft ?? policy?.policy ?? '';
const dirty = draft !== null && draft !== (policy?.policy ?? '');
const submit = async () => {
if (!dirty || readOnly || save.isPending) return;
setFailure(null);
try {
await save.mutateAsync(text);
setDraft(null);
setSavedAt(Date.now());
} catch (err) {
const parsed = policySaveFailure(err);
setFailure(parsed);
if (parsed.kind === 'readOnly') setReadOnly(parsed.message);
}
};
useSaveShortcut(() => void submit(), dirty && !readOnly);
const revert = () => {
setDraft(null);
setFailure(null);
};
return (
<ViewShell isLoading={isLoading} error={error} label="the access policy">
<div className="mx-auto flex max-w-4xl flex-col gap-3">
<SectionHeader
title="Access policy"
subtitle="HuJSON — JSON with comments and trailing commas. Headscale validates it on save; nothing is stored unless it passes."
action={
<div className="flex items-center gap-2">
{dirty && !readOnly && (
<Button onClick={revert} disabled={save.isPending}>
<RotateCcw className="h-3.5 w-3.5" />
Revert
</Button>
)}
<Button variant="primary" onClick={() => void submit()} disabled={!dirty || !!readOnly || save.isPending}>
{save.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
{save.isPending ? 'Validating' : 'Save'}
</Button>
</div>
}
/>
{readOnly && <ReadOnlyBanner message={readOnly} />}
{failure?.kind === 'rejected' && <Rejected message={failure.message} />}
{failure?.kind === 'unknown' && <ErrorNote>{failure.message}</ErrorNote>}
<Card>
<textarea
value={text}
onChange={(ev) => {
setDraft(ev.target.value);
setFailure(null);
setSavedAt(null);
}}
spellCheck={false}
autoComplete="off"
placeholder={'{\n "acls": [\n { "action": "accept", "src": ["*"], "dst": ["*:*"] },\n ],\n}'}
className="block h-[28rem] w-full resize-y bg-black/40 p-4 font-mono text-[12px] leading-relaxed text-zinc-200 outline-none placeholder:text-zinc-700"
/>
<div className="flex flex-wrap items-center gap-3 border-t border-white/10 px-3 py-2 text-[11px] text-zinc-500">
<span>
{text.split('\n').length} lines · {text.length} characters
</span>
<span className="ml-auto flex items-center gap-3">
{savedAt !== null && !dirty && (
<span className="flex items-center gap-1 text-emerald-400">
<ShieldCheck className="h-3.5 w-3.5" />
Saved and accepted
</span>
)}
{dirty && <span className="text-amber-400">Unsaved changes</span>}
{policy?.updatedAt && <span>Last changed {timeAgo(policy.updatedAt)}</span>}
</span>
</div>
</Card>
<p className="px-1 text-[11px] leading-relaxed text-zinc-600">
This document decides which node may reach which. A policy that saves cleanly can still cut a machine off
Headscale checks that the document is valid, not that it is what you meant. Ctrl/Cmd-S saves.
</p>
</div>
</ViewShell>
);
};
@@ -9,6 +9,7 @@ export const HEADSCALE_SECTIONS = [
{ id: 'nodes', label: 'Nodes' },
{ id: 'users', label: 'Users' },
{ id: 'keys', label: 'Pre-auth keys' },
{ id: 'policy', label: 'Access policy' },
{ id: 'diagnostics', label: 'Diagnostics' },
{ id: 'console', label: 'Console' },
] as const;
@@ -57,6 +58,24 @@ export type HeadscaleSshTest = { ok: boolean; error?: string; ms: number };
/** 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';
// ── Access policy ─────────────────────────────────────────────────────────────────────────────────
/**
* The tailnet's ACL document, in HuJSON (JSON with comments and trailing commas). Headscale serves it
* whether it is stored in the database or read from a file — so this carries no "is it editable" flag,
* because there is nothing on the server that reports one. Only an attempted save finds out.
*/
export type HeadscalePolicy = {
policy: string;
/** Null when Headscale has never recorded one, which includes every file-backed policy. */
updatedAt: string | null;
};
/** Headscale refused the write outright — this server's policy is read-only over the API. */
export const POLICY_READ_ONLY = 'policy_read_only';
/** Headscale parsed the document and rejected it. The message is a syntax position or a bad reference. */
export const POLICY_REJECTED = 'policy_rejected';
// ── Companion API ─────────────────────────────────────────────────────────────────────────────────
// The Officer Companion is a service deployed next to a Headscale server that can see the container the
// admin API is served from: whether it is running, what it logged, and start/stop/restart. It is optional
@@ -42,6 +42,13 @@ export function useHeadscaleNodes() {
onSuccess: invalidate,
});
// Re-owning a node. Takes the target user's id, not its name — Headscale's ids are uint64-as-string.
const moveToUser = useMutation({
mutationFn: ({ id, userId }: { id: string; userId: string }) =>
post(`/headscale/_officer/nodes/${id}/user`, { userId }),
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({
@@ -66,6 +73,7 @@ export function useHeadscaleNodes() {
error: query.error,
rename,
setTags,
moveToUser,
toggleRoute,
expire,
remove,
@@ -0,0 +1,53 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import type { HeadscalePolicy } from './shared';
import { POLICY_READ_ONLY, POLICY_REJECTED } from './shared';
// The active server's ACL policy. One document, one query, one mutation — the interesting part is entirely
// in how a failed save is classified, because the two failures need opposite reactions from the owner:
// "your document is wrong, here is where" versus "this server does not accept written policies at all".
const POLICY_KEY = ['headscale', 'policy'] as const;
const PATH = '/headscale/_officer/policy';
/** What a rejected save means. `rejected` carries Headscale's own message; `readOnly` ends the editing. */
export type PolicySaveFailure = { kind: 'rejected' | 'readOnly' | 'unknown'; message: string };
/** useClient throws `{status, message}` with the raw body text — dig the sidecar's `{error, code}` out. */
export function policySaveFailure(err: unknown): PolicySaveFailure {
const raw = (err as { message?: unknown } | null)?.message;
if (typeof raw !== 'string' || !raw) return { kind: 'unknown', message: 'The policy could not be saved' };
try {
const parsed = JSON.parse(raw) as { error?: unknown; code?: unknown };
const message = typeof parsed.error === 'string' && parsed.error ? parsed.error : 'The policy could not be saved';
if (parsed.code === POLICY_READ_ONLY) return { kind: 'readOnly', message };
if (parsed.code === POLICY_REJECTED) return { kind: 'rejected', message };
return { kind: 'unknown', message };
} catch {
return { kind: 'unknown', message: raw.slice(0, 300) };
}
}
export function useHeadscalePolicy() {
const { get, put } = useClient();
const qc = useQueryClient();
const query = useQuery({
queryKey: POLICY_KEY,
queryFn: () => get<HeadscalePolicy>(PATH),
// No polling and a long staleTime: this is a document someone is editing. A background refetch that
// replaced the textarea under a half-written rule would be the worst thing this screen could do.
staleTime: 5 * 60_000,
refetchOnWindowFocus: false,
});
const save = useMutation({
mutationFn: (policy: string) => put<HeadscalePolicy>(PATH, { policy }),
// Seed the cache from the response rather than invalidating: a refetch here would race the editor's
// own state and could show the pre-save document for a frame.
onSuccess: (data) => qc.setQueryData(POLICY_KEY, data),
});
return { policy: query.data ?? null, isLoading: query.isLoading, error: query.error, refetch: query.refetch, save };
}