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
This commit is contained in:
Claude 2026-06-19 11:40:59 +00:00
parent 27abbc8ce1
commit eaa971bb5a
No known key found for this signature in database
12 changed files with 824 additions and 89 deletions

View file

@ -58,9 +58,25 @@ Rationale for splitting: 1a alone is a real, useful terminal (matches what `/ter
- **Session recording/logging to disk**: new SSH integration config field `sessionLogging` (checkbox in Settings' SSH host form). When set, all outbound terminal output (both the `ssh2` path and the cert-auth pty path) is appended to `<ARCHNEST_SESSION_LOG_DIR ?? './data/session-logs'>/<integrationId>_<timestamp>.log`. No log browsing/download UI yet (not built — out of scope for this pass, not silently dropped). **Verified end-to-end**: a real shell session's output was confirmed present in its log file on disk. - **Session recording/logging to disk**: new SSH integration config field `sessionLogging` (checkbox in Settings' SSH host form). When set, all outbound terminal output (both the `ssh2` path and the cert-auth pty path) is appended to `<ARCHNEST_SESSION_LOG_DIR ?? './data/session-logs'>/<integrationId>_<timestamp>.log`. No log browsing/download UI yet (not built — out of scope for this pass, not silently dropped). **Verified end-to-end**: a real shell session's output was confirmed present in its log file on disk.
- No real `ssh` CLI / no real OPKSSH certificate available in this sandbox to test against, see verification gap above. Everything else in this phase was tested against live processes, not mocked. - No real `ssh` CLI / no real OPKSSH certificate available in this sandbox to test against, see verification gap above. Everything else in this phase was tested against live processes, not mocked.
### Phase 2 — SSH Tunnels (NOT STARTED) ### Phase 2 — SSH Tunnels (DONE)
Source: `src/backend/ssh/tunnel.ts` (2,414 lines) + `tunnel-c2s-relay.ts`, `tunnel-socks5-relay.ts`, `tunnel-ssh-primitives.ts`, `tunnel-utils.ts`, `tunnel-c2s-relay-utils.ts` (~830 lines combined) + frontend `src/ui/features/tunnel/*`. Local/remote/dynamic SOCKS forwarding, automatic reconnection, health monitoring. Builds on Phase 1's connection pool. Client-to-server tunnel presets (save/rename/load/delete) need a small new table in ArchNest's schema. Source: `src/backend/ssh/tunnel.ts` (2,414 lines) + `tunnel-c2s-relay.ts`, `tunnel-socks5-relay.ts`, `tunnel-ssh-primitives.ts`, `tunnel-utils.ts`, `tunnel-c2s-relay-utils.ts` (~830 lines combined) + frontend `src/ui/features/tunnel/*`.
**Scope decision**: Termix distinguishes "S2S" (server-to-server, backend-managed) and "C2S" (client-to-server, routed through Termix's desktop/Electron app) tunnels. ArchNest has no desktop client (explicitly out of scope per the top of this doc), so only the **S2S model** was ported — a single persistent backend process manages all tunnels, same as Termix's S2S path. C2S's WebSocket data-multiplexing-to-a-desktop-client layer was not ported; it has no equivalent need in a pure web app.
**What was built:**
- `backend/src/ssh/connect.ts` — extracted `loadSshHost`/`baseConnectConfig`/`connectTarget` (jump-host chaining + TOFU host-key verification) out of `terminal.ts` into a shared module, since tunnels need the exact same SSH-connection logic terminal sessions do.
- `backend/src/tunnels/manager.ts` — in-memory tunnel runtime manager (`Map<tunnelId, RuntimeState>`), mirroring Termix's `activeTunnels`/`connectionStatus` maps but scoped down to this app's needs. Three modes:
- **Local forward**: a `net.Server` listens on `sourcePort`; each inbound connection calls `client.forwardOut()` to `endpointHost:endpointPort` and pipes the two sockets together.
- **Remote forward**: `client.forwardIn('0.0.0.0', sourcePort)` asks the SSH server to bind that port; incoming `'tcp connection'` events are piped to a local `net.connect()` against `endpointHost:endpointPort`.
- **Dynamic (SOCKS5)**: a `net.Server` listens on `sourcePort` running a minimal SOCKS5 handshake (`backend/src/tunnels/socks5.ts`, CONNECT-only, no-auth — sufficient for this use case, not a general SOCKS5 server), then `forwardOut()`s to whatever target the client requested per-connection.
- Automatic reconnection: on SSH error/close or listener bind failure, schedules a retry after `retryIntervalMs`, up to `maxRetries`, then settles into an `error` status (mirrors Termix's retry/backoff but simplified to a fixed interval rather than exponential — sufficient for this scale).
- `startAutoStartTunnels()` is called once at server boot to bring up any tunnel with `autoStart` set.
- `backend/src/routes/tunnels.ts` — REST CRUD (`GET/POST /api/tunnels`, `DELETE /api/tunnels/:id`) plus `POST /api/tunnels/:id/connect` / `/disconnect`. Status (`stopped`/`connecting`/`connected`/`retrying`/`error` + retry count + last error) is read directly off the in-memory runtime state on every `GET /api/tunnels` (simple polling from the frontend every 3s — no SSE/EventSource, unlike Termix; not needed at this scale and keeps the implementation smaller).
- `backend/src/db/index.ts` — new `tunnels` table: `id, name, integration_id, mode, source_port, endpoint_host, endpoint_port, auto_start, max_retries, retry_interval_ms, created_at`. Each tunnel references an existing SSH `integrations` row (no separate host table, consistent with the rest of this migration) — no separate "preset" concept needed since a tunnel row already *is* the saved preset.
- `src/pages/Tunnels.tsx` — new page (`/tunnels`, added to the sidebar with a `Waypoints` icon) with a creation form (name, SSH host picker, mode, source port, endpoint host/port, auto-start) and a card grid showing each tunnel's status, mode, route, and Start/Stop/Delete actions, polling every 3 seconds.
**Verified end-to-end** against a real test SSH server (extending the same real-`ssh2`-`Server` + `node-pty` pattern used in Phase 1c) that genuinely handles `tcpip` (forwardOut) and `tcpip-forward`/`cancel-tcpip-forward` (forwardIn) requests, plus a real upstream TCP echo server: created one tunnel of each mode (local/remote/dynamic), connected all three, and confirmed real data flowed through each — local forward and remote forward both delivered the upstream server's banner through the tunnel, and the dynamic tunnel completed a real SOCKS5 CONNECT handshake and relayed data. Also verified disconnect correctly tears down the local listener (`ECONNREFUSED` after stopping). All test artifacts (test SSH server, test backend instance, test DB, tokens) were cleaned up afterward.
### Phase 3 — Remote File Manager (NOT STARTED) ### Phase 3 — Remote File Manager (NOT STARTED)

View file

@ -71,6 +71,20 @@ db.exec(`
fingerprint TEXT NOT NULL, fingerprint TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
CREATE TABLE IF NOT EXISTS tunnels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
integration_id INTEGER NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,
mode TEXT NOT NULL,
source_port INTEGER NOT NULL,
endpoint_host TEXT NOT NULL DEFAULT '',
endpoint_port INTEGER NOT NULL DEFAULT 0,
auto_start INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3,
retry_interval_ms INTEGER NOT NULL DEFAULT 5000,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`) `)
export function logEvent(type: string, title: string, source?: string | null) { export function logEvent(type: string, title: string, source?: string | null) {

View file

@ -1,17 +1,10 @@
import type { FastifyInstance } from 'fastify' import type { FastifyInstance } from 'fastify'
import { Client, type ClientChannel, type ConnectConfig } from 'ssh2' import { Client, type ClientChannel } from 'ssh2'
import { spawn as spawnPty, type IPty } from 'node-pty' import { spawn as spawnPty, type IPty } from 'node-pty'
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, appendFileSync } from 'node:fs' import { mkdtempSync, rmSync, writeFileSync, mkdirSync, appendFileSync } from 'node:fs'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { db } from '../db/index.js' import { loadSshHost, connectTarget, type SshHost } from '../ssh/connect.js'
import { loadSecrets } from '../db/secrets.js'
interface IntegrationRow {
id: number
type: string
config_json: string
}
interface ClientMessage { interface ClientMessage {
type: 'connect' | 'input' | 'resize' | 'disconnect' | 'list_tmux' type: 'connect' | 'input' | 'resize' | 'disconnect' | 'list_tmux'
@ -30,84 +23,6 @@ function send(socket: { send: (data: string) => void }, payload: Record<string,
socket.send(JSON.stringify(payload)) socket.send(JSON.stringify(payload))
} }
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 }
}
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
}
}
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(). */
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 }
}
/** OPKSSH / certificate auth has no support in the ssh2 library, so this path shells out to the /** 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 * system `ssh` binary (which natively understands OpenSSH certificates via CertificateFile) under
* a real pty. Jump-host chaining is not supported on this path. */ * a real pty. Jump-host chaining is not supported on this path. */

View file

@ -0,0 +1,93 @@
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) }
})
}

View file

@ -8,6 +8,8 @@ import { integrationRoutes } from './routes/integrations.js'
import { bookmarkRoutes } from './routes/bookmarks.js' import { bookmarkRoutes } from './routes/bookmarks.js'
import { eventRoutes } from './routes/events.js' import { eventRoutes } from './routes/events.js'
import { terminalRoutes } from './routes/terminal.js' import { terminalRoutes } from './routes/terminal.js'
import { tunnelRoutes } from './routes/tunnels.js'
import { startAutoStartTunnels } from './tunnels/manager.js'
const JWT_SECRET = process.env.ARCHNEST_JWT_SECRET const JWT_SECRET = process.env.ARCHNEST_JWT_SECRET
if (!JWT_SECRET) { if (!JWT_SECRET) {
@ -33,6 +35,7 @@ await app.register(integrationRoutes)
await app.register(bookmarkRoutes) await app.register(bookmarkRoutes)
await app.register(eventRoutes) await app.register(eventRoutes)
await app.register(terminalRoutes) await app.register(terminalRoutes)
await app.register(tunnelRoutes)
app.get('/api/health', async () => ({ ok: true })) app.get('/api/health', async () => ({ ok: true }))
@ -41,3 +44,5 @@ app.listen({ port, host: '0.0.0.0' }).catch((err) => {
app.log.error(err) app.log.error(err)
process.exit(1) process.exit(1)
}) })
startAutoStartTunnels()

View file

@ -0,0 +1,87 @@
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 }
}

View file

@ -0,0 +1,218 @@
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)
}

View file

@ -0,0 +1,61 @@
import type { Socket } from 'node:net'
export interface Socks5Target {
host: string
port: number
}
/** Minimal SOCKS5 handshake (no-auth only) + CONNECT request parser. Resolves once the
* client's target address is known, after which the caller is expected to pipe the
* raw bytes that follow straight through to an upstream connection. */
export function readSocks5Target(socket: Socket): Promise<Socks5Target> {
return new Promise((resolve, reject) => {
let stage: 'greeting' | 'request' = 'greeting'
const onData = (chunk: Buffer) => {
try {
if (stage === 'greeting') {
if (chunk[0] !== 0x05) throw new Error('Unsupported SOCKS version')
socket.write(Buffer.from([0x05, 0x00]))
stage = 'request'
return
}
if (chunk[0] !== 0x05 || chunk[1] !== 0x01) {
throw new Error('Only CONNECT is supported')
}
const addrType = chunk[3]
let host: string
let offset: number
if (addrType === 0x01) {
host = `${chunk[4]}.${chunk[5]}.${chunk[6]}.${chunk[7]}`
offset = 8
} else if (addrType === 0x03) {
const len = chunk[4]
host = chunk.subarray(5, 5 + len).toString('ascii')
offset = 5 + len
} else {
throw new Error('Unsupported SOCKS5 address type')
}
const port = chunk.readUInt16BE(offset)
socket.removeListener('data', onData)
resolve({ host, port })
} catch (err) {
socket.removeListener('data', onData)
reject(err instanceof Error ? err : new Error('SOCKS5 parse error'))
}
}
socket.on('data', onData)
socket.on('error', reject)
socket.on('close', () => reject(new Error('Socket closed before SOCKS5 handshake completed')))
})
}
export function sendSocks5Success(socket: Socket) {
socket.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]))
}
export function sendSocks5Failure(socket: Socket) {
socket.write(Buffer.from([0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0]))
}

View file

@ -6,6 +6,7 @@ import Glance from './pages/Glance'
import Infrastructure from './pages/Infrastructure' import Infrastructure from './pages/Infrastructure'
import BookNest from './pages/BookNest' import BookNest from './pages/BookNest'
import Terminal from './pages/Terminal' import Terminal from './pages/Terminal'
import Tunnels from './pages/Tunnels'
import Settings from './pages/Settings' import Settings from './pages/Settings'
import Login from './pages/Login' import Login from './pages/Login'
import Enrollment from './pages/Enrollment' import Enrollment from './pages/Enrollment'
@ -82,6 +83,7 @@ function Dashboard() {
<Route path="/infrastructure" element={<Infrastructure />} /> <Route path="/infrastructure" element={<Infrastructure />} />
<Route path="/booknest" element={<BookNest />} /> <Route path="/booknest" element={<BookNest />} />
<Route path="/terminal" element={<Terminal />} /> <Route path="/terminal" element={<Terminal />} />
<Route path="/tunnels" element={<Tunnels />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
</Routes> </Routes>
</section> </section>

View file

@ -5,6 +5,7 @@ import {
Server, Server,
Bookmark, Bookmark,
Terminal, Terminal,
Waypoints,
Settings, Settings,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
@ -21,6 +22,7 @@ const navItems = [
{ icon: Server, label: 'Infrastructure', route: '/infrastructure' }, { icon: Server, label: 'Infrastructure', route: '/infrastructure' },
{ icon: Bookmark, label: 'BookNest', route: '/booknest' }, { icon: Bookmark, label: 'BookNest', route: '/booknest' },
{ icon: Terminal, label: 'Terminal', route: '/terminal' }, { icon: Terminal, label: 'Terminal', route: '/terminal' },
{ icon: Waypoints, label: 'Tunnels', route: '/tunnels' },
{ icon: Settings, label: 'Settings', route: '/settings' }, { icon: Settings, label: 'Settings', route: '/settings' },
] ]

View file

@ -72,6 +72,24 @@ export const api = {
listEvents: (limit = 20) => apiFetch<{ events: Event[] }>(`/events?limit=${limit}`), listEvents: (limit = 20) => apiFetch<{ events: Event[] }>(`/events?limit=${limit}`),
listResources: () => apiFetch<{ resources: Resource[] }>('/integrations/resources'), listResources: () => apiFetch<{ resources: Resource[] }>('/integrations/resources'),
listTunnels: () => apiFetch<{ tunnels: Tunnel[] }>('/tunnels'),
createTunnel: (data: {
name: string
integrationId: number
mode: 'local' | 'remote' | 'dynamic'
sourcePort: number
endpointHost?: string
endpointPort?: number
autoStart?: boolean
maxRetries?: number
retryIntervalMs?: number
}) => apiFetch<{ tunnel: Tunnel }>('/tunnels', { method: 'POST', body: JSON.stringify(data) }),
deleteTunnel: (id: number) => apiFetch<void>(`/tunnels/${id}`, { method: 'DELETE' }),
connectTunnel: (id: number) =>
apiFetch<{ ok: boolean; status: string; error: string | null; retryCount: number }>(`/tunnels/${id}/connect`, { method: 'POST' }),
disconnectTunnel: (id: number) =>
apiFetch<{ ok: boolean; status: string; error: string | null; retryCount: number }>(`/tunnels/${id}/disconnect`, { method: 'POST' }),
} }
export interface AuthUser { export interface AuthUser {
@ -93,6 +111,23 @@ export interface Integration {
createdAt: string createdAt: string
} }
export interface Tunnel {
id: number
name: string
integrationId: number
mode: 'local' | 'remote' | 'dynamic'
sourcePort: number
endpointHost: string
endpointPort: number
autoStart: boolean
maxRetries: number
retryIntervalMs: number
createdAt: string
status: 'stopped' | 'connecting' | 'connected' | 'retrying' | 'error'
error: string | null
retryCount: number
}
export interface Bookmark { export interface Bookmark {
id: number id: number
category_id: number | null category_id: number | null

287
src/pages/Tunnels.tsx Normal file
View file

@ -0,0 +1,287 @@
import { useEffect, useState } from 'react'
import { Plus, Play, Square, Trash2, ArrowRightLeft, ArrowLeftRight, Shuffle } from 'lucide-react'
import { api, type Tunnel, type Integration } from '../lib/api'
const TEXT_PRIMARY = '#E8E6E0'
const TEXT_SECONDARY = '#7A7D85'
const GOLD = '#C8A434'
const cardBase: React.CSSProperties = {
backgroundColor: 'rgba(10, 10, 12, 0.92)',
border: '1px solid rgba(200, 164, 52, 0.08)',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 0 20px rgba(200, 164, 52, 0.03)',
}
const MODE_LABEL: Record<Tunnel['mode'], string> = {
local: 'Local Forward',
remote: 'Remote Forward',
dynamic: 'Dynamic (SOCKS5)',
}
const MODE_ICON: Record<Tunnel['mode'], React.ComponentType<{ size?: number; color?: string }>> = {
local: ArrowRightLeft,
remote: ArrowLeftRight,
dynamic: Shuffle,
}
const STATUS_COLOR: Record<Tunnel['status'], string> = {
stopped: TEXT_SECONDARY,
connecting: GOLD,
retrying: '#E0A030',
connected: '#2ECC71',
error: '#E74C3C',
}
function inputStyle(): React.CSSProperties {
return {
backgroundColor: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: '6px',
padding: '6px 10px',
color: TEXT_PRIMARY,
fontSize: '13px',
width: '100%',
}
}
export default function Tunnels() {
const [tunnels, setTunnels] = useState<Tunnel[]>([])
const [hosts, setHosts] = useState<Integration[]>([])
const [showForm, setShowForm] = useState(false)
const [busyId, setBusyId] = useState<number | null>(null)
const [error, setError] = useState<string | null>(null)
const [name, setName] = useState('')
const [integrationId, setIntegrationId] = useState<number | ''>('')
const [mode, setMode] = useState<Tunnel['mode']>('local')
const [sourcePort, setSourcePort] = useState('')
const [endpointHost, setEndpointHost] = useState('')
const [endpointPort, setEndpointPort] = useState('')
const [autoStart, setAutoStart] = useState(false)
function refresh() {
api.listTunnels().then(({ tunnels }) => setTunnels(tunnels))
}
useEffect(() => {
refresh()
api.listIntegrations().then(({ integrations }) => setHosts(integrations.filter((i) => i.type === 'ssh')))
const interval = setInterval(refresh, 3000)
return () => clearInterval(interval)
}, [])
async function handleCreate() {
if (!name.trim() || !integrationId || !sourcePort) {
setError('Name, SSH host, and source port are required')
return
}
setError(null)
try {
await api.createTunnel({
name: name.trim(),
integrationId: Number(integrationId),
mode,
sourcePort: Number(sourcePort),
endpointHost: mode === 'dynamic' ? '' : endpointHost,
endpointPort: mode === 'dynamic' ? 0 : Number(endpointPort) || 0,
autoStart,
})
setName('')
setIntegrationId('')
setSourcePort('')
setEndpointHost('')
setEndpointPort('')
setAutoStart(false)
setShowForm(false)
refresh()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create tunnel')
}
}
async function handleConnect(id: number) {
setBusyId(id)
try {
await api.connectTunnel(id)
refresh()
} finally {
setBusyId(null)
}
}
async function handleDisconnect(id: number) {
setBusyId(id)
try {
await api.disconnectTunnel(id)
refresh()
} finally {
setBusyId(null)
}
}
async function handleDelete(id: number) {
setBusyId(id)
try {
await api.deleteTunnel(id)
refresh()
} finally {
setBusyId(null)
}
}
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold" style={{ color: TEXT_PRIMARY }}>
SSH Tunnels
</h1>
<p className="text-sm" style={{ color: TEXT_SECONDARY }}>
Local / remote / dynamic SOCKS5 port forwarding through your configured SSH hosts.
</p>
</div>
<button
onClick={() => setShowForm((s) => !s)}
className="flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium"
style={{ backgroundColor: GOLD, color: '#0A0A0C' }}
>
<Plus size={16} />
New Tunnel
</button>
</div>
{showForm && (
<div style={cardBase} className="space-y-3">
{error && <div style={{ color: '#E74C3C', fontSize: '13px' }}>{error}</div>}
<div className="grid grid-cols-2 gap-3">
<div>
<label style={{ color: TEXT_SECONDARY, fontSize: '11px' }}>Name</label>
<input style={inputStyle()} value={name} onChange={(e) => setName(e.target.value)} placeholder="my-tunnel" />
</div>
<div>
<label style={{ color: TEXT_SECONDARY, fontSize: '11px' }}>SSH Host</label>
<select
style={inputStyle()}
value={integrationId}
onChange={(e) => setIntegrationId(e.target.value ? Number(e.target.value) : '')}
>
<option value="">Select host</option>
{hosts.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</select>
</div>
<div>
<label style={{ color: TEXT_SECONDARY, fontSize: '11px' }}>Mode</label>
<select style={inputStyle()} value={mode} onChange={(e) => setMode(e.target.value as Tunnel['mode'])}>
<option value="local">Local Forward</option>
<option value="remote">Remote Forward</option>
<option value="dynamic">Dynamic (SOCKS5)</option>
</select>
</div>
<div>
<label style={{ color: TEXT_SECONDARY, fontSize: '11px' }}>
{mode === 'dynamic' ? 'SOCKS5 Listen Port' : 'Source Port'}
</label>
<input style={inputStyle()} value={sourcePort} onChange={(e) => setSourcePort(e.target.value)} placeholder="8080" />
</div>
{mode !== 'dynamic' && (
<>
<div>
<label style={{ color: TEXT_SECONDARY, fontSize: '11px' }}>Endpoint Host</label>
<input style={inputStyle()} value={endpointHost} onChange={(e) => setEndpointHost(e.target.value)} placeholder="127.0.0.1" />
</div>
<div>
<label style={{ color: TEXT_SECONDARY, fontSize: '11px' }}>Endpoint Port</label>
<input style={inputStyle()} value={endpointPort} onChange={(e) => setEndpointPort(e.target.value)} placeholder="80" />
</div>
</>
)}
</div>
<label className="flex items-center gap-2 text-xs" style={{ color: TEXT_PRIMARY }}>
<input type="checkbox" checked={autoStart} onChange={(e) => setAutoStart(e.target.checked)} />
Auto-start when the server boots
</label>
<div className="flex gap-2">
<button onClick={handleCreate} className="rounded-md px-3 py-1.5 text-sm font-medium" style={{ backgroundColor: GOLD, color: '#0A0A0C' }}>
Create
</button>
<button onClick={() => setShowForm(false)} className="rounded-md px-3 py-1.5 text-sm" style={{ color: TEXT_SECONDARY }}>
Cancel
</button>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{tunnels.map((t) => {
const Icon = MODE_ICON[t.mode]
const host = hosts.find((h) => h.id === t.integrationId)
return (
<div key={t.id} style={cardBase} className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Icon size={16} color={GOLD} />
<span className="font-medium" style={{ color: TEXT_PRIMARY }}>
{t.name}
</span>
</div>
<span
className="text-xs rounded-full px-2 py-0.5"
style={{ color: STATUS_COLOR[t.status], border: `1px solid ${STATUS_COLOR[t.status]}33` }}
>
{t.status}
{t.status === 'retrying' ? ` (${t.retryCount}/${t.maxRetries})` : ''}
</span>
</div>
<div style={{ color: TEXT_SECONDARY, fontSize: '12px' }} className="space-y-1">
<div>{MODE_LABEL[t.mode]}</div>
<div>via {host?.name ?? `integration #${t.integrationId}`}</div>
<div>
localhost:{t.sourcePort} {t.mode === 'dynamic' ? '(SOCKS5 proxy)' : `${t.endpointHost}:${t.endpointPort}`}
</div>
{t.error && <div style={{ color: '#E74C3C' }}>{t.error}</div>}
</div>
<div className="flex gap-2">
{t.status === 'connected' || t.status === 'connecting' || t.status === 'retrying' ? (
<button
disabled={busyId === t.id}
onClick={() => handleDisconnect(t.id)}
className="flex items-center gap-1 rounded-md px-2.5 py-1 text-xs"
style={{ border: '1px solid rgba(255,255,255,0.1)', color: TEXT_PRIMARY }}
>
<Square size={12} /> Stop
</button>
) : (
<button
disabled={busyId === t.id}
onClick={() => handleConnect(t.id)}
className="flex items-center gap-1 rounded-md px-2.5 py-1 text-xs"
style={{ backgroundColor: GOLD, color: '#0A0A0C' }}
>
<Play size={12} /> Start
</button>
)}
<button
disabled={busyId === t.id}
onClick={() => handleDelete(t.id)}
className="flex items-center gap-1 rounded-md px-2.5 py-1 text-xs"
style={{ border: '1px solid rgba(231,76,60,0.3)', color: '#E74C3C' }}
>
<Trash2 size={12} /> Delete
</button>
</div>
</div>
)
})}
{tunnels.length === 0 && !showForm && (
<div style={{ color: TEXT_SECONDARY, fontSize: '13px' }}>No tunnels configured yet.</div>
)}
</div>
</div>
)
}