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(null); const [sshResult, setSshResult] = useState(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 = (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 (
{ ev.preventDefault(); void submit(); }} className="flex flex-col gap-3 p-4" >
{editing ? `Edit ${server?.name}` : 'Register a Headscale server'}
SSH console (optional)

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 ssh here in a terminal, using the keys already on this machine. Officer stores no password, key or port.

{ 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 && (
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.
)} {sshResult && (
{sshResult.ok ? ( ) : ( )} {sshResult.ok ? ( <>Connected and ran a command in {sshResult.ms}ms. ) : ( <> {sshResult.error ?? 'Could not connect'} The test never prompts, so a key that needs a passphrase, or one this machine does not have, fails here as “Permission denied”. )}
)}
{error && {error}}
{pending && Checking the server and the key…}
); };