diff --git a/TERMIX_MIGRATION.md b/TERMIX_MIGRATION.md index c5b8e07..ff57525 100644 --- a/TERMIX_MIGRATION.md +++ b/TERMIX_MIGRATION.md @@ -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 `/_.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. -### 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`), 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) diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index 8dee5ac..7ccfa42 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -71,6 +71,20 @@ db.exec(` fingerprint TEXT NOT NULL, 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) { diff --git a/backend/src/routes/terminal.ts b/backend/src/routes/terminal.ts index 2b17d49..6a4c2b2 100644 --- a/backend/src/routes/terminal.ts +++ b/backend/src/routes/terminal.ts @@ -1,17 +1,10 @@ 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 { mkdtempSync, rmSync, writeFileSync, mkdirSync, appendFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { db } from '../db/index.js' -import { loadSecrets } from '../db/secrets.js' - -interface IntegrationRow { - id: number - type: string - config_json: string -} +import { loadSshHost, connectTarget, type SshHost } from '../ssh/connect.js' interface ClientMessage { type: 'connect' | 'input' | 'resize' | 'disconnect' | 'list_tmux' @@ -30,84 +23,6 @@ function send(socket: { send: (data: string) => void }, payload: Record - const secrets = loadSecrets(row.id) - return { id: row.id, config, secrets } -} - -type SshHost = NonNullable> - -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 * system `ssh` binary (which natively understands OpenSSH certificates via CertificateFile) under * a real pty. Jump-host chaining is not supported on this path. */ diff --git a/backend/src/routes/tunnels.ts b/backend/src/routes/tunnels.ts new file mode 100644 index 0000000..41ede9b --- /dev/null +++ b/backend/src/routes/tunnels.ts @@ -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) } + }) +} diff --git a/backend/src/server.ts b/backend/src/server.ts index d80d285..9d87d65 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -8,6 +8,8 @@ import { integrationRoutes } from './routes/integrations.js' import { bookmarkRoutes } from './routes/bookmarks.js' import { eventRoutes } from './routes/events.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 if (!JWT_SECRET) { @@ -33,6 +35,7 @@ await app.register(integrationRoutes) await app.register(bookmarkRoutes) await app.register(eventRoutes) await app.register(terminalRoutes) +await app.register(tunnelRoutes) 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) process.exit(1) }) + +startAutoStartTunnels() diff --git a/backend/src/ssh/connect.ts b/backend/src/ssh/connect.ts new file mode 100644 index 0000000..61d3f14 --- /dev/null +++ b/backend/src/ssh/connect.ts @@ -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 + const secrets = loadSecrets(row.id) + return { id: row.id, config, secrets } +} + +export type SshHost = NonNullable> + +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 } +} diff --git a/backend/src/tunnels/manager.ts b/backend/src/tunnels/manager.ts new file mode 100644 index 0000000..edd6e70 --- /dev/null +++ b/backend/src/tunnels/manager.ts @@ -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() + +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) +} diff --git a/backend/src/tunnels/socks5.ts b/backend/src/tunnels/socks5.ts new file mode 100644 index 0000000..301b54d --- /dev/null +++ b/backend/src/tunnels/socks5.ts @@ -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 { + 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])) +} diff --git a/src/App.tsx b/src/App.tsx index 9ea2b22..217fbb2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import Glance from './pages/Glance' import Infrastructure from './pages/Infrastructure' import BookNest from './pages/BookNest' import Terminal from './pages/Terminal' +import Tunnels from './pages/Tunnels' import Settings from './pages/Settings' import Login from './pages/Login' import Enrollment from './pages/Enrollment' @@ -82,6 +83,7 @@ function Dashboard() { } /> } /> } /> + } /> } /> diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index b2023a1..fc2414d 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -5,6 +5,7 @@ import { Server, Bookmark, Terminal, + Waypoints, Settings, ChevronLeft, ChevronRight, @@ -21,6 +22,7 @@ const navItems = [ { icon: Server, label: 'Infrastructure', route: '/infrastructure' }, { icon: Bookmark, label: 'BookNest', route: '/booknest' }, { icon: Terminal, label: 'Terminal', route: '/terminal' }, + { icon: Waypoints, label: 'Tunnels', route: '/tunnels' }, { icon: Settings, label: 'Settings', route: '/settings' }, ] diff --git a/src/lib/api.ts b/src/lib/api.ts index 74fd1f7..85eb15a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -72,6 +72,24 @@ export const api = { listEvents: (limit = 20) => apiFetch<{ events: Event[] }>(`/events?limit=${limit}`), 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(`/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 { @@ -93,6 +111,23 @@ export interface Integration { 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 { id: number category_id: number | null diff --git a/src/pages/Tunnels.tsx b/src/pages/Tunnels.tsx new file mode 100644 index 0000000..dc87725 --- /dev/null +++ b/src/pages/Tunnels.tsx @@ -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 = { + local: 'Local Forward', + remote: 'Remote Forward', + dynamic: 'Dynamic (SOCKS5)', +} + +const MODE_ICON: Record> = { + local: ArrowRightLeft, + remote: ArrowLeftRight, + dynamic: Shuffle, +} + +const STATUS_COLOR: Record = { + 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([]) + const [hosts, setHosts] = useState([]) + const [showForm, setShowForm] = useState(false) + const [busyId, setBusyId] = useState(null) + const [error, setError] = useState(null) + + const [name, setName] = useState('') + const [integrationId, setIntegrationId] = useState('') + const [mode, setMode] = useState('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 ( +
+
+
+

+ SSH Tunnels +

+

+ Local / remote / dynamic SOCKS5 port forwarding through your configured SSH hosts. +

+
+ +
+ + {showForm && ( +
+ {error &&
{error}
} +
+
+ + setName(e.target.value)} placeholder="my-tunnel" /> +
+
+ + +
+
+ + +
+
+ + setSourcePort(e.target.value)} placeholder="8080" /> +
+ {mode !== 'dynamic' && ( + <> +
+ + setEndpointHost(e.target.value)} placeholder="127.0.0.1" /> +
+
+ + setEndpointPort(e.target.value)} placeholder="80" /> +
+ + )} +
+ +
+ + +
+
+ )} + +
+ {tunnels.map((t) => { + const Icon = MODE_ICON[t.mode] + const host = hosts.find((h) => h.id === t.integrationId) + return ( +
+
+
+ + + {t.name} + +
+ + {t.status} + {t.status === 'retrying' ? ` (${t.retryCount}/${t.maxRetries})` : ''} + +
+
+
{MODE_LABEL[t.mode]}
+
via {host?.name ?? `integration #${t.integrationId}`}
+
+ localhost:{t.sourcePort} {t.mode === 'dynamic' ? '(SOCKS5 proxy)' : `→ ${t.endpointHost}:${t.endpointPort}`} +
+ {t.error &&
{t.error}
} +
+
+ {t.status === 'connected' || t.status === 'connecting' || t.status === 'retrying' ? ( + + ) : ( + + )} + +
+
+ ) + })} + {tunnels.length === 0 && !showForm && ( +
No tunnels configured yet.
+ )} +
+
+ ) +}