const http = require('http'); const net = require('net'); const { execFileSync } = require('child_process'); const fs = require('fs'); const vm = require('vm'); const path = require('path'); 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 OVERRIDES_FILE = process.env.PROXY_BRIDGE_OVERRIDES_FILE || '/opt/proxy-bridge/overrides.json'; const LISTEN_HOST = process.env.PROXY_BRIDGE_LISTEN_HOST || '127.0.0.1'; const BASE_PORT = Number(process.env.PROXY_BRIDGE_BASE_PORT || 8888); let INTERNET_PROXY_HOST, INTERNET_PROXY_PORT, UPSTREAM_ENABLED = true; try { const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); INTERNET_PROXY_HOST = config.host; INTERNET_PROXY_PORT = Number(config.port); UPSTREAM_ENABLED = (config.enabled !== false); } catch (e) { INTERNET_PROXY_HOST = 'your-proxy.example.com'; INTERNET_PROXY_PORT = 8080; } let requestCounter = 0; let dynamicProfile = UPSTREAM_ENABLED ? 'internet' : 'direct'; const pacContexts = new Map(); function now() { return new Date().toISOString(); } function log(level, id, message, fields = {}) { const suffix = Object.entries(fields) .filter(([, value]) => value !== undefined && value !== null && value !== '') .map(([key, value]) => `${key}=${JSON.stringify(value)}`) .join(' '); console.log(`${now()} ${level} [${id}] ${message}${suffix ? ` ${suffix}` : ''}`); } function nextId(prefix) { requestCounter += 1; return `${prefix}-${requestCounter}`; } function ipv4ToLong(ip) { const parts = String(ip).split('.'); if (parts.length !== 4) return null; let n = 0; for (const part of parts) { const octet = Number(part); if (!Number.isInteger(octet) || octet < 0 || octet > 255 || !/^\d+$/.test(part)) return null; n = (n * 256) + octet; } return n >>> 0; } function cidrContains(ipLong, baseLong, prefix) { if (ipLong === null || baseLong === null || prefix < 0 || prefix > 32) return false; const mask = prefix === 0 ? 0 : (0xFFFFFFFF << (32 - prefix)) >>> 0; return ((ipLong & mask) >>> 0) === ((baseLong & mask) >>> 0); } function loadCredentials() { if (!UPSTREAM_ENABLED) return null; try { const user = JSON.parse(fs.readFileSync(USER_FILE, 'utf8')).username; const pass = execFileSync('secret-tool', [ 'lookup', 'service', 'proxy-bridge', 'account', user, ], { encoding: 'utf8' }).trim(); return pass ? { user, pass } : null; } catch { return null; } } let AUTH_HEADER; function refreshAuth() { const creds = loadCredentials(); if (creds) { AUTH_HEADER = 'Basic ' + Buffer.from(`${creds.user}:${creds.pass}`).toString('base64'); return true; } return false; } refreshAuth(); function createPacSandbox() { const resolveHost = (host) => { try { return execFileSync('getent', ['hosts', host]).toString().split(/\s+/)[0]; } catch { return ""; } }; const sandbox = { isPlainHostName: (host) => !host.includes('.'), dnsDomainIs: (host, domain) => host.endsWith(domain), localHostOrDomainIs: (host, hostdom) => host === hostdom || (!host.includes('.') && hostdom.split('.')[0] === host), isResolvable: (host) => { try { execFileSync('getent', ['hosts', host]); return true; } catch { return false; } }, isInNet: (host, pattern, mask) => { let ip = ipv4ToLong(host); if (ip === null) ip = ipv4ToLong(resolveHost(host)); const pat = ipv4ToLong(pattern); const m = ipv4ToLong(mask); if (ip === null || pat === null || m === null) return false; return ((ip & m) >>> 0) === ((pat & m) >>> 0); }, dnsResolve: (host) => resolveHost(host), myIpAddress: () => "127.0.0.1", dnsDomainLevels: (host) => host.split('.').length - 1, shExpMatch: (str, pattern) => { const p = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.'); return new RegExp('^' + p + '$').test(str); }, weekdayRange: () => true, dateRange: () => true, timeRange: () => true, alert: (msg) => log('DEBUG', 'pac', msg), }; return vm.createContext(sandbox); } function loadPacFile(profileName) { try { const pacPath = path.join(os.homedir(), '.mozilla', `${profileName}.pac`); if (!fs.existsSync(pacPath)) return null; const script = fs.readFileSync(pacPath, 'utf8'); const context = createPacSandbox(); vm.runInContext(script, context); return typeof context.FindProxyForURL === 'function' ? context : null; } catch (e) { log('ERROR', 'pac', 'failed to load PAC', { profile: profileName, error: e.message }); return null; } } function getAvailableProfiles() { const profiles = []; if (UPSTREAM_ENABLED) profiles.push('internet'); profiles.push('direct', 'off'); const pacDir = path.join(os.homedir(), '.mozilla'); if (fs.existsSync(pacDir)) { fs.readdirSync(pacDir).forEach(f => { if (f.endsWith('.pac')) profiles.push(f.slice(0, -4)); }); } return profiles; } function refreshPacContexts() { getAvailableProfiles().forEach(name => { if (!['internet', 'direct', 'off'].includes(name)) { pacContexts.set(name, loadPacFile(name)); } }); } function updateDynamicProfile() { try { if (fs.existsSync(PROFILE_FILE)) { const data = JSON.parse(fs.readFileSync(PROFILE_FILE, 'utf8')); const newProfile = data.profile; const valid = getAvailableProfiles(); if (newProfile && valid.includes(newProfile) && newProfile !== dynamicProfile) { log('INFO', 'profile', 'dynamic profile switch', { old: dynamicProfile, new: newProfile }); dynamicProfile = newProfile; } else if (newProfile && !valid.includes(newProfile)) { log('WARN', 'profile', 'saved profile is invalid/disabled', { profile: newProfile }); dynamicProfile = UPSTREAM_ENABLED ? 'internet' : 'direct'; } } } catch {} } let overrideRules = []; function compileOverrideMatch(raw) { const m = raw.trim(); if (m.includes('/')) { const [base, prefixStr] = m.split('/'); const baseLong = ipv4ToLong(base); const prefix = Number(prefixStr); if (baseLong === null || !Number.isInteger(prefix) || prefix < 0 || prefix > 32) return null; return { kind: 'cidr', test: (host) => { const ip = ipv4ToLong(host); return ip !== null && cidrContains(ip, baseLong, prefix); } }; } const ipLong = ipv4ToLong(m); if (ipLong !== null) { return { kind: 'ip', test: (host) => ipv4ToLong(host) === ipLong }; } if (m.includes('*') || m.includes('?')) { let rx; try { rx = new RegExp('^' + m.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i'); } catch { return null; } return { kind: 'glob', test: (host) => rx.test(host) }; } if (m.startsWith('.')) { const suffix = m.toLowerCase(); const bare = suffix.slice(1); return { kind: 'suffix', test: (host) => host === bare || host.endsWith(suffix) }; } const lower = m.toLowerCase(); return { kind: 'host', test: (host) => host === lower }; } function loadOverrides() { const compiled = []; try { if (fs.existsSync(OVERRIDES_FILE)) { const data = JSON.parse(fs.readFileSync(OVERRIDES_FILE, 'utf8')); const rules = Array.isArray(data) ? data : (Array.isArray(data.rules) ? data.rules : []); for (const rule of rules) { if (!rule || typeof rule.match !== 'string' || typeof rule.profile !== 'string' || !rule.match.trim() || !rule.profile.trim()) { log('WARN', 'override', 'skipping malformed rule', { rule: JSON.stringify(rule) }); continue; } const matcher = compileOverrideMatch(rule.match); if (!matcher) { log('WARN', 'override', 'skipping invalid match expression', { match: rule.match }); continue; } compiled.push({ raw: rule.match.trim(), profile: rule.profile.trim(), kind: matcher.kind, test: matcher.test }); } } } catch (e) { log('ERROR', 'override', 'failed to load overrides; keeping previous', { error: e.message }); return; } overrideRules = compiled; log('INFO', 'override', 'overrides loaded', { count: compiled.length }); } function matchOverride(host) { if (!host || overrideRules.length === 0) return null; const h = host.toLowerCase(); for (const rule of overrideRules) { if (rule.test(h)) { if (getAvailableProfiles().includes(rule.profile)) return rule.profile; log('WARN', 'override', 'matched rule targets invalid profile; ignoring', { match: rule.raw, profile: rule.profile }); return null; } } return null; } fs.watchFile(PROFILE_FILE, { interval: 1000 }, updateDynamicProfile); fs.watchFile(OVERRIDES_FILE, { interval: 1000 }, loadOverrides); updateDynamicProfile(); refreshPacContexts(); loadOverrides(); function getUpstream(profileName, url, host) { const overrideProfile = matchOverride(host); const profile = overrideProfile || ((profileName === 'dynamic') ? dynamicProfile : profileName); let config; if (profile === 'off') config = 'OFF'; else if (profile === 'direct') config = 'DIRECT'; else if (profile === 'internet' && UPSTREAM_ENABLED) config = `PROXY ${INTERNET_PROXY_HOST}:${INTERNET_PROXY_PORT}`; else { const ctx = pacContexts.get(profile); if (ctx) { try { config = ctx.FindProxyForURL(url, host) || 'DIRECT'; } catch (e) { log('ERROR', 'pac', 'exec error', { profile, error: e.message }); config = 'DIRECT'; } } else { config = 'DIRECT'; } } return { config, overrideProfile }; } function parseProxyConfig(config) { const first = config.split(';')[0].trim(); if (first.startsWith('PROXY ')) { const [host, port] = first.substring(6).split(':'); return { type: 'PROXY', host, port: Number(port) }; } return { type: first === 'OFF' ? 'OFF' : 'DIRECT' }; } function sanitizeProxyHeaders(headers) { const clean = { ...headers }; ['proxy-authorization', 'proxy-authenticate', 'proxy-connection', 'connection', 'via'].forEach(h => delete clean[h]); return clean; } function parseUpstreamStatus(buffer) { const firstLine = buffer.toString('latin1').split('\r\n', 1)[0]; const match = firstLine.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})\s*(.*)$/i); return { line: firstLine, code: match ? Number(match[1]) : undefined, text: match ? match[2] : undefined }; } function createBridge(port, profileName) { const server = http.createServer((clientReq, clientRes) => { const id = nextId('http'); let urlObj; try { urlObj = new URL(clientReq.url); } catch { urlObj = new URL(clientReq.url, `http://${clientReq.headers.host}`); } const { config: upstreamConfig, overrideProfile } = getUpstream(profileName, urlObj.href, urlObj.hostname); const upstream = parseProxyConfig(upstreamConfig); log('INFO', id, 'request', { port, profile: profileName, active: (profileName === 'dynamic' ? dynamicProfile : undefined), override: overrideProfile, target: urlObj.href, upstream: upstreamConfig }); if (upstream.type === 'OFF') { clientRes.writeHead(403); return clientRes.end('Forbidden by proxy profile\n'); } 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) }; if (upstream.type === 'PROXY') { if (!AUTH_HEADER) refreshAuth(); if (AUTH_HEADER) options.headers['Proxy-Authorization'] = AUTH_HEADER; } const upstreamReq = http.request(options, (upstreamRes) => { clientRes.writeHead(upstreamRes.statusCode, sanitizeProxyHeaders(upstreamRes.headers)); upstreamRes.pipe(clientRes); }); upstreamReq.on('error', (e) => { if (!clientRes.headersSent) clientRes.writeHead(502); clientRes.end('Bad Gateway\n'); }); clientReq.pipe(upstreamReq); }); server.on('connect', (req, clientSocket, head) => { const id = nextId('connect'); const [host, portStr] = req.url.split(':'); const portNum = Number(portStr || 443); const { config: upstreamConfig, overrideProfile } = getUpstream(profileName, `https://${req.url}/`, host); const upstream = parseProxyConfig(upstreamConfig); log('INFO', id, 'connect', { port, profile: profileName, override: overrideProfile, target: req.url, upstream: upstreamConfig }); if (upstream.type === 'OFF') { return clientSocket.end('HTTP/1.1 403 Forbidden\r\n\r\n'); } const 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); }); 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); } }); 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); }); } upstreamSocket.on('error', () => clientSocket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n')); clientSocket.on('error', () => upstreamSocket.end()); }); server.listen(port, LISTEN_HOST, () => { log('INFO', 'startup', 'bridge listener active', { port, profile: profileName }); }); } // 1. Dynamic bridge on 8888 createBridge(BASE_PORT, 'dynamic'); // 2. Static bridges on 8889+ getAvailableProfiles().forEach((name, i) => { createBridge(BASE_PORT + 1 + i, name); });