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

93 lines
3.2 KiB
TypeScript

import type { FastifyInstance } from 'fastify'
import { z } from 'zod'
import { db, logEvent } from '../db/index.js'
import {
getStatus,
getTunnelRow,
startTunnel,
stopTunnel,
deleteTunnelRuntime,
type TunnelRow,
} from '../tunnels/manager.js'
const createSchema = z.object({
name: z.string().min(1).max(128),
integrationId: z.number().int(),
mode: z.enum(['local', 'remote', 'dynamic']),
sourcePort: z.number().int().min(1).max(65535),
endpointHost: z.string().default(''),
endpointPort: z.number().int().min(0).max(65535).default(0),
autoStart: z.boolean().default(false),
maxRetries: z.number().int().min(0).max(20).default(3),
retryIntervalMs: z.number().int().min(500).max(60000).default(5000),
})
function serialize(row: TunnelRow) {
return {
id: row.id,
name: row.name,
integrationId: row.integration_id,
mode: row.mode,
sourcePort: row.source_port,
endpointHost: row.endpoint_host,
endpointPort: row.endpoint_port,
autoStart: !!row.auto_start,
maxRetries: row.max_retries,
retryIntervalMs: row.retry_interval_ms,
createdAt: row.created_at,
...getStatus(row.id),
}
}
export async function tunnelRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate)
app.get('/api/tunnels', async () => {
const rows = db.prepare('SELECT * FROM tunnels ORDER BY created_at').all() as TunnelRow[]
return { tunnels: rows.map(serialize) }
})
app.post('/api/tunnels', async (req, reply) => {
const parsed = createSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' })
}
const d = parsed.data
const result = db
.prepare(
`INSERT INTO tunnels (name, integration_id, mode, source_port, endpoint_host, endpoint_port, auto_start, max_retries, retry_interval_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(d.name, d.integrationId, d.mode, d.sourcePort, d.endpointHost, d.endpointPort, d.autoStart ? 1 : 0, d.maxRetries, d.retryIntervalMs)
const id = Number(result.lastInsertRowid)
const row = getTunnelRow(id)!
logEvent('tunnel_created', `${d.name} tunnel added`, d.mode)
return reply.code(201).send({ tunnel: serialize(row) })
})
app.delete('/api/tunnels/:id', async (req, reply) => {
const id = Number((req.params as { id: string }).id)
const row = getTunnelRow(id)
if (!row) return reply.code(404).send({ error: 'Tunnel not found' })
deleteTunnelRuntime(id)
db.prepare('DELETE FROM tunnels WHERE id = ?').run(id)
logEvent('tunnel_deleted', `${row.name} tunnel removed`, row.mode)
return { ok: true }
})
app.post('/api/tunnels/:id/connect', async (req, reply) => {
const id = Number((req.params as { id: string }).id)
const row = getTunnelRow(id)
if (!row) return reply.code(404).send({ error: 'Tunnel not found' })
startTunnel(id)
return { ok: true, ...getStatus(id) }
})
app.post('/api/tunnels/:id/disconnect', async (req, reply) => {
const id = Number((req.params as { id: string }).id)
const row = getTunnelRow(id)
if (!row) return reply.code(404).send({ error: 'Tunnel not found' })
stopTunnel(id)
return { ok: true, ...getStatus(id) }
})
}