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) { 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 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) 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() } }) }) }