dev_arc_aws/backend/src/routes/terminal.ts
Claude eaa971bb5a
Phase 2: SSH tunnels (local/remote/dynamic SOCKS5 port forwarding)
- backend/src/ssh/connect.ts: extracted shared SSH-connect logic
  (jump-host chaining, TOFU host-key verification) out of terminal.ts
  so tunnels can reuse it.
- backend/src/tunnels/manager.ts + socks5.ts: in-memory tunnel
  runtime manager supporting local forward (forwardOut), remote
  forward (forwardIn), and dynamic SOCKS5 proxying, with automatic
  reconnect/retry and an auto-start-on-boot option. New `tunnels`
  table persists configs as the saved presets.
- backend/src/routes/tunnels.ts: REST CRUD + connect/disconnect.
- src/pages/Tunnels.tsx: new /tunnels page (sidebar entry added) to
  create, start/stop, and delete tunnels with live status polling.
- Verified end-to-end against a real ssh2 test server handling real
  forwardOut/forwardIn requests and a real upstream TCP echo server -
  all three tunnel modes moved real data, and disconnect correctly
  tore down the local listener.

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

237 lines
7.8 KiB
TypeScript

import type { FastifyInstance } from 'fastify'
import { Client, type ClientChannel } 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 { loadSshHost, connectTarget, type SshHost } from '../ssh/connect.js'
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<string, unknown>) {
socket.send(JSON.stringify(payload))
}
/** 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()
}
})
})
}