401b3e1781
Add a hot-reloaded override table (overrides.json) that forces specific hosts/IPs to a chosen profile regardless of the active profile, applied across all bridge ports. Matching supports exact host, wildcard, domain suffix, single IP, and CIDR. Managed via `proxy-bridge override list|add|remove|clear` and surfaced in status/config. Also fix two PAC sandbox helpers: localHostOrDomainIs (unqualified-host case) and isInNet (was a no-op stub, now does real IPv4 subnet matching). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
397 lines
16 KiB
JavaScript
397 lines
16 KiB
JavaScript
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);
|
|
});
|