dev_arc_aws/backend/src/tunnels/manager.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

218 lines
6 KiB
TypeScript

import net from 'node:net'
import type { Client } from 'ssh2'
import { db } from '../db/index.js'
import { loadSshHost, connectTarget } from '../ssh/connect.js'
import { readSocks5Target, sendSocks5Success, sendSocks5Failure } from './socks5.js'
export type TunnelMode = 'local' | 'remote' | 'dynamic'
export type TunnelStatus = 'stopped' | 'connecting' | 'connected' | 'retrying' | 'error'
export interface TunnelRow {
id: number
name: string
integration_id: number
mode: TunnelMode
source_port: number
endpoint_host: string
endpoint_port: number
auto_start: number
max_retries: number
retry_interval_ms: number
created_at: string
}
interface RuntimeState {
status: TunnelStatus
error: string | null
retryCount: number
client: Client | null
jumpConn: Client | null
server: net.Server | null
retryTimer: NodeJS.Timeout | null
stopRequested: boolean
}
const runtimes = new Map<number, RuntimeState>()
function emptyState(): RuntimeState {
return {
status: 'stopped',
error: null,
retryCount: 0,
client: null,
jumpConn: null,
server: null,
retryTimer: null,
stopRequested: false,
}
}
function getState(id: number): RuntimeState {
let state = runtimes.get(id)
if (!state) {
state = emptyState()
runtimes.set(id, state)
}
return state
}
export function getTunnelRow(id: number): TunnelRow | null {
return (db.prepare('SELECT * FROM tunnels WHERE id = ?').get(id) as TunnelRow | undefined) ?? null
}
export function getStatus(id: number) {
const state = getState(id)
return { status: state.status, error: state.error, retryCount: state.retryCount }
}
function teardownNetwork(state: RuntimeState) {
state.server?.close()
state.client?.end()
state.jumpConn?.end()
state.server = null
state.client = null
state.jumpConn = null
}
function scheduleRetry(id: number, tunnel: TunnelRow, state: RuntimeState) {
if (state.stopRequested) return
if (state.retryCount >= tunnel.max_retries) {
state.status = 'error'
return
}
state.status = 'retrying'
state.retryCount += 1
state.retryTimer = setTimeout(() => startTunnel(id), tunnel.retry_interval_ms)
}
function bindLocalForward(client: Client, tunnel: TunnelRow, state: RuntimeState, onFail: (message: string) => void) {
const server = net.createServer((socket) => {
client.forwardOut(
socket.remoteAddress ?? '127.0.0.1',
socket.remotePort ?? 0,
tunnel.endpoint_host,
tunnel.endpoint_port,
(err, stream) => {
if (err) {
socket.destroy()
return
}
socket.pipe(stream).pipe(socket)
stream.on('close', () => socket.destroy())
socket.on('close', () => stream.end())
},
)
})
server.on('error', (err) => onFail(err.message))
server.listen(tunnel.source_port, '127.0.0.1')
state.server = server
}
function bindRemoteForward(client: Client, tunnel: TunnelRow, state: RuntimeState, onFail: (message: string) => void) {
client.forwardIn('0.0.0.0', tunnel.source_port, (err) => {
if (err) {
onFail(err.message)
return
}
})
client.on('tcp connection', (info, accept, reject) => {
if (info.destPort !== tunnel.source_port) {
reject()
return
}
const stream = accept()
const sock = net.connect(tunnel.endpoint_port, tunnel.endpoint_host)
sock.on('error', () => stream.end())
stream.on('error', () => sock.destroy())
sock.pipe(stream).pipe(sock)
})
}
function bindDynamicForward(client: Client, tunnel: TunnelRow, state: RuntimeState, onFail: (message: string) => void) {
const server = net.createServer((socket) => {
readSocks5Target(socket)
.then((target) => {
client.forwardOut(socket.remoteAddress ?? '127.0.0.1', socket.remotePort ?? 0, target.host, target.port, (err, stream) => {
if (err) {
sendSocks5Failure(socket)
socket.destroy()
return
}
sendSocks5Success(socket)
socket.pipe(stream).pipe(socket)
stream.on('close', () => socket.destroy())
socket.on('close', () => stream.end())
})
})
.catch(() => socket.destroy())
})
server.on('error', (err) => onFail(err.message))
server.listen(tunnel.source_port, '127.0.0.1')
state.server = server
}
export function startTunnel(id: number) {
const tunnel = getTunnelRow(id)
if (!tunnel) return
const state = getState(id)
state.stopRequested = false
state.status = 'connecting'
state.error = null
const target = loadSshHost(tunnel.integration_id)
if (!target) {
state.status = 'error'
state.error = 'SSH integration not found'
return
}
const onFail = (message: string) => {
if (state.stopRequested) return
state.error = message
teardownNetwork(state)
scheduleRetry(id, tunnel, state)
}
const result = connectTarget(
target,
(client) => {
if (state.stopRequested) {
client.end()
return
}
state.client = client
state.status = 'connected'
state.error = null
state.retryCount = 0
client.on('error', (err) => onFail(err.message))
client.on('close', () => onFail('SSH connection closed'))
if (tunnel.mode === 'local') bindLocalForward(client, tunnel, state, onFail)
else if (tunnel.mode === 'remote') bindRemoteForward(client, tunnel, state, onFail)
else bindDynamicForward(client, tunnel, state, onFail)
},
onFail,
)
state.jumpConn = result.jumpConn
}
export function stopTunnel(id: number) {
const state = getState(id)
state.stopRequested = true
if (state.retryTimer) clearTimeout(state.retryTimer)
state.retryTimer = null
state.retryCount = 0
state.status = 'stopped'
state.error = null
teardownNetwork(state)
}
export function deleteTunnelRuntime(id: number) {
stopTunnel(id)
runtimes.delete(id)
}
export function startAutoStartTunnels() {
const rows = db.prepare('SELECT * FROM tunnels WHERE auto_start = 1').all() as TunnelRow[]
for (const row of rows) startTunnel(row.id)
}