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:
+119
-18
@@ -9,6 +9,7 @@ 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);
|
||||
|
||||
@@ -39,6 +40,24 @@ function log(level, id, message, fields = {}) {
|
||||
|
||||
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 {
|
||||
@@ -66,17 +85,25 @@ function refreshAuth() {
|
||||
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.split('.')[0] === hostdom,
|
||||
localHostOrDomainIs: (host, hostdom) => host === hostdom || (!host.includes('.') && hostdom.split('.')[0] === host),
|
||||
isResolvable: (host) => {
|
||||
try { execFileSync('getent', ['hosts', host]); return true; } catch { return false; }
|
||||
},
|
||||
isInNet: (ip, pattern, mask) => false,
|
||||
dnsResolve: (host) => {
|
||||
try { return execFileSync('getent', ['hosts', host]).toString().split(/\s+/)[0]; } catch { return ""; }
|
||||
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) => {
|
||||
@@ -144,23 +171,97 @@ function updateDynamicProfile() {
|
||||
} 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 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';
|
||||
if (profile === 'internet' && UPSTREAM_ENABLED) return `PROXY ${INTERNET_PROXY_HOST}:${INTERNET_PROXY_PORT}`;
|
||||
|
||||
const ctx = pacContexts.get(profile);
|
||||
if (ctx) {
|
||||
try { return ctx.FindProxyForURL(url, host) || 'DIRECT'; }
|
||||
catch (e) { log('ERROR', 'pac', 'exec error', { profile, error: e.message }); return 'DIRECT'; }
|
||||
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 'DIRECT';
|
||||
return { config, overrideProfile };
|
||||
}
|
||||
|
||||
function parseProxyConfig(config) {
|
||||
@@ -190,10 +291,10 @@ function createBridge(port, profileName) {
|
||||
let urlObj;
|
||||
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);
|
||||
|
||||
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') {
|
||||
clientRes.writeHead(403); return clientRes.end('Forbidden by proxy profile\n');
|
||||
@@ -226,10 +327,10 @@ function createBridge(port, profileName) {
|
||||
const id = nextId('connect');
|
||||
const [host, portStr] = req.url.split(':');
|
||||
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);
|
||||
|
||||
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') {
|
||||
return clientSocket.end('HTTP/1.1 403 Forbidden\r\n\r\n');
|
||||
|
||||
@@ -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 CONFIG_FILE = process.env.PROXY_BRIDGE_CONFIG_FILE || '/opt/proxy-bridge/config.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 VSCODE_CERT_FLAG = ''; // e.g. '--ignore-certificate-errors'
|
||||
|
||||
@@ -81,6 +82,60 @@ function setProfile(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 = {}) {
|
||||
return run('systemctl', ['--user', ...args], options);
|
||||
}
|
||||
@@ -133,6 +188,7 @@ async function status() {
|
||||
console.log(`pid: ${pid && pid !== '0' ? pid : 'none'}`);
|
||||
if (isUpstreamEnabled()) console.log(`account: ${readAccount()}`);
|
||||
console.log(`upstream: ${getUpstreamConfig()}`);
|
||||
console.log(`overrides: ${readOverrides().length}`);
|
||||
console.log(`\nPort Mappings:`);
|
||||
console.log(` ${LISTEN_PORT.toString().padEnd(5)} -> dynamic (currently: ${current})${listening ? '' : ' [NOT REACHABLE]'}`);
|
||||
all.forEach((name, i) => {
|
||||
@@ -170,6 +226,10 @@ function config() {
|
||||
all.forEach((name, i) => {
|
||||
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() {
|
||||
@@ -293,6 +353,7 @@ Commands:
|
||||
status Show service, listener, upstream, profile, and account
|
||||
profile [name] Show or set the active routing profile
|
||||
toggle Cycle through profiles (internet -> direct -> off -> [pacs...])
|
||||
override [...] Manage destination overrides (see below)
|
||||
start Start the local proxy bridge
|
||||
stop Stop the local proxy bridge
|
||||
restart Restart the local proxy bridge
|
||||
@@ -304,6 +365,14 @@ Commands:
|
||||
vscode Manage VS Code proxy settings
|
||||
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:
|
||||
proxy-bridge vscode status
|
||||
proxy-bridge vscode setup
|
||||
@@ -326,6 +395,7 @@ async function main() {
|
||||
const next = profiles[(profiles.indexOf(current) + 1) % profiles.length];
|
||||
setProfile(next);
|
||||
break;
|
||||
case 'override': overrideCmd(process.argv.slice(3)); break;
|
||||
case 'start':
|
||||
case 'stop':
|
||||
case 'restart':
|
||||
|
||||
Reference in New Issue
Block a user