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,226 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2, Terminal, Check, X } from 'lucide-react';
|
||||
import type { HeadscaleServer, HeadscaleSshTest } from './shared';
|
||||
import { MIN_HEADSCALE_VERSION } from './shared';
|
||||
import { useHeadscaleServers, useHeadscaleSshTest, headscaleErrorMessage } from './useHeadscaleServers';
|
||||
import { Card, Button, Field, ErrorNote } from './Cards';
|
||||
|
||||
// Register / edit one Headscale server. The sidecar validates before it saves — reachable, >= 0.29, and the
|
||||
// key actually accepted — so this form is genuinely slow on submit and genuinely fails. Both are shown:
|
||||
// a pending state saying what is being checked, and the server's own reason inline on rejection.
|
||||
//
|
||||
// On edit the API key field is intentionally blank rather than pre-filled. Officer cannot pre-fill it (the
|
||||
// key is encrypted at rest and never leaves the sidecar), and leaving it empty means "keep the current key".
|
||||
//
|
||||
// The SSH host is the odd one out: it is NOT validated on save. A control server that is down is exactly when
|
||||
// you want the console, so refusing to save the escape hatch because the machine is unreachable would be
|
||||
// precisely backwards. Test is a separate, explicit button.
|
||||
|
||||
/** The host part of the control-server URL, for the "you have typed the same machine" warning. */
|
||||
function urlHost(url: string): string | null {
|
||||
try {
|
||||
return new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).hostname.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Strip any `user@` so `root@1.2.3.4` still matches the control-server hostname. */
|
||||
const sshTarget = (host: string) => host.trim().split('@').pop()?.toLowerCase() ?? '';
|
||||
|
||||
type ServerFormProps = { server?: HeadscaleServer | null; onClose: () => void };
|
||||
|
||||
export const ServerForm = ({ server, onClose }: ServerFormProps) => {
|
||||
const { register, update } = useHeadscaleServers();
|
||||
const sshTest = useHeadscaleSshTest();
|
||||
const editing = !!server;
|
||||
|
||||
const [name, setName] = useState(server?.name ?? '');
|
||||
const [url, setUrl] = useState(server?.url ?? '');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [sshHost, setSshHost] = useState(server?.sshHost ?? '');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sshResult, setSshResult] = useState<HeadscaleSshTest | null>(null);
|
||||
|
||||
const mutation = editing ? update : register;
|
||||
const pending = mutation.isPending;
|
||||
|
||||
// A rejection leaves its reason under the button, and the reason is about values that have since been
|
||||
// corrected. Editing anything clears it, so a stale message can never make a live form look dead.
|
||||
const edit =
|
||||
<T,>(set: (value: T) => void) =>
|
||||
(value: T) => {
|
||||
set(value);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
// The point of a separate SSH address is reaching the box when the tailnet or Headscale itself is down. If
|
||||
// it resolves through the same name the control server does, it goes down with it — which is the one thing
|
||||
// this field is supposed to survive.
|
||||
const sameAsControl = !!sshHost.trim() && !!urlHost(url) && sshTarget(sshHost) === urlHost(url);
|
||||
|
||||
const runSshTest = async () => {
|
||||
setSshResult(null);
|
||||
try {
|
||||
setSshResult(await sshTest.mutateAsync(sshHost.trim()));
|
||||
} catch (err) {
|
||||
setSshResult({ ok: false, error: headscaleErrorMessage(err), ms: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (pending) return;
|
||||
setError(null);
|
||||
// Drop the previous rejection from the mutation too — this is a fresh attempt, not a retry of that one.
|
||||
mutation.reset();
|
||||
if (!url.trim()) return setError('A server URL is required');
|
||||
if (!editing && !apiKey.trim()) return setError('An API key is required');
|
||||
|
||||
try {
|
||||
if (editing && server) {
|
||||
// Send only what changed: an unchanged url+key pair skips the sidecar's re-validation round trips.
|
||||
await update.mutateAsync({
|
||||
id: server.id,
|
||||
name: name.trim() || undefined,
|
||||
url: url.trim() === server.url ? undefined : url.trim(),
|
||||
apiKey: apiKey.trim() || undefined,
|
||||
// '' is meaningful here — it clears the console target — so this is sent whenever it differs.
|
||||
sshHost: sshHost.trim() === (server.sshHost ?? '') ? undefined : sshHost.trim(),
|
||||
});
|
||||
} else {
|
||||
await register.mutateAsync({
|
||||
name: name.trim() || undefined,
|
||||
url: url.trim(),
|
||||
apiKey: apiKey.trim(),
|
||||
sshHost: sshHost.trim() || undefined,
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(headscaleErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<form
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
void submit();
|
||||
}}
|
||||
className="flex flex-col gap-3 p-4"
|
||||
>
|
||||
<div className="text-sm font-semibold text-zinc-100">
|
||||
{editing ? `Edit ${server?.name}` : 'Register a Headscale server'}
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Server URL"
|
||||
value={url}
|
||||
onChange={edit(setUrl)}
|
||||
placeholder="https://headscale.example.com"
|
||||
hint={`The control server's base URL. Officer requires Headscale ${MIN_HEADSCALE_VERSION} or newer.`}
|
||||
autoFocus={!editing}
|
||||
/>
|
||||
<Field
|
||||
label={editing ? 'API key (leave blank to keep the current one)' : 'API key'}
|
||||
value={apiKey}
|
||||
onChange={edit(setApiKey)}
|
||||
type="password"
|
||||
placeholder="hskey-api-..."
|
||||
hint="Generate one on the server with `headscale apikeys create`. It is stored encrypted and never leaves Officer."
|
||||
/>
|
||||
<Field
|
||||
label="Name (optional)"
|
||||
value={name}
|
||||
onChange={edit(setName)}
|
||||
placeholder="defaults to the hostname"
|
||||
hint="A label for switching between servers."
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-white/10 bg-white/[0.02] p-3">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-zinc-300">
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
SSH console (optional)
|
||||
</div>
|
||||
<p className="text-[11px] leading-snug text-zinc-500">
|
||||
The last resort for when the API cannot answer — Headscale crashed, the tailnet is down, the logs are the
|
||||
only evidence. The Console section runs plain <code className="font-mono">ssh</code> here in a terminal,
|
||||
using the keys already on this machine. Officer stores no password, key or port.
|
||||
</p>
|
||||
<Field
|
||||
label="SSH address"
|
||||
value={sshHost}
|
||||
onChange={edit((value: string) => {
|
||||
setSshHost(value);
|
||||
setSshResult(null);
|
||||
})}
|
||||
placeholder="203.0.113.10 or root@203.0.113.10"
|
||||
hint="Use the machine's own address, not the Headscale hostname. Leave blank for no console."
|
||||
/>
|
||||
|
||||
{sameAsControl && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] leading-snug text-amber-300">
|
||||
That is the same host as the server URL. If Headscale is what resolves or routes that name, the console
|
||||
will be unreachable in exactly the situations you would need it. Prefer the machine's raw IP on a path
|
||||
that does not depend on the tailnet.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sshResult && (
|
||||
<div
|
||||
className={`flex items-start gap-2 rounded-lg border px-3 py-2 text-[11px] leading-snug ${
|
||||
sshResult.ok
|
||||
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300'
|
||||
: 'border-red-500/30 bg-red-500/10 text-red-300'
|
||||
}`}
|
||||
>
|
||||
{sshResult.ok ? (
|
||||
<Check className="mt-px h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<X className="mt-px h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
<span>
|
||||
{sshResult.ok ? (
|
||||
<>Connected and ran a command in {sshResult.ms}ms.</>
|
||||
) : (
|
||||
<>
|
||||
{sshResult.error ?? 'Could not connect'}
|
||||
<span className="mt-1 block text-red-300/70">
|
||||
The test never prompts, so a key that needs a passphrase, or one this machine does not have, fails
|
||||
here as “Permission denied”.
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Button onClick={() => void runSshTest()} disabled={!sshHost.trim() || sshTest.isPending}>
|
||||
{sshTest.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{sshTest.isPending ? 'Connecting…' : 'Test connection'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <ErrorNote>{error}</ErrorNote>}
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button type="submit" variant="primary" disabled={pending}>
|
||||
{pending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
{pending ? 'Verifying…' : editing ? 'Save changes' : 'Register server'}
|
||||
</Button>
|
||||
<Button onClick={onClose} disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
{pending && <span className="text-[11px] text-zinc-500">Checking the server and the key…</span>}
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user