fix(proxy): port HTTPS-upstream TLS-wrap from deployed bridge; unify CONNECT establishment

- parseProxyConfig handles PAC 'HTTPS host:port' (tls.connect + CONNECT inside TLS) in
  both the CONNECT and plain-HTTP handlers — ports live fix d8b9db2 into the repo
- CONNECT: split tunnel establishment into establishDirect / sendConnectToProxy,
  removing the duplicate DIRECT 'connect' listener that raced onUpstreamData
- env-overridable PROFILE_FILE / REMOTES_FILE / PAC_DIR (test hermeticity)
- tests: +DIRECT echo exactly-once, +HTTPS upstream via local PAC dir + self-signed
  TLS stub (no internet needed); 8/8 pass
This commit is contained in:
tke committed 2026-09-23 14:49:07 +02:00
1 parent f434d58ef9
commit 0b8c5001fc
5 files changed
+531 -48

No files matched your search

+108 -48
View File
@@ -1,5 +1,7 @@
const http = require('http');
const https = require('https');
const net = require('net');
const tls = require('tls');
const { execFileSync } = require('child_process');
const fs = require('fs');
const vm = require('vm');
@@ -8,8 +10,10 @@ const os = require('os');
const CONFIG_FILE = process.env.PROXY_BRIDGE_CONFIG_FILE || '/opt/proxy-bridge/config.json';
const USER_FILE = process.env.PROXY_BRIDGE_USER_FILE || '/opt/proxy-bridge/user.json';
const PROFILE_FILE = '/opt/proxy-bridge/profile.json';
const PROFILE_FILE = process.env.PROXY_BRIDGE_PROFILE_FILE || '/opt/proxy-bridge/profile.json';
const OVERRIDES_FILE = process.env.PROXY_BRIDGE_OVERRIDES_FILE || '/opt/proxy-bridge/overrides.json';
const REMOTES_FILE = process.env.PROXY_BRIDGE_REMOTES_FILE || '/opt/proxy-bridge/remotes.json';
const PAC_DIR = process.env.PROXY_BRIDGE_PAC_DIR || path.join(os.homedir(), '.mozilla');
const LISTEN_HOST = process.env.PROXY_BRIDGE_LISTEN_HOST || '127.0.0.1';
const BASE_PORT = Number(process.env.PROXY_BRIDGE_BASE_PORT || 8888);
@@ -120,7 +124,7 @@ function createPacSandbox() {
function loadPacFile(profileName) {
try {
const pacPath = path.join(os.homedir(), '.mozilla', `${profileName}.pac`);
const pacPath = path.join(PAC_DIR, `${profileName}.pac`);
if (!fs.existsSync(pacPath)) return null;
const script = fs.readFileSync(pacPath, 'utf8');
const context = createPacSandbox();
@@ -137,7 +141,7 @@ function getAvailableProfiles() {
if (UPSTREAM_ENABLED) profiles.push('internet');
profiles.push('direct', 'off');
const pacDir = path.join(os.homedir(), '.mozilla');
const pacDir = PAC_DIR;
if (fs.existsSync(pacDir)) {
fs.readdirSync(pacDir).forEach(f => {
if (f.endsWith('.pac')) profiles.push(f.slice(0, -4));
@@ -270,6 +274,10 @@ function parseProxyConfig(config) {
const [host, port] = first.substring(6).split(':');
return { type: 'PROXY', host, port: Number(port) };
}
if (first.startsWith('HTTPS ')) {
const [host, port] = first.substring(6).split(':');
return { type: 'HTTPS', host, port: Number(port) };
}
return { type: first === 'OFF' ? 'OFF' : 'DIRECT' };
}
@@ -303,23 +311,35 @@ function createBridge(port, profileName) {
const options = {
method: clientReq.method,
headers: sanitizeProxyHeaders(clientReq.headers),
host: upstream.type === 'PROXY' ? upstream.host : urlObj.hostname,
port: upstream.type === 'PROXY' ? upstream.port : (urlObj.port || 80),
path: upstream.type === 'PROXY' ? urlObj.href : (urlObj.pathname + urlObj.search)
host: (upstream.type === 'PROXY' || upstream.type === 'HTTPS') ? upstream.host : urlObj.hostname,
port: (upstream.type === 'PROXY' || upstream.type === 'HTTPS') ? upstream.port : (urlObj.port || 80),
path: (upstream.type === 'PROXY' || upstream.type === 'HTTPS') ? urlObj.href : (urlObj.pathname + urlObj.search)
};
if (upstream.type === 'PROXY') {
if (upstream.type === 'PROXY' || upstream.type === 'HTTPS') {
if (!AUTH_HEADER) refreshAuth();
if (AUTH_HEADER) options.headers['Proxy-Authorization'] = AUTH_HEADER;
}
const upstreamReq = http.request(options, (upstreamRes) => {
const requestModule = upstream.type === 'HTTPS' ? https : http;
const upstreamReq = requestModule.request(options, (upstreamRes) => {
clientRes.writeHead(upstreamRes.statusCode, sanitizeProxyHeaders(upstreamRes.headers));
upstreamRes.pipe(clientRes);
upstreamRes.on('error', (err) => {
clientRes.destroy(err);
});
upstreamRes.on('close', () => {
if (!upstreamRes.complete) {
clientRes.destroy(new Error('Upstream response truncated'));
}
});
});
upstreamReq.on('error', (e) => {
if (!clientRes.headersSent) clientRes.writeHead(502);
clientRes.end('Bad Gateway\n');
});
clientReq.on('close', () => {
if (!clientReq.complete) upstreamReq.destroy();
});
clientReq.pipe(upstreamReq);
});
@@ -330,10 +350,17 @@ function createBridge(port, profileName) {
const { config: upstreamConfig, overrideProfile } = getUpstream(profileName, `https://${req.url}/`, host);
const upstream = parseProxyConfig(upstreamConfig);
let upstreamSocket;
let connected = false;
clientSocket.setKeepAlive(true, 10000);
clientSocket.setNoDelay(true);
clientSocket.on('error', () => {
if (upstreamSocket) upstreamSocket.destroy();
});
clientSocket.on('close', () => {
if (upstreamSocket) upstreamSocket.destroy();
});
log('INFO', id, 'connect', { port, profile: profileName, override: overrideProfile, target: req.url, upstream: upstreamConfig });
@@ -341,49 +368,82 @@ function createBridge(port, profileName) {
return clientSocket.end('HTTP/1.1 403 Forbidden\r\n\r\n');
}
upstreamSocket = net.connect(upstream.type === 'PROXY' ? upstream.port : portNum, upstream.type === 'PROXY' ? upstream.host : host, () => {
if (upstream.type === 'PROXY') {
if (!AUTH_HEADER) refreshAuth();
upstreamSocket.write(`CONNECT ${req.url} HTTP/1.1\r\nHost: ${req.url}\r\n`);
if (AUTH_HEADER) upstreamSocket.write(`Proxy-Authorization: ${AUTH_HEADER}\r\n`);
upstreamSocket.write('\r\n');
}
if (head && head.length > 0 && upstream.type === 'DIRECT') upstreamSocket.write(head);
});
const establishDirect = () => {
upstreamSocket.setKeepAlive(true, 10000);
upstreamSocket.setNoDelay(true);
connected = true;
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
if (head && head.length > 0) upstreamSocket.write(head);
upstreamSocket.pipe(clientSocket);
clientSocket.pipe(upstreamSocket);
};
let connected = false;
upstreamSocket.on('data', (data) => {
if (connected) return;
if (upstream.type === 'PROXY') {
const status = parseUpstreamStatus(data);
if (status.code === 200) {
connected = true;
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
const headerEnd = data.indexOf('\r\n\r\n');
if (headerEnd !== -1 && data.length > headerEnd + 4) clientSocket.write(data.subarray(headerEnd + 4));
upstreamSocket.pipe(clientSocket); clientSocket.pipe(upstreamSocket);
} else {
clientSocket.write(data); clientSocket.end(); upstreamSocket.end();
}
} else {
connected = true;
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
clientSocket.write(data);
upstreamSocket.pipe(clientSocket);
clientSocket.pipe(upstreamSocket);
}
});
const sendConnectToProxy = () => {
upstreamSocket.setKeepAlive(true, 10000);
upstreamSocket.setNoDelay(true);
if (!AUTH_HEADER) refreshAuth();
upstreamSocket.write(`CONNECT ${req.url} HTTP/1.1\r\nHost: ${req.url}\r\n`);
if (AUTH_HEADER) upstreamSocket.write(`Proxy-Authorization: ${AUTH_HEADER}\r\n`);
upstreamSocket.write('\r\n');
};
if (upstream.type === 'DIRECT') {
upstreamSocket.on('connect', () => {
if (connected) return;
connected = true;
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
upstreamSocket.pipe(clientSocket);
clientSocket.pipe(upstreamSocket);
});
if (upstream.type === 'HTTPS') {
upstreamSocket = tls.connect(
{ host: upstream.host, port: upstream.port, servername: upstream.host, rejectUnauthorized: false },
sendConnectToProxy
);
} else if (upstream.type === 'PROXY') {
upstreamSocket = net.connect(upstream.port, upstream.host, sendConnectToProxy);
} else { // DIRECT
upstreamSocket = net.connect(portNum, host, establishDirect);
}
upstreamSocket.on('error', () => clientSocket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n'));
upstreamSocket.on('error', () => {
if (!connected) {
try { clientSocket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n'); } catch {}
} else {
clientSocket.destroy();
}
});
upstreamSocket.on('close', () => {
if (!connected) {
try { clientSocket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n'); } catch {}
} else {
clientSocket.destroy();
}
});
let connectBuffer = Buffer.alloc(0);
function onUpstreamData(data) {
if (connected) return;
if (upstream.type === 'PROXY' || upstream.type === 'HTTPS') {
connectBuffer = Buffer.concat([connectBuffer, data]);
const headerEnd = connectBuffer.indexOf('\r\n\r\n');
if (headerEnd === -1) return;
connected = true;
upstreamSocket.removeListener('data', onUpstreamData);
const status = parseUpstreamStatus(connectBuffer);
if (status.code === 200) {
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
const remaining = connectBuffer.subarray(headerEnd + 4);
if (remaining.length > 0) clientSocket.write(remaining);
if (head && head.length > 0) upstreamSocket.write(head);
upstreamSocket.pipe(clientSocket);
clientSocket.pipe(upstreamSocket);
} else {
clientSocket.write(connectBuffer);
clientSocket.end();
upstreamSocket.end();
}
}
// DIRECT is established in the socket's connect callback (establishDirect);
// nothing to do here — piped streams carry the bytes from here on.
}
upstreamSocket.on('data', onUpstreamData);
});
server.listen(port, LISTEN_HOST, () => {