diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index 8cac5ee8..719828fa 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -479,6 +479,15 @@ http { proxy_read_timeout 60s; } + location ~ ^/(refresh|host-updated)$ { + proxy_pass http://127.0.0.1:30005; + proxy_http_version 1.1; + 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; + } + location ~ ^/global-settings(/.*)?$ { proxy_pass http://127.0.0.1:30005; proxy_http_version 1.1; diff --git a/docker/nginx.conf b/docker/nginx.conf index 3a644a79..2781e3ec 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -468,6 +468,15 @@ http { proxy_read_timeout 60s; } + location ~ ^/(refresh|host-updated)$ { + proxy_pass http://127.0.0.1:30005; + proxy_http_version 1.1; + 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; + } + location ~ ^/global-settings(/.*)?$ { proxy_pass http://127.0.0.1:30005; proxy_http_version 1.1; diff --git a/electron/main.cjs b/electron/main.cjs index e55cf069..a898c7fb 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -52,6 +52,29 @@ function compareSemver(a, b) { return 0; } +const INSECURE_MODE_VALUES = new Set(["true", "1", "yes"]); + +function isInsecureModeEnabled() { + return INSECURE_MODE_VALUES.has( + String(process.env.ENABLE_INSECURE_MODE || "") + .trim() + .toLowerCase(), + ); +} + +function getTlsVerificationOptions() { + return { + rejectUnauthorized: !isInsecureModeEnabled(), + }; +} + +function getWebSocketOptions(url, options = {}) { + return { + ...options, + ...(String(url).startsWith("wss:") ? getTlsVerificationOptions() : {}), + }; +} + function httpFetch(url, options = {}) { return new Promise((resolve, reject) => { const urlObj = new URL(url); @@ -62,16 +85,9 @@ function httpFetch(url, options = {}) { method: options.method || "GET", headers: options.headers || {}, timeout: options.timeout || 10000, + ...(isHttps ? getTlsVerificationOptions() : {}), }; - if (isHttps) { - requestOptions.rejectUnauthorized = false; - requestOptions.agent = new https.Agent({ - rejectUnauthorized: false, - checkServerIdentity: () => undefined, - }); - } - const req = client.request(url, requestOptions, (res) => { let data = ""; res.on("data", (chunk) => (data += chunk)); @@ -104,9 +120,14 @@ if (process.platform === "linux") { app.commandLine.appendSwitch("--enable-features=VaapiVideoDecoder"); } -app.commandLine.appendSwitch("--ignore-certificate-errors"); -app.commandLine.appendSwitch("--ignore-ssl-errors"); -app.commandLine.appendSwitch("--ignore-certificate-errors-spki-list"); +if (isInsecureModeEnabled()) { + logToFile( + "[security] ENABLE_INSECURE_MODE is enabled; TLS certificate validation is disabled.", + ); + app.commandLine.appendSwitch("--ignore-certificate-errors"); + app.commandLine.appendSwitch("--ignore-ssl-errors"); + app.commandLine.appendSwitch("--ignore-certificate-errors-spki-list"); +} app.commandLine.appendSwitch("--enable-features=NetworkService"); let mainWindow = null; @@ -675,14 +696,34 @@ ipcMain.handle("save-c2s-tunnel-config", async (_event, config) => { return { success: false, error: "C2S tunnel config must be an array" }; } const autoStartListeners = new Set(); + const autoStartRemoteListeners = 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 sourceHostId = Number(tunnel.sourceHostId); + const sourcePort = Number(tunnel.sourcePort); + if ( + !Number.isInteger(sourceHostId) || + sourceHostId < 1 || + !Number.isInteger(sourcePort) || + sourcePort < 1 || + sourcePort > 65535 + ) { + return { + success: false, + error: "Invalid remote client tunnel endpoint or port", + }; + } + const listenerKey = `${sourceHostId}:${sourcePort}`; + if (autoStartRemoteListeners.has(listenerKey)) { + return { + success: false, + error: `Another auto-start client tunnel already uses remote ${listenerKey}`, + }; + } + autoStartRemoteListeners.add(listenerKey); + continue; } const bindHost = tunnel.bindHost || "127.0.0.1"; @@ -739,7 +780,31 @@ function checkLocalPortAvailable(host, port) { }); } +function checkTcpConnection(host, port) { + return new Promise((resolve) => { + const socket = net.createConnection({ host, port }); + const timer = setTimeout(() => { + socket.destroy(); + resolve({ success: false, error: "Connection timed out" }); + }, 5000); + + socket.once("connect", () => { + clearTimeout(timer); + socket.destroy(); + resolve({ success: true }); + }); + socket.once("error", (error) => { + clearTimeout(timer); + socket.destroy(); + resolve({ success: false, error: error.message }); + }); + }); +} + const c2sTunnelRuntimes = new Map(); +const C2S_WS_HIGH_WATERMARK = 1024 * 1024; +const C2S_WS_LOW_WATERMARK = 256 * 1024; +const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024; function getServerConfigSync() { try { @@ -806,13 +871,36 @@ function getC2STunnelStatus(tunnelName) { ); } +function getAllC2STunnelStatuses() { + const statuses = {}; + for (const [tunnelName] of c2sTunnelRuntimes.entries()) { + statuses[tunnelName] = getC2STunnelStatus(tunnelName); + } + return statuses; +} + +function emitC2STunnelStatuses() { + if (!mainWindow || mainWindow.isDestroyed()) return; + mainWindow.webContents.send("c2s-tunnel-statuses", getAllC2STunnelStatuses()); +} + function setC2STunnelStatus(tunnelName, status) { const runtime = c2sTunnelRuntimes.get(tunnelName); if (runtime) { runtime.status = status; + emitC2STunnelStatuses(); } } +function setC2STunnelError(tunnelName, message) { + logToFile(`[c2s] ${tunnelName} failed:`, message); + setC2STunnelStatus(tunnelName, { + connected: false, + status: "ERROR", + reason: message, + }); +} + function parseSocks5Target(buffer) { if (buffer.length < 7 || buffer[0] !== 0x05 || buffer[1] !== 0x01) { return null; @@ -855,12 +943,23 @@ async function openC2SRelay( socket, initialData, ) { + const tunnelName = tunnel.name || getC2STunnelName(tunnel); const relayUrl = getC2SRelayUrl(); const headers = await getC2SRelayHeaders(relayUrl); - const ws = new WebSocket(relayUrl, { - headers, - rejectUnauthorized: false, + logToFile(`[c2s] opening relay for ${tunnelName}`, { + relayUrl, + targetHost, + targetPort, }); + setC2STunnelStatus(tunnelName, { + connected: false, + status: "CONNECTING", + reason: `Opening relay to ${targetHost}:${targetPort}`, + }); + const ws = new WebSocket( + relayUrl, + getWebSocketOptions(relayUrl, { headers }), + ); const pendingChunks = []; let ready = false; let closed = false; @@ -890,11 +989,18 @@ async function openC2SRelay( socket.on("data", sendChunk); socket.on("close", cleanup); - socket.on("error", cleanup); + socket.on("error", (error) => { + setC2STunnelError(tunnelName, error.message || "Local socket error"); + cleanup(); + }); ws.on("close", cleanup); - ws.on("error", cleanup); + ws.on("error", (error) => { + setC2STunnelError(tunnelName, error.message || "Relay connection failed"); + cleanup(); + }); ws.on("open", () => { + logToFile(`[c2s] relay connected for ${tunnelName}`); ws.send( JSON.stringify({ type: "open", @@ -915,6 +1021,11 @@ async function openC2SRelay( const message = JSON.parse(data.toString()); if (message.type === "ready") { ready = true; + logToFile(`[c2s] relay ready for ${tunnelName}`); + setC2STunnelStatus(tunnelName, { + connected: true, + status: "CONNECTED", + }); if (initialData?.length) { ws.send(initialData); } @@ -923,20 +1034,150 @@ async function openC2SRelay( } } else if (message.type === "error") { logToFile("[c2s] relay error:", message.error); + setC2STunnelError( + tunnelName, + message.error || "Relay rejected the client tunnel", + ); cleanup(); } } catch (error) { logToFile("[c2s] invalid relay message:", error.message); + setC2STunnelError(tunnelName, error.message || "Invalid relay response"); cleanup(); } }); } +async function testC2SRelay(tunnel, targetHost, targetPort) { + const relayUrl = getC2SRelayUrl(); + const headers = await getC2SRelayHeaders(relayUrl); + const ws = new WebSocket( + relayUrl, + getWebSocketOptions(relayUrl, { headers }), + ); + + return new Promise((resolve) => { + let settled = false; + const settle = (result) => { + if (settled) return; + settled = true; + try { + ws.close(); + } catch { + // expected during shutdown + } + resolve(result); + }; + + const timer = setTimeout(() => { + settle({ success: false, error: "Tunnel test timed out" }); + }, 15000); + + ws.on("open", () => { + ws.send( + JSON.stringify({ + type: "test", + tunnelConfig: tunnel, + targetHost, + targetPort, + }), + ); + }); + ws.on("message", (data, isBinary) => { + if (isBinary) return; + + try { + const message = JSON.parse(data.toString()); + if (message.type === "ready") { + clearTimeout(timer); + settle({ success: true }); + } else if (message.type === "error") { + clearTimeout(timer); + settle({ + success: false, + error: message.error || "Tunnel test failed", + }); + } + } catch (error) { + clearTimeout(timer); + settle({ success: false, error: error.message }); + } + }); + ws.on("error", (error) => { + clearTimeout(timer); + settle({ success: false, error: error.message }); + }); + ws.on("close", () => { + clearTimeout(timer); + settle({ success: false, error: "Tunnel test connection closed" }); + }); + }); +} + +async function testC2STunnel(tunnel, index = 0) { + const mode = tunnel.mode || tunnel.tunnelType || "local"; + const testTunnel = { + ...tunnel, + name: `${getC2STunnelName(tunnel, index)}::test`, + mode, + }; + const bindHost = tunnel.bindHost || "127.0.0.1"; + const sourcePort = Number(tunnel.sourcePort); + const endpointPort = Number(tunnel.endpointPort); + + if (!tunnel.sourceHostId) { + return { success: false, error: "Endpoint SSH host is required" }; + } + + if (mode === "remote") { + const localTarget = await checkTcpConnection(bindHost, endpointPort); + if (!localTarget.success) { + return { + success: false, + error: `Local target ${bindHost}:${endpointPort} is not reachable: ${localTarget.error}`, + }; + } + + return testC2SRelay(testTunnel, undefined, undefined); + } + + if (!Number.isInteger(sourcePort) || sourcePort < 1 || sourcePort > 65535) { + return { success: false, error: "Invalid local port" }; + } + + const runtime = c2sTunnelRuntimes.get(getC2STunnelName(tunnel, index)); + if (!runtime) { + const availability = await checkLocalPortAvailable(bindHost, sourcePort); + if (!availability.available) { + return { + success: false, + error: `Local listener ${bindHost}:${sourcePort} is not available: ${availability.error}`, + }; + } + } + + if (mode === "dynamic") { + return testC2SRelay(testTunnel, undefined, undefined); + } + + if (!Number.isInteger(endpointPort) || endpointPort < 1) { + return { success: false, error: "Invalid remote port" }; + } + + return testC2SRelay( + testTunnel, + tunnel.targetHost || "127.0.0.1", + endpointPort, + ); +} + function handleC2SDynamicConnection(tunnel, socket) { + const tunnelName = tunnel.name || getC2STunnelName(tunnel); let buffer = Buffer.alloc(0); let stage = "greeting"; - const fail = (code = 0x01) => { + const fail = (code = 0x01, message = "SOCKS5 request failed") => { + setC2STunnelError(tunnelName, message); if (!socket.destroyed) { socket.write(Buffer.from([0x05, code, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); socket.destroy(); @@ -950,7 +1191,7 @@ function handleC2SDynamicConnection(tunnel, socket) { if (stage === "greeting") { if (buffer.length < 2) return; if (buffer[0] !== 0x05) { - fail(); + fail(0x01, "Invalid SOCKS5 greeting"); return; } const methodsLength = buffer[1]; @@ -971,13 +1212,13 @@ function handleC2SDynamicConnection(tunnel, socket) { openC2SRelay(tunnel, target.host, target.port, socket, remainder).catch( (error) => { logToFile("[c2s] dynamic relay failed:", error.message); - fail(0x05); + fail(0x05, error.message || "Dynamic relay failed"); }, ); } } catch (error) { logToFile("[c2s] SOCKS5 parse failed:", error.message); - fail(); + fail(0x01, error.message || "SOCKS5 parse failed"); } }; @@ -986,25 +1227,299 @@ function handleC2SDynamicConnection(tunnel, socket) { } function handleC2SLocalConnection(tunnel, socket) { + const tunnelName = tunnel.name || getC2STunnelName(tunnel); 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); + setC2STunnelError(tunnelName, error.message || "Local relay failed"); socket.destroy(); }); } +function pauseSourceForC2SWebSocket(ws, source) { + if (!source?.pause || !source?.resume) return; + if (ws.bufferedAmount <= C2S_WS_HIGH_WATERMARK) return; + + source.pause(); + const resumeTimer = setInterval(() => { + if ( + ws.readyState !== WebSocket.OPEN || + source.destroyed || + ws.bufferedAmount <= C2S_WS_LOW_WATERMARK + ) { + clearInterval(resumeTimer); + if (ws.readyState === WebSocket.OPEN && !source.destroyed) { + source.resume(); + } + } + }, 25); +} + +function sendC2SRemoteMessage(ws, message, source) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(message), (error) => { + if (error && source?.destroy) { + source.destroy(error); + } + }); + pauseSourceForC2SWebSocket(ws, source); + } +} + +function writeC2SRemoteChunk(target, chunk, ws, closeTarget) { + if (!target || target.destroyed) return; + + if (target.writableLength > C2S_STREAM_WRITE_LIMIT) { + closeTarget(); + return; + } + + const canContinue = target.write(chunk); + if (!canContinue && typeof ws.pause === "function") { + ws.pause(); + target.once("drain", () => { + if (ws.readyState === WebSocket.OPEN && typeof ws.resume === "function") { + ws.resume(); + } + }); + } +} + +async function startC2SRemoteTunnel(tunnel, index = 0) { + const tunnelName = getC2STunnelName(tunnel, index); + const localHost = tunnel.bindHost || "127.0.0.1"; + const localPort = Number(tunnel.endpointPort); + const remotePort = Number(tunnel.sourcePort); + + if (!tunnel.sourceHostId) { + return { success: false, error: "Endpoint SSH host is required" }; + } + if (!Number.isInteger(remotePort) || remotePort < 1 || remotePort > 65535) { + return { success: false, error: "Invalid remote port" }; + } + if (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535) { + return { success: false, error: "Invalid local port" }; + } + + const localTarget = await checkTcpConnection(localHost, localPort); + if (!localTarget.success) { + return { + success: false, + error: `Local target ${localHost}:${localPort} is not reachable: ${localTarget.error}`, + }; + } + + const existing = c2sTunnelRuntimes.get(tunnelName); + if (existing) { + return { success: true, tunnelName }; + } + + for (const runtime of c2sTunnelRuntimes.values()) { + if ( + runtime.mode === "remote" && + runtime.sourceHostId === Number(tunnel.sourceHostId) && + runtime.sourcePort === remotePort + ) { + return { + success: false, + error: `Another client remote tunnel already uses ${remotePort} on this endpoint`, + }; + } + } + + const relayUrl = getC2SRelayUrl(); + const headers = await getC2SRelayHeaders(relayUrl); + const ws = new WebSocket( + relayUrl, + getWebSocketOptions(relayUrl, { headers }), + ); + const sockets = new Map(); + let closed = false; + + const cleanup = () => { + if (closed) return; + closed = true; + for (const socket of sockets.values()) { + socket.destroy(); + } + sockets.clear(); + try { + ws.close(); + } catch { + // expected during shutdown + } + }; + + c2sTunnelRuntimes.set(tunnelName, { + ws, + sockets, + mode: "remote", + sourceHostId: Number(tunnel.sourceHostId), + sourcePort: remotePort, + bindHost: localHost, + status: { connected: false, status: "CONNECTING" }, + close: cleanup, + }); + emitC2STunnelStatuses(); + + return new Promise((resolve) => { + let settled = false; + const settle = (result) => { + if (settled) return; + settled = true; + resolve(result); + }; + + ws.on("open", () => { + logToFile(`[c2s] opening remote tunnel ${tunnelName}`, { + relayUrl, + remotePort, + localHost, + localPort, + }); + ws.send( + JSON.stringify({ + type: "open", + tunnelConfig: { ...tunnel, name: tunnelName, mode: "remote" }, + }), + ); + }); + + ws.on("message", (data, isBinary) => { + if (isBinary) return; + + let message; + try { + message = JSON.parse(data.toString()); + } catch (error) { + setC2STunnelError(tunnelName, error.message || "Invalid relay message"); + cleanup(); + settle({ success: false, error: error.message }); + return; + } + + if (message.type === "ready") { + setC2STunnelStatus(tunnelName, { + connected: true, + status: "CONNECTED", + }); + settle({ success: true, tunnelName }); + return; + } + + if (message.type === "error") { + const error = message.error || "Relay rejected the client tunnel"; + setC2STunnelError(tunnelName, error); + cleanup(); + c2sTunnelRuntimes.delete(tunnelName); + emitC2STunnelStatuses(); + settle({ success: false, error }); + return; + } + + if (message.type === "connection" && message.streamId) { + const socket = net.createConnection( + { host: localHost, port: localPort }, + () => { + logToFile(`[c2s] remote stream ${message.streamId} connected`, { + tunnelName, + localHost, + localPort, + }); + }, + ); + sockets.set(message.streamId, socket); + socket.on("data", (chunk) => { + sendC2SRemoteMessage( + ws, + { + type: "data", + streamId: message.streamId, + data: chunk.toString("base64"), + }, + socket, + ); + }); + socket.on("close", () => { + sockets.delete(message.streamId); + sendC2SRemoteMessage(ws, { + type: "close", + streamId: message.streamId, + }); + }); + socket.on("error", (error) => { + logToFile(`[c2s] remote stream ${message.streamId} failed:`, { + tunnelName, + error: error.message, + }); + sockets.delete(message.streamId); + sendC2SRemoteMessage(ws, { + type: "close", + streamId: message.streamId, + error: error.message, + }); + }); + return; + } + + if (message.type === "data" && message.streamId && message.data) { + const socket = sockets.get(message.streamId); + writeC2SRemoteChunk( + socket, + Buffer.from(message.data, "base64"), + ws, + () => { + if (socket) { + sockets.delete(message.streamId); + socket.destroy(); + } + }, + ); + return; + } + + if (message.type === "close" && message.streamId) { + const socket = sockets.get(message.streamId); + if (socket) { + sockets.delete(message.streamId); + socket.destroy(); + } + } + }); + + ws.on("close", () => { + cleanup(); + c2sTunnelRuntimes.delete(tunnelName); + emitC2STunnelStatuses(); + settle({ success: false, error: "Remote tunnel relay closed" }); + }); + + ws.on("error", (error) => { + setC2STunnelError(tunnelName, error.message || "Relay connection failed"); + cleanup(); + c2sTunnelRuntimes.delete(tunnelName); + emitC2STunnelStatuses(); + settle({ success: false, error: error.message }); + }); + }); +} + 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); + logToFile(`[c2s] starting tunnel ${tunnelName}`, { + mode, + bindHost, + sourcePort, + sourceHostId: tunnel.sourceHostId, + endpointPort: tunnel.endpointPort, + }); if (mode === "remote") { - return { - success: false, - error: "Client remote forwarding is not available yet", - }; + return startC2SRemoteTunnel(tunnel, index); } if (!tunnel.sourceHostId) { return { success: false, error: "Endpoint SSH host is required" }; @@ -1019,7 +1534,11 @@ async function startC2STunnel(tunnel, index = 0) { } for (const runtime of c2sTunnelRuntimes.values()) { - if (runtime.bindHost === bindHost && runtime.sourcePort === sourcePort) { + if ( + runtime.mode !== "remote" && + runtime.bindHost === bindHost && + runtime.sourcePort === sourcePort + ) { return { success: false, error: `Another client tunnel already uses ${bindHost}:${sourcePort}`, @@ -1057,9 +1576,14 @@ async function startC2STunnel(tunnel, index = 0) { return new Promise((resolve) => { server.once("error", (error) => { c2sTunnelRuntimes.delete(tunnelName); + logToFile(`[c2s] failed to listen for ${tunnelName}:`, error.message); + emitC2STunnelStatuses(); resolve({ success: false, error: error.message }); }); server.listen({ host: bindHost, port: sourcePort }, () => { + logToFile( + `[c2s] listening for ${tunnelName} on ${bindHost}:${sourcePort}`, + ); setC2STunnelStatus(tunnelName, { connected: true, status: "CONNECTED", @@ -1081,11 +1605,20 @@ async function stopC2STunnel(tunnelName) { }); return new Promise((resolve) => { + if (typeof runtime.close === "function") { + runtime.close(); + c2sTunnelRuntimes.delete(tunnelName); + emitC2STunnelStatuses(); + resolve({ success: true }); + return; + } + for (const socket of runtime.sockets || []) { socket.destroy(); } - runtime.server.close(() => { + runtime.server?.close(() => { c2sTunnelRuntimes.delete(tunnelName); + emitC2STunnelStatuses(); resolve({ success: true }); }); }); @@ -1094,15 +1627,20 @@ async function stopC2STunnel(tunnelName) { function stopAllC2STunnels() { for (const [tunnelName, runtime] of c2sTunnelRuntimes.entries()) { try { - for (const socket of runtime.sockets || []) { - socket.destroy(); + if (typeof runtime.close === "function") { + runtime.close(); + } else { + for (const socket of runtime.sockets || []) { + socket.destroy(); + } + runtime.server?.close(); } - runtime.server.close(); } catch (error) { logToFile(`[c2s] failed to stop tunnel ${tunnelName}:`, error.message); } c2sTunnelRuntimes.delete(tunnelName); } + emitC2STunnelStatuses(); } async function startC2SAutoStartTunnels() { @@ -1151,6 +1689,14 @@ ipcMain.handle("start-c2s-tunnel", async (_event, tunnel, index) => { } }); +ipcMain.handle("test-c2s-tunnel", async (_event, tunnel, index) => { + try { + return await testC2STunnel(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); @@ -1160,11 +1706,7 @@ ipcMain.handle("stop-c2s-tunnel", async (_event, tunnelName) => { }); ipcMain.handle("get-c2s-tunnel-statuses", () => { - const statuses = {}; - for (const [tunnelName] of c2sTunnelRuntimes.entries()) { - statuses[tunnelName] = getC2STunnelStatus(tunnelName); - } - return statuses; + return getAllC2STunnelStatuses(); }); ipcMain.handle("start-c2s-autostart-tunnels", async () => { diff --git a/electron/preload.js b/electron/preload.js index e3211783..0ea54cd6 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -19,9 +19,16 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.invoke("get-c2s-tunnel-preset-default-name"), startC2STunnel: (tunnel, index) => ipcRenderer.invoke("start-c2s-tunnel", tunnel, index), + testC2STunnel: (tunnel, index) => + ipcRenderer.invoke("test-c2s-tunnel", tunnel, index), stopC2STunnel: (tunnelName) => ipcRenderer.invoke("stop-c2s-tunnel", tunnelName), getC2STunnelStatuses: () => ipcRenderer.invoke("get-c2s-tunnel-statuses"), + onC2STunnelStatuses: (callback) => { + const listener = (_event, statuses) => callback(statuses); + ipcRenderer.on("c2s-tunnel-statuses", listener); + return () => ipcRenderer.removeListener("c2s-tunnel-statuses", listener); + }, startC2SAutoStartTunnels: () => ipcRenderer.invoke("start-c2s-autostart-tunnels"), diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index a3449f53..c0070687 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -27,6 +27,7 @@ import { inArray, } from "drizzle-orm"; import type { Request, Response } from "express"; +import axios from "axios"; import multer from "multer"; import { sshLogger, databaseLogger } from "../../utils/logger.js"; import { SimpleDBOps } from "../../utils/simple-db-ops.js"; @@ -42,6 +43,32 @@ const router = express.Router(); const upload = multer({ storage: multer.memoryStorage() }); +function notifyStatsHostUpdated( + hostId: number, + headers: Pick, + operation: string, +): void { + axios + .post( + "http://localhost:30005/host-updated", + { hostId }, + { + headers: { + Authorization: headers.authorization || "", + Cookie: headers.cookie || "", + }, + timeout: 5000, + }, + ) + .catch((err) => { + sshLogger.warn("Failed to notify stats server of host update", { + operation, + hostId, + error: err instanceof Error ? err.message : String(err), + }); + }); +} + function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } @@ -581,29 +608,12 @@ router.post( name, }); - try { - const axios = (await import("axios")).default; - const statsPort = 30005; - await axios.post( - `http://localhost:${statsPort}/host-updated`, - { hostId: createdHost.id }, - { - headers: { - Authorization: req.headers.authorization || "", - Cookie: req.headers.cookie || "", - }, - timeout: 5000, - }, - ); - } catch (err) { - sshLogger.warn("Failed to notify stats server of new host", { - operation: "host_create", - hostId: createdHost.id as number, - error: err instanceof Error ? err.message : String(err), - }); - } - res.json(resolvedHost); + notifyStatsHostUpdated( + createdHost.id as number, + req.headers, + "host_create", + ); } catch (err) { sshLogger.error("Failed to save SSH host to database", err, { operation: "host_create", @@ -1189,29 +1199,8 @@ router.put( hostId: parseInt(hostId), }); - try { - const axios = (await import("axios")).default; - const statsPort = 30005; - await axios.post( - `http://localhost:${statsPort}/host-updated`, - { hostId: parseInt(hostId) }, - { - headers: { - Authorization: req.headers.authorization || "", - Cookie: req.headers.cookie || "", - }, - timeout: 5000, - }, - ); - } catch (err) { - sshLogger.warn("Failed to notify stats server of host update", { - operation: "host_update", - hostId: parseInt(hostId), - error: err instanceof Error ? err.message : String(err), - }); - } - res.json(resolvedHost); + notifyStatsHostUpdated(parseInt(hostId), req.headers, "host_update"); } catch (err) { sshLogger.error("Failed to update SSH host in database", err, { operation: "host_update", diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts index a248b230..149640fd 100644 --- a/src/backend/ssh/tunnel.ts +++ b/src/backend/ssh/tunnel.ts @@ -53,11 +53,17 @@ const countdownIntervals = new Map(); const retryExhaustedTunnels = new Set(); const cleanupInProgress = new Set(); const tunnelConnecting = new Set(); +const lastTunnelErrors = new Map(); +const lastTunnelErrorTypes = new Map(); const tunnelConfigs = new Map(); const activeTunnelProcesses = new Map(); const pendingTunnelOperations = new Map>(); const tunnelStatusClients = new Set(); +let c2sRemoteStreamCounter = 0; +const C2S_WS_HIGH_WATERMARK = 1024 * 1024; +const C2S_WS_LOW_WATERMARK = 256 * 1024; +const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024; type ActiveTunnelRuntime = { sourceClient: Client; @@ -71,7 +77,7 @@ type ActiveTunnelRuntime = { const activeTunnelRuntimes = new Map(); type C2SOpenMessage = { - type: "open"; + type: "open" | "test"; tunnelConfig?: Partial; targetHost?: string; targetPort?: number; @@ -98,6 +104,35 @@ function sendC2SError(ws: WebSocket, message: string): void { } } +function describeC2SRelayError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + const lowerMessage = message.toLowerCase(); + + if ( + lowerMessage.includes("administratively prohibited") || + lowerMessage.includes("forwarding disabled") || + lowerMessage.includes("open failed") + ) { + return `SSH forwarding was rejected by the endpoint server: ${message}`; + } + if ( + lowerMessage.includes("address already in use") || + lowerMessage.includes("unable to bind") || + lowerMessage.includes("bind") + ) { + return `Remote port is not available on the endpoint server: ${message}`; + } + if ( + lowerMessage.includes("name or service not known") || + lowerMessage.includes("enotfound") || + lowerMessage.includes("econnrefused") + ) { + return `Tunnel target is not reachable from the endpoint host: ${message}`; + } + + return message || "Failed to open relay"; +} + function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void { if ( status.status === CONNECTION_STATES.CONNECTED && @@ -106,14 +141,41 @@ function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void { return; } + const nextStatus = { ...status }; + if ( retryExhaustedTunnels.has(tunnelName) && - status.status === CONNECTION_STATES.FAILED + nextStatus.status === CONNECTION_STATES.FAILED ) { - status.reason = "Max retries exhausted"; + const previousReason = lastTunnelErrors.get(tunnelName); + nextStatus.reason = previousReason + ? `Max retries exhausted: ${previousReason}` + : "Max retries exhausted"; } - connectionStatus.set(tunnelName, status); + if (nextStatus.status === CONNECTION_STATES.FAILED && nextStatus.reason) { + lastTunnelErrors.set(tunnelName, nextStatus.reason); + if (nextStatus.errorType) { + lastTunnelErrorTypes.set(tunnelName, nextStatus.errorType); + } + } else if ( + (nextStatus.status === CONNECTION_STATES.CONNECTING || + nextStatus.status === CONNECTION_STATES.RETRYING || + nextStatus.status === CONNECTION_STATES.WAITING) && + !nextStatus.reason + ) { + nextStatus.reason = lastTunnelErrors.get(tunnelName); + nextStatus.errorType = lastTunnelErrorTypes.get(tunnelName); + } else if ( + nextStatus.status === CONNECTION_STATES.CONNECTED || + (nextStatus.status === CONNECTION_STATES.DISCONNECTED && + nextStatus.manualDisconnect) + ) { + lastTunnelErrors.delete(tunnelName); + lastTunnelErrorTypes.delete(tunnelName); + } + + connectionStatus.set(tunnelName, nextStatus); broadcastTunnelStatusSnapshot(); } @@ -372,6 +434,8 @@ async function cleanupTunnelResources( function resetRetryState(tunnelName: string): void { retryCounters.delete(tunnelName); retryExhaustedTunnels.delete(tunnelName); + lastTunnelErrors.delete(tunnelName); + lastTunnelErrorTypes.delete(tunnelName); cleanupInProgress.delete(tunnelName); tunnelConnecting.delete(tunnelName); @@ -672,10 +736,19 @@ function forwardOut( client: Client, targetHost: string, targetPort: number, + tunnelName?: string, ): Promise { return new Promise((resolve, reject) => { client.forwardOut("127.0.0.1", 0, targetHost, targetPort, (err, stream) => { if (err) { + if (tunnelName) { + tunnelLogger.error("Managed tunnel forwardOut failed", err, { + operation: "managed_tunnel_forward_out_failed", + tunnelName, + targetHost, + targetPort, + }); + } reject(err); return; } @@ -864,6 +937,7 @@ async function connectEndpointThroughSource( sourceClient, tunnelConfig.endpointIP, tunnelConfig.endpointSSHPort, + tunnelConfig.name, ); const endpointOptions: Record = { sock: endpointSock, @@ -881,6 +955,20 @@ async function connectEndpointThroughSource( return connectClient(endpointOptions, tunnelConfig.name, "endpoint"); } +function resolveS2SLocalTargetHost(tunnelConfig: TunnelConfig): string { + const targetHost = tunnelConfig.targetHost?.trim(); + + if ( + !targetHost || + targetHost === tunnelConfig.endpointHost || + targetHost === tunnelConfig.hostName + ) { + return "127.0.0.1"; + } + + return targetHost; +} + async function establishManagedS2STunnel( sourceClient: Client, tunnelConfig: TunnelConfig, @@ -906,10 +994,24 @@ async function establishManagedS2STunnel( const bindPort = mode === "remote" ? tunnelConfig.endpointPort : tunnelConfig.sourcePort; const staticTargetHost = - tunnelConfig.targetHost || (mode === "remote" ? "127.0.0.1" : "127.0.0.1"); + mode === "remote" + ? tunnelConfig.targetHost || "127.0.0.1" + : resolveS2SLocalTargetHost(tunnelConfig); const staticTargetPort = mode === "remote" ? tunnelConfig.sourcePort : tunnelConfig.endpointPort; + tunnelLogger.info("Managed S2S tunnel route resolved", { + operation: "managed_tunnel_route_resolved", + tunnelName, + mode, + bindHost, + bindPort, + targetHost: staticTargetHost, + targetPort: staticTargetPort, + endpointHost: tunnelConfig.endpointHost, + endpointIP: tunnelConfig.endpointIP, + }); + const actualPort = await bindForwardIn(bindClient, bindHost, bindPort); const tcpHandler = ( @@ -939,7 +1041,12 @@ async function establishManagedS2STunnel( pipeTunnelStreams( inbound, - forwardOut(outboundClient, staticTargetHost, staticTargetPort), + forwardOut( + outboundClient, + staticTargetHost, + staticTargetPort, + tunnelName, + ), tunnelName, ); }; @@ -1097,6 +1204,199 @@ async function connectC2SSourceClient( return connectClient(connOptions, tunnelConfig.name, "source"); } +function pauseSourceForC2SWebSocket(ws: WebSocket, source?: Duplex): void { + if (!source) return; + if (ws.bufferedAmount <= C2S_WS_HIGH_WATERMARK) return; + + source.pause(); + const resumeTimer = setInterval(() => { + if ( + ws.readyState !== 1 || + source.destroyed || + ws.bufferedAmount <= C2S_WS_LOW_WATERMARK + ) { + clearInterval(resumeTimer); + if (ws.readyState === 1 && !source.destroyed) { + source.resume(); + } + } + }, 25); +} + +function sendC2SMessage( + ws: WebSocket, + message: Record, + source?: Duplex, +): void { + if (ws.readyState === 1) { + ws.send(JSON.stringify(message), (error) => { + if (error && source && !source.destroyed) { + source.destroy(error); + } + }); + pauseSourceForC2SWebSocket(ws, source); + } +} + +function writeC2SRemoteChunk( + target: ClientChannel, + chunk: Buffer, + ws: WebSocket, + closeTarget: () => void, +): void { + if (!target || target.destroyed) return; + + if (target.writableLength > C2S_STREAM_WRITE_LIMIT) { + closeTarget(); + return; + } + + const canContinue = target.write(chunk); + if (!canContinue) { + ws.pause(); + target.once("drain", () => { + if (ws.readyState === 1) { + ws.resume(); + } + }); + } +} + +async function handleC2SRemoteRelayOpen( + ws: WebSocket, + tunnelConfig: TunnelConfig, +): Promise { + const tunnelName = tunnelConfig.name; + const sourceClient = await connectC2SSourceClient(tunnelConfig); + const bindHost = tunnelConfig.targetHost || "127.0.0.1"; + const bindPort = Number(tunnelConfig.sourcePort); + let closed = false; + + if (!Number.isInteger(bindPort) || bindPort < 1 || bindPort > 65535) { + throw new Error("Invalid remote port"); + } + + const actualPort = await bindForwardIn(sourceClient, bindHost, bindPort); + const streams = new Map(); + + const closeStream = (streamId: string): void => { + const stream = streams.get(streamId); + if (!stream) return; + streams.delete(streamId); + try { + stream.destroy(); + } catch { + // expected during shutdown + } + }; + + const close = (): void => { + if (closed) return; + closed = true; + for (const streamId of streams.keys()) { + closeStream(streamId); + } + unbindForwardIn(sourceClient, bindHost, actualPort); + try { + sourceClient.end(); + } catch { + // expected during shutdown + } + }; + + sourceClient.on("tcp connection", (info, accept, reject) => { + if (info.destPort !== actualPort) { + reject(); + return; + } + + const inbound = accept(); + const streamId = `${Date.now()}-${++c2sRemoteStreamCounter}`; + streams.set(streamId, inbound); + + sendC2SMessage(ws, { type: "connection", streamId }); + + inbound.on("data", (chunk) => { + sendC2SMessage( + ws, + { + type: "data", + streamId, + data: chunk.toString("base64"), + }, + inbound, + ); + }); + inbound.on("close", () => { + streams.delete(streamId); + sendC2SMessage(ws, { type: "close", streamId }); + }); + inbound.on("error", (error) => { + streams.delete(streamId); + sendC2SMessage(ws, { + type: "close", + streamId, + error: error instanceof Error ? error.message : String(error), + }); + }); + }); + + ws.on("message", (data, isBinary) => { + if (isBinary) return; + + try { + const message = JSON.parse(data.toString()) as { + type?: string; + streamId?: string; + data?: string; + }; + if (!message.streamId) return; + + if (message.type === "data" && message.data) { + const stream = streams.get(message.streamId); + if (stream) { + writeC2SRemoteChunk( + stream, + Buffer.from(message.data, "base64"), + ws, + () => closeStream(message.streamId as string), + ); + } + } else if (message.type === "close") { + closeStream(message.streamId); + } + } catch (error) { + tunnelLogger.warn("Invalid C2S remote relay message", { + operation: "c2s_remote_relay_invalid_message", + tunnelName, + error: error instanceof Error ? error.message : String(error), + }); + } + }); + + ws.on("close", close); + ws.on("error", close); + sourceClient.on("close", () => { + if (ws.readyState === 1) ws.close(); + }); + sourceClient.on("error", (error) => { + sendC2SMessage(ws, { + type: "error", + error: error instanceof Error ? error.message : String(error), + }); + if (ws.readyState === 1) ws.close(); + }); + + tunnelLogger.info("C2S remote tunnel ready", { + operation: "c2s_remote_tunnel_ready", + tunnelName, + bindHost, + bindPort: actualPort, + endpointHost: tunnelConfig.endpointHost, + }); + sendC2SMessage(ws, { type: "ready", bindHost, bindPort: actualPort }); +} + async function handleC2SRelayOpen( ws: WebSocket, message: C2SOpenMessage, @@ -1108,7 +1408,8 @@ async function handleC2SRelayOpen( ); const mode = getTunnelMode(tunnelConfig); if (mode === "remote") { - throw new Error("Client remote forwarding is not available yet"); + await handleC2SRemoteRelayOpen(ws, tunnelConfig); + return; } const targetHost = @@ -1166,6 +1467,49 @@ async function handleC2SRelayOpen( ws.send(JSON.stringify({ type: "ready" })); } +async function handleC2SRelayTest( + ws: WebSocket, + message: C2SOpenMessage, + userId: string, +): Promise { + const tunnelConfig = await resolveC2STunnelSource( + message.tunnelConfig || {}, + userId, + ); + const mode = getTunnelMode(tunnelConfig); + const sourceClient = await connectC2SSourceClient(tunnelConfig); + + try { + if (mode === "remote") { + const bindHost = tunnelConfig.targetHost || "127.0.0.1"; + const bindPort = Number(tunnelConfig.sourcePort); + if (!Number.isInteger(bindPort) || bindPort < 1 || bindPort > 65535) { + throw new Error("Invalid remote port"); + } + + const actualPort = await bindForwardIn(sourceClient, bindHost, bindPort); + unbindForwardIn(sourceClient, bindHost, actualPort); + } else if (mode === "local") { + const targetHost = tunnelConfig.targetHost || "127.0.0.1"; + const targetPort = Number(tunnelConfig.endpointPort); + if (!Number.isInteger(targetPort) || targetPort < 1) { + throw new Error("Invalid remote target port"); + } + + const outbound = await forwardOut(sourceClient, targetHost, targetPort); + outbound.destroy(); + } + + sendC2SMessage(ws, { type: "ready" }); + } finally { + try { + sourceClient.end(); + } catch { + // expected during shutdown + } + } +} + async function connectSSHTunnel( tunnelConfig: TunnelConfig, retryAttempt = 0, @@ -2711,15 +3055,18 @@ c2sRelayWss.on("connection", (ws, req) => { } const message = JSON.parse(raw.toString()) as C2SOpenMessage; - if (message.type !== "open") { + if (message.type !== "open" && message.type !== "test") { throw new Error("Invalid client tunnel relay request"); } opened = true; - await handleC2SRelayOpen(ws, message, payload.userId); + if (message.type === "test") { + await handleC2SRelayTest(ws, message, payload.userId); + } else { + await handleC2SRelayOpen(ws, message, payload.userId); + } } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to open relay"; + const message = describeC2SRelayError(error); tunnelLogger.error("Failed to open C2S relay", error, { operation: "c2s_relay_open_failed", }); diff --git a/src/locales/en.json b/src/locales/en.json index 5d32e331..1a44d64b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2007,7 +2007,6 @@ "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", @@ -2039,17 +2038,21 @@ "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.", - "clientRemoteUnavailable": "Client Remote (-R) forwarding is not available yet.", - "clientRemoteAutoStartUnavailable": "Client Remote (-R) tunnels cannot use Auto Start yet.", + "clientAutoStartDesc": "Starts when this desktop client opens and stays connected.", + "clientManualStartDesc": "Use Start and Stop from this row. Termix will not open it automatically.", + "clientRemoteServerNote": "Remote forwarding may require AllowTcpForwarding and GatewayPorts on the endpoint SSH server. The remote port closes when this desktop disconnects.", "clientTunnelStarted": "Client tunnel started", "clientTunnelStopped": "Client tunnel stopped", + "tunnelTestSucceeded": "Tunnel test succeeded", + "tunnelTestFailed": "Tunnel test failed", "localSaved": "Client tunnels saved", "localSaveError": "Failed to save local client tunnels", "invalidBindIp": "Local IP must be a valid IPv4 address.", + "invalidLocalTargetIp": "Local target 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.", + "invalidRemotePort": "Remote port must be between 1 and 65535.", + "invalidLocalTargetPort": "Local target 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}}.", "manualControlError": "Failed to update tunnel state.", @@ -2059,6 +2062,7 @@ "inactive": "Inactive", "start": "Start", "stop": "Stop", + "test": "Test", "restart": "Restart", "connectionType": "Connection Type", "type": "Tunnel Type", @@ -2067,9 +2071,9 @@ "typeDynamic": "Dynamic (-D)", "typeServerLocalDesc": "Current host to endpoint.", "typeServerRemoteDesc": "Endpoint back to current host.", - "typeClientLocalDesc": "Listen on this computer and forward to the endpoint host.", - "typeClientRemoteDesc": "Listen on the endpoint host and forward back to this computer.", - "typeClientDynamicDesc": "Open a local SOCKS5 proxy through the endpoint host.", + "typeClientLocalDesc": "Local computer to endpoint.", + "typeClientRemoteDesc": "Endpoint back to local computer.", + "typeClientDynamicDesc": "SOCKS on local computer.", "typeDynamicDesc": "Forward SOCKS5 CONNECT traffic through SSH", "forwardDescriptionServerLocal": "Current host {{sourcePort}} → endpoint {{endpointPort}}.", "forwardDescriptionServerRemote": "Endpoint {{endpointPort}} → current host {{sourcePort}}.", @@ -2077,6 +2081,16 @@ "forwardDescriptionClientLocal": "Local {{sourcePort}} → remote {{endpointPort}}.", "forwardDescriptionClientRemote": "Remote {{sourcePort}} → local {{endpointPort}}.", "forwardDescriptionClientDynamic": "SOCKS on local port {{sourcePort}}.", + "summaryClientLocal": "{{localHost}}:{{localPort}} → {{endpoint}}:{{remotePort}}", + "summaryClientRemote": "{{endpoint}}:{{remotePort}} → {{localHost}}:{{localPort}}", + "summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS via {{endpoint}}", + "autoNameClientLocal": "Local {{localPort}} → {{endpoint}} {{remotePort}}", + "autoNameClientRemote": "{{endpoint}} {{remotePort}} → local {{localPort}}", + "autoNameClientDynamic": "SOCKS {{localPort}} via {{endpoint}}", + "route": "Route:", + "lastStarted": "Last started", + "lastTested": "Last tested", + "lastError": "Last error", "maxRetries": "Max Retries", "maxRetriesDescription": "Maximum amount of retry attempts.", "retryInterval": "Retry Interval (seconds)", @@ -2479,6 +2493,10 @@ "description": "SSH credential description", "searchCredentials": "Search credentials by name, username, or tags...", "sshConfig": "endpoint ssh configuration", + "bindLocalhost": "127.0.0.1 (bind to localhost)", + "localListenerHost": "127.0.0.1 (listen locally)", + "localTargetHost": "127.0.0.1 (target on this computer)", + "socksListenerHost": "127.0.0.1 (SOCKS listener)", "homePath": "/home", "clientId": "your-client-id", "clientSecret": "your-client-secret", diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 15244801..66f776ba 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -45,10 +45,17 @@ export interface ElectronAPI { tunnel: unknown, index: number, ) => Promise<{ success: boolean; tunnelName?: string; error?: string }>; + testC2STunnel: ( + tunnel: unknown, + index: number, + ) => Promise<{ success: boolean; message?: string; error?: string }>; stopC2STunnel: ( tunnelName: string, ) => Promise<{ success: boolean; error?: string }>; getC2STunnelStatuses: () => Promise>; + onC2STunnelStatuses?: ( + callback: (statuses: Record) => void, + ) => () => void; startC2SAutoStartTunnels: () => Promise<{ success: boolean; started: number; diff --git a/src/ui/desktop/apps/features/terminal/Terminal.tsx b/src/ui/desktop/apps/features/terminal/Terminal.tsx index 07f34402..7e6578a2 100644 --- a/src/ui/desktop/apps/features/terminal/Terminal.tsx +++ b/src/ui/desktop/apps/features/terminal/Terminal.tsx @@ -334,6 +334,7 @@ const TerminalInner = forwardRef( const isReconnectingRef = useRef(false); const isConnectingRef = useRef(false); const wasConnectedRef = useRef(false); + const closeAfterDisconnectRef = useRef(false); useEffect(() => { isUnmountingRef.current = false; @@ -343,6 +344,7 @@ const TerminalInner = forwardRef( reconnectAttempts.current = 0; wasConnectedRef.current = false; isAttachingSessionRef.current = false; + closeAfterDisconnectRef.current = false; return () => {}; }, [hostConfig.id]); @@ -1267,11 +1269,17 @@ const TerminalInner = forwardRef( }, 100); } else if (msg.type === "disconnected") { wasDisconnectedBySSH.current = true; + shouldNotReconnectRef.current = true; setIsConnected(false); setIsConnecting(false); if (wasConnectedRef.current) { wasConnectedRef.current = false; - setShowDisconnectedOverlay(true); + setShowDisconnectedOverlay(false); + if (onClose && !closeAfterDisconnectRef.current) { + closeAfterDisconnectRef.current = true; + isUnmountingRef.current = true; + window.setTimeout(onClose, 0); + } } else if (!connectionErrorRef.current) { updateConnectionError( msg.message || t("terminal.connectionRejected"), diff --git a/src/ui/desktop/apps/features/tunnel/TunnelInlineControls.tsx b/src/ui/desktop/apps/features/tunnel/TunnelInlineControls.tsx index 706e8a16..14b503f9 100644 --- a/src/ui/desktop/apps/features/tunnel/TunnelInlineControls.tsx +++ b/src/ui/desktop/apps/features/tunnel/TunnelInlineControls.tsx @@ -36,6 +36,35 @@ function getStatusKind(status?: TunnelStatus) { return "disconnected"; } +function getStatusTitle( + status: TunnelStatus | undefined, + statusText: string, + t: ReturnType["t"], +) { + if (!status) return statusText; + + const details = []; + if (status.reason) details.push(status.reason); + if (status.retryCount && status.maxRetries) { + details.push( + t("tunnels.attempt", { + current: status.retryCount, + max: status.maxRetries, + }), + ); + } + if (status.nextRetryIn) { + details.push( + t("tunnels.nextRetryIn", { + seconds: status.nextRetryIn, + }), + ); + } + if (status.errorType && !status.reason) details.push(status.errorType); + + return details.length > 0 ? details.join("\n") : statusText; +} + export function TunnelInlineControls({ status, loading = false, @@ -55,7 +84,7 @@ export function TunnelInlineControls({ : kind === "error" ? t("tunnels.error") : t("tunnels.disconnected"); - const title = kind === "error" && status?.reason ? status.reason : statusText; + const title = getStatusTitle(status, statusText, t); const statusClass = kind === "connected" diff --git a/src/ui/desktop/apps/host-manager/hosts/HostManager.tsx b/src/ui/desktop/apps/host-manager/hosts/HostManager.tsx index 03dfe06a..0545f8b6 100644 --- a/src/ui/desktop/apps/host-manager/hosts/HostManager.tsx +++ b/src/ui/desktop/apps/host-manager/hosts/HostManager.tsx @@ -144,9 +144,21 @@ export function HostManager({ lastProcessedHostIdRef.current = undefined; }; - const handleFormSubmit = () => { + const handleFormSubmit = (savedHost?: SSHHost) => { ignoreNextHostConfigChangeRef.current = true; - const savedHostId = editingHost?.id; + const isUpdatingHost = Boolean(editingHost?.id && savedHost?.id); + + if (isUpdatingHost) { + setEditingHost((current) => ({ ...current, ...savedHost }) as SSHHost); + setIsAddingHost(false); + lastProcessedHostIdRef.current = savedHost.id; + if (updateTab && currentTabId !== undefined) { + updateTab(currentTabId, { hostConfig: savedHost }); + } + return; + } + + const savedHostId = savedHost?.id || editingHost?.id; setEditingHost(null); setIsAddingHost(false); setTimeout(() => { diff --git a/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx b/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx index 4c30ddfb..a8fcd26b 100644 --- a/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx +++ b/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx @@ -52,7 +52,6 @@ import { getHostAccess, revokeHostAccess, getSSHHostById, - notifyHostCreatedOrUpdated, getGuacamoleSettings, type Role, type AccessRecord, @@ -1092,10 +1091,6 @@ export function HostManagerEditor({ } window.dispatchEvent(new CustomEvent("ssh-hosts:changed")); - - if (savedHost?.id) { - notifyHostCreatedOrUpdated(savedHost.id); - } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); 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 8f9eff7e..ff81726d 100644 --- a/src/ui/desktop/apps/host-manager/hosts/tabs/HostTunnelTab.tsx +++ b/src/ui/desktop/apps/host-manager/hosts/tabs/HostTunnelTab.tsx @@ -30,7 +30,7 @@ import { subscribeTunnelStatuses, } from "@/ui/main-axios.ts"; import type { SSHHost, TunnelConfig, TunnelStatus } from "@/types/index.js"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import type { HostTunnelTabProps } from "./shared/tab-types"; @@ -47,6 +47,7 @@ export function HostTunnelTab({ const [tunnelActions, setTunnelActions] = useState>( {}, ); + const previousTunnelStatusesRef = useRef>({}); const supportsC2S = typeof window !== "undefined" && window.electronAPI?.isElectron === true; @@ -56,6 +57,47 @@ export function HostTunnelTab({ }); }, []); + useEffect(() => { + const previousStatuses = previousTunnelStatusesRef.current; + + for (const [tunnelName, status] of Object.entries(tunnelStatuses)) { + const previous = previousStatuses[tunnelName]; + const statusChanged = + previous?.status !== status.status || + previous?.reason !== status.reason || + previous?.retryCount !== status.retryCount || + previous?.nextRetryIn !== status.nextRetryIn; + + if (!statusChanged) continue; + + console.info("[tunnels] Server tunnel status changed", { + tunnelName, + status, + }); + + const statusValue = status.status?.toUpperCase(); + const hasFailureDetail = + statusValue === "ERROR" || + statusValue === "FAILED" || + (Boolean(status.errorType) && + Boolean(status.reason) && + previous?.reason !== status.reason); + + if (hasFailureDetail) { + const message = status.reason || t("tunnels.manualControlError"); + console.error("[tunnels] Server tunnel failed", { + tunnelName, + status, + }); + toast.error(message, { + id: `server-tunnel-error-${tunnelName}`, + }); + } + } + + previousTunnelStatusesRef.current = tunnelStatuses; + }, [t, tunnelStatuses]); + const openC2SPresets = () => { const profileTab = tabs.find((tab) => tab.type === "user_profile"); if (profileTab) { @@ -98,6 +140,10 @@ export function HostTunnelTab({ const tunnel = form.getValues(`serverTunnels.${index}`); const tunnelName = getTunnelName(host, index); setTunnelActions((current) => ({ ...current, [tunnelName]: true })); + console.info(`[tunnels] ${action} server tunnel`, { + tunnelName, + tunnel, + }); try { if (action === "connect") { @@ -169,6 +215,10 @@ export function HostTunnelTab({ await disconnectTunnel(tunnelName); } } catch (error) { + console.error(`[tunnels] Failed to ${action} server tunnel`, { + tunnelName, + error, + }); toast.error( error instanceof Error ? error.message @@ -327,8 +377,8 @@ export function HostTunnelTab({ scope: "s2s", mode: "remote", tunnelType: "remote", - bindHost: "127.0.0.1", - targetHost: "127.0.0.1", + bindHost: "", + targetHost: "", sourcePort: 22, endpointPort: 224, endpointHost: "", @@ -497,6 +547,9 @@ export function HostTunnelTab({ )} /> )} + {mode === "dynamic" && ( +
+ )} {!isC2S && ( {t("tunnels.currentHostIp")} - - {t("tunnels.currentHostIpDescription")} - )} /> diff --git a/src/ui/desktop/user/C2STunnelPresetManager.tsx b/src/ui/desktop/user/C2STunnelPresetManager.tsx index 8fe1c38e..4807cfb4 100644 --- a/src/ui/desktop/user/C2STunnelPresetManager.tsx +++ b/src/ui/desktop/user/C2STunnelPresetManager.tsx @@ -14,6 +14,7 @@ import { TunnelInlineControls } from "@/ui/desktop/apps/features/tunnel/TunnelIn 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 { @@ -29,7 +30,7 @@ import type { TunnelConnection, TunnelStatus, } from "@/types/index.js"; -import { Download, Pencil, Plus, Save, Trash2 } from "lucide-react"; +import { Activity, Download, Pencil, Plus, Save, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; @@ -37,6 +38,10 @@ type ClientTunnel = TunnelConnection & { bindHost: string; sourceHostId?: number; sourceHostName?: string; + displayName?: string; + lastStartedAt?: string; + lastTestedAt?: string; + lastError?: string; }; function sortPresets(presets: C2STunnelPreset[]) { @@ -64,12 +69,28 @@ function isValidPort(value: unknown) { return Number.isInteger(port) && port >= 1 && port <= 65535; } +function getEffectiveBindHost(bindHost?: string) { + const trimmedBindHost = bindHost?.trim(); + return trimmedBindHost || "127.0.0.1"; +} + +function getTunnelMode(tunnel: Partial) { + return tunnel.mode || tunnel.tunnelType || "local"; +} + +function formatDateTime(value?: string) { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return date.toLocaleString(); +} + function createClientTunnel(): ClientTunnel { return { scope: "c2s", mode: "local", tunnelType: "local", - bindHost: "127.0.0.1", + bindHost: "", sourcePort: 8080, endpointPort: 22, endpointHost: "", @@ -82,23 +103,39 @@ function createClientTunnel(): ClientTunnel { function normalizeClientTunnel( tunnel: Partial, ): ClientTunnel { - const mode = tunnel.mode || tunnel.tunnelType || "local"; + const mode = getTunnelMode(tunnel); + const metadata = tunnel as Partial; return { ...tunnel, scope: "c2s", mode, tunnelType: mode === "dynamic" ? "local" : mode, - bindHost: tunnel.bindHost || "127.0.0.1", + bindHost: tunnel.bindHost?.trim() || "", sourcePort: Number(tunnel.sourcePort) || 8080, endpointPort: Number(tunnel.endpointPort) || 22, endpointHost: tunnel.endpointHost || tunnel.sourceHostName || "", maxRetries: Number(tunnel.maxRetries) || 3, retryInterval: Number(tunnel.retryInterval) || 10, autoStart: Boolean(tunnel.autoStart), + displayName: metadata.displayName?.trim() || "", + lastStartedAt: metadata.lastStartedAt, + lastTestedAt: metadata.lastTestedAt, + lastError: metadata.lastError, }; } +function stripClientTunnelDiagnostics(tunnel: ClientTunnel): TunnelConnection { + const { + lastStartedAt: _lastStartedAt, + lastTestedAt: _lastTestedAt, + lastError: _lastError, + ...presetTunnel + } = normalizeClientTunnel(tunnel); + + return presetTunnel; +} + export function C2STunnelPresetManager(): React.ReactElement { const { t } = useTranslation(); const [localConfig, setLocalConfig] = React.useState([]); @@ -113,6 +150,12 @@ export function C2STunnelPresetManager(): React.ReactElement { const [tunnelActions, setTunnelActions] = React.useState< Record >({}); + const [tunnelTests, setTunnelTests] = React.useState>( + {}, + ); + const previousTunnelStatusesRef = React.useRef>( + {}, + ); const [selectedPresetId, setSelectedPresetId] = React.useState(""); const [presetName, setPresetName] = React.useState(""); const isElectron = @@ -133,7 +176,10 @@ export function C2STunnelPresetManager(): React.ReactElement { ); const selectedMatchesCurrent = React.useMemo(() => { return selectedPreset - ? sameConfig(selectedPreset.config, localConfig) + ? sameConfig( + selectedPreset.config.map(normalizeClientTunnel), + localConfig.map(stripClientTunnelDiagnostics), + ) : false; }, [localConfig, selectedPreset]); const hasUnsavedLocalChanges = React.useMemo( @@ -149,13 +195,95 @@ export function C2STunnelPresetManager(): React.ReactElement { index, tunnel.sourceHostId || 0, tunnel.mode || tunnel.tunnelType || "local", - tunnel.bindHost || "127.0.0.1", + getEffectiveBindHost(tunnel.bindHost), tunnel.sourcePort, tunnel.endpointPort || 0, ].join("::"), [], ); + const getEndpointName = React.useCallback( + (tunnel: ClientTunnel) => { + const host = sshHosts.find((item) => item.id === tunnel.sourceHostId); + return ( + tunnel.sourceHostName || + host?.name || + tunnel.endpointHost || + t("tunnels.endpointSshHost") + ); + }, + [sshHosts, t], + ); + + const getTunnelDisplayName = React.useCallback( + (tunnel: ClientTunnel, index: number) => { + if (tunnel.displayName?.trim()) return tunnel.displayName.trim(); + + const mode = getTunnelMode(tunnel); + const endpointName = getEndpointName(tunnel); + if (mode === "remote") { + return t("tunnels.autoNameClientRemote", { + endpoint: endpointName, + remotePort: tunnel.sourcePort, + localPort: tunnel.endpointPort, + }); + } + if (mode === "dynamic") { + return t("tunnels.autoNameClientDynamic", { + localPort: tunnel.sourcePort, + endpoint: endpointName, + }); + } + return t("tunnels.autoNameClientLocal", { + localPort: tunnel.sourcePort, + endpoint: endpointName, + remotePort: tunnel.endpointPort, + index: index + 1, + }); + }, + [getEndpointName, t], + ); + + const getBindPlaceholder = React.useCallback( + (mode: string) => { + if (mode === "remote") return t("placeholders.localTargetHost"); + if (mode === "dynamic") return t("placeholders.socksListenerHost"); + return t("placeholders.localListenerHost"); + }, + [t], + ); + + const getTunnelSummary = React.useCallback( + (tunnel: ClientTunnel) => { + const mode = getTunnelMode(tunnel); + const bindHost = getEffectiveBindHost(tunnel.bindHost); + const endpointName = getEndpointName(tunnel); + + if (mode === "remote") { + return t("tunnels.summaryClientRemote", { + endpoint: endpointName, + remotePort: tunnel.sourcePort, + localHost: bindHost, + localPort: tunnel.endpointPort, + }); + } + if (mode === "dynamic") { + return t("tunnels.summaryClientDynamic", { + localHost: bindHost, + localPort: tunnel.sourcePort, + endpoint: endpointName, + }); + } + return t("tunnels.summaryClientLocal", { + localHost: bindHost, + localPort: tunnel.sourcePort, + endpoint: endpointName, + remotePort: tunnel.endpointPort, + }); + }, + [getEndpointName, t], + ); + const refreshPresets = React.useCallback(async () => { const nextPresets = await getC2STunnelPresets(); setPresets(sortPresets(nextPresets)); @@ -197,31 +325,90 @@ export function C2STunnelPresetManager(): React.ReactElement { }; refreshStatuses().catch(() => {}); + const unsubscribe = window.electronAPI.onC2STunnelStatuses?.((statuses) => { + setTunnelStatuses(statuses as Record); + }); + + return () => unsubscribe?.(); }, [isElectron]); + React.useEffect(() => { + const previousStatuses = previousTunnelStatusesRef.current; + + for (const [tunnelName, status] of Object.entries(tunnelStatuses)) { + const previous = previousStatuses[tunnelName]; + const statusChanged = + previous?.status !== status.status || + previous?.reason !== status.reason || + previous?.retryCount !== status.retryCount; + + if (!statusChanged) continue; + + console.info("[tunnels] Client tunnel status changed", { + tunnelName, + status, + }); + + const statusValue = status.status?.toUpperCase(); + const hasFailureDetail = + statusValue === "ERROR" || + statusValue === "FAILED" || + (Boolean(status.errorType) && + Boolean(status.reason) && + previous?.reason !== status.reason); + + if (hasFailureDetail) { + const message = status.reason || t("tunnels.manualControlError"); + console.error("[tunnels] Client tunnel failed", { + tunnelName, + status, + }); + toast.error(message, { + id: `client-tunnel-error-${tunnelName}`, + }); + } + } + + previousTunnelStatusesRef.current = tunnelStatuses; + }, [t, tunnelStatuses]); + const validateLocalConfig = (config: ClientTunnel[]) => { const autoStartListeners = new Set(); for (const tunnel of config) { - if (!isValidIPv4(tunnel.bindHost)) { - return t("tunnels.invalidBindIp"); + const bindHost = getEffectiveBindHost(tunnel.bindHost); + const mode = getTunnelMode(tunnel); + + if (!isValidIPv4(bindHost)) { + return mode === "remote" + ? t("tunnels.invalidLocalTargetIp") + : t("tunnels.invalidBindIp"); } if (!isValidPort(tunnel.sourcePort)) { - return t("tunnels.invalidLocalPort"); + return mode === "remote" + ? t("tunnels.invalidRemotePort") + : t("tunnels.invalidLocalPort"); } - if (tunnel.mode !== "dynamic" && !isValidPort(tunnel.endpointPort)) { - return t("tunnels.invalidEndpointPort"); + if (mode !== "dynamic" && !isValidPort(tunnel.endpointPort)) { + return mode === "remote" + ? t("tunnels.invalidLocalTargetPort") + : t("tunnels.invalidEndpointPort"); } 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}`; + const listenerKey = + mode === "remote" + ? `${tunnel.sourceHostId}:${tunnel.sourcePort}` + : `${bindHost}:${tunnel.sourcePort}`; if (autoStartListeners.has(listenerKey)) { - return t("tunnels.duplicateAutoStartBind", { bind: listenerKey }); + return t("tunnels.duplicateAutoStartBind", { + bind: + mode === "remote" + ? `${tunnel.sourceHostName || tunnel.sourceHostId}:${tunnel.sourcePort}` + : listenerKey, + }); } autoStartListeners.add(listenerKey); } @@ -282,13 +469,88 @@ export function C2STunnelPresetManager(): React.ReactElement { } }; + const setTunnelMetadata = ( + index: number, + updates: Partial, + ): void => { + setLocalConfig((current) => + current.map((tunnel, tunnelIndex) => + tunnelIndex === index + ? normalizeClientTunnel({ ...tunnel, ...updates }) + : tunnel, + ), + ); + }; + + const handleTunnelTest = async (tunnel: ClientTunnel, index: number) => { + const tunnelName = getTunnelName(tunnel, index); + const normalizedTunnel = { + ...normalizeClientTunnel(tunnel), + name: tunnelName, + }; + const validationError = validateLocalConfig([normalizedTunnel]); + if (validationError) { + toast.error(validationError); + setTunnelMetadata(index, { lastError: validationError }); + return; + } + + setTunnelTests((current) => ({ ...current, [tunnelName]: true })); + console.info("[tunnels] Testing client tunnel", { + tunnelName, + tunnel: normalizedTunnel, + }); + + try { + const result = await window.electronAPI.testC2STunnel( + normalizedTunnel, + index, + ); + if (!result.success) { + throw new Error(result.error || t("tunnels.tunnelTestFailed")); + } + + setTunnelMetadata(index, { + lastTestedAt: new Date().toISOString(), + lastError: "", + }); + toast.success(t("tunnels.tunnelTestSucceeded")); + } catch (error) { + const message = + error instanceof Error ? error.message : t("tunnels.tunnelTestFailed"); + console.error("[tunnels] Client tunnel test failed", { + tunnelName, + error, + }); + setTunnelMetadata(index, { lastError: message }); + toast.error(message); + } finally { + setTunnelTests((current) => ({ ...current, [tunnelName]: false })); + } + }; + const handleTunnelStart = async (tunnel: ClientTunnel, index: number) => { const tunnelName = getTunnelName(tunnel, index); + const normalizedTunnel = { + ...normalizeClientTunnel(tunnel), + name: tunnelName, + }; + const validationError = validateLocalConfig([normalizedTunnel]); + if (validationError) { + toast.error(validationError); + setTunnelMetadata(index, { lastError: validationError }); + return; + } + setTunnelActions((current) => ({ ...current, [tunnelName]: true })); + console.info("[tunnels] Starting client tunnel", { + tunnelName, + tunnel: normalizedTunnel, + }); try { const result = await window.electronAPI.startC2STunnel( - { ...normalizeClientTunnel(tunnel), name: tunnelName }, + normalizedTunnel, index, ); if (!result.success) { @@ -296,13 +558,22 @@ export function C2STunnelPresetManager(): React.ReactElement { } const statuses = await window.electronAPI.getC2STunnelStatuses(); setTunnelStatuses(statuses as Record); + setTunnelMetadata(index, { + lastStartedAt: new Date().toISOString(), + lastError: "", + }); toast.success(t("tunnels.clientTunnelStarted")); } catch (error) { - toast.error( + const message = error instanceof Error ? error.message - : t("tunnels.manualControlError"), - ); + : t("tunnels.manualControlError"); + console.error("[tunnels] Failed to start client tunnel", { + tunnelName, + error, + }); + setTunnelMetadata(index, { lastError: message }); + toast.error(message); } finally { setTunnelActions((current) => ({ ...current, [tunnelName]: false })); } @@ -338,7 +609,7 @@ export function C2STunnelPresetManager(): React.ReactElement { await saveLocalConfig(localConfig); await createC2STunnelPreset({ name: presetName.trim(), - config: localConfig, + config: localConfig.map(stripClientTunnelDiagnostics), }); await refreshPresets(); toast.success(t("profile.c2sPresetSaved")); @@ -442,9 +713,10 @@ export function C2STunnelPresetManager(): React.ReactElement {
{localConfig.length > 0 ? ( localConfig.map((tunnel, index) => { + const mode = getTunnelMode(tunnel); const modeDescription = getTunnelModeDescription( "client", - tunnel.mode || "local", + mode, { sourcePort: tunnel.sourcePort, endpointPort: tunnel.endpointPort, @@ -454,20 +726,54 @@ export function C2STunnelPresetManager(): React.ReactElement { 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"); + const isTunnelTestLoading = Boolean(tunnelTests[tunnelName]); + const startDisabled = !tunnel.sourceHostId; + const startDisabledReason = t("tunnels.endpointSshHostRequired"); + const { sourcePortLabel, endpointPortLabel } = + getTunnelPortLabels("client", mode, t); + const tunnelSummary = getTunnelSummary(tunnel); + const statusError = + tunnelStatus?.reason || + (tunnelStatus?.errorType ? String(tunnelStatus.errorType) : ""); + const lastError = statusError || tunnel.lastError || ""; + const lastStarted = formatDateTime(tunnel.lastStartedAt); + const lastTested = formatDateTime(tunnel.lastTestedAt); return (
-

- {t("tunnels.clientTunnel")} {index + 1} -

+
+ + + updateTunnel(index, { + displayName: event.target.value, + }) + } + placeholder={getTunnelDisplayName(tunnel, index)} + className="h-8 max-w-md bg-background" + /> +
+ {t("tunnels.type")}
updateTunnel(index, { @@ -544,7 +850,7 @@ export function C2STunnelPresetManager(): React.ReactElement { {tunnel.mode !== "dynamic" && (
- + @@ -556,6 +862,9 @@ export function C2STunnelPresetManager(): React.ReactElement { />
)} + {tunnel.mode === "dynamic" && ( +
+ )}
@@ -566,12 +875,12 @@ export function C2STunnelPresetManager(): React.ReactElement { bindHost: event.target.value.trim(), }) } - placeholder="127.0.0.1" + placeholder={getBindPlaceholder(mode)} />
- + @@ -587,6 +896,42 @@ export function C2STunnelPresetManager(): React.ReactElement {

{modeDescription}

+
+ + {t("tunnels.route")} + {" "} + {tunnelSummary} +
+ {mode === "remote" && ( +

+ {t("tunnels.clientRemoteServerNote")} +

+ )} + {(lastError || lastStarted || lastTested) && ( +
+ {lastStarted && ( + + {t("tunnels.lastStarted")}: {lastStarted} + + )} + {lastTested && ( + + {t("tunnels.lastTested")}: {lastTested} + + )} + {lastError && ( + + {t("tunnels.lastError")}: {lastError} + + )} +
+ )}
@@ -626,7 +971,6 @@ export function C2STunnelPresetManager(): React.ReactElement { updateTunnel(index, { autoStart: checked }) } @@ -634,11 +978,9 @@ export function C2STunnelPresetManager(): React.ReactElement {

{t( - tunnel.mode === "remote" - ? "tunnels.clientRemoteAutoStartUnavailable" - : tunnel.autoStart - ? "tunnels.clientAutoStartDesc" - : "tunnels.clientManualStartDesc", + tunnel.autoStart + ? "tunnels.clientAutoStartDesc" + : "tunnels.clientManualStartDesc", )}