feat(proxy): add destination overrides and fix PAC helpers

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>
This commit is contained in:
tobias
2026-05-28 21:58:11 +02:00
parent 7f73746427
commit 401b3e1781
2 changed files with 190 additions and 19 deletions
+120 -19
View File
@@ -9,6 +9,7 @@ const os = require('os');
const CONFIG_FILE = process.env.PROXY_BRIDGE_CONFIG_FILE || '/opt/proxy-bridge/config.json'; 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 USER_FILE = process.env.PROXY_BRIDGE_USER_FILE || '/opt/proxy-bridge/user.json';
const PROFILE_FILE = '/opt/proxy-bridge/profile.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 LISTEN_HOST = process.env.PROXY_BRIDGE_LISTEN_HOST || '127.0.0.1';
const BASE_PORT = Number(process.env.PROXY_BRIDGE_BASE_PORT || 8888); const BASE_PORT = Number(process.env.PROXY_BRIDGE_BASE_PORT || 8888);
@@ -39,6 +40,24 @@ function log(level, id, message, fields = {}) {
function nextId(prefix) { requestCounter += 1; return `${prefix}-${requestCounter}`; } 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() { function loadCredentials() {
if (!UPSTREAM_ENABLED) return null; if (!UPSTREAM_ENABLED) return null;
try { try {
@@ -66,17 +85,25 @@ function refreshAuth() {
refreshAuth(); refreshAuth();
function createPacSandbox() { function createPacSandbox() {
const resolveHost = (host) => {
try { return execFileSync('getent', ['hosts', host]).toString().split(/\s+/)[0]; } catch { return ""; }
};
const sandbox = { const sandbox = {
isPlainHostName: (host) => !host.includes('.'), isPlainHostName: (host) => !host.includes('.'),
dnsDomainIs: (host, domain) => host.endsWith(domain), dnsDomainIs: (host, domain) => host.endsWith(domain),
localHostOrDomainIs: (host, hostdom) => host === hostdom || host.split('.')[0] === hostdom, localHostOrDomainIs: (host, hostdom) => host === hostdom || (!host.includes('.') && hostdom.split('.')[0] === host),
isResolvable: (host) => { isResolvable: (host) => {
try { execFileSync('getent', ['hosts', host]); return true; } catch { return false; } try { execFileSync('getent', ['hosts', host]); return true; } catch { return false; }
}, },
isInNet: (ip, pattern, mask) => false, isInNet: (host, pattern, mask) => {
dnsResolve: (host) => { let ip = ipv4ToLong(host);
try { return execFileSync('getent', ['hosts', host]).toString().split(/\s+/)[0]; } catch { return ""; } 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", myIpAddress: () => "127.0.0.1",
dnsDomainLevels: (host) => host.split('.').length - 1, dnsDomainLevels: (host) => host.split('.').length - 1,
shExpMatch: (str, pattern) => { shExpMatch: (str, pattern) => {
@@ -144,23 +171,97 @@ function updateDynamicProfile() {
} catch {} } 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(PROFILE_FILE, { interval: 1000 }, updateDynamicProfile);
fs.watchFile(OVERRIDES_FILE, { interval: 1000 }, loadOverrides);
updateDynamicProfile(); updateDynamicProfile();
refreshPacContexts(); refreshPacContexts();
loadOverrides();
function getUpstream(profileName, url, host) { function getUpstream(profileName, url, host) {
const profile = (profileName === 'dynamic') ? dynamicProfile : profileName; const overrideProfile = matchOverride(host);
const profile = overrideProfile || ((profileName === 'dynamic') ? dynamicProfile : profileName);
if (profile === 'off') return 'OFF';
if (profile === 'direct') return 'DIRECT'; let config;
if (profile === 'internet' && UPSTREAM_ENABLED) return `PROXY ${INTERNET_PROXY_HOST}:${INTERNET_PROXY_PORT}`; if (profile === 'off') config = 'OFF';
else if (profile === 'direct') config = 'DIRECT';
const ctx = pacContexts.get(profile); else if (profile === 'internet' && UPSTREAM_ENABLED) config = `PROXY ${INTERNET_PROXY_HOST}:${INTERNET_PROXY_PORT}`;
if (ctx) { else {
try { return ctx.FindProxyForURL(url, host) || 'DIRECT'; } const ctx = pacContexts.get(profile);
catch (e) { log('ERROR', 'pac', 'exec error', { profile, error: e.message }); return 'DIRECT'; } 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 'DIRECT'; return { config, overrideProfile };
} }
function parseProxyConfig(config) { function parseProxyConfig(config) {
@@ -190,10 +291,10 @@ function createBridge(port, profileName) {
let urlObj; let urlObj;
try { urlObj = new URL(clientReq.url); } catch { urlObj = new URL(clientReq.url, `http://${clientReq.headers.host}`); } try { urlObj = new URL(clientReq.url); } catch { urlObj = new URL(clientReq.url, `http://${clientReq.headers.host}`); }
const upstreamConfig = getUpstream(profileName, urlObj.href, urlObj.hostname); const { config: upstreamConfig, overrideProfile } = getUpstream(profileName, urlObj.href, urlObj.hostname);
const upstream = parseProxyConfig(upstreamConfig); const upstream = parseProxyConfig(upstreamConfig);
log('INFO', id, 'request', { port, profile: profileName, active: (profileName === 'dynamic' ? dynamicProfile : undefined), target: urlObj.href, upstream: upstreamConfig }); log('INFO', id, 'request', { port, profile: profileName, active: (profileName === 'dynamic' ? dynamicProfile : undefined), override: overrideProfile, target: urlObj.href, upstream: upstreamConfig });
if (upstream.type === 'OFF') { if (upstream.type === 'OFF') {
clientRes.writeHead(403); return clientRes.end('Forbidden by proxy profile\n'); clientRes.writeHead(403); return clientRes.end('Forbidden by proxy profile\n');
@@ -226,10 +327,10 @@ function createBridge(port, profileName) {
const id = nextId('connect'); const id = nextId('connect');
const [host, portStr] = req.url.split(':'); const [host, portStr] = req.url.split(':');
const portNum = Number(portStr || 443); const portNum = Number(portStr || 443);
const upstreamConfig = getUpstream(profileName, `https://${req.url}/`, host); const { config: upstreamConfig, overrideProfile } = getUpstream(profileName, `https://${req.url}/`, host);
const upstream = parseProxyConfig(upstreamConfig); const upstream = parseProxyConfig(upstreamConfig);
log('INFO', id, 'connect', { port, profile: profileName, target: req.url, upstream: upstreamConfig }); log('INFO', id, 'connect', { port, profile: profileName, override: overrideProfile, target: req.url, upstream: upstreamConfig });
if (upstream.type === 'OFF') { if (upstream.type === 'OFF') {
return clientSocket.end('HTTP/1.1 403 Forbidden\r\n\r\n'); return clientSocket.end('HTTP/1.1 403 Forbidden\r\n\r\n');
+70
View File
@@ -12,6 +12,7 @@ const LISTEN_PORT = Number(process.env.PROXY_BRIDGE_LISTEN_PORT || 8888);
const USER_FILE = process.env.PROXY_BRIDGE_USER_FILE || '/opt/proxy-bridge/user.json'; const USER_FILE = process.env.PROXY_BRIDGE_USER_FILE || '/opt/proxy-bridge/user.json';
const CONFIG_FILE = process.env.PROXY_BRIDGE_CONFIG_FILE || '/opt/proxy-bridge/config.json'; const CONFIG_FILE = process.env.PROXY_BRIDGE_CONFIG_FILE || '/opt/proxy-bridge/config.json';
const PROFILE_FILE = '/opt/proxy-bridge/profile.json'; const PROFILE_FILE = '/opt/proxy-bridge/profile.json';
const OVERRIDES_FILE = process.env.PROXY_BRIDGE_OVERRIDES_FILE || '/opt/proxy-bridge/overrides.json';
const LOCAL_PROXY_URL = `http://${LISTEN_HOST}:${LISTEN_PORT}`; const LOCAL_PROXY_URL = `http://${LISTEN_HOST}:${LISTEN_PORT}`;
const VSCODE_CERT_FLAG = ''; // e.g. '--ignore-certificate-errors' const VSCODE_CERT_FLAG = ''; // e.g. '--ignore-certificate-errors'
@@ -81,6 +82,60 @@ function setProfile(profile) {
console.log(`Profile set to: ${profile}`); console.log(`Profile set to: ${profile}`);
} }
function readOverrides() {
try {
const data = readJsonFile(OVERRIDES_FILE);
if (Array.isArray(data)) return data;
if (Array.isArray(data.rules)) return data.rules;
} catch {}
return [];
}
function writeOverrides(rules) {
writeJsonFile(OVERRIDES_FILE, { rules });
}
function overrideCmd(args) {
const action = args[0] || 'list';
switch (action) {
case 'list': {
const rules = readOverrides();
if (!rules.length) { console.log('No overrides configured.'); return; }
console.log('Overrides (first match wins):');
rules.forEach((r, i) => console.log(` [${i}] ${r.match} -> ${r.profile}`));
return;
}
case 'add': {
const [match, profile] = [args[1], args[2]];
if (!match || !profile) { console.error('Usage: proxy-bridge override add <host|ip|cidr|glob> <profile>'); process.exit(1); }
const valid = getAvailableProfiles();
if (!valid.includes(profile)) { console.error(`Invalid profile: ${profile}. Valid options: ${valid.join(', ')}`); process.exit(1); }
const rules = readOverrides().filter(r => r.match !== match);
rules.push({ match, profile });
writeOverrides(rules);
console.log(`Override set: ${match} -> ${profile}`);
return;
}
case 'remove': {
const match = args[1];
if (!match) { console.error('Usage: proxy-bridge override remove <match>'); process.exit(1); }
const before = readOverrides();
const after = before.filter(r => r.match !== match);
if (after.length === before.length) { console.log(`No override found for: ${match}`); return; }
writeOverrides(after);
console.log(`Removed override: ${match}`);
return;
}
case 'clear':
writeOverrides([]);
console.log('All overrides cleared.');
return;
default:
console.error('Usage: proxy-bridge override [list|add <match> <profile>|remove <match>|clear]');
process.exit(1);
}
}
function systemctl(args, options = {}) { function systemctl(args, options = {}) {
return run('systemctl', ['--user', ...args], options); return run('systemctl', ['--user', ...args], options);
} }
@@ -133,6 +188,7 @@ async function status() {
console.log(`pid: ${pid && pid !== '0' ? pid : 'none'}`); console.log(`pid: ${pid && pid !== '0' ? pid : 'none'}`);
if (isUpstreamEnabled()) console.log(`account: ${readAccount()}`); if (isUpstreamEnabled()) console.log(`account: ${readAccount()}`);
console.log(`upstream: ${getUpstreamConfig()}`); console.log(`upstream: ${getUpstreamConfig()}`);
console.log(`overrides: ${readOverrides().length}`);
console.log(`\nPort Mappings:`); console.log(`\nPort Mappings:`);
console.log(` ${LISTEN_PORT.toString().padEnd(5)} -> dynamic (currently: ${current})${listening ? '' : ' [NOT REACHABLE]'}`); console.log(` ${LISTEN_PORT.toString().padEnd(5)} -> dynamic (currently: ${current})${listening ? '' : ' [NOT REACHABLE]'}`);
all.forEach((name, i) => { all.forEach((name, i) => {
@@ -170,6 +226,10 @@ function config() {
all.forEach((name, i) => { all.forEach((name, i) => {
console.log(` ${(LISTEN_PORT + 1 + i).toString().padEnd(5)} -> ${name}`); console.log(` ${(LISTEN_PORT + 1 + i).toString().padEnd(5)} -> ${name}`);
}); });
const overrides = readOverrides();
console.log(`\nOverrides (first match wins, apply to all ports):`);
if (!overrides.length) console.log(' (none)');
else overrides.forEach((r, i) => console.log(` [${i}] ${r.match} -> ${r.profile}`));
} }
function codeSettingsPath() { function codeSettingsPath() {
@@ -293,6 +353,7 @@ Commands:
status Show service, listener, upstream, profile, and account status Show service, listener, upstream, profile, and account
profile [name] Show or set the active routing profile profile [name] Show or set the active routing profile
toggle Cycle through profiles (internet -> direct -> off -> [pacs...]) toggle Cycle through profiles (internet -> direct -> off -> [pacs...])
override [...] Manage destination overrides (see below)
start Start the local proxy bridge start Start the local proxy bridge
stop Stop the local proxy bridge stop Stop the local proxy bridge
restart Restart the local proxy bridge restart Restart the local proxy bridge
@@ -304,6 +365,14 @@ Commands:
vscode Manage VS Code proxy settings vscode Manage VS Code proxy settings
help Show this help help Show this help
Overrides (force a destination to a profile, regardless of active profile):
proxy-bridge override list
proxy-bridge override add <match> <profile>
proxy-bridge override remove <match>
proxy-bridge override clear
<match> may be an exact host, a wildcard (*.example.com), a domain
suffix (.example.com), an IP (192.168.1.1), or a CIDR (10.0.0.0/8).
VS Code: VS Code:
proxy-bridge vscode status proxy-bridge vscode status
proxy-bridge vscode setup proxy-bridge vscode setup
@@ -326,6 +395,7 @@ async function main() {
const next = profiles[(profiles.indexOf(current) + 1) % profiles.length]; const next = profiles[(profiles.indexOf(current) + 1) % profiles.length];
setProfile(next); setProfile(next);
break; break;
case 'override': overrideCmd(process.argv.slice(3)); break;
case 'start': case 'start':
case 'stop': case 'stop':
case 'restart': case 'restart':