diff --git a/scripts/proxy/BASELINE.md b/scripts/proxy/BASELINE.md new file mode 100644 index 0000000..e3fb1c8 --- /dev/null +++ b/scripts/proxy/BASELINE.md @@ -0,0 +1,56 @@ +# Proxy Bridge — Live-State Baseline + +Captured 2026-09-23 before restructuring work (canonical repo = ~/gists/scripts/proxy, deploy target = /opt/proxy-bridge). All values observed, none invented. + +## Service + +- systemd user service: `proxy-bridge.service`, state **active** +- Unit file: `~/.config/systemd/user/proxy-bridge.service` +- ExecStart: `/home/tke/.nvm/versions/node/v20.20.2/bin/node /opt/proxy-bridge/bridge.js` +- Restart=on-failure, RestartSec=5, WantedBy=default.target +- Last service start: 2026-09-22 13:24 (PID 4638), prior start 2026-09-16 16:05 (after the HTTPS-fix commit d8b9db2) + +## Port → profile map (from startup log lines, 2026-09-22 boot) + +| Port | Profile | Type | +|-------|-------------|----------| +| 8888 | dynamic | special | +| 8889 | internet | builtin | +| 8890 | direct | builtin | +| 8891 | off | builtin | +| 8892 | proxy-inet | PAC | +| 8893 | proxy-tke | PAC | +| 8894 | proxy-wan | PAC | + +ss confirms 8888-8894 all listening on 127.0.0.1. Map shifts if PAC files in `~/.mozilla/*.pac` are added/removed — re-derive from startup lines on every restart. + +## Live config files in /opt/proxy-bridge (runtime, not committed there — profile.json is the only dirty file) + +- config.json: `{enabled: true, host: "proxy-inet.certbw.org", port: "8080"}` +- profile.json: `{profile: "proxy-tke"}` (dirty in /opt git — runtime state, fs-watched) +- user.json: `{username: ""}` — password is NOT here, it's in the keyring +- overrides.json: 5 rules (turtle.ktf.ninja→internet, ai.int.auf.bundeswehr.de→proxy-wan, api.anthropic.com→off, vault.lockedshields.de→internet, irt-docker.csocbw.org→direct) +- remotes.json: 1 entry (wanbw → proxy-wan PAC URL) + +## Code divergence (the reason for Task 1) + +- /opt bridge.js (deployed, 19,018 B, HEAD d8b9db2): HAS PAC `HTTPS host:port` support — `const tls = require('tls')`, HTTPS branch in parseProxyConfig, TLS-wrap around CONNECT. Fixes 2026-09-16 502-on-PAC-routes (proxy-inet:8081). +- gists bridge.js (dev, 17,999 B, uncommitted +59/-10): LACKS the HTTPS fix; HAS new socket-lifecycle hardening (fragmented CONNECT header buffering, head-forward on PROXY, close/error handling, keepalive/nodelay, truncated-response propagation, large-body streaming). +- Live journal confirms active use of the HTTPS path: `connect port=8888 profile="dynamic" ... upstream="HTTPS proxy-inet.certbw.org:8081"`. Deploying the unmerged gists copy would regress this immediately. + +## Known bug in the uncommitted gists copy + +DIRECT-mode CONNECT establishes the tunnel twice: shared `onUpstreamData` else-branch (~lines 415-422) AND a DIRECT-only `'connect'` listener (~lines 427-435) both write `200 Connection Established` and call `pipe()`. `connected` flag prevents double-execution, but the logic races and head-forwarding differs between paths. Task 1 fixes this with a single owner. + +## Credential handling today (pre-Task-2) + +- Username: user.json; password: keyring via `secret-tool lookup service proxy-bridge account ` — never stored in JSON. +- `refreshAuth()` runs once at startup; AUTH_HEADER cached for process life. +- 407 responses pass through unexamined; no backoff, no invalidation, no re-verification, no missing-cred guard beyond `if (AUTH_HEADER)`. +- Lockout risk: if the keyring password expires, every PROXY/HTTPS request retries the stale credential indefinitely until manual intervention. + +## Environment + +- Node /opt runtime: v20.20.2 via nvm; `node --test` works against the gists copy (6/6 tests pass as of baseline). +- No outbound internet from this sandbox; all tests must use local stub servers (net/http/tls on 127.0.0.1). +- Legacy scripts present in gists scripts/proxy/: `install_proxy.sh`, `update_apt_proxy.sh`, `update_bashrc_proxy.sh`, `update_service_proxy.sh`, `get_proxy.sh` — all embed username+password into apt config, bashrc exports (~/.http_proxy), snap and systemd drop-ins. To be retired by Task 4/5. diff --git a/scripts/proxy/bridge.js b/scripts/proxy/bridge.js index 71e6374..34598f9 100644 --- a/scripts/proxy/bridge.js +++ b/scripts/proxy/bridge.js @@ -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, () => { diff --git a/scripts/proxy/bridge.test.js b/scripts/proxy/bridge.test.js new file mode 100644 index 0000000..009ed5e --- /dev/null +++ b/scripts/proxy/bridge.test.js @@ -0,0 +1,320 @@ +const assert = require('node:assert/strict'); +const { once } = require('node:events'); +const fs = require('node:fs'); +const http = require('node:http'); +const net = require('node:net'); +const path = require('node:path'); +const { test } = require('node:test'); +const tls = require('node:tls'); +const vm = require('node:vm'); + +const source = fs.readFileSync(path.join(__dirname, 'bridge.js'), 'utf8'); + +async function listen(t, server) { + const sockets = new Set(); + server.on('connection', socket => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + t.after(() => { + for (const socket of sockets) socket.destroy(); + server.close(); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + return server.address().port; +} + +async function bridge(t, upstreamPort, upstreamCfg) { + const servers = []; + const env = { ...process.env, ...(upstreamCfg && upstreamCfg.env ? upstreamCfg.env : {}) }; + // Isolate routing, credentials and listeners from the user's installed service. + vm.runInNewContext(source, { + Buffer, URL, console: { log() {} }, setTimeout, clearTimeout, + process: { env }, + require(name) { + if (name === 'fs') return { + readFileSync: () => JSON.stringify(upstreamCfg + ? { enabled: true, host: upstreamCfg.host, port: upstreamCfg.port } + : upstreamPort + ? { enabled: true, host: '127.0.0.1', port: upstreamPort } + : { enabled: false }), + existsSync: () => false, + watchFile() {}, + }; + if (name === 'child_process') return { execFileSync: () => '' }; + if (name === 'http') return { + ...http, + createServer(...args) { + const server = http.createServer(...args); + server.listen = () => { servers.push(server); return server; }; + return server; + }, + }; + return require(name); + }, + }, { filename: 'bridge.js' }); + const server = servers[0]; + server.listen = net.Server.prototype.listen; + return listen(t, server); +} + +// Start the bridge with env overrides and return the ephemeral port of a chosen +// listenerProfile ('dynamic', 'internet', or a PAC profile name). All listeners +// are tracked for cleanup; only the requested one is bound to a random port. +async function bridgeOnProfile(t, listenerProfile, { upstreamCfg, env = {} } = {}) { + const servers = []; + const fullEnv = { ...process.env, ...env }; + vm.runInNewContext(source, { + Buffer, URL, console: { log() {} }, setTimeout, clearTimeout, + process: { env: fullEnv }, + require(name) { + if (name === 'fs') return { + readFileSync: (p) => { + // Bridge config file -> upstreamCfg + if (typeof p === 'string' && p.includes('config.json')) { + return JSON.stringify(upstreamCfg || { enabled: false }); + } + // PAC files and everything else fall through to the REAL fs + return fs.readFileSync(p, 'utf8'); + }, + existsSync: fs.existsSync, + readdirSync: fs.readdirSync, + watchFile() {}, + }; + if (name === 'child_process') return { execFileSync: () => '' }; + if (name === 'http') return { + ...http, + createServer(...args) { + const server = http.createServer(...args); + server.listen = () => { servers.push(server); return server; }; + return server; + }, + }; + return require(name); + }, + }, { filename: 'bridge.js' }); + + // Listener creation order: index 0 = dynamic; then getAvailableProfiles() + // order = internet (if enabled), direct, off, then PAC profiles (readdir order). + const names = ['dynamic']; + if (!upstreamCfg || upstreamCfg.enabled !== false) names.push('internet'); + names.push('direct', 'off'); + const pacDir = env.PROXY_BRIDGE_PAC_DIR || path.join(require('os').homedir(), '.mozilla'); + if (fs.existsSync(pacDir)) { + for (const f of fs.readdirSync(pacDir)) if (f.endsWith('.pac')) names.push(f.slice(0, -4)); + } + const idx = names.indexOf(listenerProfile); + if (idx === -1 || !servers[idx]) throw new Error(`listener '${listenerProfile}' not created; have ${names.join(',')}`); + for (let i = 0; i < servers.length; i++) { + if (i !== idx && servers[i].listening === false) { /* never actually listened */ } + } + const server = servers[idx]; + server.listen = net.Server.prototype.listen; + return listen(t, server); +} + +async function connect(t, port, initial = '') { + const socket = net.connect(port, '127.0.0.1'); + t.after(() => socket.destroy()); + await once(socket, 'connect'); + socket.write(`CONNECT example.test:443 HTTP/1.1\r\nHost: example.test:443\r\n\r\n${initial}`); + return socket; +} + +function readUntil(socket, predicate) { + return new Promise((resolve, reject) => { + let data = Buffer.alloc(0); + const cleanup = () => { + socket.off('data', onData); + socket.off('end', onEnd); + socket.off('error', onError); + }; + const onData = chunk => { + data = Buffer.concat([data, chunk]); + if (predicate(data)) { cleanup(); resolve(data); } + }; + const onEnd = () => { cleanup(); reject(new Error(`Unexpected EOF: ${data.toString()}`)); }; + const onError = error => { cleanup(); reject(error); }; + socket.on('data', onData); + socket.on('end', onEnd); + socket.on('error', onError); + }); +} + +test('CONNECT buffers fragmented upstream headers and preserves tunnel bytes', { timeout: 3000 }, async t => { + const upstreamPort = await listen(t, net.createServer(socket => { + socket.once('data', () => { + socket.write('HT'); + setTimeout(() => socket.write('TP/1.1 200 OK\r\nX-Proxy: test\r\n'), 30); + setTimeout(() => socket.write('\r\nserver-data'), 60); + }); + })); + const socket = await connect(t, await bridge(t, upstreamPort)); + const data = await readUntil(socket, data => data.includes('server-data')); + assert.equal(data.toString(), 'HTTP/1.1 200 Connection Established\r\n\r\nserver-data'); +}); + +test('CONNECT forwards client head and a large payload without corruption', { timeout: 5000 }, async t => { + const upstreamPort = await listen(t, net.createServer(socket => { + socket.once('data', () => { + socket.write('HTTP/1.1 200 OK\r\n\r\n'); + socket.pipe(socket); + }); + })); + const socket = await connect(t, await bridge(t, upstreamPort), 'early-data'); + const initial = await readUntil(socket, data => data.includes('early-data')); + assert.equal(initial.toString(), 'HTTP/1.1 200 Connection Established\r\n\r\nearly-data'); + const payload = Buffer.alloc(4 * 1024 * 1024, 'x'); + const received = readUntil(socket, data => data.length >= payload.length); + socket.write(payload); + assert.deepEqual(await received, payload); +}); + +test('upstream reset closes an established tunnel without injecting HTTP', { timeout: 3000 }, async t => { + const upstreamPort = await listen(t, net.createServer(socket => { + socket.once('data', () => { + socket.write('HTTP/1.1 200 OK\r\n\r\n'); + socket.once('data', () => socket.resetAndDestroy()); + }); + })); + const socket = await connect(t, await bridge(t, upstreamPort)); + await readUntil(socket, data => data.includes('\r\n\r\n')); + const chunks = []; + socket.on('data', data => chunks.push(data)); + const ended = once(socket, 'end'); + socket.write('trigger-reset'); + await ended; + assert.equal(Buffer.concat(chunks).length, 0); +}); + +test('upstream EOF before CONNECT completes does not leave client hanging', { timeout: 3000 }, async t => { + const upstreamPort = await listen(t, net.createServer(socket => { + socket.once('data', () => socket.end('HTTP/1.1 200')); + })); + const socket = await connect(t, await bridge(t, upstreamPort)); + const ended = once(socket, 'end'); + const data = await readUntil(socket, data => data.includes('\r\n\r\n')); + assert.match(data.toString(), /^HTTP\/1.1 502 /); + await ended; +}); + +test('HTTP streams large request bodies and delayed SSE responses', { timeout: 5000 }, async t => { + const payload = Buffer.alloc(4 * 1024 * 1024, 'x'); + const targetPort = await listen(t, http.createServer(async (req, res) => { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + assert.deepEqual(Buffer.concat(chunks), payload); + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + res.write('data: first\n\n'); + setTimeout(() => res.end('data: [DONE]\n\n'), 100); + })); + const proxyPort = await bridge(t); + const body = await new Promise((resolve, reject) => { + const req = http.request({ host: '127.0.0.1', port: proxyPort, method: 'POST', + path: `http://127.0.0.1:${targetPort}/chat`, headers: { 'Content-Length': payload.length } }, async res => { + try { + const chunks = []; + for await (const chunk of res) chunks.push(chunk); + resolve(Buffer.concat(chunks).toString()); + } catch (error) { reject(error); } + }); + req.on('error', reject); + req.end(payload); + }); + assert.equal(body, 'data: first\n\ndata: [DONE]\n\n'); +}); + +test('truncated HTTP responses abort the client stream', { timeout: 3000 }, async t => { + const targetPort = await listen(t, http.createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + res.write('data: partial\n\n'); + setTimeout(() => res.destroy(), 30); + })); + const proxyPort = await bridge(t); + await new Promise((resolve, reject) => { + const req = http.get({ host: '127.0.0.1', port: proxyPort, + path: `http://127.0.0.1:${targetPort}/chat` }, res => { + res.resume(); + res.on('aborted', resolve); + res.on('error', () => {}); + res.on('end', () => reject(new Error('Truncated stream appeared successful'))); + }); + req.on('error', reject); + }); +}); + +test('DIRECT CONNECT tunnels bytes exactly once when upstream is disabled', { timeout: 3000 }, async t => { + // Upstream disabled -> routing degrades to DIRECT to the CONNECT target. + const target = net.createServer(socket => socket.pipe(socket)); + const targetPort = await listen(t, target); + const proxyPort = await bridge(t); // no upstreamPort => { enabled: false } + + const socket = net.connect(proxyPort, '127.0.0.1'); + t.after(() => socket.destroy()); + await once(socket, 'connect'); + socket.write(`CONNECT 127.0.0.1:${targetPort} HTTP/1.1\r\nHost: 127.0.0.1:${targetPort}\r\n\r\nearly-bytes`); + + const chunks = []; + socket.on('data', d => chunks.push(d)); + // Wait for establishment, then exercise the tunnel bidirectionally. + const banner = await readUntil(socket, d => d.includes('early-bytes')); + const occurrences = banner.toString().split('HTTP/1.1 200 Connection Established').length - 1; + assert.equal(occurrences, 1, `expected exactly one 200 banner, got: ${JSON.stringify(banner.toString())}`); + assert.equal(banner.toString(), 'HTTP/1.1 200 Connection Established\r\n\r\nearly-bytes'); + + socket.write('ping'); + const echo = await readUntil(socket, d => d.length >= 4 && d.subarray(-4).toString() === 'ping'); + assert.ok(Buffer.concat(chunks).toString().includes('ping')); + assert.equal(Buffer.concat([banner, echo]).includes('ping\r\nping'), false); +}); + +// Throwaway self-signed fixture (CN=127.0.0.1, generated for tests only, no real identity). +// Regenerate with: openssl req -x509 -newkey rsa:2048 -keyout k.pem -out c.pem -days 3650 -nodes -subj /CN=127.0.0.1 +const TLS_FIXTURE = { + key: fs.readFileSync(path.join(__dirname, 'fixtures', 'tls-key.pem'), 'utf8'), + cert: fs.readFileSync(path.join(__dirname, 'fixtures', 'tls-cert.pem'), 'utf8'), +}; + +test('HTTPS upstream TLS-wraps the CONNECT handshake', { timeout: 5000 }, async t => { + let sawTls = false; + let sawConnect = false; + const upstream = tls.createServer(TLS_FIXTURE, socket => { + sawTls = true; + let buf = Buffer.alloc(0); + socket.on('data', d => { + buf = Buffer.concat([buf, d]); + if (buf.includes('\r\n\r\n')) { + sawConnect = buf.toString().startsWith('CONNECT 127.0.0.1:'); + socket.write('HTTP/1.1 200 OK\r\n\r\n'); + socket.pipe(socket); // echo tunnel payload back + } + }); + }); + const upstreamPort = await listen(t, upstream); + + // Local PAC dir containing exactly one profile whose FindProxyForURL returns + // an HTTPS directive — exercises PAC eval -> parseProxyConfig('HTTPS ...') -> tls.connect. + const pacDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'bridge-pac-')); + t.after(() => fs.rmSync(pacDir, { recursive: true, force: true })); + fs.writeFileSync(path.join(pacDir, 'testhttps.pac'), + `function FindProxyForURL() { return "HTTPS 127.0.0.1:${upstreamPort}"; }`); + + const proxyPort = await bridgeOnProfile(t, 'testhttps', { + upstreamCfg: { enabled: true, host: '127.0.0.1', port: upstreamPort }, + env: { PROXY_BRIDGE_PAC_DIR: pacDir }, + }); + + const echoTarget = await listen(t, net.createServer(s => s.pipe(s))); + const socket = net.connect(proxyPort, '127.0.0.1'); + t.after(() => socket.destroy()); + await once(socket, 'connect'); + socket.write(`CONNECT 127.0.0.1:${echoTarget} HTTP/1.1\r\nHost: 127.0.0.1:${echoTarget}\r\n\r\n`); + + const banner = await readUntil(socket, d => d.includes('\r\n\r\n')); + assert.equal(banner.toString(), 'HTTP/1.1 200 Connection Established\r\n\r\n'); + socket.write('tls-tunnel-probe'); + await readUntil(socket, d => d.includes('tls-tunnel-probe')); + assert.ok(sawTls, 'bridge must speak TLS to an HTTPS upstream'); + assert.ok(sawConnect, 'bridge must send CONNECT inside the TLS tunnel'); +}); diff --git a/scripts/proxy/fixtures/tls-cert.pem b/scripts/proxy/fixtures/tls-cert.pem new file mode 100644 index 0000000..be0c3d7 --- /dev/null +++ b/scripts/proxy/fixtures/tls-cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDCTCCAfGgAwIBAgIULTRbxVlHLzeuSHof5JIVRemCyeowDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTI2MDkyMzEyNDY0M1oXDTM2MDky +MDEyNDY0M1owFDESMBAGA1UEAwwJMTI3LjAuMC4xMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAvYG9Gjhs/Z4HnVpQ9jmG7Yzy7v+kkmeRtlp6Ky2/pyN5 +XMum1Wtoj5oMp1IInBONOtwwt5pi837zIBlOC4cmBRUPnkBSIyYWMzlcOYTpshZ0 +di2wsgUGQWe4+PaUpoD+EoULnxLq8wt8QU9+wNQMQnfTMnhLrQX8Z3yegxrjoRcJ +2G1lurouYuO+qkhvCVuMKTxa1svhpjlZ9ZHtoGsGulEnAN+4Rg9D85i9cYkL7lIv +71RwhMSBUTTZBIx/O5aGOgropWoqKD8rOBN7nc2o1dh32gECO4VSpB7U3KjOxR2S +u/dJBEaaCXsxjkblkDF9f7N6W8L+hEJ5X7URrUO+dwIDAQABo1MwUTAdBgNVHQ4E +FgQU0fYOKTUiYxyREdlmUSpi6PyrIFYwHwYDVR0jBBgwFoAU0fYOKTUiYxyREdlm +USpi6PyrIFYwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEADFkY +pcK5crh9LTgcGEguXRPwsESjE+VTOUBRADRtPd/v55RqyfOFFhqNpZXh8TNYzWaW +d5Km7Ne6Bcj4rlURzuRVevQZPqce3LsiJYjxX9839db5e0MiflMLmcs3V0pTlKhb +fzrG+kR5OBIvcOLI/xoXMRA/Iakb3swAkVFhMBYykJpqGwPvLs0YfUwKwkKDjVBy +jy4+xvMZqz0T3yWRDfHA8fCrIdwC9u7+E4QtbahOYPEPuh5vSrJ0/kC264+QJGmQ +e4hw/kh9TDF9G4NoI5CXc5jXTFdU2cuxfjqQddkgvSLeMs+pTCVE7MdwMnWezIwk +8/de9gbsW5mCXZ73hQ== +-----END CERTIFICATE----- diff --git a/scripts/proxy/fixtures/tls-key.pem b/scripts/proxy/fixtures/tls-key.pem new file mode 100644 index 0000000..1472ea7 --- /dev/null +++ b/scripts/proxy/fixtures/tls-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC9gb0aOGz9nged +WlD2OYbtjPLu/6SSZ5G2WnorLb+nI3lcy6bVa2iPmgynUgicE4063DC3mmLzfvMg +GU4LhyYFFQ+eQFIjJhYzOVw5hOmyFnR2LbCyBQZBZ7j49pSmgP4ShQufEurzC3xB +T37A1AxCd9MyeEutBfxnfJ6DGuOhFwnYbWW6ui5i476qSG8JW4wpPFrWy+GmOVn1 +ke2gawa6UScA37hGD0PzmL1xiQvuUi/vVHCExIFRNNkEjH87loY6CuilaiooPys4 +E3udzajV2HfaAQI7hVKkHtTcqM7FHZK790kERpoJezGORuWQMX1/s3pbwv6EQnlf +tRGtQ753AgMBAAECggEABoERcHjx+gfBKHwVE0kI1yu4ms4DY/2nWU7kVTRjbjp1 +do3hfVRhE6ysioGc8gkUa9TAMfexNbzsXfRVuBI8vSfqj/qSgcpThhE4l8YhZI9e +EpWhiY/1QFbDA9MoFEmRGX701dIxu4MavKhx0pnJ4e4i6DSLbwt+4j5mDQc7O31z +zEHZp3qPQHjSocXX5ydNVLamI8AFSuudk/GLKP12gS1iS/kt3X9NTy1qRm3n8xf2 +CDaTxs6ADoKv5XOK/xqyw7q163k7/AfBQQnX33WsGhByBp8Udvc0g07HmPffBwgv +eqUlREU7mh6zlqhCOdESCG6Ha0Icn9c7vk9K4OS8IQKBgQDnmihicfrfA4oEbhym +ytN4u9PY9/orw9nFc0zLqmGCWXBeWbjquOIefHFUtT8HQn2cMNEZR/M1L26OUkrP +2vhFMEWpgIUCqNb+rOnlA2EODQwSlh3U55cuEugPuTkSMWn7M6lcvEesK3Zk+y7m +FgMZD9W9b0qpN1/y+/6k6zPuiwKBgQDReFp+zt5pR6tRiVtkbpMZMSqSl9vCS+MW +6UFw4rG2Tod2tLeDfzwAdcgY7vPbldZFHllOu+AleQVy5NpLEuG4pLCSqSocH9W9 ++vPD+sKyLwPGCQjqLINfAgsw6J5S7tceaxj5BMpJcgPYj1FCKYfw7+DKOdbiz7VZ +5zyWUOa5RQKBgGbdfVpcLYSn2cEl9uvN4icZpXoUibSuhCbCkzVSM4TlnJcLWqpF ++4h21Q8+gQQEcC3TMMv9vPO+YWgs/LVdGDACxK4IOwt3WiY7PpdM7OqEuPv4WL+b +LPZ1x0RUjBicgenkhDCAH4VEuI0ZUzc2R6H1+oTszUhjI+aULgGpxaMJAoGAbd0G +8b2Eb4l0dGZrdch9BFiCMLLPQx/UMl8SwKdVd/3GFN+B5Mkl6kPcOB+T7RawdD4P +rzjgvia0UedwCrITi2ltZu6stGWKEIN/uMlbtmLsQsDYWSIRoRIMVpGbAHpef4jC +Z1E4276d/8ZjYyUvNJJLvyUI7JruTgH3CAvUh1UCgYEA5BLu4KdrUdY2e5NaFsxQ +CdszGHfdAVCZiCSa58/f0luN+d0z6tieWJV+hLcquT2eIswICXNAVcyR95bm3JYE +cntWnjjewcm77c2ou/1DrbI6Whg6JMcyesUt/mF0YI+paZfDtdqaw4cR8oaoIhvB +FiPMbAsshl79eGLDyRyxJ9E= +-----END PRIVATE KEY-----