mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-05-04 00:21:19 +00:00
Add client remote tunnel support
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Vendored
+7
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user