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:
parent
27abbc8ce1
commit
eaa971bb5a
12 changed files with 824 additions and 89 deletions
|
|
@ -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.
|
||||
- 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<string,
|
|||
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
|
||||
* system `ssh` binary (which natively understands OpenSSH certificates via CertificateFile) under
|
||||
* a real pty. Jump-host chaining is not supported on this path. */
|
||||
|
|
|
|||
93
backend/src/routes/tunnels.ts
Normal file
93
backend/src/routes/tunnels.ts
Normal 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) }
|
||||
})
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
87
backend/src/ssh/connect.ts
Normal file
87
backend/src/ssh/connect.ts
Normal 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 }
|
||||
}
|
||||
218
backend/src/tunnels/manager.ts
Normal file
218
backend/src/tunnels/manager.ts
Normal 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)
|
||||
}
|
||||
61
backend/src/tunnels/socks5.ts
Normal file
61
backend/src/tunnels/socks5.ts
Normal 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]))
|
||||
}
|
||||
|
|
@ -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() {
|
|||
<Route path="/infrastructure" element={<Infrastructure />} />
|
||||
<Route path="/booknest" element={<BookNest />} />
|
||||
<Route path="/terminal" element={<Terminal />} />
|
||||
<Route path="/tunnels" element={<Tunnels />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
|
|
@ -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
|
||||
|
|
|
|||
287
src/pages/Tunnels.tsx
Normal file
287
src/pages/Tunnels.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue