Files
NetMesh/electron/bridges/portForwardingBridge.cjs

2141 lines
76 KiB
JavaScript
Raw Normal View History

/**
* Port Forwarding Bridge - Handles SSH port forwarding tunnels
* Extracted from main.cjs for single responsibility
*/
const net = require("node:net");
require("./boringSslDhCompat.cjs").installBoringSslDhCompat();
const { Client: SSHClient } = require("ssh2");
const { NetcattyAgent } = require("./netcattyAgent.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const { connectThroughChain, buildAlgorithms } = require("./sshBridge.cjs");
const { resolveSshConnectionTimeouts } = require("./sshBridge/startSession.cjs");
const hostKeyVerifier = require("./hostKeyVerifier.cjs");
const { createProxySocket, runWhenProxyConnectionReady } = require("./proxyUtils.cjs");
const {
openBoundedForwardInCallback,
openBoundedForwardOutCallback,
} = require("./boundedSshChannelOpen.cjs");
const {
buildAuthHandler,
createKeyboardInteractiveHandler,
applyAuthToConnOpts,
shouldSkipKiPasswordAutoFill,
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
preparePrivateKeyForAuth,
loadFirstIdentityFileForAuth,
getAvailableAgentSocket,
prepareSystemSshAgentForAuth,
isPassphraseCancelledError,
} = require("./sshAuthHelper.cjs");
const {
createTransport,
borrowTransport,
returnTransport,
discardTransport,
findTransportByEndpoint,
beginTransportDial,
waitForTransportDial,
completeTransportDial,
failTransportDial,
buildConnectionReuseEndpoint,
resolveConnectionKeepalivePolicy,
LEASE_KINDS,
} = require("./sshConnectionPool.cjs");
// Active port forwarding tunnels
const portForwardingTunnels = new Map();
// Process-scoped authority metadata for renderer projections (#2288).
// Epoch changes whenever this module (main or terminal worker) boots.
const PROCESS_EPOCH = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
let runtimeRevision = 0;
/** @type {Map<number, { sender: any, onDestroyed?: () => void }>} */
const runtimeEventSubscribers = new Map();
function bumpRuntimeRevision() {
runtimeRevision += 1;
return runtimeRevision;
}
function resolveRuntimePhase(tunnel) {
if (!tunnel) return "inactive";
if (tunnel.cleanupInProgress) return "stopping";
if (tunnel.status === "connecting") return "connecting";
if (tunnel.status === "error") return "error";
if (tunnel.status === "active") return "active";
if (tunnel.status === "inactive") return "inactive";
return tunnel.status || "active";
}
function toRuntimeRecord(tunnelId, tunnel, revision = runtimeRevision) {
return {
ruleId: tunnel?.ruleId,
tunnelId,
phase: resolveRuntimePhase(tunnel),
...(tunnel?.error ? { error: tunnel.error } : {}),
cleanupRequired: Boolean(tunnel?.cleanupFailed),
revision,
updatedAt: tunnel?.updatedAt || Date.now(),
};
}
function getPortForwardSnapshot() {
const records = [];
for (const [tunnelId, tunnel] of portForwardingTunnels) {
records.push(toRuntimeRecord(tunnelId, tunnel));
}
return {
epoch: PROCESS_EPOCH,
revision: runtimeRevision,
records,
};
}
function publishRuntimeEvent(event) {
const payload = {
epoch: PROCESS_EPOCH,
revision: runtimeRevision,
...event,
};
for (const [subscriberId, entry] of runtimeEventSubscribers) {
const sender = entry?.sender;
if (sender?.isDestroyed?.()) {
runtimeEventSubscribers.delete(subscriberId);
continue;
}
safeSend(sender, "netcatty:portforward:runtime", payload);
}
return payload;
}
function publishRuntimeUpsert(tunnelId, tunnel) {
const revision = bumpRuntimeRevision();
if (tunnel) tunnel.updatedAt = Date.now();
return publishRuntimeEvent({
kind: "upsert",
record: toRuntimeRecord(tunnelId, tunnel, revision),
});
}
function publishRuntimeRemove(tunnelId, ruleId) {
bumpRuntimeRevision();
return publishRuntimeEvent({
kind: "remove",
tunnelId,
ruleId,
});
}
function subscribePortForwardRuntime(event) {
const sender = event?.sender;
if (sender && Number.isSafeInteger(sender.id) && !sender.isDestroyed?.()) {
const existing = runtimeEventSubscribers.get(sender.id);
if (existing?.onDestroyed) {
existing.sender.removeListener?.("destroyed", existing.onDestroyed);
}
const onDestroyed = () => {
runtimeEventSubscribers.delete(sender.id);
};
runtimeEventSubscribers.set(sender.id, { sender, onDestroyed });
sender.once?.("destroyed", onDestroyed);
}
// Atomic subscribe + snapshot from the same revision.
return getPortForwardSnapshot();
}
function unsubscribePortForwardRuntime(event) {
const sender = event?.sender;
if (!sender || !Number.isSafeInteger(sender.id)) {
return { success: true };
}
const existing = runtimeEventSubscribers.get(sender.id);
if (existing?.onDestroyed) {
existing.sender.removeListener?.("destroyed", existing.onDestroyed);
}
runtimeEventSubscribers.delete(sender.id);
return { success: true };
}
function resetPortForwardRuntimeMetaForTests() {
runtimeRevision = 0;
runtimeEventSubscribers.clear();
}
function seedPortForwardTunnelForTests(tunnelId, tunnel) {
portForwardingTunnels.set(tunnelId, tunnel);
}
function clearPortForwardTunnelsForTests() {
portForwardingTunnels.clear();
}
function buildPortForwardEndpoint(options = {}) {
return buildConnectionReuseEndpoint({
...options,
// Port forwarding never requests agent forwarding. Channel reuse remains
// asymmetric: a ForwardAgent terminal can serve PF, but not vice versa.
agentForwarding: false,
protocol: "ssh",
sftpSudo: false,
}, { sftpSudo: false });
}
function buildPortForwardEndpointFromStartPayload(payload = {}) {
return buildPortForwardEndpoint({
...payload,
authType: payload.authType || payload.authMethod,
keepaliveInterval: payload.resolvedKeepaliveInterval ?? payload.keepaliveInterval,
keepaliveCountMax: payload.resolvedKeepaliveCountMax ?? payload.keepaliveCountMax,
});
}
function normalizeRemoteAddress(value) {
const raw = String(value || "").trim().toLowerCase();
if (!raw) return "";
if (raw === "localhost" || raw === "127.0.0.1" || raw === "::1") return "loopback";
if (raw === "0.0.0.0" || raw === "::" || raw === "*") return "wildcard";
return raw;
}
/**
* When a shared/reused transport dies, tear down local listeners and mark the
* tunnel inactive so rules can restart without a zombie "active" entry.
*/
function attachSharedTransportLifecycle(tunnelId, tunnelState, conn, sendStatus) {
if (!conn || !tunnelState) return;
const onTransportGone = (reason, { discard = false } = {}) => {
const tunnel = portForwardingTunnels.get(tunnelId) || tunnelState;
if (!tunnel || tunnel.cancelled || tunnel._sharedLifecycleSettled) return;
tunnel._sharedLifecycleSettled = true;
console.log(`[PortForward] Shared transport ${reason} for tunnel ${tunnelId}`);
detachSharedTransportLifecycle(tunnel);
if (tunnel.tcpConnectionHandler && tunnel.conn?.removeListener) {
try { tunnel.conn.removeListener("tcp connection", tunnel.tcpConnectionHandler); } catch { /* ignore */ }
tunnel.tcpConnectionHandler = null;
}
if (tunnel.server) {
try { tunnel.server.close(); } catch { /* ignore */ }
tunnel.server = null;
}
if (tunnel.sshTransportManaged) {
try {
// Errors mean the shared conn may be half-dead; discard so later
// terminal/SFTP/PF work does not park and reuse a broken socket.
if (discard && typeof discardTransport === "function") {
if (tunnel.connRef) {
discardTransport(tunnel.connRef, "shared-transport-error");
}
} else {
returnTransport(tunnel);
}
} catch { /* ignore */ }
tunnel.sshTransportManaged = false;
}
tunnel.conn = null;
tunnel.connRef = null;
if (shouldFinalizeTunnelClose(tunnel)) {
sendStatus?.(discard ? "error" : "inactive", discard ? "shared transport error" : null);
portForwardingTunnels.delete(tunnelId);
}
};
// Prefer once so a dead socket does not re-enter cleanup. Store handlers so
// normal cancel can detach them and avoid leaking listeners on long-lived
// shared transports.
const onClose = () => onTransportGone("closed");
const onError = (err) => {
console.warn(`[PortForward] Shared transport error for ${tunnelId}:`, err?.message || err);
onTransportGone("error", { discard: true });
};
tunnelState._sharedOnClose = onClose;
tunnelState._sharedOnError = onError;
try { conn.once("close", onClose); } catch { /* ignore */ }
try { conn.once("error", onError); } catch { /* ignore */ }
}
function detachSharedTransportLifecycle(tunnel) {
if (!tunnel?.conn) return;
if (tunnel._sharedOnClose && tunnel.conn.removeListener) {
try { tunnel.conn.removeListener("close", tunnel._sharedOnClose); } catch { /* ignore */ }
}
if (tunnel._sharedOnError && tunnel.conn.removeListener) {
try { tunnel.conn.removeListener("error", tunnel._sharedOnError); } catch { /* ignore */ }
}
tunnel._sharedOnClose = null;
tunnel._sharedOnError = null;
}
function isLocalBindFailure(err) {
const code = String(err?.code || "").toUpperCase();
const message = String(err?.message || "").toLowerCase();
return code === "EADDRINUSE"
|| code === "EACCES"
|| code === "EADDRNOTAVAIL"
|| message.includes("eaddrinuse")
|| message.includes("address already in use")
|| message.includes("permission denied")
|| message.includes("listen");
}
function destroyTunnelPipeEndpoint(endpoint) {
if (!endpoint) return;
try { endpoint.destroy?.(); } catch { /* ignore */ }
try { endpoint.close?.(); } catch { /* ignore */ }
try { endpoint.end?.(); } catch { /* ignore */ }
}
function destroyTunnelPipeEntry(tunnelState, entry, { abortOpen = true, remove = true } = {}) {
if (!entry) return;
entry.closed = true;
const openAbortController = entry.openAbortController;
const shouldDestroyEndpoints = !entry.endpointsDestroyed;
entry.endpointsDestroyed = true;
if (abortOpen && openAbortController && !openAbortController.signal.aborted) {
try { openAbortController.abort(new Error("Port forward client closed during SSH channel open")); } catch { /* ignore */ }
}
if (remove) {
entry.openAbortController = null;
try { tunnelState?.activePipes?.delete(entry); } catch { /* ignore */ }
}
if (shouldDestroyEndpoints) {
destroyTunnelPipeEndpoint(entry.socket);
destroyTunnelPipeEndpoint(entry.stream);
}
}
function attachTunnelPipeStream(tunnelState, entry, stream) {
if (!entry || entry.closed || isTunnelCancelled(tunnelState)) {
if (entry) destroyTunnelPipeEntry(tunnelState, entry, { abortOpen: false });
destroyTunnelPipeEndpoint(stream);
return false;
}
entry.openAbortController = null;
entry.stream = stream;
const drop = () => destroyTunnelPipeEntry(tunnelState, entry);
try { stream?.once?.("close", drop); } catch { /* ignore */ }
try { stream?.once?.("error", drop); } catch { /* ignore */ }
return true;
}
function trackTunnelPipe(tunnelState, socket, stream = null) {
if (!tunnelState) return null;
if (!(tunnelState.activePipes instanceof Set)) tunnelState.activePipes = new Set();
const entry = {
socket,
stream: null,
closed: false,
openStarted: false,
endpointsDestroyed: false,
openAbortController: stream ? null : new AbortController(),
};
tunnelState.activePipes.add(entry);
// A client disconnect only abandons this channel open. The bounded open
// helper invalidates the physical SSH connection when its signal aborts;
// doing that here would tear down a shared local-forward listener. Tunnel-
// wide cleanup still aborts pending opens through destroyTunnelPipes(). Keep
// a pending entry tracked until its late channel callback is closed, so a
// later tunnel stop can still cancel the underlying request.
const drop = () => {
const pendingOpen = Boolean(entry.openStarted && entry.openAbortController);
destroyTunnelPipeEntry(tunnelState, entry, {
abortOpen: false,
remove: !pendingOpen,
});
};
try { socket?.once?.("close", drop); } catch { /* ignore */ }
try { socket?.once?.("error", drop); } catch { /* ignore */ }
if (stream) attachTunnelPipeStream(tunnelState, entry, stream);
return entry;
}
function destroyTunnelPipes(tunnel) {
if (!(tunnel?.activePipes instanceof Set)) return;
for (const entry of [...tunnel.activePipes]) {
destroyTunnelPipeEntry(tunnel, entry);
}
tunnel.activePipes.clear();
}
/**
* Release SSH for a tunnel: if the tunnel holds a transport lease, return it
* (may idle-park). Otherwise end the dedicated Client as before.
*/
function releaseTunnelSsh(tunnel) {
if (!tunnel) return;
if (tunnel.sshTransportManaged) {
try {
returnTransport(tunnel);
} catch {
/* ignore */
}
tunnel.conn = null;
tunnel.chainConnections = [];
tunnel.sshTransportManaged = false;
return;
}
if (Array.isArray(tunnel.chainConnections)) {
cleanupChainConnections(tunnel.chainConnections);
tunnel.chainConnections = [];
}
if (tunnel.conn) {
try { tunnel.conn.end(); } catch { /* ignore */ }
tunnel.conn = null;
}
}
/**
* Register an authenticated SSH connection as a shared transport + forward lease.
* Lets terminal/SFTP later borrow the same conn; stop returns the lease only.
*/
function attachForwardTransportLease(tunnel, conn, chainConnections, endpoint) {
if (!tunnel || !conn) return null;
const transport = createTransport({
conn,
chainConnections: Array.isArray(chainConnections) ? chainConnections : [],
endpoint,
});
borrowTransport(transport, {
kind: LEASE_KINDS.forward,
holder: tunnel,
leaseId: `forward:${tunnel.tunnelId || tunnel.id || "unknown"}`,
meta: { source: "port-forward" },
});
tunnel.sshTransportManaged = true;
tunnel.conn = conn;
// Chain is owned by the transport registry now.
tunnel.chainConnections = [];
return transport;
}
/** Max wait for remote-side unforwardIn before treating the listen as stuck. */
const UNFORWARD_TIMEOUT_MS = 5_000;
const REMOTE_FORWARD_START_CLEANUP_TIMEOUT_MS = 5_000;
/**
* Force-end a shared transport after unforward failure/timeout so a remote
* listen cannot stay exposed while the lease is only returned (parked).
*/
function discardUnforwardTransport(conn, transport, reason) {
try {
if (transport && typeof discardTransport === "function") {
discardTransport(transport, reason);
} else if (conn) {
try { conn.end(); } catch { /* ignore */ }
}
} catch { /* ignore discard errors */ }
}
/**
* Cancel a remote forward listen and wait for the ssh2 callback.
* On timeout, callback error, or throw, discard the shared transport so a
* half-open remote listen cannot be idle-parked for another session to reuse.
*
* @param {object|null} conn
* @param {string} bindAddress
* @param {number} port
* @param {object|null} [transport] transport registry entry (connRef) when known
* @returns {Promise<{ ok: boolean, timedOut?: boolean, discarded?: boolean, error?: Error }>}
*/
function unforwardRemoteListen(conn, bindAddress, port, transport = null) {
return new Promise((resolve) => {
if (!conn || typeof conn.unforwardIn !== "function") {
resolve({ ok: true });
return;
}
let settled = false;
const finish = (result) => {
if (settled) return;
settled = true;
resolve(result);
};
const timer = setTimeout(() => {
console.warn(
`[PortForward] unforwardIn timed out after ${UNFORWARD_TIMEOUT_MS}ms`
+ ` for ${bindAddress}:${port}; discarding transport`,
);
discardUnforwardTransport(conn, transport, "unforward-timeout");
finish({
ok: false,
timedOut: true,
discarded: true,
error: new Error(`unforwardIn timed out after ${UNFORWARD_TIMEOUT_MS}ms`),
});
}, UNFORWARD_TIMEOUT_MS);
try { timer.unref?.(); } catch { /* ignore */ }
try {
conn.unforwardIn(bindAddress, port, (err) => {
clearTimeout(timer);
if (err) {
const error = err instanceof Error ? err : new Error(String(err));
console.warn(
`[PortForward] unforwardIn failed for ${bindAddress}:${port}; discarding transport:`,
error.message,
);
// Server did not confirm listen removal — do not park the shared conn.
discardUnforwardTransport(conn, transport, "unforward-error");
finish({ ok: false, discarded: true, error });
return;
}
finish({ ok: true });
});
} catch (syncErr) {
clearTimeout(timer);
const error = syncErr instanceof Error ? syncErr : new Error(String(syncErr));
console.warn(
`[PortForward] unforwardIn threw for ${bindAddress}:${port}; discarding transport:`,
error.message,
);
discardUnforwardTransport(conn, transport, "unforward-throw");
finish({ ok: false, discarded: true, error });
}
});
}
function settleRemoteForwardStart(tunnel, outcome) {
tunnel._remoteForwardOutcome = outcome;
tunnel.pendingRemoteForward = false;
const resolve = tunnel._resolveRemoteForwardStart;
tunnel._resolveRemoteForwardStart = null;
resolve?.(outcome);
}
function waitForRemoteForwardStart(tunnel) {
if (!tunnel?.pendingRemoteForward) {
return Promise.resolve(tunnel?._remoteForwardOutcome || { ok: false });
}
const pending = tunnel._remoteForwardStartPromise;
if (!pending) return Promise.resolve({ ok: false });
return new Promise((resolve) => {
let settled = false;
const finish = (outcome) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(outcome);
};
const configuredTimeoutMs = Number(tunnel?._remoteForwardStartCleanupTimeoutMs);
const timeoutMs = Number.isFinite(configuredTimeoutMs) && configuredTimeoutMs >= 0
? configuredTimeoutMs
: REMOTE_FORWARD_START_CLEANUP_TIMEOUT_MS;
const timer = setTimeout(() => finish({ ok: false, timedOut: true }), timeoutMs);
timer.unref?.();
pending.then(finish, (error) => finish({ ok: false, error }));
});
}
function unforwardRemoteListenOnce(tunnel, conn, bindAddress, port, transport = null) {
if (tunnel._remoteUnforwardPromise) return tunnel._remoteUnforwardPromise;
if (tunnel._remoteUnforwardDone) {
return Promise.resolve(tunnel._remoteUnforwardResult || { ok: true });
}
const promise = unforwardRemoteListen(conn, bindAddress, port, transport)
.then((result) => {
tunnel._remoteUnforwardDone = true;
tunnel._remoteUnforwardResult = result;
return result;
});
tunnel._remoteUnforwardPromise = promise;
return promise;
}
/**
* Bind local/remote/dynamic forwarding onto an already-authenticated conn.
* Used for both shared-transport and post-dial paths.
*/
function bindPortForwardChannels({
type,
conn,
tunnelId,
tunnelState,
sender,
bindAddress,
localPort,
remoteHost,
remotePort,
chainConnections,
sendStatus,
releaseOnError = false,
endpoint = null,
registerTransport = false,
dialCoordination = null,
}) {
return new Promise((resolve, reject) => {
let settled = false;
const fail = (err) => {
const message = err?.message || String(err);
sendStatus?.("error", message);
// Post-bind listener errors: start promise may already be resolved, so
// always demote the tunnel so restart cannot reuse a zombie "active" entry.
try { destroyTunnelPipes(tunnelState); } catch { /* ignore */ }
if (tunnelState.server) {
try { tunnelState.server.close(); } catch { /* ignore */ }
tunnelState.server = null;
}
// Detach shared lifecycle while conn is still on the tunnel — release
// clears conn and would make detach a no-op, leaking close/error listeners
// on long-lived shared transports after EADDRINUSE etc.
try { detachSharedTransportLifecycle(tunnelState); } catch { /* ignore */ }
// Always release a managed lease (shared reuse or newly registered).
// Otherwise a dedicated dial that registered into the pool can orphan
// its transport after a post-bind listener error.
if (tunnelState.sshTransportManaged || releaseOnError) {
releaseTunnelSsh(tunnelState);
} else {
try { conn.end(); } catch { /* ignore */ }
cleanupChainConnections(chainConnections);
}
tunnelState.status = "error";
tunnelState.error = message;
if (shouldFinalizeTunnelClose(tunnelState)) {
portForwardingTunnels.delete(tunnelId);
}
if (!settled) {
settled = true;
reject(err);
}
};
if (type === "local") {
const server = net.createServer((socket) => {
const pipeEntry = trackTunnelPipe(tunnelState, socket);
pipeEntry.openStarted = true;
openBoundedForwardOutCallback(
conn,
bindAddress,
localPort,
remoteHost,
remotePort,
(err, stream) => {
if (err) {
console.error(`[PortForward] Forward error:`, err.message);
destroyTunnelPipeEntry(tunnelState, pipeEntry, { abortOpen: false });
return;
}
if (!attachTunnelPipeStream(tunnelState, pipeEntry, stream)) return;
socket.pipe(stream).pipe(socket);
socket.on("error", (e) => console.warn("[PortForward] Socket error:", e.message));
stream.on("error", (e) => console.warn("[PortForward] Stream error:", e.message));
},
{ signal: pipeEntry?.openAbortController?.signal },
);
});
server.on("error", (err) => {
console.error(`[PortForward] Server error:`, err.message);
fail(err);
});
server.listen(localPort, bindAddress, () => {
console.log(`[PortForward] Local forwarding active: ${bindAddress}:${localPort} -> ${remoteHost}:${remotePort}`);
try {
// User may have cancelled while listen was pending (shared transport
// path). Do not resurrect a stopped tunnel or leave the port open.
if (isTunnelCancelled(tunnelState)) {
try { server.close(); } catch { /* ignore */ }
try { destroyTunnelPipes(tunnelState); } catch { /* ignore */ }
settled = true;
resolve({ tunnelId, success: false, cancelled: true });
return;
}
tunnelState.type = "local";
tunnelState.conn = conn;
tunnelState.server = server;
tunnelState.chainConnections = chainConnections;
tunnelState.status = "active";
tunnelState.webContentsId = sender.id;
tunnelState.pendingConn = null;
if (registerTransport && endpoint && !tunnelState.sshTransportManaged) {
const transport = attachForwardTransportLease(tunnelState, conn, chainConnections, endpoint);
if (dialCoordination && transport) completeTransportDial(dialCoordination, transport);
}
portForwardingTunnels.set(tunnelId, tunnelState);
sendStatus?.("active");
settled = true;
resolve({ tunnelId, success: true });
} catch (regErr) {
try { server.close(); } catch { /* ignore */ }
fail(regErr);
}
});
return;
}
if (type === "remote") {
// Filter by destPort so multiple remote forwards on a shared transport
// do not accept each other's connections. Attach only after forwardIn
// succeeds; remove on cancel.
const onTcpConnection = (info, accept, rejectConn) => {
// Match listen bind + port so two remote forwards on the same transport
// with the same port but different bind addresses do not steal each other.
const destPort = Number(info?.destPort);
if (destPort !== Number(localPort)) return;
const destNorm = normalizeRemoteAddress(info?.destIP);
const bindNorm = normalizeRemoteAddress(bindAddress || "127.0.0.1");
// Wildcard binds accept any destIP (ssh2 reports the concrete NIC).
// Loopback binds accept localhost / 127.0.0.1 / ::1 interchangeably.
if (bindNorm !== "wildcard" && destNorm && destNorm !== bindNorm) return;
let stream;
try {
stream = accept();
} catch (acceptErr) {
console.warn("[PortForward] accept failed:", acceptErr?.message || acceptErr);
try { rejectConn?.(); } catch { /* ignore */ }
return;
}
const socket = net.connect(remotePort, remoteHost || "127.0.0.1", () => {
stream.pipe(socket).pipe(stream);
});
trackTunnelPipe(tunnelState, socket, stream);
socket.on("error", (e) => {
console.warn("[PortForward] Local socket error:", e.message);
stream.end();
});
stream.on("error", (e) => {
console.warn("[PortForward] Remote stream error:", e.message);
socket.end();
});
};
// Record remote bind target *before* forwardIn so cancel-during-listen can
// always unforward or discard — localPort is not only set on success.
tunnelState.type = "remote";
tunnelState.bindAddress = bindAddress;
tunnelState.localPort = localPort;
tunnelState.remoteHost = remoteHost;
tunnelState.remotePort = remotePort;
tunnelState.pendingRemoteForward = true;
tunnelState.conn = conn;
tunnelState._remoteForwardOutcome = null;
tunnelState._remoteForwardStartPromise = new Promise((resolve) => {
tunnelState._resolveRemoteForwardStart = resolve;
});
tunnelState.remoteForwardAbortController = new AbortController();
openBoundedForwardInCallback(conn, bindAddress, localPort, (err) => {
tunnelState.remoteForwardAbortController = null;
settleRemoteForwardStart(tunnelState, err ? { ok: false, error: err } : { ok: true });
if (err) {
if (isTunnelCancelled(tunnelState)) {
settled = true;
resolve({ tunnelId, success: false, cancelled: true });
return;
}
console.error(`[PortForward] Remote forward error:`, err.message);
fail(err);
return;
}
console.log(`[PortForward] Remote forwarding active: remote ${bindAddress}:${localPort} -> local ${remoteHost}:${remotePort}`);
// Async cleanup paths must not leave remote listens fire-and-forget when
// cancel races forwardIn on a shared transport.
void (async () => {
try {
if (isTunnelCancelled(tunnelState)) {
const transport = tunnelState.connRef || null;
const unfwd = await unforwardRemoteListenOnce(
tunnelState,
conn,
bindAddress,
localPort,
transport,
);
if (unfwd.discarded || unfwd.timedOut) {
// Transport already discarded; drop local refs so release does
// not try to park a dead shared conn.
tunnelState.sshTransportManaged = false;
tunnelState.conn = null;
tunnelState.connRef = null;
}
settled = true;
resolve({ tunnelId, success: false, cancelled: true });
return;
}
tunnelState.server = null;
tunnelState.tcpConnectionHandler = onTcpConnection;
tunnelState.chainConnections = chainConnections;
tunnelState.status = "active";
tunnelState.webContentsId = sender.id;
tunnelState.pendingConn = null;
conn.on("tcp connection", onTcpConnection);
if (registerTransport && endpoint && !tunnelState.sshTransportManaged) {
const transport = attachForwardTransportLease(tunnelState, conn, chainConnections, endpoint);
if (dialCoordination && transport) completeTransportDial(dialCoordination, transport);
}
portForwardingTunnels.set(tunnelId, tunnelState);
sendStatus?.("active");
settled = true;
resolve({ tunnelId, success: true });
} catch (regErr) {
try { conn.removeListener("tcp connection", onTcpConnection); } catch { /* ignore */ }
const transport = tunnelState.connRef || null;
const unfwd = await unforwardRemoteListenOnce(
tunnelState,
conn,
bindAddress,
localPort,
transport,
);
if (unfwd.discarded || unfwd.timedOut) {
tunnelState.sshTransportManaged = false;
tunnelState.conn = null;
tunnelState.connRef = null;
}
fail(regErr);
}
})();
}, { signal: tunnelState.remoteForwardAbortController.signal });
return;
}
if (type === "dynamic") {
const server = net.createServer((socket) => {
const pipeEntry = trackTunnelPipe(tunnelState, socket);
socket.once("data", (data) => {
if (data[0] !== 0x05) {
destroyTunnelPipeEntry(tunnelState, pipeEntry, { abortOpen: false });
socket.end();
return;
}
socket.write(Buffer.from([0x05, 0x00]));
socket.once("data", (request) => {
if (request[0] !== 0x05 || request[1] !== 0x01) {
socket.write(Buffer.from([0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
socket.end();
destroyTunnelPipeEntry(tunnelState, pipeEntry, { abortOpen: false });
return;
}
let targetHost;
let targetPort;
const addressType = request[3];
if (addressType === 0x01) {
targetHost = `${request[4]}.${request[5]}.${request[6]}.${request[7]}`;
targetPort = request.readUInt16BE(8);
} else if (addressType === 0x03) {
const domainLength = request[4];
targetHost = request.slice(5, 5 + domainLength).toString();
targetPort = request.readUInt16BE(5 + domainLength);
} else if (addressType === 0x04) {
socket.write(Buffer.from([0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
socket.end();
destroyTunnelPipeEntry(tunnelState, pipeEntry, { abortOpen: false });
return;
} else {
socket.write(Buffer.from([0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
socket.end();
destroyTunnelPipeEntry(tunnelState, pipeEntry, { abortOpen: false });
return;
}
pipeEntry.openStarted = true;
openBoundedForwardOutCallback(
conn,
bindAddress,
0,
targetHost,
targetPort,
(err, stream) => {
if (err) {
socket.write(Buffer.from([0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
socket.end();
destroyTunnelPipeEntry(tunnelState, pipeEntry, { abortOpen: false });
return;
}
if (!attachTunnelPipeStream(tunnelState, pipeEntry, stream)) return;
const reply = Buffer.alloc(10);
reply[0] = 0x05;
reply[1] = 0x00;
reply[2] = 0x00;
reply[3] = 0x01;
reply.writeUInt16BE(0, 8);
socket.write(reply);
socket.pipe(stream).pipe(socket);
socket.on("error", () => stream.end());
stream.on("error", () => socket.end());
},
{ signal: pipeEntry?.openAbortController?.signal },
);
});
});
});
server.on("error", (err) => {
console.error(`[PortForward] SOCKS server error:`, err.message);
fail(err);
});
server.listen(localPort, bindAddress, () => {
console.log(`[PortForward] Dynamic SOCKS5 proxy active on ${bindAddress}:${localPort}`);
try {
if (isTunnelCancelled(tunnelState)) {
try { server.close(); } catch { /* ignore */ }
try { destroyTunnelPipes(tunnelState); } catch { /* ignore */ }
settled = true;
resolve({ tunnelId, success: false, cancelled: true });
return;
}
tunnelState.type = "dynamic";
tunnelState.conn = conn;
tunnelState.server = server;
tunnelState.chainConnections = chainConnections;
tunnelState.status = "active";
tunnelState.webContentsId = sender.id;
tunnelState.pendingConn = null;
if (registerTransport && endpoint && !tunnelState.sshTransportManaged) {
const transport = attachForwardTransportLease(tunnelState, conn, chainConnections, endpoint);
if (dialCoordination && transport) completeTransportDial(dialCoordination, transport);
}
portForwardingTunnels.set(tunnelId, tunnelState);
sendStatus?.("active");
settled = true;
resolve({ tunnelId, success: true });
} catch (regErr) {
try { server.close(); } catch { /* ignore */ }
fail(regErr);
}
});
return;
}
reject(new Error(`Unknown forwarding type: ${type}`));
});
}
function cleanupChainConnections(connections) {
if (!Array.isArray(connections)) return;
for (const chainConn of connections) {
try { chainConn.end(); } catch { /* ignore */ }
}
}
function isTunnelCancelled(tunnelState) {
return Boolean(tunnelState?.cancelled);
}
function isReusableTunnelStatus(status) {
return status === 'active' || status === 'connecting';
}
function publishTunnelStatus(tunnelId, tunnel, status, error = null) {
if (!tunnel) return;
tunnel.status = status;
tunnel.error = error || undefined;
tunnel.updatedAt = Date.now();
const runtimeEvent = publishRuntimeUpsert(tunnelId, tunnel);
const subscribers = tunnel.subscribers instanceof Map
? Array.from(tunnel.subscribers.entries())
: [];
for (const [subscriberId, subscriber] of subscribers) {
if (subscriber?.isDestroyed?.()) {
tunnel.subscribers.delete(subscriberId);
continue;
}
safeSend(subscriber, "netcatty:portforward:status", {
tunnelId,
status,
error,
ruleId: tunnel.ruleId,
epoch: runtimeEvent.epoch,
revision: runtimeEvent.revision,
cleanupRequired: Boolean(tunnel.cleanupFailed),
});
}
}
function shouldFinalizeTunnelClose(tunnel) {
return !tunnel?.cleanupFailed && !tunnel?.cleanupInProgress;
}
async function cancelTunnel(tunnelId, tunnel, sendStatus, { deleteEntry = false } = {}) {
if (!tunnel) return;
const errors = [];
const cleanup = (label, action) => {
try {
action();
return true;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
errors.push(`${label}: ${message}`);
return false;
}
};
tunnel.cancelled = true;
tunnel.cleanupInProgress = true;
keyboardInteractiveHandler.cancelRequestsForSession(tunnelId, "tunnel-stopped");
// Destroy accepted sockets/streams so traffic cannot outlive the tunnel on a
// shared transport held by another session or long idle park.
cleanup("active forwarded pipes", () => destroyTunnelPipes(tunnel));
if (tunnel.server) {
if (cleanup('server', () => tunnel.server.close())) tunnel.server = null;
}
if (tunnel.passphraseAbortController && !tunnel.passphraseAbortController.signal.aborted) {
cleanup('passphrase prompt', () => tunnel.passphraseAbortController.abort());
}
if (tunnel.transportWaitAbortController && !tunnel.transportWaitAbortController.signal.aborted) {
cleanup('shared SSH wait', () => tunnel.transportWaitAbortController.abort(
new Error("Port forward connection cancelled"),
));
}
if (tunnel.remoteForwardAbortController && !tunnel.remoteForwardAbortController.signal.aborted) {
const transport = tunnel.connRef || null;
const abortedRemoteOpen = cleanup('remote forward open', () => tunnel.remoteForwardAbortController.abort(
new Error("Port forward connection cancelled"),
));
if (abortedRemoteOpen) {
// forwardIn has no request-level cancellation in ssh2. The bounded open
// invalidates the physical connection so its retained callback cannot
// accumulate. Drop the tunnel's references as well: release cleanup must
// not end the same dedicated client twice or return the dead shared
// transport to the pool.
if (transport && typeof discardTransport === "function") {
cleanup('remote forward transport', () => discardTransport(
transport,
"pending-remote-forward-cancel",
));
}
tunnel._remoteForwardTransportInvalidated = true;
tunnel.sshTransportManaged = false;
tunnel.conn = null;
tunnel.connRef = null;
}
}
if (tunnel.pendingConn) {
if (cleanup('pending SSH connection', () => tunnel.pendingConn.end())) tunnel.pendingConn = null;
}
// Detach shared-transport lifecycle + remote tcp filters first so long-lived
// shared connections do not accumulate close/error listeners across restarts.
cleanup("shared transport lifecycle", () => detachSharedTransportLifecycle(tunnel));
if (tunnel.tcpConnectionHandler && tunnel.conn?.removeListener) {
cleanup("remote tcp handler", () => {
tunnel.conn.removeListener("tcp connection", tunnel.tcpConnectionHandler);
tunnel.tcpConnectionHandler = null;
});
}
if (tunnel.pendingRemoteForward && tunnel._remoteForwardStartPromise) {
const startOutcome = await waitForRemoteForwardStart(tunnel);
if (tunnel._remoteForwardTransportInvalidated) {
tunnel.pendingRemoteForward = false;
}
if (startOutcome?.timedOut) {
const transport = tunnel.connRef || null;
discardUnforwardTransport(
tunnel.conn,
transport,
"pending-remote-forward-timeout",
);
tunnel._remoteForwardOutcome = startOutcome;
tunnel._remoteUnforwardDone = true;
tunnel._remoteUnforwardResult = { ok: true, discarded: true, timedOut: true };
tunnel.pendingRemoteForward = false;
tunnel.sshTransportManaged = false;
tunnel.conn = null;
tunnel.connRef = null;
}
}
// Remote forwards leave a server-side listen until unforwardIn succeeds.
// Also cover cancel-during-forwardIn: bind target is recorded before the
// ssh2 callback so we never skip unforward just because status was still
// "connecting".
if (
(tunnel.type === "remote" || tunnel.pendingRemoteForward)
&& tunnel.conn
&& typeof tunnel.conn.unforwardIn === "function"
&& Number.isFinite(tunnel.localPort)
&& !tunnel._remoteUnforwardDone
&& (
tunnel._remoteForwardOutcome?.ok === true
|| !tunnel._remoteForwardStartPromise
)
) {
const bind = tunnel.bindAddress || "127.0.0.1";
const port = tunnel.localPort;
const unfwd = await unforwardRemoteListenOnce(
tunnel,
tunnel.conn,
bind,
port,
tunnel.connRef || null,
);
tunnel.pendingRemoteForward = false;
if (unfwd.discarded || unfwd.timedOut) {
// Transport already discarded inside the helper; clear local refs so
// releaseTunnelSsh does not try to park a dead shared conn.
tunnel.sshTransportManaged = false;
tunnel.conn = null;
tunnel.connRef = null;
}
if (!unfwd.ok) {
const message = unfwd.error?.message || String(unfwd.error || "unforward failed");
// Still record the failure for the caller; remote listen state is unclean.
errors.push(`remote forward listen: ${message}`);
}
} else if (tunnel.pendingRemoteForward && tunnel.sshTransportManaged) {
// Pending remote bind without a known port (should not happen after the
// pre-record above) — discard rather than park an uncertain listen.
try {
const transport = tunnel.connRef || null;
if (transport && typeof discardTransport === "function") {
discardTransport(transport, "pending-remote-forward-cancel");
}
tunnel.sshTransportManaged = false;
tunnel.conn = null;
tunnel.connRef = null;
} catch { /* ignore */ }
tunnel.pendingRemoteForward = false;
}
// Keep cleaning the SSH connection even when an earlier resource (for
// example the local listener) failed to close. Throw only after every
// independent cleanup step has had a chance to run; otherwise a listener
// error strands the underlying SSH socket indefinitely.
if (tunnel.sshTransportManaged) {
if (cleanup('SSH transport lease', () => releaseTunnelSsh(tunnel))) {
/* lease returned */
}
} else {
if (Array.isArray(tunnel.chainConnections)) {
tunnel.chainConnections = tunnel.chainConnections.filter((chainConn, index) => (
!cleanup(`jump connection ${index + 1}`, () => chainConn.end())
));
}
if (tunnel.conn) {
if (cleanup('SSH connection', () => tunnel.conn.end())) tunnel.conn = null;
}
}
if (errors.length > 0) {
const error = errors.join('; ');
tunnel.status = 'error';
tunnel.error = error;
tunnel.cleanupFailed = true;
tunnel.cleanupInProgress = false;
sendStatus?.('error', error);
throw new Error(error);
}
tunnel.status = 'inactive';
tunnel.cleanupFailed = false;
tunnel.cleanupInProgress = false;
sendStatus?.('inactive');
if (deleteEntry) {
const ruleId = tunnel.ruleId;
portForwardingTunnels.delete(tunnelId);
// Removal is a distinct revision after the inactive upsert so subscribers
// can detect delete vs retained error records (cleanupRequired).
publishRuntimeRemove(tunnelId, ruleId);
}
}
const { safeSend } = require("./ipcUtils.cjs");
/**
* Start a port forwarding tunnel
*/
async function startPortForward(event, payload) {
const {
ruleId,
tunnelId,
type, // 'local' | 'remote' | 'dynamic'
localPort,
bindAddress = '127.0.0.1',
remoteHost,
remotePort,
hostname,
hostId,
port = 22,
username,
authMethod,
authPolicyVersion,
requiresMfa,
password,
privateKey,
publicKey,
certificate,
keyId,
passphrase,
knownHosts,
verifyHostKeys,
proxy,
jumpHosts = [],
identityFilePaths,
useSshAgent,
agentPublicKeys,
identityAgent,
identitiesOnly,
addKeysToAgent,
useKeychain,
legacyAlgorithms,
skipEcdsaHostKey,
algorithmOverrides,
keepaliveInterval: resolvedKeepaliveInterval,
keepaliveCountMax: resolvedKeepaliveCountMax,
sshTcpConnectTimeoutMs,
sshAuthReadyTimeoutMs,
reuseTransport = true,
} = payload;
// The rule is the durable identity; tunnelId is only one renderer's
// attempt. Reuse an in-flight/live tunnel so two windows cannot create
// duplicate listeners for the same saved rule.
if (ruleId) {
for (const [existingTunnelId, existingTunnel] of portForwardingTunnels) {
if (existingTunnel.ruleId !== ruleId) continue;
if (existingTunnel.cancelled) {
if (existingTunnel.cleanupFailed) {
return {
tunnelId: existingTunnelId,
success: false,
blockedByCleanup: true,
error: 'The existing tunnel could not be cleaned up. Stop it successfully before restarting.',
};
}
continue;
}
if (!isReusableTunnelStatus(existingTunnel.status)) {
return {
tunnelId: existingTunnelId,
success: false,
error: existingTunnel.error || 'The existing tunnel is no longer reusable.',
};
}
if (!(existingTunnel.subscribers instanceof Map)) {
existingTunnel.subscribers = new Map();
}
existingTunnel.subscribers.set(event.sender.id, event.sender);
return {
tunnelId: existingTunnelId,
success: true,
reused: true,
status: existingTunnel.status || 'active',
};
}
}
const connectionTimeouts = resolveSshConnectionTimeouts({
sshTcpConnectTimeoutMs,
sshAuthReadyTimeoutMs,
});
const sender = event.sender;
const hasJumpHosts = jumpHosts.length > 0;
const hasProxy = !!proxy;
let chainConnections = [];
let connectionSocket = null;
const passphraseAbortController = new AbortController();
const transportWaitAbortController = new AbortController();
const tunnelState = {
type,
tunnelId,
conn: null,
pendingConn: null,
server: null,
chainConnections,
passphraseAbortController,
ruleId,
status: 'connecting',
webContentsId: sender.id,
subscribers: new Map([[sender.id, sender]]),
cancelled: false,
sshTransportManaged: false,
};
const sendStatus = (status, error = null) => {
publishTunnelStatus(tunnelId, tunnelState, status, error);
};
// Publish before waiting for another opener's physical dial. A stop request
// must be able to find and cancel this waiter immediately.
portForwardingTunnels.set(tunnelId, tunnelState);
sendStatus('connecting');
const reuseEndpoint = buildPortForwardEndpointFromStartPayload(payload);
// Atomically join a compatible physical dial when no authenticated
// transport exists yet. Explicitly dedicated forwards remain isolated and
// are not published into the shared pool.
let pendingDialCoordination = null;
let existingTransport = reuseTransport !== false
? findTransportByEndpoint(reuseEndpoint)
: null;
try {
if (!existingTransport && reuseTransport !== false && typeof beginTransportDial === "function") {
const coordination = beginTransportDial(reuseEndpoint, { kind: "channel" });
if (coordination.role === "reuse") {
existingTransport = coordination.transport;
} else if (coordination.role === "join") {
tunnelState.transportWaitAbortController = transportWaitAbortController;
existingTransport = await waitForTransportDial(coordination, {
signal: transportWaitAbortController.signal,
});
tunnelState.transportWaitAbortController = null;
} else {
pendingDialCoordination = coordination;
}
}
} catch (error) {
if (isTunnelCancelled(tunnelState) || transportWaitAbortController.signal.aborted) {
portForwardingTunnels.delete(tunnelId);
return { tunnelId, success: false, cancelled: true };
}
portForwardingTunnels.delete(tunnelId);
sendStatus('error', error?.message || String(error));
throw error;
}
if (isTunnelCancelled(tunnelState)) {
portForwardingTunnels.delete(tunnelId);
return { tunnelId, success: false, cancelled: true };
}
const abandonPendingDial = (reason) => {
if (!pendingDialCoordination) return;
failTransportDial(
pendingDialCoordination,
reason instanceof Error ? reason : new Error(String(reason || "Port forward connection cancelled")),
);
};
// Prefer an already-authenticated transport (live terminal or idle park).
if (existingTransport?.conn) {
tunnelState.conn = existingTransport.conn;
tunnelState.chainConnections = [];
portForwardingTunnels.set(tunnelId, tunnelState);
try {
borrowTransport(existingTransport, {
kind: LEASE_KINDS.forward,
holder: tunnelState,
leaseId: `forward:${tunnelId}`,
meta: { source: "port-forward-shared" },
});
tunnelState.sshTransportManaged = true;
console.log(`[PortForward] Reusing shared SSH transport for tunnel ${tunnelId}`);
// Attach before bind so a mid-listen transport drop cannot leave a zombie
// local/dynamic server without close/error teardown.
attachSharedTransportLifecycle(tunnelId, tunnelState, existingTransport.conn, sendStatus);
let sharedResult;
try {
sharedResult = await bindPortForwardChannels({
type,
conn: existingTransport.conn,
tunnelId,
tunnelState,
sender,
bindAddress,
localPort,
remoteHost,
remotePort,
chainConnections: [],
sendStatus,
releaseOnError: true,
});
} catch (bindErr) {
try { detachSharedTransportLifecycle(tunnelState); } catch { /* ignore */ }
throw bindErr;
}
if (!sharedResult?.success) {
// Cancelled / failed bind: drop lifecycle so we do not keep listeners on
// a tunnel that will not become active.
try { detachSharedTransportLifecycle(tunnelState); } catch { /* ignore */ }
}
return sharedResult;
} catch (shareErr) {
// Local bind failures (port in use, permission) cannot be fixed by a new
// SSH dial — re-auth would only re-prompt MFA. Only retry dedicated when
// the shared transport itself looks unusable.
if (isLocalBindFailure(shareErr)) {
console.warn(
`[PortForward] Shared transport bind failed for ${hostname} (local bind); not retrying dedicated:`,
shareErr?.message || shareErr,
);
try { detachSharedTransportLifecycle(tunnelState); } catch { /* ignore */ }
try { returnTransport(tunnelState); } catch { /* ignore */ }
portForwardingTunnels.delete(tunnelId);
sendStatus("error", shareErr?.message || String(shareErr));
return {
tunnelId,
success: false,
error: shareErr?.message || String(shareErr),
};
}
console.warn(
`[PortForward] Shared transport bind failed for ${hostname}; dialing dedicated:`,
shareErr?.message || shareErr,
);
try { detachSharedTransportLifecycle(tunnelState); } catch { /* ignore */ }
try { returnTransport(tunnelState); } catch { /* ignore */ }
portForwardingTunnels.delete(tunnelId);
tunnelState.conn = null;
tunnelState.sshTransportManaged = false;
// Fall through to a dedicated dial.
}
}
const conn = new SSHClient();
tunnelState.conn = conn;
tunnelState.chainConnections = chainConnections;
// Keepalive policy:
// - positive value: honor it
// - explicit 0: truly disabled (host opted out via per-host override —
// a router/switch that doesn't reply to keepalive@openssh.com would
// otherwise be killed by ssh2 after countMax unanswered probes)
// - undefined: legacy caller path, fall back to 10s/3 so an idle
// forwarded TCP tunnel doesn't get dropped by NAT state tables.
const keepalivePolicy = resolveConnectionKeepalivePolicy({
keepaliveInterval: resolvedKeepaliveInterval,
keepaliveCountMax: resolvedKeepaliveCountMax,
});
const connectOpts = {
host: hostname,
port: port,
username: username || 'root',
timeout: connectionTimeouts.tcpConnectTimeoutMs,
readyTimeout: 0,
keepaliveInterval: keepalivePolicy.keepaliveIntervalMs,
keepaliveCountMax: keepalivePolicy.keepaliveCountMax,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
algorithms: buildAlgorithms(legacyAlgorithms, { skipEcdsaHostKey, algorithmOverrides }),
};
connectOpts.hostVerifier = hostKeyVerifier.createHostVerifier({
sender,
sessionId: tunnelId,
hostId,
hostname,
port,
knownHosts,
verifyHostKeys,
});
const hasCertificate = typeof certificate === "string" && certificate.trim().length > 0;
portForwardingTunnels.set(tunnelId, tunnelState);
let defaultKeys = [];
let portForwardAuthPhase = { hadPartialSuccess: false, passwordAlreadySucceeded: false };
let authBanner = "";
try {
const fallbackAgentSocket = useSshAgent === false
? null
: useSshAgent === true
? undefined
: await getAvailableAgentSocket(identityAgent, { hostname, port, username });
const systemAuthAgent = hasCertificate ? null : await prepareSystemSshAgentForAuth({
useSshAgent,
agentPublicKeys,
identityAgent,
identityFilePaths,
identitiesOnly,
addKeysToAgent,
useKeychain,
hostname,
port,
username,
}, "[PortForward]");
const identityFile = !privateKey && !systemAuthAgent
? await loadFirstIdentityFileForAuth({
sender,
identityFilePaths,
hostname,
initialPassphrase: passphrase,
passphraseSignal: passphraseAbortController.signal,
logPrefix: "[PortForward]",
onError: (err, keyPath) => {
console.warn(`[PortForward] Failed to read identity file ${keyPath}:`, err.message);
},
})
: null;
const inlineKey = privateKey && !systemAuthAgent
? await preparePrivateKeyForAuth({
sender,
privateKey,
keyId,
keyName: keyId || username,
hostname,
initialPassphrase: passphrase,
passphraseSignal: passphraseAbortController.signal,
logPrefix: "[PortForward]",
})
: null;
const effectivePrivateKey = inlineKey?.privateKey || identityFile?.privateKey;
const effectivePassphrase = inlineKey?.passphrase || identityFile?.passphrase;
if (isTunnelCancelled(tunnelState)) {
portForwardingTunnels.delete(tunnelId);
abandonPendingDial("Port forward connection cancelled");
return { tunnelId, success: false, cancelled: true };
}
if (systemAuthAgent) {
connectOpts.agent = systemAuthAgent;
}
if (hasCertificate) {
connectOpts.agent = new NetcattyAgent({
mode: "certificate",
webContents: sender,
meta: {
label: keyId || username || "",
certificate,
privateKey: effectivePrivateKey,
passphrase: effectivePassphrase,
},
});
} else if (effectivePrivateKey) {
connectOpts.privateKey = effectivePrivateKey;
if (effectivePassphrase) {
connectOpts.passphrase = effectivePassphrase;
}
}
if (password) {
connectOpts.password = password;
}
// Keep the discovered keys available to unrelated jump hosts even when
// strict agent selection disables them for the final target.
const discoveredDefaultKeys = await findAllDefaultPrivateKeysFromHelper();
defaultKeys = systemAuthAgent && identitiesOnly
? []
: discoveredDefaultKeys;
if (isTunnelCancelled(tunnelState)) {
portForwardingTunnels.delete(tunnelId);
abandonPendingDial("Port forward connection cancelled");
return { tunnelId, success: false, cancelled: true };
}
// Build auth handler using shared helper
const authConfig = buildAuthHandler({
authMethod,
requiresMfa: !!requiresMfa,
privateKey: connectOpts.privateKey,
password,
passphrase: connectOpts.passphrase,
agent: connectOpts.agent,
username: connectOpts.username,
logPrefix: "[PortForward]",
defaultKeys,
sshAgentSocketOverride: fallbackAgentSocket,
allowAgentFallback: useSshAgent !== false,
});
applyAuthToConnOpts(connectOpts, authConfig);
portForwardAuthPhase = authConfig.authPhase || portForwardAuthPhase;
if (isTunnelCancelled(tunnelState)) {
portForwardingTunnels.delete(tunnelId);
abandonPendingDial("Port forward connection cancelled");
return { tunnelId, success: false, cancelled: true };
}
if (hasJumpHosts) {
const chainResult = await connectThroughChain(
event,
{
hostname,
port,
username,
authMethod,
password,
privateKey,
passphrase,
useSshAgent,
identityAgent,
identityFilePaths,
identitiesOnly,
addKeysToAgent,
useKeychain,
proxy,
knownHosts,
verifyHostKeys,
jumpHosts,
legacyAlgorithms,
skipEcdsaHostKey,
algorithmOverrides,
sshTcpConnectTimeoutMs: connectionTimeouts.tcpConnectTimeoutMs,
sshAuthReadyTimeoutMs: connectionTimeouts.authReadyTimeoutMs,
_defaultKeys: discoveredDefaultKeys,
_connectionsRef: chainConnections,
_tunnelRef: tunnelState,
_passphraseSignal: passphraseAbortController.signal,
_keyboardInteractiveScope: "external",
},
jumpHosts,
hostname,
port,
tunnelId,
);
connectionSocket = chainResult.socket;
chainConnections = chainResult.connections;
tunnelState.chainConnections = chainConnections;
if (isTunnelCancelled(tunnelState)) {
cleanupChainConnections(chainConnections);
if (!tunnelState.cleanupFailed) {
portForwardingTunnels.delete(tunnelId);
}
abandonPendingDial("Port forward connection cancelled");
return { tunnelId, success: false, cancelled: true };
}
connectOpts.sock = connectionSocket;
delete connectOpts.host;
delete connectOpts.port;
} else if (hasProxy) {
connectionSocket = await createProxySocket(proxy, hostname, port, {
timeoutMs: connectionTimeouts.tcpConnectTimeoutMs,
onSocket: (socket) => {
tunnelState.pendingConn = socket;
},
});
if (isTunnelCancelled(tunnelState)) {
try { connectionSocket?.end?.(); } catch { /* ignore */ }
try { connectionSocket?.destroy?.(); } catch { /* ignore */ }
if (!tunnelState.cleanupFailed) {
portForwardingTunnels.delete(tunnelId);
}
abandonPendingDial("Port forward connection cancelled");
return { tunnelId, success: false, cancelled: true };
}
tunnelState.pendingConn = null;
connectOpts.sock = connectionSocket;
delete connectOpts.host;
delete connectOpts.port;
}
} catch (err) {
if (isTunnelCancelled(tunnelState)) {
if (!tunnelState.cleanupFailed) {
portForwardingTunnels.delete(tunnelId);
}
abandonPendingDial("Port forward connection cancelled");
return { tunnelId, success: false, cancelled: true };
}
if (isPassphraseCancelledError(err)) {
try {
await cancelTunnel(tunnelId, tunnelState, sendStatus, { deleteEntry: true });
} catch {
/* best-effort cancel on passphrase cancel */
}
abandonPendingDial(err);
return { tunnelId, success: false, cancelled: true };
}
tunnelState.cancelled = true;
if (tunnelState.pendingConn) {
try { tunnelState.pendingConn.end(); } catch { /* ignore */ }
}
cleanupChainConnections(tunnelState.chainConnections);
if (connectionSocket) {
try { connectionSocket.end?.(); } catch { /* ignore */ }
try { connectionSocket.destroy?.(); } catch { /* ignore */ }
}
keyboardInteractiveHandler.cancelRequestsForSession(tunnelId, "connection-ended");
portForwardingTunnels.delete(tunnelId);
sendStatus('error', err?.message || String(err));
abandonPendingDial(err);
throw err;
}
// Handle keyboard-interactive authentication (2FA/MFA)
conn.on("banner", (message) => {
authBanner = String(message || "").trim();
});
conn.on("keyboard-interactive", createKeyboardInteractiveHandler({
sender,
sessionId: tunnelId,
hostId,
hostname,
password,
logPrefix: "[PortForward]",
scope: "external",
getAuthBanner: () => authBanner,
shouldSkipAutoFill: () => shouldSkipKiPasswordAutoFill(portForwardAuthPhase),
}));
return new Promise((resolve, reject) => {
// Track whether the Promise has been settled so conn.on('close')
// can reject if the tunnel was killed during SSH handshake.
let settled = false;
let authReadyTimer = null;
const clearAuthReadyTimer = () => {
if (!authReadyTimer) return;
clearTimeout(authReadyTimer);
authReadyTimer = null;
};
conn.once('connect', () => {
runWhenProxyConnectionReady(conn._sock, () => {
try { conn._sock?.setTimeout?.(0); } catch { /* ignore */ }
clearAuthReadyTimer();
authReadyTimer = setTimeout(
() => conn.emit('timeout'),
connectionTimeouts.authReadyTimeoutMs,
);
authReadyTimer.unref?.();
});
});
conn.once('ready', () => {
clearAuthReadyTimer();
console.log(`[PortForward] SSH connection ready for tunnel ${tunnelId}`);
bindPortForwardChannels({
type,
conn,
tunnelId,
tunnelState,
sender,
bindAddress,
localPort,
remoteHost,
remotePort,
chainConnections,
sendStatus,
releaseOnError: false,
endpoint: reuseEndpoint,
registerTransport: reuseTransport !== false,
dialCoordination: pendingDialCoordination,
}).then((result) => {
if (!result?.success && pendingDialCoordination) {
failTransportDial(pendingDialCoordination, new Error("Port forward cancelled before activation"));
}
settled = true;
resolve(result);
}).catch((err) => {
if (pendingDialCoordination) failTransportDial(pendingDialCoordination, err);
settled = true;
reject(err);
});
});
conn.on('error', (err) => {
clearAuthReadyTimer();
console.error(`[PortForward] SSH error:`, err.message);
if (settled) return;
if (pendingDialCoordination) failTransportDial(pendingDialCoordination, err);
sendStatus('error', err.message);
cleanupChainConnections(chainConnections);
settled = true;
reject(err);
});
conn.once('close', () => {
clearAuthReadyTimer();
keyboardInteractiveHandler.cancelRequestsForSession(tunnelId, "connection-ended");
console.log(`[PortForward] SSH connection closed for tunnel ${tunnelId}`);
const tunnel = portForwardingTunnels.get(tunnelId) || tunnelState;
// Capture the cancelled flag BEFORE cleanup deletes the entry.
const wasCancelled = !!tunnel?.cancelled;
if (tunnel) {
if (tunnel.server) {
try { tunnel.server.close(); } catch { }
}
if (tunnel.pendingConn) {
try { tunnel.pendingConn.end(); } catch { /* ignore */ }
}
if (tunnel.sshTransportManaged) {
// Socket died under us; drop the lease without trying to end again.
try { returnTransport(tunnel); } catch { /* ignore */ }
tunnel.sshTransportManaged = false;
tunnel.conn = null;
} else if (Array.isArray(tunnel.chainConnections)) {
cleanupChainConnections(tunnel.chainConnections);
}
if (shouldFinalizeTunnelClose(tunnel)) {
sendStatus('inactive');
portForwardingTunnels.delete(tunnelId);
}
}
// If the Promise was never settled (tunnel killed during
// handshake by stopPortForwardByRuleId), settle it.
if (!settled) {
settled = true;
if (wasCancelled) {
if (pendingDialCoordination) {
failTransportDial(pendingDialCoordination, new Error("Port forward connection cancelled"));
}
resolve({ tunnelId, success: false, cancelled: true });
} else {
const err = new Error(`Tunnel ${tunnelId} closed before connection established`);
if (pendingDialCoordination) failTransportDial(pendingDialCoordination, err);
reject(err);
}
}
});
conn.once('timeout', () => {
clearAuthReadyTimer();
if (settled) return;
const err = new Error(`Connection timeout to ${hostname}`);
if (pendingDialCoordination) failTransportDial(pendingDialCoordination, err);
sendStatus('error', err.message);
cleanupChainConnections(chainConnections);
settled = true;
reject(err);
conn.end();
});
conn.connect(connectOpts);
});
}
/**
* Stop a port forwarding tunnel
*/
async function stopPortForward(event, payload) {
const { tunnelId } = payload;
const tunnel = portForwardingTunnels.get(tunnelId);
if (!tunnel) {
return { tunnelId, success: false, error: 'Tunnel not found' };
}
try {
await cancelTunnel(
tunnelId,
tunnel,
(status, error) => publishTunnelStatus(tunnelId, tunnel, status, error),
{ deleteEntry: true },
);
return { tunnelId, success: true };
} catch (err) {
return { tunnelId, success: false, error: err.message };
}
}
/**
* Get status of a tunnel
*/
async function getPortForwardStatus(event, payload) {
const { tunnelId } = payload;
const tunnel = portForwardingTunnels.get(tunnelId);
if (!tunnel) {
return { tunnelId, status: 'inactive' };
}
return {
tunnelId,
status: tunnel.status || 'active',
type: tunnel.type,
...(tunnel.error ? { error: tunnel.error } : {}),
};
}
/**
* Register the calling renderer for status events from an existing tunnel and
* return the status from the same main-process turn.
*/
async function subscribePortForward(event, payload) {
const { tunnelId } = payload;
const tunnel = portForwardingTunnels.get(tunnelId);
if (!tunnel) {
return { tunnelId, status: 'inactive' };
}
if (!(tunnel.subscribers instanceof Map)) {
tunnel.subscribers = new Map();
}
tunnel.subscribers.set(event.sender.id, event.sender);
return {
tunnelId,
status: tunnel.status || 'active',
type: tunnel.type,
...(tunnel.error ? { error: tunnel.error } : {}),
};
}
/** Remove a renderer-owned subscription from every tunnel in this process. */
async function unsubscribePortForwardSender(event, payload = {}) {
const webContentsId = payload.webContentsId ?? event?.sender?.id;
if (!Number.isSafeInteger(webContentsId)) return { removed: 0 };
let removed = 0;
for (const tunnel of portForwardingTunnels.values()) {
if (tunnel.subscribers instanceof Map && tunnel.subscribers.delete(webContentsId)) {
removed += 1;
}
}
const runtimeEntry = runtimeEventSubscribers.get(webContentsId);
if (runtimeEntry) {
if (runtimeEntry.onDestroyed) {
runtimeEntry.sender.removeListener?.("destroyed", runtimeEntry.onDestroyed);
}
runtimeEventSubscribers.delete(webContentsId);
removed += 1;
}
return { removed };
}
/**
* List all active port forwards
*/
async function listPortForwards() {
const list = [];
for (const [tunnelId, tunnel] of portForwardingTunnels) {
list.push({
ruleId: tunnel.ruleId,
tunnelId,
type: tunnel.type,
status: tunnel.status || 'active',
...(tunnel.error ? { error: tunnel.error } : {}),
});
}
return list;
}
/**
* Stop all active port forwards (cleanup on app quit)
*/
async function stopAllPortForwards() {
console.log(`[PortForward] Stopping all ${portForwardingTunnels.size} active tunnels...`);
const jobs = [];
for (const [tunnelId, tunnel] of portForwardingTunnels) {
jobs.push(
cancelTunnel(
tunnelId,
tunnel,
(status, error) => publishTunnelStatus(tunnelId, tunnel, status, error),
{ deleteEntry: true },
).then(
() => console.log(`[PortForward] Stopped tunnel ${tunnelId}`),
(err) => console.warn(`[PortForward] Failed to stop tunnel ${tunnelId}:`, err.message),
),
);
}
await Promise.all(jobs);
console.log('[PortForward] All tunnels stopped');
}
/**
* Stop all active port forwards for a given rule ID.
* This catches tunnels in ANY state (connecting, active) because it
* operates on the main-process portForwardingTunnels map directly.
*/
async function stopPortForwardByRuleId(_event, { ruleId }) {
let stopped = 0;
let failed = 0;
const errors = [];
for (const [tunnelId, tunnel] of portForwardingTunnels) {
if (tunnel.ruleId === ruleId) {
try {
await cancelTunnel(
tunnelId,
tunnel,
(status, error) => publishTunnelStatus(tunnelId, tunnel, status, error),
{ deleteEntry: true },
);
console.log(`[PortForward] Stopped tunnel ${tunnelId} for rule ${ruleId}`);
stopped++;
} catch (err) {
console.warn(`[PortForward] Failed to stop tunnel ${tunnelId}:`, err.message);
failed++;
errors.push(err instanceof Error ? err.message : String(err));
}
}
}
return { stopped, failed, errors };
}
/**
* Register IPC handlers for port forwarding operations
*/
function registerHandlers(ipcMain, options = {}) {
const terminalWorkerManager = options.terminalWorkerManager || null;
if (terminalWorkerManager) {
const subscriptionsBySender = new Map();
const trackedTunnelIds = new Set();
const tunnelRuleIds = new Map();
const unsubscribeDestroyedSender = (webContentsId) => {
void terminalWorkerManager.request(
"netcatty:portforward:unsubscribeSender",
{ webContentsId },
{ webContentsId },
).catch(() => {});
};
const forgetTunnel = (tunnelId) => {
if (!tunnelId) return;
trackedTunnelIds.delete(tunnelId);
tunnelRuleIds.delete(tunnelId);
for (const [webContentsId, entry] of subscriptionsBySender) {
entry.tunnelIds.delete(tunnelId);
if (entry.tunnelIds.size > 0) continue;
entry.sender.removeListener?.("destroyed", entry.onDestroyed);
subscriptionsBySender.delete(webContentsId);
}
};
const ensureSenderLifecycle = (sender) => {
if (!sender || !Number.isSafeInteger(sender.id)) return null;
if (sender.isDestroyed?.()) {
unsubscribeDestroyedSender(sender.id);
return null;
}
let entry = subscriptionsBySender.get(sender.id);
if (!entry) {
const onDestroyed = () => {
subscriptionsBySender.delete(sender.id);
unsubscribeDestroyedSender(sender.id);
};
entry = { sender, tunnelIds: new Set(), onDestroyed };
subscriptionsBySender.set(sender.id, entry);
sender.once?.("destroyed", onDestroyed);
}
return entry;
};
const releaseEmptySenderLifecycle = (sender, entry) => {
if (
!entry
|| entry.tunnelIds.size > 0
|| entry.runtimeSubscribed
|| subscriptionsBySender.get(sender?.id) !== entry
) return;
entry.sender.removeListener?.("destroyed", entry.onDestroyed);
subscriptionsBySender.delete(sender.id);
};
const trackSubscription = (sender, tunnelId, ruleId) => {
if (!tunnelId) return;
const entry = ensureSenderLifecycle(sender);
if (!entry) return;
entry.tunnelIds.add(tunnelId);
trackedTunnelIds.add(tunnelId);
if (ruleId) tunnelRuleIds.set(tunnelId, ruleId);
};
const requestWorker = (channel, { track = false, cleanup = null } = {}) => {
ipcMain.handle(channel, async (event, payload) => {
const pendingSenderEntry = track ? ensureSenderLifecycle(event?.sender) : null;
try {
const result = await terminalWorkerManager.request(channel, payload, {
webContentsId: event?.sender?.id,
});
if (
track
&& result?.tunnelId
&& result.success !== false
&& result.status !== "inactive"
&& result.status !== "error"
) {
trackSubscription(event?.sender, result.tunnelId, payload?.ruleId);
}
if (cleanup === "tunnel" && result?.success) forgetTunnel(payload?.tunnelId);
if (cleanup === "all") {
for (const tunnelId of [...trackedTunnelIds]) forgetTunnel(tunnelId);
}
if (cleanup === "rule" && result?.failed === 0) {
for (const [tunnelId, ruleId] of tunnelRuleIds) {
if (ruleId === payload?.ruleId) forgetTunnel(tunnelId);
}
}
return result;
} finally {
releaseEmptySenderLifecycle(event?.sender, pendingSenderEntry);
}
});
};
requestWorker("netcatty:portforward:start", { track: true });
requestWorker("netcatty:portforward:stop", { cleanup: "tunnel" });
requestWorker("netcatty:portforward:status");
requestWorker("netcatty:portforward:subscribe", { track: true });
requestWorker("netcatty:portforward:list");
requestWorker("netcatty:portforward:snapshot");
// Runtime subscriptions are process-scoped (no tunnelId). Keep the sender
// lifecycle entry so a destroyed window still calls unsubscribeSender,
// which clears worker-side runtimeEventSubscribers.
ipcMain.handle("netcatty:portforward:subscribeRuntime", async (event, payload) => {
const entry = ensureSenderLifecycle(event?.sender);
if (entry) entry.runtimeSubscribed = true;
try {
return await terminalWorkerManager.request(
"netcatty:portforward:subscribeRuntime",
payload,
{ webContentsId: event?.sender?.id },
);
} catch (error) {
if (entry) {
entry.runtimeSubscribed = false;
releaseEmptySenderLifecycle(event?.sender, entry);
}
throw error;
}
});
ipcMain.handle("netcatty:portforward:unsubscribeRuntime", async (event, payload) => {
const entry = subscriptionsBySender.get(event?.sender?.id);
try {
return await terminalWorkerManager.request(
"netcatty:portforward:unsubscribeRuntime",
payload,
{ webContentsId: event?.sender?.id },
);
} finally {
if (entry) entry.runtimeSubscribed = false;
releaseEmptySenderLifecycle(event?.sender, entry);
}
});
requestWorker("netcatty:portforward:stopAll", { cleanup: "all" });
requestWorker("netcatty:portforward:stopByRuleId", { cleanup: "rule" });
terminalWorkerManager.onWorkerRendererEvent?.((message) => {
if (message?.channel === "netcatty:portforward:runtime") {
// Runtime events are already targeted at subscribed renderers by the
// worker; main only needs to forget tunnel tracking on remove.
if (message.payload?.kind === "remove") {
forgetTunnel(message.payload?.tunnelId);
}
return;
}
if (message?.channel !== "netcatty:portforward:status") return;
if (message.payload?.status === "inactive" || message.payload?.status === "error") {
forgetTunnel(message.payload?.tunnelId);
}
});
terminalWorkerManager.onWorkerExit?.((error) => {
const message = error?.message || "Terminal worker exited";
const notified = new Set();
for (const entry of subscriptionsBySender.values()) {
for (const tunnelId of entry.tunnelIds) {
const key = `${entry.sender.id}:${tunnelId}`;
if (notified.has(key) || entry.sender.isDestroyed?.()) continue;
notified.add(key);
safeSend(entry.sender, "netcatty:portforward:status", {
tunnelId,
status: "error",
error: message,
});
}
}
for (const tunnelId of [...trackedTunnelIds]) forgetTunnel(tunnelId);
});
return;
}
ipcMain.handle("netcatty:portforward:start", startPortForward);
ipcMain.handle("netcatty:portforward:stop", stopPortForward);
ipcMain.handle("netcatty:portforward:status", getPortForwardStatus);
ipcMain.handle("netcatty:portforward:subscribe", subscribePortForward);
ipcMain.handle("netcatty:portforward:list", listPortForwards);
ipcMain.handle("netcatty:portforward:snapshot", () => getPortForwardSnapshot());
ipcMain.handle("netcatty:portforward:subscribeRuntime", subscribePortForwardRuntime);
ipcMain.handle("netcatty:portforward:unsubscribeRuntime", unsubscribePortForwardRuntime);
ipcMain.handle("netcatty:portforward:stopAll", () => stopAllPortForwards());
ipcMain.handle("netcatty:portforward:stopByRuleId", stopPortForwardByRuleId);
ipcMain.handle("netcatty:portforward:unsubscribeSender", unsubscribePortForwardSender);
}
module.exports = {
registerHandlers,
startPortForward,
stopPortForward,
getPortForwardStatus,
subscribePortForward,
unsubscribePortForwardSender,
listPortForwards,
getPortForwardSnapshot,
subscribePortForwardRuntime,
unsubscribePortForwardRuntime,
stopAllPortForwards,
stopPortForwardByRuleId,
cancelTunnel,
publishTunnelStatus,
shouldFinalizeTunnelClose,
isReusableTunnelStatus,
buildPortForwardEndpoint,
buildPortForwardEndpointFromStartPayload,
_resetPortForwardRuntimeMetaForTests: resetPortForwardRuntimeMetaForTests,
_seedPortForwardTunnelForTests: seedPortForwardTunnelForTests,
_clearPortForwardTunnelsForTests: clearPortForwardTunnelsForTests,
_bindPortForwardChannelsForTests: bindPortForwardChannels,
_trackTunnelPipeForTests: trackTunnelPipe,
_attachTunnelPipeStreamForTests: attachTunnelPipeStream,
_destroyTunnelPipesForTests: destroyTunnelPipes,
};