[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
347
electron/bridges/proxyUtils.cjs
Normal file
347
electron/bridges/proxyUtils.cjs
Normal file
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Proxy Utilities - Shared proxy socket creation for SSH connections
|
||||
* Extracted from sshBridge.cjs and sftpBridge.cjs to eliminate code duplication
|
||||
*/
|
||||
|
||||
const net = require("node:net");
|
||||
const { spawn } = require("node:child_process");
|
||||
const { Duplex } = require("node:stream");
|
||||
const { enableTcpNoDelay } = require("./tcpNoDelay.cjs");
|
||||
|
||||
function quotePosixShellArg(value) {
|
||||
return "'" + String(value).replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
function quoteWindowsCmdArg(value) {
|
||||
const text = String(value);
|
||||
if (/[\0\r\n"%!]/.test(text)) {
|
||||
throw new Error("ProxyCommand target contains characters that cannot be safely substituted on Windows");
|
||||
}
|
||||
return `"${text}"`;
|
||||
}
|
||||
|
||||
function quoteShellArg(value, platform = process.platform) {
|
||||
return platform === "win32" ? quoteWindowsCmdArg(value) : quotePosixShellArg(value);
|
||||
}
|
||||
|
||||
function substituteProxyCommand(command, targetHost, targetPort, options = {}) {
|
||||
const platform = options.platform || process.platform;
|
||||
return String(command || "").replace(/%%|%h|%p/g, (token) => {
|
||||
if (token === "%%") return "%";
|
||||
if (token === "%h") return quoteShellArg(targetHost, platform);
|
||||
if (token === "%p") return quoteShellArg(targetPort, platform);
|
||||
return token;
|
||||
});
|
||||
}
|
||||
|
||||
function createProcessSocket(child) {
|
||||
let proxyReady = false;
|
||||
const proxyReadyCallbacks = new Set();
|
||||
const markProxyReady = () => {
|
||||
if (proxyReady) return;
|
||||
proxyReady = true;
|
||||
for (const callback of proxyReadyCallbacks) callback();
|
||||
proxyReadyCallbacks.clear();
|
||||
};
|
||||
const socket = new Duplex({
|
||||
read() {
|
||||
child.stdout.resume();
|
||||
},
|
||||
write(chunk, encoding, callback) {
|
||||
if (!child.stdin.writable) {
|
||||
callback(new Error("ProxyCommand stdin is not writable"));
|
||||
return;
|
||||
}
|
||||
if (child.stdin.write(chunk, encoding)) {
|
||||
callback();
|
||||
} else {
|
||||
child.stdin.once("drain", callback);
|
||||
}
|
||||
},
|
||||
final(callback) {
|
||||
child.stdin.end(callback);
|
||||
},
|
||||
destroy(error, callback) {
|
||||
proxyReadyCallbacks.clear();
|
||||
try { child.stdin.destroy(); } catch { /* ignore */ }
|
||||
try { child.stdout.destroy(); } catch { /* ignore */ }
|
||||
if (!child.killed) {
|
||||
try { child.kill(); } catch { /* ignore */ }
|
||||
}
|
||||
callback(error);
|
||||
},
|
||||
});
|
||||
socket.setNoDelay = () => socket;
|
||||
socket.setKeepAlive = () => socket;
|
||||
socket.setTimeout = () => socket;
|
||||
socket.__netcattyOnProxyReady = (callback) => {
|
||||
if (proxyReady) queueMicrotask(callback);
|
||||
else proxyReadyCallbacks.add(callback);
|
||||
};
|
||||
|
||||
child.stdout.on("data", (chunk) => {
|
||||
markProxyReady();
|
||||
if (!socket.push(chunk)) child.stdout.pause();
|
||||
});
|
||||
child.stdout.on("end", () => socket.push(null));
|
||||
child.stdout.on("error", (err) => socket.destroy(err));
|
||||
child.stdin.on("error", (err) => socket.destroy(err));
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
function runWhenProxyConnectionReady(socket, callback) {
|
||||
if (typeof socket?.__netcattyOnProxyReady === "function") {
|
||||
socket.__netcattyOnProxyReady(callback);
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
}
|
||||
|
||||
function createProxyCommandSocket(proxy, targetHost, targetPort, options = {}) {
|
||||
const command = substituteProxyCommand(proxy.command, targetHost, targetPort, { platform: process.platform }).trim();
|
||||
if (!command) return Promise.reject(new Error("ProxyCommand is required"));
|
||||
|
||||
// Keep SSH ProxyCommand on the launch-time proxy env. App-level Direct/Custom
|
||||
// rewrites process.env for Node HTTP clients only and must not change SSH.
|
||||
const { buildTerminalProcessEnv } = require("./httpNetworkProxyBridge.cjs");
|
||||
const child = spawn(command, {
|
||||
shell: true,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
env: buildTerminalProcessEnv(process.env),
|
||||
});
|
||||
const socket = createProcessSocket(child);
|
||||
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
|
||||
? Number(options.timeoutMs)
|
||||
: 0;
|
||||
let settled = false;
|
||||
let stderr = "";
|
||||
let timeoutId = null;
|
||||
|
||||
const clearConnectTimeout = () => {
|
||||
if (!timeoutId) return;
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
};
|
||||
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr = (stderr + chunk.toString()).slice(-4096);
|
||||
});
|
||||
child.stdout.once("data", clearConnectTimeout);
|
||||
child.once("close", clearConnectTimeout);
|
||||
|
||||
if (timeoutMs) {
|
||||
timeoutId = setTimeout(() => {
|
||||
const err = new Error(`ProxyCommand connection timeout to ${targetHost}:${targetPort}`);
|
||||
socket.destroy(err);
|
||||
}, timeoutMs);
|
||||
timeoutId.unref?.();
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
child.once("error", (err) => {
|
||||
clearConnectTimeout();
|
||||
if (settled) {
|
||||
socket.destroy(err);
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
reject(err);
|
||||
});
|
||||
child.once("spawn", () => {
|
||||
settled = true;
|
||||
try { options.onSocket?.(socket); } catch { /* ignore */ }
|
||||
resolve(socket);
|
||||
});
|
||||
child.once("close", (code, signal) => {
|
||||
if (code === 0 || socket.destroyed) return;
|
||||
const detail = stderr.trim() ? `: ${stderr.trim()}` : "";
|
||||
const err = new Error(`ProxyCommand exited ${signal ? `with signal ${signal}` : `with code ${code}`}${detail}`);
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(err);
|
||||
} else {
|
||||
socket.destroy(err);
|
||||
}
|
||||
});
|
||||
}).catch((err) => {
|
||||
clearConnectTimeout();
|
||||
try { child.kill(); } catch { /* ignore */ }
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a socket through a proxy (HTTP CONNECT or SOCKS5)
|
||||
* @param {Object} proxy - Proxy configuration
|
||||
* @param {string} proxy.type - 'http' or 'socks5'
|
||||
* @param {string} proxy.host - Proxy host
|
||||
* @param {number} proxy.port - Proxy port
|
||||
* @param {string} [proxy.username] - Optional username for auth
|
||||
* @param {string} [proxy.password] - Optional password for auth
|
||||
* @param {string} targetHost - Target host to connect through proxy
|
||||
* @param {number} targetPort - Target port to connect through proxy
|
||||
* @param {Object} [options]
|
||||
* @param {(socket: net.Socket) => void} [options.onSocket] - Called immediately with the underlying socket
|
||||
* @returns {Promise<net.Socket>} Connected socket through proxy
|
||||
*/
|
||||
function createProxySocket(proxy, targetHost, targetPort, options = {}) {
|
||||
const { onSocket } = options;
|
||||
if (proxy.type === 'command') {
|
||||
return createProxyCommandSocket(proxy, targetHost, targetPort, options);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
|
||||
? Number(options.timeoutMs)
|
||||
: 0;
|
||||
let settled = false;
|
||||
let timeoutId = null;
|
||||
let socket = null;
|
||||
|
||||
const clearHandshakeTimeout = () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
};
|
||||
const settleResolve = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearHandshakeTimeout();
|
||||
resolve(socket);
|
||||
};
|
||||
const settleReject = (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearHandshakeTimeout();
|
||||
try { socket?.destroy?.(); } catch { /* ignore */ }
|
||||
reject(err);
|
||||
};
|
||||
const armHandshakeTimeout = () => {
|
||||
if (!timeoutMs) return;
|
||||
timeoutId = setTimeout(() => {
|
||||
settleReject(new Error(`Proxy connection timeout to ${targetHost}:${targetPort}`));
|
||||
}, timeoutMs);
|
||||
timeoutId.unref?.();
|
||||
};
|
||||
|
||||
if (proxy.type === 'http') {
|
||||
// HTTP CONNECT proxy
|
||||
socket = net.connect(proxy.port, proxy.host, () => {
|
||||
enableTcpNoDelay(socket);
|
||||
let authHeader = '';
|
||||
if (proxy.username && proxy.password) {
|
||||
const auth = Buffer.from(`${proxy.username}:${proxy.password}`).toString('base64');
|
||||
authHeader = `Proxy-Authorization: Basic ${auth}\r\n`;
|
||||
}
|
||||
const connectRequest = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n${authHeader}\r\n`;
|
||||
socket.write(connectRequest);
|
||||
|
||||
let response = '';
|
||||
const onData = (data) => {
|
||||
response += data.toString();
|
||||
if (response.includes('\r\n\r\n')) {
|
||||
socket.removeListener('data', onData);
|
||||
if (response.startsWith('HTTP/1.1 200') || response.startsWith('HTTP/1.0 200')) {
|
||||
settleResolve();
|
||||
} else {
|
||||
settleReject(new Error(`HTTP proxy error: ${response.split('\r\n')[0]}`));
|
||||
}
|
||||
}
|
||||
};
|
||||
socket.on('data', onData);
|
||||
});
|
||||
enableTcpNoDelay(socket);
|
||||
try { onSocket?.(socket); } catch { /* ignore */ }
|
||||
armHandshakeTimeout();
|
||||
socket.on('error', settleReject);
|
||||
} else if (proxy.type === 'socks5') {
|
||||
// SOCKS5 proxy
|
||||
socket = net.connect(proxy.port, proxy.host, () => {
|
||||
enableTcpNoDelay(socket);
|
||||
// SOCKS5 greeting
|
||||
const authMethods = proxy.username && proxy.password ? [0x00, 0x02] : [0x00];
|
||||
socket.write(Buffer.from([0x05, authMethods.length, ...authMethods]));
|
||||
|
||||
let step = 'greeting';
|
||||
const onData = (data) => {
|
||||
if (step === 'greeting') {
|
||||
if (data[0] !== 0x05) {
|
||||
settleReject(new Error('Invalid SOCKS5 response'));
|
||||
return;
|
||||
}
|
||||
const method = data[1];
|
||||
if (method === 0x02 && proxy.username && proxy.password) {
|
||||
// Username/password auth
|
||||
step = 'auth';
|
||||
const userBuf = Buffer.from(proxy.username);
|
||||
const passBuf = Buffer.from(proxy.password);
|
||||
socket.write(Buffer.concat([
|
||||
Buffer.from([0x01, userBuf.length]),
|
||||
userBuf,
|
||||
Buffer.from([passBuf.length]),
|
||||
passBuf
|
||||
]));
|
||||
} else if (method === 0x00) {
|
||||
// No auth, proceed to connect
|
||||
step = 'connect';
|
||||
sendConnectRequest();
|
||||
} else {
|
||||
settleReject(new Error('SOCKS5 authentication method not supported'));
|
||||
}
|
||||
} else if (step === 'auth') {
|
||||
if (data[1] !== 0x00) {
|
||||
settleReject(new Error('SOCKS5 authentication failed'));
|
||||
return;
|
||||
}
|
||||
step = 'connect';
|
||||
sendConnectRequest();
|
||||
} else if (step === 'connect') {
|
||||
socket.removeListener('data', onData);
|
||||
if (data[1] === 0x00) {
|
||||
settleResolve();
|
||||
} else {
|
||||
const errors = {
|
||||
0x01: 'General failure',
|
||||
0x02: 'Connection not allowed',
|
||||
0x03: 'Network unreachable',
|
||||
0x04: 'Host unreachable',
|
||||
0x05: 'Connection refused',
|
||||
0x06: 'TTL expired',
|
||||
0x07: 'Command not supported',
|
||||
0x08: 'Address type not supported',
|
||||
};
|
||||
settleReject(new Error(`SOCKS5 error: ${errors[data[1]] || 'Unknown'}`));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const sendConnectRequest = () => {
|
||||
// SOCKS5 connect request
|
||||
const hostBuf = Buffer.from(targetHost);
|
||||
const request = Buffer.concat([
|
||||
Buffer.from([0x05, 0x01, 0x00, 0x03, hostBuf.length]),
|
||||
hostBuf,
|
||||
Buffer.from([(targetPort >> 8) & 0xff, targetPort & 0xff])
|
||||
]);
|
||||
socket.write(request);
|
||||
};
|
||||
|
||||
socket.on('data', onData);
|
||||
});
|
||||
enableTcpNoDelay(socket);
|
||||
try { onSocket?.(socket); } catch { /* ignore */ }
|
||||
armHandshakeTimeout();
|
||||
socket.on('error', settleReject);
|
||||
} else {
|
||||
reject(new Error(`Unknown proxy type: ${proxy.type}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createProxySocket,
|
||||
runWhenProxyConnectionReady,
|
||||
substituteProxyCommand,
|
||||
};
|
||||
Reference in New Issue
Block a user