diff --git a/TERMIX_MIGRATION.md b/TERMIX_MIGRATION.md index b70b04e..a64439d 100644 --- a/TERMIX_MIGRATION.md +++ b/TERMIX_MIGRATION.md @@ -45,7 +45,11 @@ Rationale for splitting: 1a alone is a real, useful terminal (matches what `/ter **Status:** - ✅ **Phase 1a — done.** `/terminal` is a real interactive SSH terminal: `backend/src/routes/terminal.ts` (WebSocket, connect/input/resize/disconnect over `ssh2`), `backend/src/db/secrets.ts` (shared secret loader), `src/pages/Terminal.tsx` (xterm.js + host picker, reuses ArchNest's existing SSH integrations — no duplicate host table). Verified end-to-end against a real test SSH server. No jump hosts, no tabs/split panes, no OPKSSH, no tmux monitor yet — see 1b/1c below. -- ⬜ Phase 1b — not started. +- 🟡 **Phase 1b — in progress.** Done so far: + - **Jump-host chaining**: an SSH integration's config can carry `jumpHostIntegrationId` referencing another SSH integration. `backend/src/routes/terminal.ts` connects to the jump host first, opens a `forwardOut()` channel to the real target, and connects the target `Client` over that channel (single-hop; mirrors Termix's core mechanism without its multi-hop/credential-sharing complexity). Verified end-to-end with two real test SSH servers (one as jump, one as target). + - **Host-key verification (TOFU)**: new `ssh_host_keys` table (`backend/src/db/index.ts`) stores a SHA-256 fingerprint per SSH integration on first successful connect; subsequent connects are rejected if the fingerprint changes, via `ssh2`'s `hostVerifier` connect option. No interactive accept/reject-changed-key UI yet — first-use accept-and-store, hard-reject on mismatch. Verified both the accept-on-first-use and reject-on-mismatch paths against a real test server. + - **Settings UI for multiple SSH hosts**: `src/pages/Settings.tsx` previously could only show/edit one integration per type, which silently broke multi-host SSH. Added a dedicated `SshHostsSection` with its own per-host cards (Save/Test/Delete) and an "Add SSH Host" flow, including a `Jump Host` dropdown populated from the other configured SSH hosts. + - Not yet done: tab system + up-to-4 split panes and terminal theme/font customization in `src/pages/Terminal.tsx` — that page is still single-session only. - ⬜ Phase 1c — not started. ### Phase 2 — SSH Tunnels (NOT STARTED) diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index da75948..8dee5ac 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -65,6 +65,12 @@ db.exec(` source TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); + + CREATE TABLE IF NOT EXISTS ssh_host_keys ( + integration_id INTEGER PRIMARY KEY REFERENCES integrations(id) ON DELETE CASCADE, + fingerprint TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); `) export function logEvent(type: string, title: string, source?: string | null) { diff --git a/backend/src/routes/terminal.ts b/backend/src/routes/terminal.ts index 7e3fce1..2a9f34f 100644 --- a/backend/src/routes/terminal.ts +++ b/backend/src/routes/terminal.ts @@ -1,6 +1,5 @@ import type { FastifyInstance } from 'fastify' -import { Client } from 'ssh2' -import type { ClientChannel } from 'ssh2' +import { Client, type ClientChannel, type ConnectConfig } from 'ssh2' import { db } from '../db/index.js' import { loadSecrets } from '../db/secrets.js' @@ -22,16 +21,56 @@ function send(socket: { send: (data: string) => void }, payload: Record + const secrets = loadSecrets(row.id) + return { id: row.id, config, secrets } +} + +function makeHostVerifier(integrationId: number): ConnectConfig['hostVerifier'] { + return (keyHash: string): boolean => { + const row = db + .prepare('SELECT fingerprint FROM ssh_host_keys WHERE integration_id = ?') + .get(integrationId) as { fingerprint: string } | undefined + if (!row) { + db.prepare('INSERT INTO ssh_host_keys (integration_id, fingerprint) VALUES (?, ?)').run(integrationId, keyHash) + return true + } + return row.fingerprint === keyHash + } +} + +function baseConnectConfig(host: ReturnType extends infer T ? NonNullable : never): ConnectConfig { + return { + host: host.config.host, + port: Number(host.config.port) || 22, + username: host.config.username, + password: host.secrets.password || undefined, + privateKey: host.secrets.privateKey || undefined, + passphrase: host.secrets.passphrase || undefined, + readyTimeout: 8000, + hostHash: 'sha256', + hostVerifier: makeHostVerifier(host.id), + } +} + export async function terminalRoutes(app: FastifyInstance) { app.get('/api/terminal', { websocket: true }, (socket, req) => { let conn: Client | null = null + let jumpConn: Client | null = null let stream: ClientChannel | null = null const cleanup = () => { stream?.end() conn?.end() + jumpConn?.end() stream = null conn = null + jumpConn = null } socket.on('close', cleanup) @@ -55,19 +94,15 @@ export async function terminalRoutes(app: FastifyInstance) { return } - const row = db - .prepare('SELECT id, type, config_json FROM integrations WHERE id = ?') - .get(msg.integrationId) as IntegrationRow | undefined - if (!row || row.type !== 'ssh') { + const target = msg.integrationId !== undefined ? loadSshHost(msg.integrationId) : null + if (!target) { send(socket, { type: 'error', message: 'SSH integration not found' }) return } - const config = JSON.parse(row.config_json) as Record - const secrets = loadSecrets(row.id) - conn = new Client() - conn.on('ready', () => { - conn!.shell({ cols: msg.cols ?? 80, rows: msg.rows ?? 24, term: 'xterm-256color' }, (err, ch) => { + const startShell = (client: Client) => { + conn = client + client.shell({ cols: msg.cols ?? 80, rows: msg.rows ?? 24, term: 'xterm-256color' }, (err, ch) => { if (err) { send(socket, { type: 'error', message: err.message }) return @@ -81,19 +116,42 @@ export async function terminalRoutes(app: FastifyInstance) { cleanup() }) }) - }) - conn.on('error', (err) => { + } + + const jumpHostId = target.config.jumpHostIntegrationId ? Number(target.config.jumpHostIntegrationId) : null + + if (jumpHostId) { + const jumpHost = loadSshHost(jumpHostId) + if (!jumpHost) { + send(socket, { type: 'error', message: 'Jump host integration not found' }) + return + } + jumpConn = new Client() + jumpConn.on('ready', () => { + jumpConn!.forwardOut('127.0.0.1', 0, target.config.host, Number(target.config.port) || 22, (err, sock) => { + if (err) { + send(socket, { type: 'error', message: `Jump host forward failed: ${err.message}` }) + return + } + const client = new Client() + client.on('ready', () => startShell(client)) + client.on('error', (err2) => send(socket, { type: 'error', message: err2.message })) + client.connect({ ...baseConnectConfig(target), sock }) + }) + }) + jumpConn.on('error', (err) => { + send(socket, { type: 'error', message: `Jump host error: ${err.message}` }) + }) + jumpConn.connect(baseConnectConfig(jumpHost)) + return + } + + const client = new Client() + client.on('ready', () => startShell(client)) + client.on('error', (err) => { send(socket, { type: 'error', message: err.message }) }) - conn.connect({ - host: config.host, - port: Number(config.port) || 22, - username: config.username, - password: secrets.password || undefined, - privateKey: secrets.privateKey || undefined, - passphrase: secrets.passphrase || undefined, - readyTimeout: 8000, - }) + client.connect(baseConnectConfig(target)) return } diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 30e6d23..f1c5ab7 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -46,18 +46,15 @@ const integrationTypeDefs: { type: string; name: string; fields: FieldDef[] }[] { type: 'aws', name: 'AWS', fields: [{ key: 'accessKey', label: 'Access Key' }, { key: 'secretKey', label: 'Secret', secret: true }, { key: 'region', label: 'Region' }] }, { type: 'uptime_kuma', name: 'Uptime Kuma', fields: [{ key: 'baseUrl', label: 'URL' }, { key: 'apiKey', label: 'API Key', secret: true }] }, { type: 'weather', name: 'Weather API', fields: [{ key: 'location', label: 'Location' }, { key: 'units', label: 'Units' }] }, - { - type: 'ssh', - name: 'SSH Host', - fields: [ - { key: 'host', label: 'Host / IP' }, - { key: 'port', label: 'Port (default 22)' }, - { key: 'username', label: 'Username' }, - { key: 'password', label: 'Password', secret: true }, - { key: 'privateKey', label: 'Private Key (PEM)', secret: true }, - { key: 'passphrase', label: 'Key Passphrase', secret: true }, - ], - }, +] + +const sshFields: FieldDef[] = [ + { key: 'host', label: 'Host / IP' }, + { key: 'port', label: 'Port (default 22)' }, + { key: 'username', label: 'Username' }, + { key: 'password', label: 'Password', secret: true }, + { key: 'privateKey', label: 'Private Key (PEM)', secret: true }, + { key: 'passphrase', label: 'Key Passphrase', secret: true }, ] const cardBase: React.CSSProperties = { @@ -349,6 +346,290 @@ function AppearanceSection() { ) } +function SshHostsSection() { + const [hosts, setHosts] = useState(null) + const [revealed, setRevealed] = useState>(new Set()) + const [drafts, setDrafts] = useState>>({}) + const [statusMsg, setStatusMsg] = useState>({}) + const [busy, setBusy] = useState>(new Set()) + const [newDrafts, setNewDrafts] = useState<{ key: number; values: Record }[]>([]) + const nextNewKey = useRef(-1) + + useEffect(() => { + refresh() + }, []) + + function refresh() { + api.listIntegrations().then(({ integrations }) => setHosts(integrations.filter((i) => i.type === 'ssh'))) + } + + function toggleReveal(key: string) { + setRevealed((prev) => { + const next = new Set(prev) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + } + + function setBusyFlag(id: number, value: boolean) { + setBusy((prev) => { + const next = new Set(prev) + if (value) next.add(id) + else next.delete(id) + return next + }) + } + + function addNewHost() { + const key = nextNewKey.current-- + setNewDrafts((prev) => [...prev, { key, values: {} }]) + } + + function setNewDraftField(key: number, fieldKey: string, value: string) { + setNewDrafts((prev) => prev.map((d) => (d.key === key ? { ...d, values: { ...d.values, [fieldKey]: value } } : d))) + } + + function removeNewDraft(key: number) { + setNewDrafts((prev) => prev.filter((d) => d.key !== key)) + } + + function setDraftField(id: number, fieldKey: string, value: string) { + setDrafts((prev) => ({ ...prev, [id]: { ...prev[id], [fieldKey]: value } })) + } + + const fieldsWithJumpHost = (excludeId?: number): FieldDef[] => [ + ...sshFields, + { key: 'jumpHostIntegrationId', label: 'Jump Host (optional)' }, + ] + + function buildPayload(fields: FieldDef[], values: Record) { + const config: Record = {} + const secrets: Record = {} + for (const f of fields) { + const value = values[f.key] + if (value === undefined) continue + if (f.secret) secrets[f.key] = value + else config[f.key] = value + } + return { config, secrets } + } + + async function handleSaveExisting(host: Integration) { + setBusyFlag(host.id, true) + setStatusMsg((prev) => ({ ...prev, [host.id]: '' })) + try { + const draft = drafts[host.id] ?? {} + const { config, secrets } = buildPayload(fieldsWithJumpHost(host.id), draft) + const { integration } = await api.updateIntegration(host.id, { config, secrets }) + setHosts((prev) => (prev ?? []).map((h) => (h.id === integration.id ? integration : h))) + setStatusMsg((prev) => ({ ...prev, [host.id]: 'Saved' })) + } catch (err) { + setStatusMsg((prev) => ({ ...prev, [host.id]: err instanceof ApiError ? err.message : 'Save failed' })) + } finally { + setBusyFlag(host.id, false) + } + } + + async function handleSaveNew(key: number, values: Record) { + setBusyFlag(key, true) + try { + const { config, secrets } = buildPayload(fieldsWithJumpHost(), values) + const name = values.host ? `SSH: ${values.host}` : 'SSH Host' + await api.createIntegration({ type: 'ssh', name, config, secrets }) + removeNewDraft(key) + refresh() + } catch (err) { + setStatusMsg((prev) => ({ ...prev, [key]: err instanceof ApiError ? err.message : 'Save failed' })) + } finally { + setBusyFlag(key, false) + } + } + + async function handleTest(host: Integration) { + setBusyFlag(host.id, true) + try { + const result = await api.testIntegration(host.id) + setStatusMsg((prev) => ({ ...prev, [host.id]: result.message })) + refresh() + } catch (err) { + setStatusMsg((prev) => ({ ...prev, [host.id]: err instanceof ApiError ? err.message : 'Test failed' })) + } finally { + setBusyFlag(host.id, false) + } + } + + async function handleDelete(host: Integration) { + setBusyFlag(host.id, true) + try { + await api.deleteIntegration(host.id) + refresh() + } catch (err) { + setStatusMsg((prev) => ({ ...prev, [host.id]: err instanceof ApiError ? err.message : 'Delete failed' })) + setBusyFlag(host.id, false) + } + } + + function renderFields( + fields: FieldDef[], + values: Record, + onChange: (fieldKey: string, value: string) => void, + idForReveal: number, + existing: Integration | undefined, + excludeHostId?: number, + ) { + return fields.map((f) => { + const key = `${idForReveal}-${f.key}` + if (f.key === 'jumpHostIntegrationId') { + const options = (hosts ?? []).filter((h) => h.id !== excludeHostId) + const savedValue = existing?.config[f.key] ?? '' + const value = values[f.key] ?? savedValue + return ( +
+ + +
+ ) + } + const isRevealed = revealed.has(key) + const savedValue = f.secret ? '' : existing?.config[f.key] ?? '' + const value = values[f.key] ?? savedValue + return ( +
+ +
+ onChange(f.key, e.target.value)} + placeholder={f.secret && existing ? '••••••••••••' : 'Not configured'} + /> + {f.secret && ( + + )} +
+
+ ) + }) + } + + if (!hosts) { + return ( +
+

Loading SSH hosts…

+
+ ) + } + + return ( +
+ {hosts.map((host) => { + const online = host.status === 'connected' + const draft = drafts[host.id] ?? {} + return ( +
+
+
+ + {host.name} +
+
+ {statusMsg[host.id] && {statusMsg[host.id]}} + + + +
+
+
+ {renderFields(fieldsWithJumpHost(host.id), draft, (k, v) => setDraftField(host.id, k, v), host.id, host, host.id)} +
+
+ ) + })} + + {newDrafts.map((d) => ( +
+
+ New SSH Host +
+ {statusMsg[d.key] && {statusMsg[d.key]}} + + +
+
+
+ {renderFields(fieldsWithJumpHost(), d.values, (k, v) => setNewDraftField(d.key, k, v), d.key, undefined)} +
+
+ ))} + + +
+ ) +} + function IntegrationsSection() { const [integrations, setIntegrations] = useState(null) const [revealed, setRevealed] = useState>(new Set()) @@ -441,6 +722,13 @@ function IntegrationsSection() { return (
+
+

SSH Hosts

+ +
+
+

Other Integrations

+
{integrationTypeDefs.map((def) => { const existing = integrations.find((i) => i.type === def.type) const online = existing?.status === 'connected'