dev_arc_aws/backend/src/ssh/connect.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

87 lines
3 KiB
TypeScript

import { Client, 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
}
export 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 }
}
export type SshHost = NonNullable<ReturnType<typeof loadSshHost>>
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
}
}
export 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(). */
export 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 }
}