Offscale was the first plugin extracted and it was done before we knew what "extracted" meant. Music, done last, is the standard. This brings offscale to it. ── The rebrand ── The plugin was `offscale` to the platform and `headscale` to itself: sidecar name and handles, the port announcement, the API proxy name, the React components, every hook, the react-query keys, the panel ids and appTypes, and the Postgres table. Now all of those say offscale. The line drawn, and it is deliberate: OffScale is Officer's tooling layer, and Headscale is the server it manages. So every IDENTIFIER is offscale, while a message like `headscale unreachable`, the `headscale apikeys create` hint and the ACL assistant's prompt still say Headscale — because they are talking about the remote server, and renaming them would make the code lie about what it reached. 495 occurrences became 180, and the 180 are all of that second kind. ── The live bug this uncovered ── `headscaleSectionPath` built links to `/headscale/<section>`. The shell has no such route — plugin routes come from `plugin.route`, which is `/offscale` — and it redirects unknown paths to the home page. So every section link in the nav, the console and the server picker silently went home. The extraction moved the route and left the link builder behind. Also live: ServersView told the user to run `pm2 start ecosystem.config.cjs --only officer-headscale`, a process that has not existed since the sidecar was renamed. ── The correctness fix music already had ── api/router.ts hardcoded `prefix: '/api/offscale'`. The proxy strips `prefix.length` characters, so a literal is correct only for a first-party publisher; published by anyone else this mounts at `/api/p/<publisher>/offscale` and forwards the wrong subpath. Derived from `mountPrefix()` now, as music does. ── The rest ── - assets/icon.png — the OffScale artwork, 256px to match music's. The tile stops being a glyph badge. - First tests: 21 of them, over the version floor and the protobuf normalisers. Those are the two places a Headscale release actually breaks this, and they had no coverage at all. `meetsFloor` has a real trap pinned now — comparing minor first would refuse 1.0 as older than 0.29. - OFFSCALE_API.md — the contract was a 45-line comment inside sidecar/index.ts, which is not linkable and not published. Now a document, as MUSIC_API.md is. - web/panels.ts re-exported three components. A plugin cannot export components; that was residue of the platform importing them before extraction. - Comments pointed at src/servers/api/headscale/ and src/servers/sidecar/headscale/, neither of which has existed since the extraction. The crypto purpose moved headscale → offscale too, and the secret-store row was renamed rather than left to create a fresh key — the material is preserved, so this is reversible. Free to do only because offscale_servers had 0 rows; with one stored API key it would have been a migration.
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 { useOffscalePolicy, policySaveFailure, type PolicySaveFailure } from './useOffscalePolicy';
|
|
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 } = useOffscalePolicy();
|
|
|
|
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>
|
|
);
|
|
};
|