dev_arc_aws/backend/src/routes/terminal.ts
Claude 71f49e0700
Add Phase 1a: core SSH terminal (Termix migration)
Implements the minimal-viable terminal described in TERMIX_MIGRATION.md
Phase 1a: a real interactive SSH session in the browser over a
WebSocket, using xterm.js on the frontend and ssh2 on the backend.
Reuses ArchNest's existing SSH integrations (host/port/username/
password/privateKey/passphrase) instead of introducing a second,
duplicate host-management system the way Termix has one.

Backend: new /api/terminal WebSocket route (registered via
@fastify/websocket) handling connect/input/resize/disconnect messages,
authenticated via a JWT passed as a query param (browsers can't set
custom headers on the WS handshake). Extracted the integration secret
loader out of routes/integrations.ts into db/secrets.ts so the new
terminal route can reuse it without duplicating the decrypt logic.

Frontend: new Terminal.tsx page listing configured SSH hosts and
rendering an xterm.js terminal wired to the WebSocket; wired into
App.tsx at /terminal. vite.config.ts's dev proxy now forwards
WebSocket upgrades (ws: true) so this works under `npm run dev`.

Verified end-to-end against a real (test) ssh2-based SSH server:
connect, shell banner, keystroke echo, and prompt redraw all worked
correctly over the actual WebSocket protocol.

Deliberately deferred to Phase 1b/1c per the migration doc: jump-host
chaining, tab/split-pane UI, terminal theme/font settings, OPKSSH cert
auth, tmux session monitor, session recording.

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

115 lines
3.3 KiB
TypeScript

import type { FastifyInstance } from 'fastify'
import { Client } from 'ssh2'
import type { ClientChannel } 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))
}
export async function terminalRoutes(app: FastifyInstance) {
app.get('/api/terminal', { websocket: true }, (socket, req) => {
let conn: Client | null = null
let stream: ClientChannel | null = null
const cleanup = () => {
stream?.end()
conn?.end()
stream = null
conn = 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 row = db
.prepare('SELECT id, type, config_json FROM integrations WHERE id = ?')
.get(msg.integrationId) as IntegrationRow | undefined
if (!row || row.type !== 'ssh') {
send(socket, { type: 'error', message: 'SSH integration not found' })
return
}
const config = JSON.parse(row.config_json) as Record<string, string>
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) => {
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()
})
})
})
conn.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,
})
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()
}
})
})
}