headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.
api/router.ts the thin auth-gated proxy, now at /api/offscale
sidecar/ 18 files, the whole headscale contract and its admin keys
db/ schema + queries, offscale_servers
web/ 26 files as panels and a layout — no screen, per the rule
removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.
the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.
AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.
install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.
verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.
757 pass, same 10 pre-existing failures.
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>
|
|
);
|
|
};
|