diff --git a/src/databases/officer_db/src/queries/headscale.ts b/src/databases/officer_db/src/queries/headscale.ts index 5e750496..81adbdda 100644 --- a/src/databases/officer_db/src/queries/headscale.ts +++ b/src/databases/officer_db/src/queries/headscale.ts @@ -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 { - 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) diff --git a/src/databases/officer_db/src/schema/headscale.ts b/src/databases/officer_db/src/schema/headscale.ts index fa23a1a2..ae1f3ea3 100644 --- a/src/databases/officer_db/src/schema/headscale.ts +++ b/src/databases/officer_db/src/schema/headscale.ts @@ -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 }), diff --git a/src/servers/sidecar/headscale/routes.ts b/src/servers/sidecar/headscale/routes.ts index 5de94120..ca899a85 100644 --- a/src/servers/sidecar/headscale/routes.ts +++ b/src/servers/sidecar/headscale/routes.ts @@ -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 { 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 { 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'); } diff --git a/src/servers/sidecar/headscale/ssh.ts b/src/servers/sidecar/headscale/ssh.ts new file mode 100644 index 00000000..7765a7d0 --- /dev/null +++ b/src/servers/sidecar/headscale/ssh.ts @@ -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 ` 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 { + 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 { + 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)); +} diff --git a/src/workspaces/officerdev/src/apps/Headscale/ConsoleView.tsx b/src/workspaces/officerdev/src/apps/Headscale/ConsoleView.tsx new file mode 100644 index 00000000..0515762f --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Headscale/ConsoleView.tsx @@ -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 ` 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 }) => ( +
+
+ +
+ {children} +
+); + +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 ( +
+ + Loading servers… +
+ ); + } + + if (!active) { + return ( + +
+
No server selected
+

Pick one in the Servers section to open its console.

+
+
+ ); + } + + if (!active.sshHost) { + return ( + +
+
No SSH address for {active.name}
+

+ 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. +

+
+ + + +
+ ); + } + + return ( +
+
+ + + ssh {active.sshHost} · {active.name} + +
+ +
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleNav.tsx b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleNav.tsx index 871d5f7b..a47c447c 100644 --- a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleNav.tsx +++ b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleNav.tsx @@ -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 = { 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'; diff --git a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleView.tsx b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleView.tsx index e7daf29f..d1907245 100644 --- a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleView.tsx +++ b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleView.tsx @@ -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 ; case 'keys': return ; + case 'console': + return ; default: return ; } diff --git a/src/workspaces/officerdev/src/apps/Headscale/ServerForm.tsx b/src/workspaces/officerdev/src/apps/Headscale/ServerForm.tsx index 07b1e5a5..18b2e910 100644 --- a/src/workspaces/officerdev/src/apps/Headscale/ServerForm.tsx +++ b/src/workspaces/officerdev/src/apps/Headscale/ServerForm.tsx @@ -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(null); + const [sshResult, setSshResult] = useState(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." /> +
+
+ + 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}}
diff --git a/src/workspaces/officerdev/src/apps/Headscale/shared.ts b/src/workspaces/officerdev/src/apps/Headscale/shared.ts index acfd69e2..398be1bf 100644 --- a/src/workspaces/officerdev/src/apps/Headscale/shared.ts +++ b/src/workspaces/officerdev/src/apps/Headscale/shared.ts @@ -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'; diff --git a/src/workspaces/officerdev/src/apps/Headscale/useHeadscaleServers.ts b/src/workspaces/officerdev/src/apps/Headscale/useHeadscaleServers.ts index a74149e5..613419dc 100644 --- a/src/workspaces/officerdev/src/apps/Headscale/useHeadscaleServers.ts +++ b/src/workspaces/officerdev/src/apps/Headscale/useHeadscaleServers.ts @@ -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('/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(); diff --git a/src/workspaces/officerdev/src/apps/Terminal/Terminal.tsx b/src/workspaces/officerdev/src/apps/Terminal/Terminal.tsx index aa829e19..1a7ae05b 100644 --- a/src/workspaces/officerdev/src/apps/Terminal/Terminal.tsx +++ b/src/workspaces/officerdev/src/apps/Terminal/Terminal.tsx @@ -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') {