Add client remote tunnel support

This commit is contained in:
Xenthys
2026-04-25 02:13:44 +02:00
parent 182a51cdcb
commit 2171d13499
14 changed files with 1522 additions and 158 deletions
+9
View File
@@ -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;
+9
View File
@@ -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;
+580 -38
View File
@@ -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 () => {
+7
View File
@@ -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"),
+33 -44
View File
@@ -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<Request["headers"], "authorization" | "cookie">,
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",
+358 -11
View File
@@ -53,11 +53,17 @@ const countdownIntervals = new Map<string, NodeJS.Timeout>();
const retryExhaustedTunnels = new Set<string>();
const cleanupInProgress = new Set<string>();
const tunnelConnecting = new Set<string>();
const lastTunnelErrors = new Map<string, string>();
const lastTunnelErrorTypes = new Map<string, ErrorType>();
const tunnelConfigs = new Map<string, TunnelConfig>();
const activeTunnelProcesses = new Map<string, ChildProcess>();
const pendingTunnelOperations = new Map<string, Promise<void>>();
const tunnelStatusClients = new Set<Response>();
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<string, ActiveTunnelRuntime>();
type C2SOpenMessage = {
type: "open";
type: "open" | "test";
tunnelConfig?: Partial<TunnelConfig>;
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<ClientChannel> {
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<string, unknown> = {
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<string, unknown>,
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<void> {
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<string, ClientChannel>();
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<void> {
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",
});
+26 -8
View File
@@ -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",
+7
View File
@@ -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<Record<string, unknown>>;
onC2STunnelStatuses?: (
callback: (statuses: Record<string, unknown>) => void,
) => () => void;
startC2SAutoStartTunnels: () => Promise<{
success: boolean;
started: number;
@@ -334,6 +334,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
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<TerminalHandle, SSHTerminalProps>(
reconnectAttempts.current = 0;
wasConnectedRef.current = false;
isAttachingSessionRef.current = false;
closeAfterDisconnectRef.current = false;
return () => {};
}, [hostConfig.id]);
@@ -1267,11 +1269,17 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}, 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"),
@@ -36,6 +36,35 @@ function getStatusKind(status?: TunnelStatus) {
return "disconnected";
}
function getStatusTitle(
status: TunnelStatus | undefined,
statusText: string,
t: ReturnType<typeof useTranslation>["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"
@@ -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(() => {
@@ -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);
@@ -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<Record<string, boolean>>(
{},
);
const previousTunnelStatusesRef = useRef<Record<string, TunnelStatus>>({});
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" && (
<div className="hidden md:block md:col-span-6" />
)}
{!isC2S && (
<FormField
control={form.control}
@@ -510,13 +563,10 @@ export function HostTunnelTab({
<FormLabel>{t("tunnels.currentHostIp")}</FormLabel>
<FormControl>
<Input
placeholder="127.0.0.1"
placeholder={t("placeholders.bindLocalhost")}
{...currentHostIpField}
/>
</FormControl>
<FormDescription>
{t("tunnels.currentHostIpDescription")}
</FormDescription>
</FormItem>
)}
/>
+383 -41
View File
@@ -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<TunnelConnection>) {
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<TunnelConnection>,
): ClientTunnel {
const mode = tunnel.mode || tunnel.tunnelType || "local";
const mode = getTunnelMode(tunnel);
const metadata = tunnel as Partial<ClientTunnel>;
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<ClientTunnel[]>([]);
@@ -113,6 +150,12 @@ export function C2STunnelPresetManager(): React.ReactElement {
const [tunnelActions, setTunnelActions] = React.useState<
Record<string, boolean>
>({});
const [tunnelTests, setTunnelTests] = React.useState<Record<string, boolean>>(
{},
);
const previousTunnelStatusesRef = React.useRef<Record<string, TunnelStatus>>(
{},
);
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<string, TunnelStatus>);
});
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<string>();
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<ClientTunnel>,
): 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<string, TunnelStatus>);
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 {
<div className="space-y-4">
{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 (
<div key={index} className="p-4 border rounded-lg bg-muted/50">
<div className="flex flex-wrap items-center justify-between gap-3">
<h4 className="text-sm font-bold">
{t("tunnels.clientTunnel")} {index + 1}
</h4>
<div className="min-w-[240px] flex-1 space-y-1">
<Label className="text-xs text-muted-foreground">
{t("tunnels.tunnelName")}
</Label>
<Input
value={tunnel.displayName || ""}
onChange={(event) =>
updateTunnel(index, {
displayName: event.target.value,
})
}
placeholder={getTunnelDisplayName(tunnel, index)}
className="h-8 max-w-md bg-background"
/>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
size="sm"
variant="outline"
disabled={startDisabled || isTunnelTestLoading}
title={startDisabled ? startDisabledReason : undefined}
onClick={() => handleTunnelTest(tunnel, index)}
className="h-8 px-3 text-xs"
>
<Activity
className={`h-3 w-3 mr-1 ${
isTunnelTestLoading ? "animate-pulse" : ""
}`}
/>
{t("tunnels.test")}
</Button>
<TunnelInlineControls
status={tunnelStatus}
loading={isTunnelActionLoading}
@@ -499,7 +805,7 @@ export function C2STunnelPresetManager(): React.ReactElement {
<Label>{t("tunnels.type")}</Label>
<div className="mt-2">
<TunnelModeSelector
mode={tunnel.mode || "local"}
mode={mode}
scope="client"
onChange={(mode) =>
updateTunnel(index, {
@@ -544,7 +850,7 @@ export function C2STunnelPresetManager(): React.ReactElement {
{tunnel.mode !== "dynamic" && (
<div className="col-span-12 md:col-span-6 space-y-2">
<Label>{t("tunnels.endpointPort")}</Label>
<Label>{endpointPortLabel}</Label>
<Input
value={tunnel.endpointPort}
onChange={(event) =>
@@ -556,6 +862,9 @@ export function C2STunnelPresetManager(): React.ReactElement {
/>
</div>
)}
{tunnel.mode === "dynamic" && (
<div className="hidden md:block md:col-span-6" />
)}
<div className="col-span-12 md:col-span-6 space-y-2">
<Label>{t("tunnels.bindIp")}</Label>
@@ -566,12 +875,12 @@ export function C2STunnelPresetManager(): React.ReactElement {
bindHost: event.target.value.trim(),
})
}
placeholder="127.0.0.1"
placeholder={getBindPlaceholder(mode)}
/>
</div>
<div className="col-span-12 md:col-span-6 space-y-2">
<Label>{t("tunnels.localPort")}</Label>
<Label>{sourcePortLabel}</Label>
<Input
value={tunnel.sourcePort}
onChange={(event) =>
@@ -587,6 +896,42 @@ export function C2STunnelPresetManager(): React.ReactElement {
<p className="text-sm text-muted-foreground mt-2">
{modeDescription}
</p>
<div
className="mt-2 rounded-md border bg-background px-3 py-2 text-xs text-muted-foreground"
title={tunnelSummary}
>
<span className="font-medium text-foreground">
{t("tunnels.route")}
</span>{" "}
{tunnelSummary}
</div>
{mode === "remote" && (
<p className="mt-2 text-xs text-muted-foreground">
{t("tunnels.clientRemoteServerNote")}
</p>
)}
{(lastError || lastStarted || lastTested) && (
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
{lastStarted && (
<span>
{t("tunnels.lastStarted")}: {lastStarted}
</span>
)}
{lastTested && (
<span>
{t("tunnels.lastTested")}: {lastTested}
</span>
)}
{lastError && (
<span
className="text-red-600 dark:text-red-400"
title={lastError}
>
{t("tunnels.lastError")}: {lastError}
</span>
)}
</div>
)}
<div className="grid grid-cols-12 gap-4 mt-4">
<div className="col-span-12 md:col-span-6 space-y-2">
@@ -626,7 +971,6 @@ export function C2STunnelPresetManager(): React.ReactElement {
<Label>{t("tunnels.autoStart")}</Label>
<Switch
checked={tunnel.autoStart}
disabled={tunnel.mode === "remote"}
onCheckedChange={(checked) =>
updateTunnel(index, { autoStart: checked })
}
@@ -634,11 +978,9 @@ export function C2STunnelPresetManager(): React.ReactElement {
</div>
<p className="text-xs text-muted-foreground">
{t(
tunnel.mode === "remote"
? "tunnels.clientRemoteAutoStartUnavailable"
: tunnel.autoStart
? "tunnels.clientAutoStartDesc"
: "tunnels.clientManualStartDesc",
tunnel.autoStart
? "tunnels.clientAutoStartDesc"
: "tunnels.clientManualStartDesc",
)}
</p>
</div>