From 401b3e17811013fbbacc7c6a36fb3b09270094c6 Mon Sep 17 00:00:00 2001 From: tobias Date: Thu, 28 May 2026 21:58:11 +0200 Subject: [PATCH] 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) --- scripts/proxy/bridge.js | 139 ++++++++++++++++++++++++++++++++------ scripts/proxy/proxyctl.js | 70 +++++++++++++++++++ 2 files changed, 190 insertions(+), 19 deletions(-) diff --git a/scripts/proxy/bridge.js b/scripts/proxy/bridge.js index 9095805..98f14aa 100644 --- a/scripts/proxy/bridge.js +++ b/scripts/proxy/bridge.js @@ -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; - - 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'; } + 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 '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'); diff --git a/scripts/proxy/proxyctl.js b/scripts/proxy/proxyctl.js index 6b3b217..b0b5889 100644 --- a/scripts/proxy/proxyctl.js +++ b/scripts/proxy/proxyctl.js @@ -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 '); 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 '); 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 |remove |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 + proxy-bridge override remove + proxy-bridge override clear + 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':