From 208f26ad896735205009fbf60384a2bb60cc117c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 5 Aug 2026 14:10:33 +0000 Subject: [PATCH] headscale: an ssh console for when the api cannot answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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 --- .../officer_db/src/queries/headscale.ts | 12 +- .../officer_db/src/schema/headscale.ts | 6 + src/servers/sidecar/headscale/routes.ts | 4 + src/servers/sidecar/headscale/servers.ts | 14 ++- src/servers/sidecar/headscale/ssh.ts | 86 +++++++++++++ .../src/apps/Headscale/ConsoleView.tsx | 96 ++++++++++++++ .../src/apps/Headscale/HeadscaleNav.tsx | 3 +- .../src/apps/Headscale/HeadscaleView.tsx | 3 + .../src/apps/Headscale/ServerForm.tsx | 118 +++++++++++++++++- .../officerdev/src/apps/Headscale/shared.ts | 9 ++ .../src/apps/Headscale/useHeadscaleServers.ts | 18 ++- .../officerdev/src/apps/Terminal/Terminal.tsx | 7 ++ 12 files changed, 365 insertions(+), 11 deletions(-) create mode 100644 src/servers/sidecar/headscale/ssh.ts create mode 100644 src/workspaces/officerdev/src/apps/Headscale/ConsoleView.tsx 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') {