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>
227 lines
9.3 KiB
TypeScript
227 lines
9.3 KiB
TypeScript
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>
|
|
);
|
|
};
|