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:
@@ -19,6 +19,7 @@ export type HeadscaleServer = {
|
||||
name: string;
|
||||
url: string;
|
||||
version: string | null;
|
||||
sshHost: string | null;
|
||||
isActive: boolean;
|
||||
lastSeenAt: Date | null;
|
||||
createdAt: Date;
|
||||
@@ -31,6 +32,7 @@ const serverCols = {
|
||||
name: headscaleServers.name,
|
||||
url: headscaleServers.url,
|
||||
version: headscaleServers.version,
|
||||
sshHost: headscaleServers.sshHost,
|
||||
isActive: headscaleServers.isActive,
|
||||
lastSeenAt: headscaleServers.lastSeenAt,
|
||||
createdAt: headscaleServers.createdAt,
|
||||
@@ -71,13 +73,15 @@ type CreateHeadscaleServerParams = {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
version: string | null;
|
||||
/** Optional SSH target for the console. Null when the owner hasn't set one. */
|
||||
sshHost: string | null;
|
||||
/** Make it the active server. True for the first registration, so the UI is never left with none selected. */
|
||||
activate: boolean;
|
||||
};
|
||||
|
||||
/** Register a server. The key is encrypted before write; the returned row carries no key. */
|
||||
export async function createHeadscaleServer(params: CreateHeadscaleServerParams): Promise<HeadscaleServer> {
|
||||
const { userId, name, url, apiKey, version, activate } = params;
|
||||
const { userId, name, url, apiKey, version, sshHost, activate } = params;
|
||||
return db.transaction(async (tx) => {
|
||||
if (activate) {
|
||||
await tx
|
||||
@@ -93,6 +97,7 @@ export async function createHeadscaleServer(params: CreateHeadscaleServerParams)
|
||||
url,
|
||||
apiKey: encryptSecret(apiKey),
|
||||
version,
|
||||
sshHost,
|
||||
isActive: activate,
|
||||
lastSeenAt: version ? new Date() : null,
|
||||
})
|
||||
@@ -101,7 +106,9 @@ export async function createHeadscaleServer(params: CreateHeadscaleServerParams)
|
||||
});
|
||||
}
|
||||
|
||||
type UpdateHeadscaleServerParams = { name?: string; url?: string; apiKey?: string };
|
||||
// `sshHost: null` clears the console target; omitting the field leaves it alone. The two must stay
|
||||
// distinguishable, which is why this is `string | null` and not `string`.
|
||||
type UpdateHeadscaleServerParams = { name?: string; url?: string; apiKey?: string; sshHost?: string | null };
|
||||
|
||||
/** Edit a registration. Omitted fields are left alone; a supplied key is re-encrypted. */
|
||||
export async function updateHeadscaleServer(
|
||||
@@ -113,6 +120,7 @@ export async function updateHeadscaleServer(
|
||||
if (params.name !== undefined) set.name = params.name;
|
||||
if (params.url !== undefined) set.url = params.url;
|
||||
if (params.apiKey !== undefined) set.apiKey = encryptSecret(params.apiKey);
|
||||
if (params.sshHost !== undefined) set.sshHost = params.sshHost;
|
||||
|
||||
const [row] = await db
|
||||
.update(headscaleServers)
|
||||
|
||||
@@ -30,6 +30,12 @@ export const headscaleServers = pgTable(
|
||||
// Last version seen from the server's unauthenticated GET /version. Null until first probed; the
|
||||
// literal 'dev' when the server was built without VCS info, which is unknown rather than too-old.
|
||||
version: text('version'),
|
||||
// Where to SSH for a shell on the box running this Headscale — the last-resort escape hatch for when the
|
||||
// API cannot answer (headscale is down, the tailnet is down, the logs are the only evidence). Deliberately
|
||||
// NOT derived from `url`: the whole point is to reach the machine when the control plane's own hostname
|
||||
// stops resolving, so this is usually a raw IP on a different path. No port, user or key material — the
|
||||
// connection uses whatever ~/.ssh already knows, so there is no credential here to protect.
|
||||
sshHost: text('ssh_host'),
|
||||
isActive: boolean('is_active').notNull().default(false),
|
||||
// Last successful probe, so the UI can distinguish "never reached" from "was reachable, now isn't".
|
||||
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { handleNodesRoute } from './nodes';
|
||||
import { handleUsersRoute } from './users';
|
||||
import { handleKeysRoute } from './keys';
|
||||
import { handleEnrollRoute } from './enroll';
|
||||
import { handleSshTestRoute } from './ssh';
|
||||
|
||||
// Officer-owned routes for the headscale sidecar — the entire feature surface lives under /_officer/.
|
||||
//
|
||||
@@ -58,6 +59,9 @@ export async function handleOfficerRoute(req: Request, url: URL): Promise<Respon
|
||||
return await handleKeysRoute(ctx, segments.slice(1));
|
||||
case 'enroll':
|
||||
return await handleEnrollRoute(ctx, segments.slice(1));
|
||||
// Not a Headscale call at all — a local `ssh` reachability probe for the console. See ssh.ts.
|
||||
case 'ssh-test':
|
||||
return await handleSshTestRoute(ctx, segments.slice(1));
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { createClient, HeadscaleError } from './client';
|
||||
import { probeVersion, MIN_VERSION_LABEL } from './version';
|
||||
import { badRequest, notFound, methodNotAllowed } from './routes';
|
||||
import { normalizeSshHost } from './ssh';
|
||||
|
||||
// Server registry routes — /_officer/servers/*. Officer manages any number of Headscale servers; the owner
|
||||
// registers each with a URL and an admin API key generated on that server, and one is active at a time.
|
||||
@@ -85,6 +86,9 @@ async function handleCollection(ctx: OfficerContext): Promise<Response> {
|
||||
if (apiKey instanceof Response) return apiKey;
|
||||
// The name is a label only; default it to the host so registration needs just a URL and a key.
|
||||
const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim() : new URL(url).host;
|
||||
// Optional, and never validated by connecting: registration should not fail because a box is rebooting.
|
||||
const sshHost = normalizeSshHost(body.sshHost);
|
||||
if (sshHost instanceof Response) return sshHost;
|
||||
|
||||
const validated = await validateServer(url, apiKey);
|
||||
if (validated instanceof Response) return validated;
|
||||
@@ -97,6 +101,7 @@ async function handleCollection(ctx: OfficerContext): Promise<Response> {
|
||||
url,
|
||||
apiKey,
|
||||
version: validated,
|
||||
sshHost,
|
||||
activate: existing.length === 0,
|
||||
});
|
||||
return Response.json({ server }, { status: 201 });
|
||||
@@ -157,6 +162,13 @@ async function handleOne(ctx: OfficerContext, id: number, action: string | undef
|
||||
apiKey = parsed;
|
||||
}
|
||||
const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim() : undefined;
|
||||
// Absent = leave it; '' or null = clear the console target. normalizeSshHost collapses both to null.
|
||||
let sshHost: string | null | undefined;
|
||||
if (body.sshHost !== undefined) {
|
||||
const parsed = normalizeSshHost(body.sshHost);
|
||||
if (parsed instanceof Response) return parsed;
|
||||
sshHost = parsed;
|
||||
}
|
||||
|
||||
// Re-validate whenever either half of the credentials moves — a saved-but-broken server is the exact
|
||||
// state registration works hard to prevent, and an edit can reintroduce it.
|
||||
@@ -165,7 +177,7 @@ async function handleOne(ctx: OfficerContext, id: number, action: string | undef
|
||||
if (validated instanceof Response) return validated;
|
||||
}
|
||||
|
||||
const server = await updateHeadscaleServer(userId, id, { name, url, apiKey });
|
||||
const server = await updateHeadscaleServer(userId, id, { name, url, apiKey, sshHost });
|
||||
return server ? Response.json({ server }) : notFound('no such server');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { badRequest, methodNotAllowed, readJson, type OfficerContext } from './routes';
|
||||
|
||||
// SSH console support — the escape hatch for when the Headscale API cannot answer.
|
||||
//
|
||||
// Officer never handles a password, a key or a port here. The console runs `ssh <host>` in the owner's own
|
||||
// shell, so it authenticates with whatever `~/.ssh` already knows; the only thing stored is where to point it.
|
||||
// That is why this file has no credential handling at all, and why it must never grow any: the moment Officer
|
||||
// starts holding a private key or a password, this stops being "run the command you would have run yourself".
|
||||
//
|
||||
// The host string is typed into an interactive shell, so it is validated to a conservative charset rather than
|
||||
// quoted. Quoting would let a plausible-looking value survive to the shell and be someone else's problem;
|
||||
// rejecting it says which character is wrong while the form is still open.
|
||||
|
||||
/** `user@` plus a hostname or IP. Deliberately no spaces, no flags, no shell metacharacters. */
|
||||
const SSH_HOST_RE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*)?(?:@[A-Za-z0-9](?:[A-Za-z0-9._:-]*)?)?$/;
|
||||
|
||||
/**
|
||||
* Validate a console target. Returns the trimmed host, null when the field was blank (meaning "no console"),
|
||||
* or an error Response.
|
||||
*/
|
||||
export function normalizeSshHost(raw: unknown): string | null | Response {
|
||||
if (raw === null) return null;
|
||||
if (typeof raw !== 'string') return badRequest('sshHost must be a string');
|
||||
const host = raw.trim();
|
||||
if (!host) return null;
|
||||
if (host.length > 255) return badRequest('sshHost is too long');
|
||||
if (!SSH_HOST_RE.test(host)) {
|
||||
return badRequest('sshHost must be a plain host, IP or user@host — no ports, flags or spaces');
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
type SshProbe = { ok: boolean; error?: string; ms: number };
|
||||
|
||||
/**
|
||||
* Prove the machine is reachable with the keys already on this box, without opening a session.
|
||||
*
|
||||
* `BatchMode=yes` is what makes this a test rather than a hang: ssh fails instead of prompting for a password
|
||||
* or a passphrase, which is exactly the outcome the owner needs to see. `accept-new` records an unknown host
|
||||
* key here rather than leaving the console to open on an interactive "are you sure" prompt the first time —
|
||||
* it still refuses a CHANGED key, which is the check worth keeping.
|
||||
*/
|
||||
export async function probeSsh(host: string): Promise<SshProbe> {
|
||||
const started = Date.now();
|
||||
const proc = Bun.spawn(
|
||||
['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', '-o', 'StrictHostKeyChecking=accept-new', host, 'true'],
|
||||
{ stdout: 'ignore', stderr: 'pipe' },
|
||||
);
|
||||
|
||||
// ConnectTimeout only bounds the TCP connect; a server that accepts and then stalls would hang forever.
|
||||
const timer = setTimeout(() => proc.kill(), 15_000);
|
||||
let stderr = '';
|
||||
try {
|
||||
[stderr] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
const ms = Date.now() - started;
|
||||
if (proc.exitCode === 0) return { ok: true, ms };
|
||||
|
||||
// ssh's own first line is the useful one ("Permission denied", "Connection timed out"); the rest is noise.
|
||||
const first = stderr
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line && !line.startsWith('Warning: Permanently added'));
|
||||
return { ok: false, error: first || `ssh exited ${proc.exitCode ?? 'on a signal'}`, ms };
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /_officer/ssh-test {host}`. Takes the host in the body rather than a server id on purpose: the form
|
||||
* needs to test a value the owner has typed but not yet saved, which is the case where a typo is still cheap.
|
||||
*/
|
||||
export async function handleSshTestRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
|
||||
if (rest.length > 0) return badRequest('unexpected path');
|
||||
if (ctx.req.method !== 'POST') return methodNotAllowed();
|
||||
|
||||
const body = await readJson(ctx.req);
|
||||
if (!body) return badRequest('expected a JSON body');
|
||||
|
||||
const host = normalizeSshHost(body.host);
|
||||
if (host instanceof Response) return host;
|
||||
if (!host) return badRequest('host is required');
|
||||
|
||||
return Response.json(await probeSsh(host));
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user