import type { FastifyInstance } from 'fastify' import { Client, type ClientChannel, type ConnectConfig } from 'ssh2' import { spawn as spawnPty, type IPty } from 'node-pty' import { mkdtempSync, rmSync, writeFileSync, mkdirSync, appendFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' 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' | 'list_tmux' integrationId?: number cols?: number rows?: number data?: string tmuxSession?: string } const TMUX_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/ const SESSION_LOG_DIR = process.env.ARCHNEST_SESSION_LOG_DIR ?? './data/session-logs' 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 } } type SshHost = NonNullable> 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: SshHost): 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), } } /** Connects to `target`, transparently chaining through its jump host (if configured) via forwardOut(). */ function connectTarget( target: SshHost, onReady: (client: Client) => void, onError: (message: string) => void, ): { conn: Client; jumpConn: Client | null } { const jumpHostId = target.config.jumpHostIntegrationId ? Number(target.config.jumpHostIntegrationId) : null if (jumpHostId) { const jumpHost = loadSshHost(jumpHostId) if (!jumpHost) { onError('Jump host integration not found') return { conn: new Client(), jumpConn: null } } const 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) { onError(`Jump host forward failed: ${err.message}`) return } client.connect({ ...baseConnectConfig(target), sock }) }) }) jumpConn.on('error', (err) => onError(`Jump host error: ${err.message}`)) const client = new Client() client.on('ready', () => onReady(client)) client.on('error', (err) => onError(err.message)) jumpConn.connect(baseConnectConfig(jumpHost)) return { conn: client, jumpConn } } const client = new Client() client.on('ready', () => onReady(client)) client.on('error', (err) => onError(err.message)) client.connect(baseConnectConfig(target)) return { conn: client, jumpConn: null } } /** OPKSSH / certificate auth has no support in the ssh2 library, so this path shells out to the * system `ssh` binary (which natively understands OpenSSH certificates via CertificateFile) under * a real pty. Jump-host chaining is not supported on this path. */ function connectWithCertificate( target: SshHost, cols: number, rows: number, onReady: (pty: IPty, keyDir: string) => void, onError: (message: string) => void, ) { const keyDir = mkdtempSync(join(tmpdir(), 'archnest-ssh-')) const keyFile = join(keyDir, 'id_key') const certFile = join(keyDir, 'id_key-cert.pub') try { writeFileSync(keyFile, target.secrets.privateKey ?? '', { mode: 0o600 }) writeFileSync(certFile, target.secrets.certificate ?? '', { mode: 0o600 }) } catch (err) { rmSync(keyDir, { recursive: true, force: true }) onError(err instanceof Error ? err.message : 'Failed to write certificate files') return } const args = [ '-tt', '-p', String(Number(target.config.port) || 22), '-i', keyFile, '-o', `CertificateFile=${certFile}`, '-o', 'StrictHostKeyChecking=accept-new', '-o', `UserKnownHostsFile=${join(keyDir, 'known_hosts')}`, `${target.config.username}@${target.config.host}`, ] let pty: IPty try { pty = spawnPty('ssh', args, { name: 'xterm-256color', cols, rows }) } catch (err) { rmSync(keyDir, { recursive: true, force: true }) onError(err instanceof Error ? `Failed to spawn ssh: ${err.message}` : 'Failed to spawn ssh') return } onReady(pty, keyDir) } function sessionLogPath(integrationId: number) { mkdirSync(SESSION_LOG_DIR, { recursive: true }) const stamp = new Date().toISOString().replace(/[:.]/g, '-') return join(SESSION_LOG_DIR, `${integrationId}_${stamp}.log`) } 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 let pty: IPty | null = null let ptyKeyDir: string | null = null let logPath: string | null = null const logData = (data: string) => { if (logPath) appendFileSync(logPath, data) } const cleanup = () => { stream?.end() conn?.end() jumpConn?.end() pty?.kill() if (ptyKeyDir) rmSync(ptyKeyDir, { recursive: true, force: true }) stream = null conn = null jumpConn = null pty = null ptyKeyDir = null logPath = 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' || msg.type === 'list_tmux') { 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 } if (msg.type === 'list_tmux') { const { conn: ephemeralConn, jumpConn: ephemeralJump } = connectTarget( target, (client) => { client.exec("command -v tmux >/dev/null && tmux list-sessions -F '#S' 2>/dev/null", (err, ch) => { if (err) { send(socket, { type: 'tmux_sessions', sessions: [] }) client.end() ephemeralJump?.end() return } let out = '' ch.on('data', (chunk: Buffer) => (out += chunk.toString('utf8'))) ch.on('close', () => { const sessions = out.split('\n').map((s) => s.trim()).filter(Boolean) send(socket, { type: 'tmux_sessions', sessions }) client.end() ephemeralJump?.end() }) }) }, (message) => send(socket, { type: 'tmux_sessions', sessions: [], error: message }), ) void ephemeralConn return } const cols = msg.cols ?? 80 const rows = msg.rows ?? 24 if (target.secrets.certificate) { connectWithCertificate( target, cols, rows, (p, keyDir) => { pty = p ptyKeyDir = keyDir if (target.config.sessionLogging === 'true') logPath = sessionLogPath(target.id) send(socket, { type: 'connected' }) p.onData((data) => { logData(data) send(socket, { type: 'data', data }) }) p.onExit(() => { send(socket, { type: 'closed' }) cleanup() }) }, (message) => send(socket, { type: 'error', message }), ) return } const startSession = (client: Client) => { conn = client const tmuxSession = msg.tmuxSession && TMUX_NAME_RE.test(msg.tmuxSession) ? msg.tmuxSession : null const onChannel = (err: Error | undefined, ch: ClientChannel) => { if (err) { send(socket, { type: 'error', message: err.message }) return } stream = ch if (target.config.sessionLogging === 'true') logPath = sessionLogPath(target.id) send(socket, { type: 'connected' }) ch.on('data', (chunk: Buffer) => { const text = chunk.toString('utf8') logData(text) send(socket, { type: 'data', data: text }) }) ch.stderr.on('data', (chunk: Buffer) => send(socket, { type: 'data', data: chunk.toString('utf8') })) ch.on('close', () => { send(socket, { type: 'closed' }) cleanup() }) } if (tmuxSession) { client.exec(`tmux attach -t ${tmuxSession} || tmux new-session -s ${tmuxSession}`, { pty: { cols, rows, term: 'xterm-256color' }, }, onChannel) } else { client.shell({ cols, rows, term: 'xterm-256color' }, onChannel) } } const result = connectTarget(target, startSession, (message) => send(socket, { type: 'error', message })) conn = result.conn jumpConn = result.jumpConn return } if (msg.type === 'input') { if (pty) pty.write(msg.data ?? '') else stream?.write(msg.data ?? '') return } if (msg.type === 'resize') { if (pty) pty.resize(msg.cols ?? 80, msg.rows ?? 24) else stream?.setWindow(msg.rows ?? 24, msg.cols ?? 80, 0, 0) return } if (msg.type === 'disconnect') { cleanup() } }) }) }