dev_arc_aws/backend/src/routes/terminal.ts
Claude 5d56a1d902
Phase 1b: SSH jump-host chaining, TOFU host-key verification, multi-host Settings UI
Terminal connections can now reference a jumpHostIntegrationId on the SSH
integration config; the backend connects to the jump host first and tunnels
to the real target via ssh2's forwardOut(), rather than connecting directly.

Added an ssh_host_keys table and a hostVerifier callback that accepts and
stores a host's fingerprint on first connect, then hard-rejects on any
mismatch on subsequent connects (trust-on-first-use).

Settings previously only ever showed/edited one integration per type, which
silently prevented configuring more than one SSH host at all. Added a
dedicated multi-host SSH section (per-host Save/Test/Delete, Add SSH Host,
and a Jump Host dropdown) so jump-host chaining is actually usable from the UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
2026-06-19 11:04:46 +00:00

173 lines
5.6 KiB
TypeScript

import type { FastifyInstance } from 'fastify'
import { Client, type ClientChannel, type ConnectConfig } from 'ssh2'
import { db } from '../db/index.js'
import { loadSecrets } from '../db/secrets.js'
interface IntegrationRow {
id: number
type: string
config_json: string
}
interface ClientMessage {
type: 'connect' | 'input' | 'resize' | 'disconnect'
integrationId?: number
cols?: number
rows?: number
data?: string
}
function send(socket: { send: (data: string) => void }, payload: Record<string, unknown>) {
socket.send(JSON.stringify(payload))
}
function loadSshHost(integrationId: number) {
const row = db
.prepare('SELECT id, type, config_json FROM integrations WHERE id = ?')
.get(integrationId) as IntegrationRow | undefined
if (!row || row.type !== 'ssh') return null
const config = JSON.parse(row.config_json) as Record<string, string>
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<typeof loadSshHost> extends infer T ? NonNullable<T> : 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)
socket.on('message', async (raw: Buffer) => {
let msg: ClientMessage
try {
msg = JSON.parse(raw.toString())
} catch {
send(socket, { type: 'error', message: 'Invalid JSON' })
return
}
if (msg.type === 'connect') {
const query = req.query as { token?: string }
try {
await app.jwt.verify(query.token ?? '')
} catch {
send(socket, { type: 'error', message: 'Unauthorized' })
socket.close()
return
}
const target = msg.integrationId !== undefined ? loadSshHost(msg.integrationId) : null
if (!target) {
send(socket, { type: 'error', message: 'SSH integration not found' })
return
}
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
}
stream = ch
send(socket, { type: 'connected' })
ch.on('data', (chunk: Buffer) => send(socket, { type: 'data', data: chunk.toString('utf8') }))
ch.stderr.on('data', (chunk: Buffer) => send(socket, { type: 'data', data: chunk.toString('utf8') }))
ch.on('close', () => {
send(socket, { type: 'closed' })
cleanup()
})
})
}
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 })
})
client.connect(baseConnectConfig(target))
return
}
if (msg.type === 'input') {
stream?.write(msg.data ?? '')
return
}
if (msg.type === 'resize') {
stream?.setWindow(msg.rows ?? 24, msg.cols ?? 80, 0, 0)
return
}
if (msg.type === 'disconnect') {
cleanup()
}
})
})
}