The tailnet plugin — machines, users, pre-auth keys, access policy and device invites. Moved out of officerdev/platform, where it had lived in plugins/ since the plugin system was built. Until now this code existed in exactly one place: the platform repository. That made "gitignore the plugins directory" impossible to do safely, because untracking it would have left 49 files on a single disk with no remote. This repository is what makes that move safe. Same extraction as plugins/music before it: source only, no history. The platform's history still holds every commit that shaped this, and the SHAs cited across the codebase keep resolving — replaying it here would have created a second, divergent account of the same work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
211 lines
9.3 KiB
TypeScript
211 lines
9.3 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { AlertTriangle, FileLock2, Loader2, Pencil, RotateCcw, Save, ShieldCheck } from 'lucide-react';
|
|
import { timeAgo } from './format';
|
|
import { useHeadscalePolicy, policySaveFailure, type PolicySaveFailure } from './useHeadscalePolicy';
|
|
import { PolicyAssistant } from './PolicyAssistant';
|
|
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.
|
|
//
|
|
// It opens READ-ONLY behind an Edit button. This is the document that decides which machine can reach
|
|
// which, it is usually being looked at rather than changed, and a textarea focused by a stray click is a
|
|
// way to alter it without meaning to. Edit mode also brings up the assistant, because "I do not know what
|
|
// this file should look like" is the actual reason this screen was hard to use.
|
|
//
|
|
// 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 [editing, setEditing] = useState(false);
|
|
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());
|
|
// A clean save is the end of the edit, not the start of the next one — back to reading.
|
|
setEditing(false);
|
|
} catch (err) {
|
|
const parsed = policySaveFailure(err);
|
|
setFailure(parsed);
|
|
if (parsed.kind === 'readOnly') setReadOnly(parsed.message);
|
|
}
|
|
};
|
|
|
|
useSaveShortcut(() => void submit(), editing && dirty && !readOnly);
|
|
|
|
const revert = () => {
|
|
setDraft(null);
|
|
setFailure(null);
|
|
};
|
|
|
|
/** Leaving edit mode throws the draft away — there is nowhere else for unsaved text to go. */
|
|
const stopEditing = () => {
|
|
revert();
|
|
setEditing(false);
|
|
};
|
|
|
|
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={
|
|
editing ? (
|
|
<div className="flex items-center gap-2">
|
|
<Button onClick={stopEditing} disabled={save.isPending}>
|
|
<RotateCcw className="h-3.5 w-3.5" />
|
|
{dirty ? 'Discard' : 'Done'}
|
|
</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>
|
|
) : (
|
|
<Button
|
|
onClick={() => setEditing(true)}
|
|
disabled={!!readOnly}
|
|
title={readOnly ? 'This server will not accept written policies' : undefined}
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
Edit
|
|
</Button>
|
|
)
|
|
}
|
|
/>
|
|
|
|
{readOnly && <ReadOnlyBanner message={readOnly} />}
|
|
{failure?.kind === 'rejected' && <Rejected message={failure.message} />}
|
|
{failure?.kind === 'unknown' && <ErrorNote>{failure.message}</ErrorNote>}
|
|
|
|
{editing && (
|
|
<PolicyAssistant
|
|
policy={text}
|
|
onApply={(proposed) => {
|
|
setDraft(proposed);
|
|
setFailure(null);
|
|
setSavedAt(null);
|
|
}}
|
|
disabled={save.isPending}
|
|
/>
|
|
)}
|
|
|
|
<Card>
|
|
<textarea
|
|
value={text}
|
|
onChange={(ev) => {
|
|
setDraft(ev.target.value);
|
|
setFailure(null);
|
|
setSavedAt(null);
|
|
}}
|
|
spellCheck={false}
|
|
autoComplete="off"
|
|
readOnly={!editing}
|
|
placeholder={'{\n "acls": [\n { "action": "accept", "src": ["*"], "dst": ["*:*"] },\n ],\n}'}
|
|
className={`block h-[28rem] w-full resize-y p-4 font-mono text-[12px] leading-relaxed outline-none placeholder:text-zinc-700 ${
|
|
editing ? 'bg-black/40 text-zinc-200' : 'bg-black/20 text-zinc-400'
|
|
}`}
|
|
/>
|
|
<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.
|
|
{editing ? ' Ctrl/Cmd-S saves.' : ' Press Edit to change it.'}
|
|
</p>
|
|
</div>
|
|
</ViewShell>
|
|
);
|
|
};
|