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:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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,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 };
|
||||
}
|
||||
Reference in New Issue
Block a user