headscale: an ssh console for when the api cannot answer

Every diagnostic in this app goes through headscale's API, which is exactly the
channel that is gone when you most need it — headscale crashed, the tailnet is
down, the logs are the only evidence. This adds the escape hatch: a per-server
SSH address and a Console section that opens a shell on that machine.

Deliberately thin. `headscale_servers.ssh_host` stores where to point ssh and
nothing else: no password, no key, no port. The console runs plain `ssh <host>`
in the same pty every other terminal panel uses, authenticating with whatever
~/.ssh on this box already knows. There is no credential here to protect and
this file must never grow one.

The address is NOT derived from the control-server URL and the form warns when
you type the same host into both — a console that resolves through the name
headscale serves goes down with it, which is the one thing it exists to survive.
It is also not validated on save, for the same reason: refusing to store the
escape hatch because the machine is unreachable is precisely backwards. Reaching
it is a separate, explicit Test connection button (BatchMode=yes, so a key that
needs a passphrase fails visibly instead of hanging on a prompt).

The host is validated to a conservative charset rather than quoted, because it
is typed into an interactive shell — rejecting `1.2.3.4; rm -rf /` while the
form is still open beats letting it survive to the shell as someone else's
problem. A jump host or an odd port belongs in ~/.ssh/config as a Host alias,
which the field accepts by name.

Also fixes a latent bug this would have hit immediately: TerminalView's
`initialInput` guard is scoped to a mount, so a remount typed the command again
into a live shell. A `replay` frame proves the session already ran it, so treat
it as sent. Harmless for `ls`; for the console it meant an ssh nested inside the
ssh you were already in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 14:10:33 +00:00
co-authored by Claude Opus 5
parent 2f92f9b15c
commit 208f26ad89
12 changed files with 365 additions and 11 deletions
@@ -0,0 +1,96 @@
import { useCallback } from 'react';
import { Link } from 'react-router';
import { Loader2, TerminalSquare } from 'lucide-react';
import { headscaleSectionPath } from './shared';
import { useHeadscaleServers } from './useHeadscaleServers';
import { TerminalView } from '../Terminal/Terminal';
import { Button } from './Cards';
// A shell on the machine behind the active Headscale server — the escape hatch for everything the API cannot
// answer (why headscale won't start, what the logs say, whether the disk is full).
//
// It is deliberately the SAME terminal every other panel uses, driven by nothing more than `ssh <host>` typed
// into a login shell. Officer holds no key, no password and no port: whatever `ssh` on this box can already
// reach, this can reach, and nothing more. If the connection needs a jump host or an odd port, that belongs in
// `~/.ssh/config` as a Host alias — which this field accepts by name.
//
// The session id is derived from the server id rather than minted per panel, so re-opening the Console lands
// back in the shell that is already running and mid-command, and switching servers is a different shell rather
// than the same one re-purposed. TerminalView suppresses its initial input when the sidecar replays a buffer,
// which is what stops a re-attach from typing a second `ssh` inside the first.
const consoleSessionId = (serverId: number) => `headscale-console-${serverId}`;
const Centred = ({ children }: { children: React.ReactNode }) => (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/5 text-zinc-400">
<TerminalSquare className="h-6 w-6" />
</div>
{children}
</div>
);
export const ConsoleView = () => {
const { active, isLoading } = useHeadscaleServers();
// The terminal reports its connection state; nothing in this section acts on it yet, but TerminalView wants
// a stable callback and an inline arrow would remount its effect on every render.
const onConnectionChange = useCallback(() => {}, []);
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading servers
</div>
);
}
if (!active) {
return (
<Centred>
<div>
<div className="text-base font-semibold text-zinc-100">No server selected</div>
<p className="mt-1 text-sm text-zinc-500">Pick one in the Servers section to open its console.</p>
</div>
</Centred>
);
}
if (!active.sshHost) {
return (
<Centred>
<div>
<div className="text-base font-semibold text-zinc-100">No SSH address for {active.name}</div>
<p className="mt-1 max-w-sm text-sm text-zinc-500">
Add one on the server to open a shell on the machine behind it. Use the machine's own address rather than
the Headscale hostname — the console is most useful exactly when that name has stopped answering.
</p>
</div>
<Link to={headscaleSectionPath('servers')}>
<Button variant="primary">Go to Servers</Button>
</Link>
</Centred>
);
}
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 items-center gap-2 border-b border-white/10 px-3 py-2 text-[11px] text-zinc-500">
<TerminalSquare className="h-3.5 w-3.5" />
<span className="truncate">
ssh <span className="font-mono text-zinc-300">{active.sshHost}</span> · {active.name}
</span>
</div>
<TerminalView
// Remount on a server switch: the session id is a mount-time argument, so without this the panel would
// keep showing the previous server's shell under the new server's name.
key={active.id}
className="min-h-0 flex-1 p-2"
sessionId={consoleSessionId(active.id)}
initialInput={`ssh ${active.sshHost}`}
onConnectionChange={onConnectionChange}
/>
</div>
);
};
@@ -1,6 +1,6 @@
import type { LucideIcon } from 'lucide-react';
import { NavLink } from 'react-router';
import { Network, Server, Laptop, Users, KeyRound, Check } from 'lucide-react';
import { Network, Server, Laptop, Users, KeyRound, TerminalSquare, Check } from 'lucide-react';
import { HEADSCALE_SECTIONS, headscaleSectionPath, type HeadscaleSectionId } from './shared';
import { useHeadscaleServers } from './useHeadscaleServers';
@@ -18,6 +18,7 @@ const ICONS: Record<HeadscaleSectionId, LucideIcon> = {
nodes: Laptop,
users: Users,
keys: KeyRound,
console: TerminalSquare,
};
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
@@ -3,6 +3,7 @@ import { ServersView } from './ServersView';
import { NodesView } from './NodesView';
import { UsersView } from './UsersView';
import { KeysView } from './KeysView';
import { ConsoleView } from './ConsoleView';
// Right panel of the /headscale workspace — renders the section named by the URL.
//
@@ -19,6 +20,8 @@ export const HeadscaleView = () => {
return <UsersView />;
case 'keys':
return <KeysView />;
case 'console':
return <ConsoleView />;
default:
return <ServersView />;
}
@@ -1,8 +1,8 @@
import { useState } from 'react';
import { Loader2 } from 'lucide-react';
import type { HeadscaleServer } from './shared';
import { Loader2, Terminal, Check, X } from 'lucide-react';
import type { HeadscaleServer, HeadscaleSshTest } from './shared';
import { MIN_HEADSCALE_VERSION } from './shared';
import { useHeadscaleServers, headscaleErrorMessage } from './useHeadscaleServers';
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
@@ -11,21 +11,54 @@ import { Card, Button, Field, ErrorNote } from './Cards';
//
// 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;
// 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 () => {
setError(null);
if (!url.trim()) return setError('A server URL is required');
@@ -39,9 +72,16 @@ export const ServerForm = ({ server, onClose }: ServerFormProps) => {
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() });
await register.mutateAsync({
name: name.trim() || undefined,
url: url.trim(),
apiKey: apiKey.trim(),
sshHost: sshHost.trim() || undefined,
});
}
onClose();
} catch (err) {
@@ -86,6 +126,76 @@ export const ServerForm = ({ server, onClose }: ServerFormProps) => {
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={(value) => {
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">
@@ -9,6 +9,7 @@ export const HEADSCALE_SECTIONS = [
{ id: 'nodes', label: 'Nodes' },
{ id: 'users', label: 'Users' },
{ id: 'keys', label: 'Pre-auth keys' },
{ id: 'console', label: 'Console' },
] as const;
export type HeadscaleSectionId = (typeof HEADSCALE_SECTIONS)[number]['id'];
@@ -28,6 +29,11 @@ export type HeadscaleServer = {
name: string;
url: string;
version: string | null;
/**
* Where the Console section SSHes. Null when unset. Not derived from `url` on purpose — it exists to reach
* the machine when the control plane's own hostname has stopped answering.
*/
sshHost: string | null;
isActive: boolean;
/** ISO string, or null when we have never successfully probed it. */
lastSeenAt: string | null;
@@ -44,6 +50,9 @@ export type HeadscaleHealth = {
ms: number;
};
/** Result of POST /_officer/ssh-test — can we open a shell there with the keys already on this box. */
export type HeadscaleSshTest = { ok: boolean; error?: string; ms: number };
/** Officer's supported floor, restated for UI copy. The sidecar is the enforcer; this is only a label. */
export const MIN_HEADSCALE_VERSION = '0.29';
@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import type { HeadscaleServer, HeadscaleHealth } from './shared';
import type { HeadscaleServer, HeadscaleHealth, HeadscaleSshTest } from './shared';
// The registered-servers cache. Every panel in the /headscale workspace reads this one query, so switching
// the active server anywhere updates the whole screen at once.
@@ -30,8 +30,9 @@ export function headscaleErrorMessage(err: unknown): string {
return raw.slice(0, 300);
}
export type RegisterServerInput = { name?: string; url: string; apiKey: string };
export type UpdateServerInput = { id: number; name?: string; url?: string; apiKey?: string };
export type RegisterServerInput = { name?: string; url: string; apiKey: string; sshHost?: string };
/** `sshHost: ''` clears the console target; omitting it leaves whatever is stored alone. */
export type UpdateServerInput = { id: number; name?: string; url?: string; apiKey?: string; sshHost?: string };
export function useHeadscaleServers() {
const { get, post, patch, delete: del } = useClient();
@@ -81,6 +82,17 @@ export function useHeadscaleServers() {
};
}
/**
* Try to open a shell on a console target with the keys already on this machine. Takes the host rather than a
* server id so the form can test a value before it is saved — which is when a typo is still cheap to fix.
*/
export function useHeadscaleSshTest() {
const { post } = useClient();
return useMutation({
mutationFn: (host: string) => post<HeadscaleSshTest>('/headscale/_officer/ssh-test', { host }),
});
}
/** On-demand reachability probe for one server. Never automatic — it costs two upstream round trips. */
export function useHeadscaleHealth() {
const { get } = useClient();
@@ -283,6 +283,13 @@ export const TerminalView = ({
// dropped, and the replay overlaps it — so rebuild from the sidecar's copy rather than append
// a second one. Deliberately outside the `output` branch: replay must not re-trigger the
// command/initial-input logic above.
//
// It does however PROVE the session already exists and has already run whatever we would have
// typed into it — `initialInputSent` is scoped to this mount, so without this a remount (a layout
// change, a route change, reopening the panel) would type the command a second time into a live
// shell. Harmless for `ls`; for the Headscale console it means an `ssh` nested inside the `ssh`
// you were already in.
initialInputSent = true;
term.reset();
term.write(msg.data);
} else if (msg.type === 'exit') {