From 9a7bfc2fa347dd4359d0a8a738c3c0bb524c31db Mon Sep 17 00:00:00 2001 From: Xenthys Date: Fri, 24 Apr 2026 21:48:39 +0200 Subject: [PATCH] Add client tunnel bridge support --- docker/nginx-https.conf | 15 + docker/nginx.conf | 15 + electron/main.cjs | 431 +++++++++++++++++- electron/preload.js | 8 + src/backend/ssh/tunnel.ts | 367 ++++++++++++++- src/locales/en.json | 14 +- src/types/electron.d.ts | 13 + src/ui/desktop/DesktopApp.tsx | 22 +- .../desktop/apps/features/tunnel/Tunnel.tsx | 16 +- .../features/tunnel/TunnelInlineControls.tsx | 29 +- .../features/tunnel/TunnelModeSelector.tsx | 72 +++ .../apps/features/tunnel/tunnel-form-utils.ts | 62 +++ .../host-manager/hosts/HostManagerEditor.tsx | 127 ++---- .../host-manager/hosts/tabs/HostTunnelTab.tsx | 294 ++++-------- .../hosts/tabs/shared/tab-types.ts | 12 - .../desktop/user/C2STunnelPresetManager.tsx | 211 +++++---- src/ui/main-axios.ts | 24 + 17 files changed, 1292 insertions(+), 440 deletions(-) create mode 100644 src/ui/desktop/apps/features/tunnel/TunnelModeSelector.tsx create mode 100644 src/ui/desktop/apps/features/tunnel/tunnel-form-utils.ts diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index b1ce0283..8cac5ee8 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -346,6 +346,21 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } + location /ssh/tunnel/ { + proxy_pass http://127.0.0.1:30003; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + proxy_buffering off; + proxy_cache off; + } + location /host/file_manager/recent { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; diff --git a/docker/nginx.conf b/docker/nginx.conf index d12fc301..3a644a79 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -335,6 +335,21 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } + location /ssh/tunnel/ { + proxy_pass http://127.0.0.1:30003; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + proxy_buffering off; + proxy_cache off; + } + location /host/file_manager/recent { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; diff --git a/electron/main.cjs b/electron/main.cjs index 725a1136..a22625b6 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -15,6 +15,7 @@ const http = require("http"); const net = require("net"); const { URL } = require("url"); const { fork } = require("child_process"); +const WebSocket = require("ws"); const logFile = path.join(app.getPath("userData"), "termix-main.log"); function logToFile(...args) { @@ -676,6 +677,13 @@ ipcMain.handle("save-c2s-tunnel-config", async (_event, config) => { const autoStartListeners = new Set(); for (const tunnel of config) { if (!tunnel?.autoStart) continue; + const mode = tunnel.mode || tunnel.tunnelType || "local"; + if (mode === "remote") { + return { + success: false, + error: "Client remote forwarding cannot use auto-start yet", + }; + } const bindHost = tunnel.bindHost || "127.0.0.1"; const sourcePort = Number(tunnel.sourcePort); @@ -694,7 +702,12 @@ ipcMain.handle("save-c2s-tunnel-config", async (_event, config) => { bindHost, Number(sourcePort), ); - if (!result.available) { + const ownedByClientTunnel = Array.from(c2sTunnelRuntimes.values()).some( + (runtime) => + runtime.bindHost === bindHost && + runtime.sourcePort === Number(sourcePort), + ); + if (!result.available && !ownedByClientTunnel) { return { success: false, error: `Cannot auto-start client tunnel on ${listenerKey}: ${result.error || "port is already in use"}`, @@ -726,6 +739,389 @@ function checkLocalPortAvailable(host, port) { }); } +const c2sTunnelRuntimes = new Map(); + +function getServerConfigSync() { + try { + const configPath = path.join(app.getPath("userData"), "server-config.json"); + if (!fs.existsSync(configPath)) return null; + return JSON.parse(fs.readFileSync(configPath, "utf8")); + } catch { + return null; + } +} + +function getC2SRelayUrl() { + const config = getServerConfigSync(); + const serverUrl = + config?.serverUrl || (!isDev ? "http://127.0.0.1:30003" : null); + if (!serverUrl) { + throw new Error("No Termix server configured"); + } + + const base = serverUrl.replace(/\/$/, ""); + const relayHttpUrl = base.endsWith(":30003") + ? `${base}/ssh/tunnel/c2s/stream` + : `${base}/ssh/tunnel/c2s/stream`; + return relayHttpUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:"); +} + +async function getC2SRelayHeaders(relayUrl) { + if (!mainWindow?.webContents?.session) return {}; + + const cookieUrl = relayUrl.replace(/^ws:/, "http:").replace(/^wss:/, "https:"); + const cookies = await mainWindow.webContents.session.cookies.get({ + url: cookieUrl, + name: "jwt", + }); + const jwt = cookies[0]?.value; + if (!jwt) return {}; + + return { + Cookie: `jwt=${encodeURIComponent(jwt)}`, + }; +} + +function getC2STunnelName(tunnel, index = 0) { + if (tunnel.name) return tunnel.name; + return [ + "c2s", + index, + tunnel.sourceHostId || 0, + tunnel.mode || tunnel.tunnelType || "local", + tunnel.bindHost || "127.0.0.1", + tunnel.sourcePort, + tunnel.endpointPort || 0, + ].join("::"); +} + +function getC2STunnelStatus(tunnelName) { + return ( + c2sTunnelRuntimes.get(tunnelName)?.status || { + connected: false, + status: "DISCONNECTED", + } + ); +} + +function setC2STunnelStatus(tunnelName, status) { + const runtime = c2sTunnelRuntimes.get(tunnelName); + if (runtime) { + runtime.status = status; + } +} + +function parseSocks5Target(buffer) { + if (buffer.length < 7 || buffer[0] !== 0x05 || buffer[1] !== 0x01) { + return null; + } + + const addressType = buffer[3]; + let offset = 4; + let host; + + if (addressType === 0x01) { + if (buffer.length < offset + 4 + 2) return null; + host = Array.from(buffer.subarray(offset, offset + 4)).join("."); + offset += 4; + } else if (addressType === 0x03) { + const length = buffer[offset]; + offset += 1; + if (buffer.length < offset + length + 2) return null; + host = buffer.subarray(offset, offset + length).toString("utf8"); + offset += length; + } else if (addressType === 0x04) { + if (buffer.length < offset + 16 + 2) return null; + const parts = []; + for (let i = 0; i < 16; i += 2) { + parts.push(buffer.readUInt16BE(offset + i).toString(16)); + } + host = parts.join(":"); + offset += 16; + } else { + throw new Error("Unsupported SOCKS5 address type"); + } + + const port = buffer.readUInt16BE(offset); + return { host, port, bytesRead: offset + 2 }; +} + +async function openC2SRelay(tunnel, targetHost, targetPort, socket, initialData) { + const relayUrl = getC2SRelayUrl(); + const headers = await getC2SRelayHeaders(relayUrl); + const ws = new WebSocket(relayUrl, { + headers, + rejectUnauthorized: false, + }); + const pendingChunks = []; + let ready = false; + let closed = false; + + const cleanup = () => { + if (closed) return; + closed = true; + try { + socket.destroy(); + } catch { + // expected during shutdown + } + try { + ws.close(); + } catch { + // expected during shutdown + } + }; + + const sendChunk = (chunk) => { + if (ready && ws.readyState === WebSocket.OPEN) { + ws.send(chunk); + } else { + pendingChunks.push(chunk); + } + }; + + socket.on("data", sendChunk); + socket.on("close", cleanup); + socket.on("error", cleanup); + ws.on("close", cleanup); + ws.on("error", cleanup); + + ws.on("open", () => { + ws.send( + JSON.stringify({ + type: "open", + tunnelConfig: tunnel, + targetHost, + targetPort, + }), + ); + }); + + ws.on("message", (data, isBinary) => { + if (isBinary) { + socket.write(Buffer.isBuffer(data) ? data : Buffer.from(data)); + return; + } + + try { + const message = JSON.parse(data.toString()); + if (message.type === "ready") { + ready = true; + if (initialData?.length) { + ws.send(initialData); + } + while (pendingChunks.length > 0) { + ws.send(pendingChunks.shift()); + } + } else if (message.type === "error") { + logToFile("[c2s] relay error:", message.error); + cleanup(); + } + } catch (error) { + logToFile("[c2s] invalid relay message:", error.message); + cleanup(); + } + }); +} + +function handleC2SDynamicConnection(tunnel, socket) { + let buffer = Buffer.alloc(0); + let stage = "greeting"; + + const fail = (code = 0x01) => { + if (!socket.destroyed) { + socket.write(Buffer.from([0x05, code, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); + socket.destroy(); + } + }; + + const onData = (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + + try { + if (stage === "greeting") { + if (buffer.length < 2) return; + if (buffer[0] !== 0x05) { + fail(); + return; + } + const methodsLength = buffer[1]; + if (buffer.length < 2 + methodsLength) return; + socket.write(Buffer.from([0x05, 0x00])); + buffer = buffer.subarray(2 + methodsLength); + stage = "connect"; + } + + if (stage === "connect") { + const target = parseSocks5Target(buffer); + if (!target) return; + + stage = "piping"; + socket.off("data", onData); + const remainder = buffer.subarray(target.bytesRead); + socket.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); + openC2SRelay(tunnel, target.host, target.port, socket, remainder).catch( + (error) => { + logToFile("[c2s] dynamic relay failed:", error.message); + fail(0x05); + }, + ); + } + } catch (error) { + logToFile("[c2s] SOCKS5 parse failed:", error.message); + fail(); + } + }; + + socket.on("data", onData); + socket.on("error", () => socket.destroy()); +} + +function handleC2SLocalConnection(tunnel, socket) { + const targetHost = tunnel.targetHost || "127.0.0.1"; + const targetPort = Number(tunnel.endpointPort); + openC2SRelay(tunnel, targetHost, targetPort, socket).catch((error) => { + logToFile("[c2s] local relay failed:", error.message); + socket.destroy(); + }); +} + +async function startC2STunnel(tunnel, index = 0) { + const mode = tunnel.mode || tunnel.tunnelType || "local"; + const tunnelName = getC2STunnelName(tunnel, index); + const bindHost = tunnel.bindHost || "127.0.0.1"; + const sourcePort = Number(tunnel.sourcePort); + + if (mode === "remote") { + return { + success: false, + error: "Client remote forwarding is not available yet", + }; + } + if (!tunnel.sourceHostId) { + return { success: false, error: "Endpoint SSH host is required" }; + } + if (!Number.isInteger(sourcePort) || sourcePort < 1 || sourcePort > 65535) { + return { success: false, error: "Invalid local port" }; + } + + const existing = c2sTunnelRuntimes.get(tunnelName); + if (existing) { + return { success: true, tunnelName }; + } + + for (const runtime of c2sTunnelRuntimes.values()) { + if (runtime.bindHost === bindHost && runtime.sourcePort === sourcePort) { + return { + success: false, + error: `Another client tunnel already uses ${bindHost}:${sourcePort}`, + }; + } + } + + const availability = await checkLocalPortAvailable(bindHost, sourcePort); + if (!availability.available) { + return { + success: false, + error: availability.error || "Port is already in use", + }; + } + + const sockets = new Set(); + const server = net.createServer((socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + if (mode === "dynamic") { + handleC2SDynamicConnection({ ...tunnel, name: tunnelName, mode }, socket); + } else { + handleC2SLocalConnection({ ...tunnel, name: tunnelName, mode }, socket); + } + }); + + c2sTunnelRuntimes.set(tunnelName, { + server, + sockets, + bindHost, + sourcePort, + status: { connected: false, status: "CONNECTING" }, + }); + + return new Promise((resolve) => { + server.once("error", (error) => { + c2sTunnelRuntimes.delete(tunnelName); + resolve({ success: false, error: error.message }); + }); + server.listen({ host: bindHost, port: sourcePort }, () => { + setC2STunnelStatus(tunnelName, { + connected: true, + status: "CONNECTED", + }); + resolve({ success: true, tunnelName }); + }); + }); +} + +async function stopC2STunnel(tunnelName) { + const runtime = c2sTunnelRuntimes.get(tunnelName); + if (!runtime) { + return { success: true }; + } + + setC2STunnelStatus(tunnelName, { + connected: false, + status: "DISCONNECTING", + }); + + return new Promise((resolve) => { + for (const socket of runtime.sockets || []) { + socket.destroy(); + } + runtime.server.close(() => { + c2sTunnelRuntimes.delete(tunnelName); + resolve({ success: true }); + }); + }); +} + +function stopAllC2STunnels() { + for (const [tunnelName, runtime] of c2sTunnelRuntimes.entries()) { + try { + for (const socket of runtime.sockets || []) { + socket.destroy(); + } + runtime.server.close(); + } catch (error) { + logToFile(`[c2s] failed to stop tunnel ${tunnelName}:`, error.message); + } + c2sTunnelRuntimes.delete(tunnelName); + } +} + +async function startC2SAutoStartTunnels() { + const configPath = getC2STunnelConfigPath(); + if (!fs.existsSync(configPath)) { + return { success: true, started: 0, errors: [] }; + } + + const config = JSON.parse(fs.readFileSync(configPath, "utf8")); + const tunnels = Array.isArray(config) ? config : []; + const errors = []; + let started = 0; + + for (let index = 0; index < tunnels.length; index += 1) { + const tunnel = tunnels[index]; + if (!tunnel?.autoStart) continue; + const result = await startC2STunnel(tunnel, index); + if (result.success) { + started += 1; + } else { + errors.push(result.error || "Failed to start client tunnel"); + } + } + + return { success: errors.length === 0, started, errors }; +} + ipcMain.handle("check-local-port-available", async (_event, host, port) => { const sourcePort = Number(port); if ( @@ -739,6 +1135,38 @@ ipcMain.handle("check-local-port-available", async (_event, host, port) => { return checkLocalPortAvailable(host, sourcePort); }); +ipcMain.handle("start-c2s-tunnel", async (_event, tunnel, index) => { + try { + return await startC2STunnel(tunnel, Number(index) || 0); + } catch (error) { + return { success: false, error: error.message }; + } +}); + +ipcMain.handle("stop-c2s-tunnel", async (_event, tunnelName) => { + try { + return await stopC2STunnel(tunnelName); + } catch (error) { + return { success: false, error: error.message }; + } +}); + +ipcMain.handle("get-c2s-tunnel-statuses", () => { + const statuses = {}; + for (const [tunnelName] of c2sTunnelRuntimes.entries()) { + statuses[tunnelName] = getC2STunnelStatus(tunnelName); + } + return statuses; +}); + +ipcMain.handle("start-c2s-autostart-tunnels", async () => { + try { + return await startC2SAutoStartTunnels(); + } catch (error) { + return { success: false, started: 0, errors: [error.message] }; + } +}); + ipcMain.handle("get-c2s-tunnel-preset-default-name", () => { const now = new Date(); const date = now.toISOString().slice(0, 10); @@ -1040,6 +1468,7 @@ app.on("before-quit", () => { app.on("will-quit", () => { console.log("App will quit..."); + stopAllC2STunnels(); stopBackendServer(); }); diff --git a/electron/preload.js b/electron/preload.js index 637505dc..cb782557 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -17,6 +17,14 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.invoke("check-local-port-available", host, port), getC2STunnelPresetDefaultName: () => ipcRenderer.invoke("get-c2s-tunnel-preset-default-name"), + startC2STunnel: (tunnel, index) => + ipcRenderer.invoke("start-c2s-tunnel", tunnel, index), + stopC2STunnel: (tunnelName) => + ipcRenderer.invoke("stop-c2s-tunnel", tunnelName), + getC2STunnelStatuses: () => + ipcRenderer.invoke("get-c2s-tunnel-statuses"), + startC2SAutoStartTunnels: () => + ipcRenderer.invoke("start-c2s-autostart-tunnels"), clearSessionCookies: () => ipcRenderer.invoke("clear-session-cookies"), diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts index a7e10ab9..bd08da2a 100644 --- a/src/backend/ssh/tunnel.ts +++ b/src/backend/ssh/tunnel.ts @@ -1,7 +1,9 @@ import express, { type Response } from "express"; +import { createServer, type IncomingMessage } from "http"; import { createCorsMiddleware } from "../utils/cors-config.js"; import cookieParser from "cookie-parser"; import { Client, type ClientChannel } from "ssh2"; +import { WebSocketServer, type WebSocket } from "ws"; import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; import { ChildProcess } from "child_process"; import type { Duplex } from "stream"; @@ -55,6 +57,7 @@ const tunnelConnecting = new Set(); const tunnelConfigs = new Map(); const activeTunnelProcesses = new Map(); const pendingTunnelOperations = new Map>(); +const tunnelStatusClients = new Set(); type ActiveTunnelRuntime = { sourceClient: Client; @@ -67,6 +70,34 @@ type ActiveTunnelRuntime = { const activeTunnelRuntimes = new Map(); +type C2SOpenMessage = { + type: "open"; + tunnelConfig?: Partial; + targetHost?: string; + targetPort?: number; +}; + +function extractRequestToken(req: IncomingMessage): string | undefined { + const cookieHeader = req.headers.cookie; + if (cookieHeader) { + const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/); + if (match) return decodeURIComponent(match[1]); + } + + const authHeader = req.headers.authorization; + if (authHeader?.startsWith("Bearer ")) { + return authHeader.slice("Bearer ".length); + } + + return undefined; +} + +function sendC2SError(ws: WebSocket, message: string): void { + if (ws.readyState === 1) { + ws.send(JSON.stringify({ type: "error", error: message })); + } +} + function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void { if ( status.status === CONNECTION_STATES.CONNECTED && @@ -83,6 +114,7 @@ function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void { } connectionStatus.set(tunnelName, status); + broadcastTunnelStatusSnapshot(); } function getAllTunnelStatus(): Record { @@ -93,6 +125,22 @@ function getAllTunnelStatus(): Record { return tunnelStatus; } +function sendTunnelStatusSnapshot(res: Response): void { + try { + res.write( + `event: statuses\ndata: ${JSON.stringify(getAllTunnelStatus())}\n\n`, + ); + } catch { + tunnelStatusClients.delete(res); + } +} + +function broadcastTunnelStatusSnapshot(): void { + for (const client of tunnelStatusClients) { + sendTunnelStatusSnapshot(client); + } +} + function classifyError(errorMessage: string): ErrorType { if (!errorMessage) return "UNKNOWN"; @@ -925,6 +973,199 @@ async function establishManagedS2STunnel( activeTunnels.set(tunnelName, sourceClient); } +async function resolveC2STunnelSource( + tunnelConfig: Partial, + userId: string, +): Promise { + if (!tunnelConfig.sourceHostId) { + throw new Error("Endpoint SSH host is required"); + } + + const accessInfo = await permissionManager.canAccessHost( + userId, + tunnelConfig.sourceHostId, + "read", + ); + if (!accessInfo.hasAccess) { + throw new Error("Access denied to this host"); + } + + const { resolveHostById } = await import("./host-resolver.js"); + const resolvedHost = await resolveHostById(tunnelConfig.sourceHostId, userId); + if (!resolvedHost) { + throw new Error("Endpoint SSH host not found"); + } + + return { + name: tunnelConfig.name || `c2s:${tunnelConfig.sourceHostId}`, + scope: "c2s", + mode: tunnelConfig.mode || "local", + tunnelType: + tunnelConfig.tunnelType || + (tunnelConfig.mode === "remote" ? "remote" : "local"), + bindHost: tunnelConfig.bindHost, + targetHost: tunnelConfig.targetHost || "127.0.0.1", + sourceHostId: resolvedHost.id || tunnelConfig.sourceHostId, + tunnelIndex: tunnelConfig.tunnelIndex || 0, + requestingUserId: userId, + hostName: + resolvedHost.name || `${resolvedHost.username}@${resolvedHost.ip}`, + sourceIP: resolvedHost.ip, + sourceSSHPort: resolvedHost.port, + sourceUsername: resolvedHost.username, + sourcePassword: resolvedHost.password, + sourceAuthMethod: resolvedHost.authType, + sourceSSHKey: resolvedHost.key, + sourceKeyPassword: resolvedHost.keyPassword, + sourceKeyType: resolvedHost.keyType, + sourceCredentialId: resolvedHost.credentialId, + sourceUserId: resolvedHost.userId, + endpointIP: tunnelConfig.endpointIP || resolvedHost.ip, + endpointSSHPort: tunnelConfig.endpointSSHPort || resolvedHost.port, + endpointUsername: resolvedHost.username, + endpointHost: + tunnelConfig.endpointHost || resolvedHost.name || resolvedHost.ip, + endpointAuthMethod: resolvedHost.authType, + endpointSSHKey: resolvedHost.key, + endpointKeyPassword: resolvedHost.keyPassword, + endpointKeyType: resolvedHost.keyType, + endpointCredentialId: resolvedHost.credentialId, + endpointUserId: resolvedHost.userId, + sourcePort: Number(tunnelConfig.sourcePort) || 0, + endpointPort: Number(tunnelConfig.endpointPort) || 0, + maxRetries: Number(tunnelConfig.maxRetries) || 0, + retryInterval: Number(tunnelConfig.retryInterval) || 0, + autoStart: Boolean(tunnelConfig.autoStart), + isPinned: Boolean(resolvedHost.pin), + useSocks5: Boolean(resolvedHost.useSocks5), + socks5Host: resolvedHost.socks5Host, + socks5Port: resolvedHost.socks5Port, + socks5Username: resolvedHost.socks5Username, + socks5Password: resolvedHost.socks5Password, + socks5ProxyChain: resolvedHost.socks5ProxyChain, + }; +} + +async function connectC2SSourceClient( + tunnelConfig: TunnelConfig, +): Promise { + const connOptions: Record = { + host: + tunnelConfig.sourceIP?.replace(/^\[|\]$/g, "") || tunnelConfig.sourceIP, + port: tunnelConfig.sourceSSHPort, + username: tunnelConfig.sourceUsername, + tryKeyboard: true, + keepaliveInterval: 30000, + keepaliveCountMax: 3, + readyTimeout: 60000, + tcpKeepAlive: true, + tcpKeepAliveInitialDelay: 30000, + algorithms: getManagedTunnelAlgorithms(), + }; + + applyAuthOptions(connOptions, { + password: tunnelConfig.sourcePassword, + sshKey: tunnelConfig.sourceSSHKey, + keyPassword: tunnelConfig.sourceKeyPassword, + keyType: tunnelConfig.sourceKeyType, + authMethod: tunnelConfig.sourceAuthMethod, + }); + + if ( + tunnelConfig.useSocks5 && + (tunnelConfig.socks5Host || + (tunnelConfig.socks5ProxyChain && + tunnelConfig.socks5ProxyChain.length > 0)) + ) { + const socks5Socket = await createSocks5Connection( + tunnelConfig.sourceIP, + tunnelConfig.sourceSSHPort, + { + useSocks5: tunnelConfig.useSocks5, + socks5Host: tunnelConfig.socks5Host, + socks5Port: tunnelConfig.socks5Port, + socks5Username: tunnelConfig.socks5Username, + socks5Password: tunnelConfig.socks5Password, + socks5ProxyChain: tunnelConfig.socks5ProxyChain, + }, + ); + if (socks5Socket) { + connOptions.sock = socks5Socket; + } + } + + return connectClient(connOptions, tunnelConfig.name, "source"); +} + +async function handleC2SRelayOpen( + ws: WebSocket, + message: C2SOpenMessage, + userId: string, +): Promise { + const tunnelConfig = await resolveC2STunnelSource( + message.tunnelConfig || {}, + userId, + ); + const mode = getTunnelMode(tunnelConfig); + if (mode === "remote") { + throw new Error("Client remote forwarding is not available yet"); + } + + const targetHost = + mode === "dynamic" + ? message.targetHost + : tunnelConfig.targetHost || "127.0.0.1"; + const targetPort = + mode === "dynamic" + ? Number(message.targetPort) + : Number(tunnelConfig.endpointPort); + + if (!targetHost || !Number.isInteger(targetPort) || targetPort < 1) { + throw new Error("Invalid client tunnel target"); + } + + const sourceClient = await connectC2SSourceClient(tunnelConfig); + const outbound = await forwardOut(sourceClient, targetHost, targetPort); + + const close = () => { + try { + outbound.destroy(); + } catch { + // expected during shutdown + } + try { + sourceClient.end(); + } catch { + // expected during shutdown + } + }; + + outbound.on("data", (chunk) => { + if (ws.readyState === 1) { + ws.send(chunk); + } + }); + outbound.on("close", () => { + if (ws.readyState === 1) ws.close(); + }); + outbound.on("error", () => { + if (ws.readyState === 1) ws.close(); + }); + ws.on("close", close); + ws.on("error", close); + ws.on("message", (data, isBinary) => { + if (!isBinary) return; + const chunk = Buffer.isBuffer(data) + ? data + : Array.isArray(data) + ? Buffer.concat(data) + : Buffer.from(data); + outbound.write(chunk); + }); + + ws.send(JSON.stringify({ type: "ready" })); +} + async function connectSSHTunnel( tunnelConfig: TunnelConfig, retryAttempt = 0, @@ -1820,9 +2061,54 @@ async function killRemoteTunnelByMarker( * 200: * description: A list of all tunnel statuses. */ -app.get("/ssh/tunnel/status", (req, res) => { - res.json(getAllTunnelStatus()); -}); +app.get( + "/ssh/tunnel/status", + authenticateJWT, + (req: AuthenticatedRequest, res: Response) => { + if (!req.userId) { + return res.status(401).json({ error: "Authentication required" }); + } + + res.json(getAllTunnelStatus()); + }, +); + +app.get( + "/ssh/tunnel/status/stream", + authenticateJWT, + (req: AuthenticatedRequest, res: Response) => { + if (!req.userId) { + return res.status(401).json({ error: "Authentication required" }); + } + + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-store, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }); + res.flushHeaders?.(); + + tunnelStatusClients.add(res); + sendTunnelStatusSnapshot(res); + + let heartbeat: NodeJS.Timeout; + const closeStream = () => { + clearInterval(heartbeat); + tunnelStatusClients.delete(res); + }; + + heartbeat = setInterval(() => { + try { + res.write(": keepalive\n\n"); + } catch { + closeStream(); + } + }, 30000); + + req.on("close", closeStream); + }, +); /** * @openapi @@ -1844,16 +2130,27 @@ app.get("/ssh/tunnel/status", (req, res) => { * 404: * description: Tunnel not found. */ -app.get("/ssh/tunnel/status/:tunnelName", (req, res) => { - const { tunnelName } = req.params; - const status = connectionStatus.get(tunnelName); +app.get( + "/ssh/tunnel/status/:tunnelName", + authenticateJWT, + (req: AuthenticatedRequest, res: Response) => { + if (!req.userId) { + return res.status(401).json({ error: "Authentication required" }); + } - if (!status) { - return res.status(404).json({ error: "Tunnel not found" }); - } + const tunnelNameParam = req.params.tunnelName; + const tunnelName = Array.isArray(tunnelNameParam) + ? tunnelNameParam[0] + : tunnelNameParam; + const status = connectionStatus.get(tunnelName); - res.json({ name: tunnelName, status }); -}); + if (!status) { + return res.status(404).json({ error: "Tunnel not found" }); + } + + res.json({ name: tunnelName, status }); + }, +); /** * @openapi @@ -2395,7 +2692,53 @@ async function initializeAutoStartTunnels(): Promise { } const PORT = 30003; -app.listen(PORT, () => { +const server = createServer(app); +const c2sRelayWss = new WebSocketServer({ + server, + path: "/ssh/tunnel/c2s/stream", +}); + +c2sRelayWss.on("connection", (ws, req) => { + let opened = false; + + ws.once("message", async (raw) => { + try { + const token = extractRequestToken(req); + const payload = token ? await authManager.verifyJWTToken(token) : null; + if (!payload?.userId || payload.pendingTOTP) { + sendC2SError(ws, "Authentication required"); + ws.close(); + return; + } + + const message = JSON.parse(raw.toString()) as C2SOpenMessage; + if (message.type !== "open") { + throw new Error("Invalid client tunnel relay request"); + } + + opened = true; + await handleC2SRelayOpen(ws, message, payload.userId); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to open relay"; + tunnelLogger.error("Failed to open C2S relay", error, { + operation: "c2s_relay_open_failed", + }); + sendC2SError(ws, message); + ws.close(); + } + }); + + ws.on("close", () => { + if (!opened) { + tunnelLogger.info("C2S relay closed before opening", { + operation: "c2s_relay_closed_before_open", + }); + } + }); +}); + +server.listen(PORT, () => { setTimeout(() => { initializeAutoStartTunnels(); }, 2000); diff --git a/src/locales/en.json b/src/locales/en.json index 15d5f85b..5d32e331 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2006,6 +2006,8 @@ "currentHostPort": "Current Host Port", "endpointPort": "Endpoint Port", "bindIp": "Local IP", + "currentHostIp": "Current Host IP", + "currentHostIpDescription": "Address used on the current host side. Keep 127.0.0.1 unless remote clients should reach it.", "endpointSshConfig": "Endpoint SSH Configuration", "endpointSshConfigRequired": "Endpoint SSH configuration is required", "endpointSshHost": "Endpoint SSH Host", @@ -2034,15 +2036,19 @@ "tunnelName": "Tunnel Name", "remoteHost": "Remote Host", "autoStart": "Auto Start", - "autoStartContainer": "Auto Start on Container Launch", - "autoStartContainerDesc": "Automatically start this tunnel when the container launches", + "autoStartContainer": "Auto Start on Launch", + "autoStartContainerDesc": "Automatically start this tunnel when your Termix server launches.", "autoStartEnableFailed": "Host saved, but failed to start auto-start tunnels for {{name}}.", "clientAutoStartDesc": "Termix opens this tunnel automatically when the desktop client is running and connected.", "clientManualStartDesc": "Auto Start is off, so Termix will not open this tunnel automatically. Use Start when the desktop local tunnel bridge is available.", - "clientManualStartUnavailable": "Client tunnel Start/Stop requires the desktop local tunnel bridge, which is not available in this build yet.", + "clientRemoteUnavailable": "Client Remote (-R) forwarding is not available yet.", + "clientRemoteAutoStartUnavailable": "Client Remote (-R) tunnels cannot use Auto Start yet.", + "clientTunnelStarted": "Client tunnel started", + "clientTunnelStopped": "Client tunnel stopped", "localSaved": "Client tunnels saved", "localSaveError": "Failed to save local client tunnels", "invalidBindIp": "Local IP must be a valid IPv4 address.", + "invalidCurrentHostIp": "Current Host IP must be a valid IPv4 address.", "invalidLocalPort": "Local port must be between 1 and 65535.", "invalidEndpointPort": "Endpoint port must be between 1 and 65535.", "duplicateAutoStartBind": "Only one auto-start client tunnel can use {{bind}}.", @@ -2066,7 +2072,7 @@ "typeClientDynamicDesc": "Open a local SOCKS5 proxy through the endpoint host.", "typeDynamicDesc": "Forward SOCKS5 CONNECT traffic through SSH", "forwardDescriptionServerLocal": "Current host {{sourcePort}} → endpoint {{endpointPort}}.", - "forwardDescriptionServerRemote": "Endpoint {{sourcePort}} → current host {{endpointPort}}.", + "forwardDescriptionServerRemote": "Endpoint {{endpointPort}} → current host {{sourcePort}}.", "forwardDescriptionServerDynamic": "SOCKS on current host {{sourcePort}}.", "forwardDescriptionClientLocal": "Local {{sourcePort}} → remote {{endpointPort}}.", "forwardDescriptionClientRemote": "Remote {{sourcePort}} → local {{endpointPort}}.", diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index fe11cc0a..15244801 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -41,6 +41,19 @@ export interface ElectronAPI { port: number, ) => Promise<{ available: boolean; error?: string }>; getC2STunnelPresetDefaultName: () => Promise; + startC2STunnel: ( + tunnel: unknown, + index: number, + ) => Promise<{ success: boolean; tunnelName?: string; error?: string }>; + stopC2STunnel: ( + tunnelName: string, + ) => Promise<{ success: boolean; error?: string }>; + getC2STunnelStatuses: () => Promise>; + startC2SAutoStartTunnels: () => Promise<{ + success: boolean; + started: number; + errors: string[]; + }>; showSaveDialog: (options: DialogOptions) => Promise; showOpenDialog: (options: DialogOptions) => Promise; diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index 5950e717..c553bcf1 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -237,6 +237,22 @@ function AppContent({ }, [addTab]); const isCheckingAuth = useRef(false); + const clientTunnelAutoStartStarted = useRef(false); + + const startClientTunnelAutoStart = useCallback(() => { + if ( + clientTunnelAutoStartStarted.current || + !window.electronAPI?.isElectron + ) { + return; + } + + clientTunnelAutoStartStarted.current = true; + window.electronAPI.startC2SAutoStartTunnels?.().catch((error) => { + clientTunnelAutoStartStarted.current = false; + console.error("Failed to start client tunnel auto-start entries:", error); + }); + }, []); useEffect(() => { const checkAuth = () => { @@ -253,6 +269,7 @@ function AppContent({ setIsAuthenticated(true); setIsAdmin(!!meRes.is_admin); setUsername(meRes.username || null); + startClientTunnelAutoStart(); } }) .catch((err) => { @@ -277,7 +294,7 @@ function AppContent({ window.addEventListener("storage", handleStorageChange); return () => window.removeEventListener("storage", handleStorageChange); - }, []); + }, [startClientTunnelAutoStart]); useEffect(() => { localStorage.setItem("topNavbarOpen", JSON.stringify(isTopbarOpen)); @@ -300,6 +317,7 @@ function AppContent({ setIsAuthenticated(true); setIsAdmin(authData.isAdmin); setUsername(authData.username); + startClientTunnelAutoStart(); setTransitionPhase("fadeIn"); setTimeout(() => { @@ -308,7 +326,7 @@ function AppContent({ }, 800); }, 1200); }, - [], + [startClientTunnelAutoStart], ); const handleLogout = useCallback(async () => { diff --git a/src/ui/desktop/apps/features/tunnel/Tunnel.tsx b/src/ui/desktop/apps/features/tunnel/Tunnel.tsx index 9d90e93e..c15d6bf7 100644 --- a/src/ui/desktop/apps/features/tunnel/Tunnel.tsx +++ b/src/ui/desktop/apps/features/tunnel/Tunnel.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { TunnelViewer } from "@/ui/desktop/apps/features/tunnel/TunnelViewer.tsx"; import { getSSHHosts, - getTunnelStatuses, + subscribeTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel, @@ -110,11 +110,6 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement { } }; - const fetchTunnelStatuses = useCallback(async () => { - const statusData = await getTunnelStatuses(); - setTunnelStatuses(statusData); - }, []); - useEffect(() => { fetchHosts(); const interval = setInterval(fetchHosts, 5000); @@ -137,10 +132,10 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement { }, [fetchHosts]); useEffect(() => { - fetchTunnelStatuses(); - const interval = setInterval(fetchTunnelStatuses, 1000); - return () => clearInterval(interval); - }, [fetchTunnelStatuses]); + return subscribeTunnelStatuses(setTunnelStatuses, () => { + // The view remains usable if the stream reconnects or is unavailable. + }); + }, []); useEffect(() => { if (visibleHosts.length > 0 && visibleHosts[0]) { @@ -231,7 +226,6 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement { await cancelTunnel(tunnelName); } - await fetchTunnelStatuses(); } catch (error) { console.error("Tunnel action failed:", { action, diff --git a/src/ui/desktop/apps/features/tunnel/TunnelInlineControls.tsx b/src/ui/desktop/apps/features/tunnel/TunnelInlineControls.tsx index 0db9803c..706e8a16 100644 --- a/src/ui/desktop/apps/features/tunnel/TunnelInlineControls.tsx +++ b/src/ui/desktop/apps/features/tunnel/TunnelInlineControls.tsx @@ -46,6 +46,7 @@ export function TunnelInlineControls({ }: TunnelInlineControlsProps) { const { t } = useTranslation(); const kind = getStatusKind(status); + const isDisconnected = kind === "disconnected"; const statusText = kind === "connected" ? t("tunnels.connected") @@ -94,20 +95,9 @@ export function TunnelInlineControls({ className="h-8 px-3 text-xs text-muted-foreground border-border" > - {kind === "connected" ? t("tunnels.stop") : t("tunnels.start")} + {isDisconnected ? t("tunnels.start") : t("tunnels.stop")} - ) : kind === "connected" ? ( - - ) : kind === "disconnected" ? ( + ) : isDisconnected ? ( - ) : null} + ) : ( + + )} ); } diff --git a/src/ui/desktop/apps/features/tunnel/TunnelModeSelector.tsx b/src/ui/desktop/apps/features/tunnel/TunnelModeSelector.tsx new file mode 100644 index 00000000..766cd935 --- /dev/null +++ b/src/ui/desktop/apps/features/tunnel/TunnelModeSelector.tsx @@ -0,0 +1,72 @@ +import { useTranslation } from "react-i18next"; +import type { TunnelMode } from "@/types/index.js"; + +type TunnelModeSelectorProps = { + mode: TunnelMode; + scope: "client" | "server"; + onChange: (mode: TunnelMode) => void; +}; + +export function TunnelModeSelector({ + mode, + scope, + onChange, +}: TunnelModeSelectorProps) { + const { t } = useTranslation(); + + const options: Array<{ + value: TunnelMode; + label: string; + description: string; + }> = [ + { + value: "local", + label: t("tunnels.typeLocal"), + description: + scope === "client" + ? t("tunnels.typeClientLocalDesc") + : t("tunnels.typeServerLocalDesc"), + }, + { + value: "remote", + label: t("tunnels.typeRemote"), + description: + scope === "client" + ? t("tunnels.typeClientRemoteDesc") + : t("tunnels.typeServerRemoteDesc"), + }, + { + value: "dynamic", + label: t("tunnels.typeDynamic"), + description: + scope === "client" + ? t("tunnels.typeClientDynamicDesc") + : t("tunnels.typeDynamicDesc"), + }, + ]; + + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +} diff --git a/src/ui/desktop/apps/features/tunnel/tunnel-form-utils.ts b/src/ui/desktop/apps/features/tunnel/tunnel-form-utils.ts new file mode 100644 index 00000000..1a7cbda1 --- /dev/null +++ b/src/ui/desktop/apps/features/tunnel/tunnel-form-utils.ts @@ -0,0 +1,62 @@ +import type { TunnelMode } from "@/types/index.js"; + +type Translate = ( + key: string, + options?: Record, +) => string; + +export function getTunnelTypeForMode(mode: TunnelMode): "local" | "remote" { + return mode === "remote" ? "remote" : "local"; +} + +export function getTunnelPortLabels( + scope: "client" | "server", + mode: TunnelMode, + t: Translate, +) { + if (scope === "client") { + return { + sourcePortLabel: + mode === "remote" ? t("tunnels.remotePort") : t("tunnels.localPort"), + endpointPortLabel: + mode === "remote" ? t("tunnels.localPort") : t("tunnels.remotePort"), + }; + } + + return { + sourcePortLabel: t("tunnels.currentHostPort"), + endpointPortLabel: t("tunnels.endpointPort"), + }; +} + +export function getTunnelModeDescription( + scope: "client" | "server", + mode: TunnelMode, + ports: { + sourcePort: string | number; + endpointPort: string | number; + }, + t: Translate, +) { + if (scope === "client") { + if (mode === "dynamic") { + return t("tunnels.forwardDescriptionClientDynamic", { + sourcePort: ports.sourcePort, + }); + } + if (mode === "local") { + return t("tunnels.forwardDescriptionClientLocal", ports); + } + return t("tunnels.forwardDescriptionClientRemote", ports); + } + + if (mode === "dynamic") { + return t("tunnels.forwardDescriptionServerDynamic", { + sourcePort: ports.sourcePort, + }); + } + if (mode === "local") { + return t("tunnels.forwardDescriptionServerLocal", ports); + } + return t("tunnels.forwardDescriptionServerRemote", ports); +} diff --git a/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx b/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx index c14f6c11..4c30ddfb 100644 --- a/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx +++ b/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx @@ -148,6 +148,18 @@ interface SSHManagerHostEditorProps { onBack?: () => void; } +function isValidIPv4(value: string) { + const parts = value.split("."); + return ( + parts.length === 4 && + parts.every((part) => { + if (!/^\d+$/.test(part)) return false; + const parsed = Number(part); + return parsed >= 0 && parsed <= 255; + }) + ); +} + export function HostManagerEditor({ editingHost, onFormSubmit, @@ -162,7 +174,6 @@ export function HostManagerEditor({ window.matchMedia("(prefers-color-scheme: dark)").matches); const editorTheme = isDarkMode ? oneDark : githubLight; const [folders, setFolders] = useState([]); - const [sshConfigurations, setSshConfigurations] = useState([]); const [hosts, setHosts] = useState([]); const [credentials, setCredentials] = useState([]); const [snippets, setSnippets] = useState< @@ -220,16 +231,7 @@ export function HostManagerEditor({ ), ].sort(); - const uniqueConfigurations = [ - ...new Set( - hostsData - .filter((host) => host.name && host.name.trim() !== "") - .map((host) => host.name), - ), - ].sort(); - setFolders(uniqueFolders); - setSshConfigurations(uniqueConfigurations); } catch (error) { console.error("Host manager operation failed:", error); } @@ -501,6 +503,20 @@ export function HostManagerEditor({ path: ["serverTunnels", index, "endpointHost"], }); } + + const currentHostIp = + tunnel.mode === "remote" ? tunnel.targetHost : tunnel.bindHost; + if (currentHostIp && !isValidIPv4(currentHostIp)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("tunnels.invalidCurrentHostIp"), + path: [ + "serverTunnels", + index, + tunnel.mode === "remote" ? "targetHost" : "bindHost", + ], + }); + } }); if (data.authType === "none") { @@ -1245,91 +1261,6 @@ export function HostManagerEditor({ return () => document.removeEventListener("mousedown", onClickOutside); }, [keyTypeDropdownOpen]); - const [sshConfigDropdownOpen, setSshConfigDropdownOpen] = useState<{ - [key: number]: boolean; - }>({}); - const sshConfigInputRefs = useRef<{ [key: number]: HTMLInputElement | null }>( - {}, - ); - const sshConfigDropdownRefs = useRef<{ - [key: number]: HTMLDivElement | null; - }>({}); - - const getFilteredSshConfigs = (index: number) => { - const value = form.watch(`serverTunnels.${index}.endpointHost`); - - const currentHostId = editingHost?.id; - - let filtered = sshConfigurations; - - if (currentHostId) { - const currentHostName = hosts.find((h) => h.id === currentHostId)?.name; - if (currentHostName) { - filtered = sshConfigurations.filter( - (config) => config !== currentHostName, - ); - } - } else { - const currentHostName = - form.watch("name") || `${form.watch("username")}@${form.watch("ip")}`; - filtered = sshConfigurations.filter( - (config) => config !== currentHostName, - ); - } - - if (value) { - filtered = filtered.filter((config) => - config.toLowerCase().includes(value.toLowerCase()), - ); - } - - return filtered; - }; - - const handleSshConfigClick = (config: string, index: number) => { - form.setValue(`serverTunnels.${index}.endpointHost`, config, { - shouldValidate: true, - shouldDirty: true, - }); - setSshConfigDropdownOpen((prev) => ({ ...prev, [index]: false })); - }; - - useEffect(() => { - function handleSshConfigClickOutside(event: MouseEvent) { - const openDropdowns = Object.keys(sshConfigDropdownOpen).filter( - (key) => sshConfigDropdownOpen[parseInt(key)], - ); - - openDropdowns.forEach((indexStr: string) => { - const index = parseInt(indexStr); - if ( - sshConfigDropdownRefs.current[index] && - !sshConfigDropdownRefs.current[index]?.contains( - event.target as Node, - ) && - sshConfigInputRefs.current[index] && - !sshConfigInputRefs.current[index]?.contains(event.target as Node) - ) { - setSshConfigDropdownOpen((prev) => ({ ...prev, [index]: false })); - } - }); - } - - const hasOpenDropdowns = Object.values(sshConfigDropdownOpen).some( - (open) => open, - ); - - if (hasOpenDropdowns) { - document.addEventListener("mousedown", handleSshConfigClickOutside); - } else { - document.removeEventListener("mousedown", handleSshConfigClickOutside); - } - - return () => { - document.removeEventListener("mousedown", handleSshConfigClickOutside); - }; - }, [sshConfigDropdownOpen]); - return (
diff --git a/src/ui/desktop/apps/host-manager/hosts/tabs/HostTunnelTab.tsx b/src/ui/desktop/apps/host-manager/hosts/tabs/HostTunnelTab.tsx index 805c9d4e..ff3205b0 100644 --- a/src/ui/desktop/apps/host-manager/hosts/tabs/HostTunnelTab.tsx +++ b/src/ui/desktop/apps/host-manager/hosts/tabs/HostTunnelTab.tsx @@ -9,12 +9,25 @@ import { Input } from "@/components/ui/input.tsx"; import { Switch } from "@/components/ui/switch.tsx"; import { Alert, AlertDescription } from "@/components/ui/alert.tsx"; import { Button } from "@/components/ui/button.tsx"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select.tsx"; import { TunnelInlineControls } from "@/ui/desktop/apps/features/tunnel/TunnelInlineControls.tsx"; +import { TunnelModeSelector } from "@/ui/desktop/apps/features/tunnel/TunnelModeSelector.tsx"; +import { + getTunnelModeDescription, + getTunnelPortLabels, + getTunnelTypeForMode, +} from "@/ui/desktop/apps/features/tunnel/tunnel-form-utils.ts"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; import { connectTunnel, disconnectTunnel, - getTunnelStatuses, + subscribeTunnelStatuses, } from "@/ui/main-axios.ts"; import type { SSHHost, TunnelConfig, TunnelStatus } from "@/types/index.js"; import { useEffect, useState } from "react"; @@ -25,12 +38,6 @@ export function HostTunnelTab({ form, hosts, editingHost, - sshConfigDropdownOpen, - setSshConfigDropdownOpen, - sshConfigInputRefs, - sshConfigDropdownRefs, - getFilteredSshConfigs, - handleSshConfigClick, t, }: HostTunnelTabProps) { const { tabs, addTab, setCurrentTab, updateTab } = useTabs(); @@ -43,18 +50,10 @@ export function HostTunnelTab({ const supportsC2S = typeof window !== "undefined" && window.electronAPI?.isElectron === true; - const fetchTunnelStatuses = async () => { - try { - setTunnelStatuses(await getTunnelStatuses()); - } catch { - // The form should stay usable even when the tunnel service is unavailable. - } - }; - useEffect(() => { - fetchTunnelStatuses(); - const interval = window.setInterval(fetchTunnelStatuses, 1000); - return () => window.clearInterval(interval); + return subscribeTunnelStatuses(setTunnelStatuses, () => { + // The form should stay usable if the tunnel status stream reconnects. + }); }, []); const openC2SPresets = () => { @@ -170,7 +169,6 @@ export function HostTunnelTab({ await disconnectTunnel(tunnelName); } - await fetchTunnelStatuses(); } catch (error) { toast.error( error instanceof Error @@ -330,6 +328,8 @@ export function HostTunnelTab({ scope: "s2s", mode: "remote", tunnelType: "remote", + bindHost: "127.0.0.1", + targetHost: "127.0.0.1", sourcePort: 22, endpointPort: 224, endpointHost: "", @@ -352,6 +352,14 @@ export function HostTunnelTab({ const mode = form.watch(`${fieldName}.${index}.mode`) || "remote"; const isC2S = scope === "c2s"; const currentHost = getCurrentHost(); + const endpointConfigOptions = hosts + .filter( + (item) => + item.id !== currentHost?.id && + (item.connectionType || "ssh") === "ssh", + ) + .map((item) => item.name || `${item.username}@${item.ip}`) + .sort((a, b) => a.localeCompare(b)); const tunnelName = currentHost ? getTunnelName(currentHost, index) : ""; @@ -361,68 +369,20 @@ export function HostTunnelTab({ const isTunnelActionLoading = tunnelName ? Boolean(tunnelActions[tunnelName]) : false; - const sourcePortLabel = isC2S - ? mode === "remote" - ? t("tunnels.remotePort") - : t("tunnels.localPort") - : mode === "remote" - ? t("tunnels.endpointPort") - : t("tunnels.currentHostPort"); - const endpointPortLabel = isC2S - ? mode === "remote" - ? t("tunnels.localPort") - : t("tunnels.remotePort") - : mode === "remote" - ? t("tunnels.currentHostPort") - : t("tunnels.endpointPort"); - const modeDescription = - mode === "dynamic" - ? isC2S - ? t("tunnels.forwardDescriptionClientDynamic", { - sourcePort: - form.watch(`${fieldName}.${index}.sourcePort`) || - "1080", - }) - : t("tunnels.forwardDescriptionServerDynamic", { - sourcePort: - form.watch(`${fieldName}.${index}.sourcePort`) || - "1080", - }) - : mode === "local" - ? isC2S - ? t("tunnels.forwardDescriptionClientLocal", { - sourcePort: - form.watch(`${fieldName}.${index}.sourcePort`) || - "22", - endpointPort: - form.watch(`${fieldName}.${index}.endpointPort`) || - "22", - }) - : t("tunnels.forwardDescriptionServerLocal", { - sourcePort: - form.watch(`${fieldName}.${index}.sourcePort`) || - "22", - endpointPort: - form.watch(`${fieldName}.${index}.endpointPort`) || - "224", - }) - : isC2S - ? t("tunnels.forwardDescriptionClientRemote", { - sourcePort: - form.watch(`${fieldName}.${index}.sourcePort`) || - "22", - endpointPort: - form.watch(`${fieldName}.${index}.endpointPort`) || - "22", - }) - : t("tunnels.forwardDescriptionServerRemote", { - sourcePort: - form.watch(`${fieldName}.${index}.sourcePort`) || - "22", - endpointPort: - form.watch(`${fieldName}.${index}.endpointPort`) || - "224", - }); + const formScope = isC2S ? "client" : "server"; + const { sourcePortLabel, endpointPortLabel } = + getTunnelPortLabels(formScope, mode, t); + const modeDescription = getTunnelModeDescription( + formScope, + mode, + { + sourcePort: + form.watch(`${fieldName}.${index}.sourcePort`) || "22", + endpointPort: + form.watch(`${fieldName}.${index}.endpointPort`) || "22", + }, + t, + ); return (
@@ -466,66 +426,21 @@ export function HostTunnelTab({ {t("tunnels.type")} -
- - - -
+ { + field.onChange(nextMode); + form.setValue( + `${fieldName}.${index}.tunnelType`, + getTunnelTypeForMode(nextMode), + { + shouldDirty: true, + shouldValidate: true, + }, + ); + }} + />
)} @@ -537,68 +452,29 @@ export function HostTunnelTab({ control={form.control} name={`${fieldName}.${index}.endpointHost`} render={({ field: endpointHostField }) => ( - + {t("tunnels.endpointSshConfig")} - { - sshConfigInputRefs.current[index] = el; - }} - placeholder={t("placeholders.sshConfig")} - className="min-h-[40px]" - autoComplete="off" + - {sshConfigDropdownOpen[index] && - getFilteredSshConfigs(index).length > 0 && ( -
{ - sshConfigDropdownRefs.current[index] = el; - }} - className="absolute top-full left-0 z-50 mt-1 w-full bg-canvas border border-input rounded-md shadow-lg max-h-40 overflow-y-auto thin-scrollbar p-1" - > -
- {getFilteredSshConfigs(index).map( - (config) => ( - - ), - )} -
-
- )}
)} /> @@ -622,6 +498,30 @@ export function HostTunnelTab({ )} /> )} + {!isC2S && ( + ( + + {t("tunnels.currentHostIp")} + + + + + {t("tunnels.currentHostIpDescription")} + + + )} + /> + )} ; hosts: SSHHost[]; editingHost?: SSHHost | null; - sshConfigDropdownOpen: { [key: number]: boolean }; - setSshConfigDropdownOpen: React.Dispatch< - React.SetStateAction<{ [key: number]: boolean }> - >; - sshConfigInputRefs: React.MutableRefObject<{ - [key: number]: HTMLInputElement | null; - }>; - sshConfigDropdownRefs: React.MutableRefObject<{ - [key: number]: HTMLDivElement | null; - }>; - getFilteredSshConfigs: (index: number) => string[]; - handleSshConfigClick: (config: string, index: number) => void; t: (key: string) => string; } diff --git a/src/ui/desktop/user/C2STunnelPresetManager.tsx b/src/ui/desktop/user/C2STunnelPresetManager.tsx index 5d874799..ffb1cb07 100644 --- a/src/ui/desktop/user/C2STunnelPresetManager.tsx +++ b/src/ui/desktop/user/C2STunnelPresetManager.tsx @@ -11,6 +11,11 @@ import { } from "@/components/ui/select.tsx"; import { Switch } from "@/components/ui/switch.tsx"; import { TunnelInlineControls } from "@/ui/desktop/apps/features/tunnel/TunnelInlineControls.tsx"; +import { TunnelModeSelector } from "@/ui/desktop/apps/features/tunnel/TunnelModeSelector.tsx"; +import { + getTunnelModeDescription, + getTunnelTypeForMode, +} from "@/ui/desktop/apps/features/tunnel/tunnel-form-utils.ts"; import { createC2STunnelPreset, deleteC2STunnelPreset, @@ -22,6 +27,7 @@ import type { C2STunnelPreset, SSHHost, TunnelConnection, + TunnelStatus, } from "@/types/index.js"; import { Download, Pencil, Plus, Save, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -101,6 +107,12 @@ export function C2STunnelPresetManager(): React.ReactElement { >([]); const [hosts, setHosts] = React.useState([]); const [presets, setPresets] = React.useState([]); + const [tunnelStatuses, setTunnelStatuses] = React.useState< + Record + >({}); + const [tunnelActions, setTunnelActions] = React.useState< + Record + >({}); const [selectedPresetId, setSelectedPresetId] = React.useState(""); const [presetName, setPresetName] = React.useState(""); const isElectron = @@ -130,6 +142,20 @@ export function C2STunnelPresetManager(): React.ReactElement { ); const hasPresets = presets.length > 0; + const getTunnelName = React.useCallback( + (tunnel: ClientTunnel, index: number) => + [ + "c2s", + index, + tunnel.sourceHostId || 0, + tunnel.mode || tunnel.tunnelType || "local", + tunnel.bindHost || "127.0.0.1", + tunnel.sourcePort, + tunnel.endpointPort || 0, + ].join("::"), + [], + ); + const refreshPresets = React.useCallback(async () => { const nextPresets = await getC2STunnelPresets(); setPresets(sortPresets(nextPresets)); @@ -162,6 +188,17 @@ export function C2STunnelPresetManager(): React.ReactElement { }); }, [isElectron, refreshLocalConfig, refreshPresets]); + React.useEffect(() => { + if (!isElectron) return; + + const refreshStatuses = async () => { + const statuses = await window.electronAPI.getC2STunnelStatuses(); + setTunnelStatuses(statuses as Record); + }; + + refreshStatuses().catch(() => {}); + }, [isElectron]); + const validateLocalConfig = (config: ClientTunnel[]) => { const autoStartListeners = new Set(); @@ -178,6 +215,9 @@ export function C2STunnelPresetManager(): React.ReactElement { if (!tunnel.sourceHostId) { return t("tunnels.endpointSshHostRequired"); } + if (tunnel.mode === "remote" && tunnel.autoStart) { + return t("tunnels.clientRemoteAutoStartUnavailable"); + } if (tunnel.autoStart) { const listenerKey = `${tunnel.bindHost}:${tunnel.sourcePort}`; if (autoStartListeners.has(listenerKey)) { @@ -242,6 +282,51 @@ export function C2STunnelPresetManager(): React.ReactElement { } }; + const handleTunnelStart = async (tunnel: ClientTunnel, index: number) => { + const tunnelName = getTunnelName(tunnel, index); + setTunnelActions((current) => ({ ...current, [tunnelName]: true })); + + try { + const result = await window.electronAPI.startC2STunnel( + { ...normalizeClientTunnel(tunnel), name: tunnelName }, + index, + ); + if (!result.success) { + throw new Error(result.error || t("tunnels.manualControlError")); + } + const statuses = await window.electronAPI.getC2STunnelStatuses(); + setTunnelStatuses(statuses as Record); + toast.success(t("tunnels.clientTunnelStarted")); + } catch (error) { + toast.error( + error instanceof Error ? error.message : t("tunnels.manualControlError"), + ); + } finally { + setTunnelActions((current) => ({ ...current, [tunnelName]: false })); + } + }; + + const handleTunnelStop = async (tunnel: ClientTunnel, index: number) => { + const tunnelName = getTunnelName(tunnel, index); + setTunnelActions((current) => ({ ...current, [tunnelName]: true })); + + try { + const result = await window.electronAPI.stopC2STunnel(tunnelName); + if (!result.success) { + throw new Error(result.error || t("tunnels.manualControlError")); + } + const statuses = await window.electronAPI.getC2STunnelStatuses(); + setTunnelStatuses(statuses as Record); + toast.success(t("tunnels.clientTunnelStopped")); + } catch (error) { + toast.error( + error instanceof Error ? error.message : t("tunnels.manualControlError"), + ); + } finally { + setTunnelActions((current) => ({ ...current, [tunnelName]: false })); + } + }; + const handleSavePreset = async () => { if (!presetName.trim()) return; @@ -353,20 +438,24 @@ export function C2STunnelPresetManager(): React.ReactElement {
{localConfig.length > 0 ? ( localConfig.map((tunnel, index) => { - const modeDescription = - tunnel.mode === "dynamic" - ? t("tunnels.forwardDescriptionClientDynamic", { - sourcePort: tunnel.sourcePort, - }) - : tunnel.mode === "local" - ? t("tunnels.forwardDescriptionClientLocal", { - sourcePort: tunnel.sourcePort, - endpointPort: tunnel.endpointPort, - }) - : t("tunnels.forwardDescriptionClientRemote", { - sourcePort: tunnel.sourcePort, - endpointPort: tunnel.endpointPort, - }); + const modeDescription = getTunnelModeDescription( + "client", + tunnel.mode || "local", + { + sourcePort: tunnel.sourcePort, + endpointPort: tunnel.endpointPort, + }, + t, + ); + const tunnelName = getTunnelName(tunnel, index); + const tunnelStatus = tunnelStatuses[tunnelName]; + const isTunnelActionLoading = Boolean(tunnelActions[tunnelName]); + const startDisabled = + tunnel.mode === "remote" || !tunnel.sourceHostId; + const startDisabledReason = + tunnel.mode === "remote" + ? t("tunnels.clientRemoteUnavailable") + : t("tunnels.endpointSshHostRequired"); return (
@@ -376,9 +465,12 @@ export function C2STunnelPresetManager(): React.ReactElement {
- toast.info(t("tunnels.clientManualStartUnavailable")) - } + status={tunnelStatus} + loading={isTunnelActionLoading} + onStart={() => handleTunnelStart(tunnel, index)} + onStop={() => handleTunnelStop(tunnel, index)} + startDisabled={startDisabled} + startDisabledReason={startDisabledReason} />