offscale is a plugin
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>
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user