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>
413 lines
16 KiB
JavaScript
413 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
const net = require('net');
|
|
const { spawn, spawnSync } = require('child_process');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
const SERVICE = 'proxy-bridge.service';
|
|
const LISTEN_HOST = process.env.PROXY_BRIDGE_LISTEN_HOST || '127.0.0.1';
|
|
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'
|
|
|
|
function run(command, args, options = {}) {
|
|
return spawnSync(command, args, {
|
|
encoding: 'utf8',
|
|
stdio: options.stdio || 'pipe',
|
|
});
|
|
}
|
|
|
|
function getUpstreamConfig() {
|
|
try {
|
|
if (fs.existsSync(CONFIG_FILE)) {
|
|
const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
if (config.enabled === false) return 'DISABLED';
|
|
return `${config.host}:${config.port}`;
|
|
}
|
|
} catch {}
|
|
return 'your-proxy.example.com:8080 (default)';
|
|
}
|
|
|
|
function getProfile() {
|
|
try {
|
|
if (fs.existsSync(PROFILE_FILE)) {
|
|
const current = JSON.parse(fs.readFileSync(PROFILE_FILE, 'utf8')).profile;
|
|
const valid = getAvailableProfiles();
|
|
if (current && valid.includes(current)) return current;
|
|
}
|
|
} catch {}
|
|
return isUpstreamEnabled() ? 'internet' : 'direct';
|
|
}
|
|
|
|
function isUpstreamEnabled() {
|
|
try {
|
|
if (fs.existsSync(CONFIG_FILE)) {
|
|
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')).enabled !== false;
|
|
}
|
|
} catch {}
|
|
return true;
|
|
}
|
|
|
|
function getAvailableProfiles() {
|
|
const profiles = [];
|
|
if (isUpstreamEnabled()) profiles.push('internet');
|
|
profiles.push('direct', 'off');
|
|
|
|
const pacDir = path.join(os.homedir(), '.mozilla');
|
|
if (fs.existsSync(pacDir)) {
|
|
const files = fs.readdirSync(pacDir);
|
|
for (const f of files) {
|
|
if (f.endsWith('.pac')) {
|
|
profiles.push(f.slice(0, -4));
|
|
}
|
|
}
|
|
}
|
|
return profiles;
|
|
}
|
|
|
|
function setProfile(profile) {
|
|
const valid = getAvailableProfiles();
|
|
if (!valid.includes(profile)) {
|
|
console.error(`Invalid profile: ${profile}. Valid options: ${valid.join(', ')}`);
|
|
process.exit(1);
|
|
}
|
|
fs.mkdirSync(path.dirname(PROFILE_FILE), { recursive: true });
|
|
fs.writeFileSync(PROFILE_FILE, JSON.stringify({ 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);
|
|
}
|
|
|
|
function printCommandFailure(result) {
|
|
if (result.error) { console.error(result.error.message); return; }
|
|
if (result.stderr) { console.error(result.stderr.trim()); }
|
|
}
|
|
|
|
function serviceValue(property) {
|
|
const result = systemctl(['show', SERVICE, '--property', property, '--value']);
|
|
if (result.status !== 0) return '';
|
|
return result.stdout.trim();
|
|
}
|
|
|
|
function serviceState(command) {
|
|
const result = systemctl([command, SERVICE]);
|
|
if (result.status !== 0) return 'unknown';
|
|
return result.stdout.trim() || 'unknown';
|
|
}
|
|
|
|
function checkPort() {
|
|
return new Promise((resolve) => {
|
|
const socket = net.connect({ host: LISTEN_HOST, port: LISTEN_PORT });
|
|
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, 1000);
|
|
socket.once('connect', () => { clearTimeout(timer); socket.end(); resolve(true); });
|
|
socket.once('error', () => { clearTimeout(timer); resolve(false); });
|
|
});
|
|
}
|
|
|
|
function readAccount() {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(USER_FILE, 'utf8')).username || 'unknown';
|
|
} catch {
|
|
return 'not configured';
|
|
}
|
|
}
|
|
|
|
async function status() {
|
|
const active = serviceState('is-active');
|
|
const enabled = serviceState('is-enabled');
|
|
const pid = serviceValue('MainPID');
|
|
const listening = await checkPort();
|
|
const current = getProfile();
|
|
const all = getAvailableProfiles();
|
|
|
|
console.log(`service: ${SERVICE}`);
|
|
console.log(`active: ${active}`);
|
|
console.log(`enabled: ${enabled}`);
|
|
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) => {
|
|
console.log(` ${(LISTEN_PORT + 1 + i).toString().padEnd(5)} -> ${name}`);
|
|
});
|
|
}
|
|
|
|
function control(action) {
|
|
const result = systemctl([action, SERVICE], { stdio: 'inherit' });
|
|
if (result.status !== 0) { printCommandFailure(result); process.exit(result.status || 1); }
|
|
}
|
|
|
|
function logs() {
|
|
const child = spawn('journalctl', ['--user', '-u', SERVICE, '-f'], { stdio: 'inherit' });
|
|
child.on('exit', (code) => process.exit(code || 0));
|
|
}
|
|
|
|
function setup() {
|
|
const child = spawn('node', ['/opt/proxy-bridge/setup.js'], { stdio: 'inherit' });
|
|
child.on('exit', (code) => process.exit(code || 0));
|
|
}
|
|
|
|
function config() {
|
|
const all = getAvailableProfiles();
|
|
console.log(`listen_host: ${LISTEN_HOST}`);
|
|
console.log(`base_port: ${LISTEN_PORT}`);
|
|
if (isUpstreamEnabled()) {
|
|
console.log(`user_file: ${USER_FILE}`);
|
|
console.log(`account: ${readAccount()}`);
|
|
}
|
|
console.log(`config_file: ${CONFIG_FILE}`);
|
|
console.log(`upstream: ${getUpstreamConfig()}`);
|
|
console.log(`\nEffective Mappings:`);
|
|
console.log(` ${LISTEN_PORT} -> dynamic (${getProfile()})`);
|
|
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() {
|
|
const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
return path.join(configHome, 'Code', 'User', 'settings.json');
|
|
}
|
|
|
|
function readJsonFile(file) {
|
|
if (!fs.existsSync(file)) return {};
|
|
const content = fs.readFileSync(file, 'utf8').trim();
|
|
return content ? JSON.parse(content) : {};
|
|
}
|
|
|
|
function writeJsonFile(file, data) {
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
fs.writeFileSync(file, `${JSON.stringify(data, null, 4)}\n`);
|
|
}
|
|
|
|
function desktopApplicationsDir() {
|
|
const dataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
|
return path.join(dataHome, 'applications');
|
|
}
|
|
|
|
function desktopLauncherPaths() {
|
|
const userDir = desktopApplicationsDir();
|
|
return [
|
|
{ name: 'main', system: '/usr/share/applications/code.desktop', user: path.join(userDir, 'code.desktop') },
|
|
{ name: 'url-handler', system: '/usr/share/applications/code-url-handler.desktop', user: path.join(userDir, 'code-url-handler.desktop') },
|
|
];
|
|
}
|
|
|
|
function addCodeFlag(command) {
|
|
if (!VSCODE_CERT_FLAG) return command;
|
|
return command.includes(VSCODE_CERT_FLAG) ? command : command.replace(/^(Exec=\S+)/, `$1 ${VSCODE_CERT_FLAG}`);
|
|
}
|
|
|
|
function launcherHasFlag(file) {
|
|
if (!VSCODE_CERT_FLAG) return false;
|
|
return fs.existsSync(file) ? fs.readFileSync(file, 'utf8').includes(VSCODE_CERT_FLAG) : false;
|
|
}
|
|
|
|
function vscodeLaunchStatus() {
|
|
for (const launcher of desktopLauncherPaths()) {
|
|
const exists = fs.existsSync(launcher.user);
|
|
const enabled = launcherHasFlag(launcher.user);
|
|
console.log(`${launcher.name}: ${launcher.user}`);
|
|
console.log(` user override: ${exists ? 'yes' : 'no'}`);
|
|
console.log(` flag present: ${enabled ? 'yes' : 'no'}`);
|
|
}
|
|
const userCode = path.join(os.homedir(), '.local', 'bin', 'code');
|
|
console.log(`terminal wrapper: ${userCode}`);
|
|
console.log(` installed: ${fs.existsSync(userCode) ? 'yes' : 'no'}`);
|
|
console.log(` flag present: ${launcherHasFlag(userCode) ? 'yes' : 'no'}`);
|
|
}
|
|
|
|
function userCodeWrapperPath() { return path.join(os.homedir(), '.local', 'bin', 'code'); }
|
|
|
|
function vscodeTerminalSetup() {
|
|
const file = userCodeWrapperPath();
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
const flagStr = VSCODE_CERT_FLAG ? ` ${VSCODE_CERT_FLAG}` : '';
|
|
fs.writeFileSync(file, `#!/bin/sh\nexec /usr/bin/code${flagStr} "$@"\n`);
|
|
fs.chmodSync(file, 0o755);
|
|
console.log(`Updated ${file}`);
|
|
}
|
|
|
|
function vscodeLaunchSetup() {
|
|
fs.mkdirSync(desktopApplicationsDir(), { recursive: true });
|
|
for (const launcher of desktopLauncherPaths()) {
|
|
if (!fs.existsSync(launcher.system)) { console.error(`Missing system launcher: ${launcher.system}`); continue; }
|
|
const content = fs.readFileSync(launcher.system, 'utf8').split('\n').map((line) => (line.startsWith('Exec=') ? addCodeFlag(line) : line)).join('\n');
|
|
fs.writeFileSync(launcher.user, content);
|
|
console.log(`Updated ${launcher.user}`);
|
|
}
|
|
vscodeTerminalSetup();
|
|
}
|
|
|
|
async function vscodeStatus() {
|
|
const file = codeSettingsPath();
|
|
let settings = readJsonFile(file);
|
|
console.log(`settings: ${file}`);
|
|
console.log(`http.proxy: ${settings['http.proxy'] || 'not set'}`);
|
|
console.log(`http.proxySupport: ${settings['http.proxySupport'] || 'not set'}`);
|
|
console.log(`http.proxyStrictSSL: ${settings['http.proxyStrictSSL'] === undefined ? 'not set' : settings['http.proxyStrictSSL']}`);
|
|
console.log(`http.systemCertificates: ${settings['http.systemCertificates'] === undefined ? 'not set' : settings['http.systemCertificates']}`);
|
|
console.log(`local proxy reachable: ${await checkPort() ? 'yes' : 'no'}`);
|
|
}
|
|
|
|
async function vscodeSetup() {
|
|
const file = codeSettingsPath();
|
|
let settings = readJsonFile(file);
|
|
settings['http.proxy'] = LOCAL_PROXY_URL;
|
|
settings['http.proxySupport'] = 'override';
|
|
settings['http.proxyStrictSSL'] = false;
|
|
settings['http.systemCertificates'] = true;
|
|
writeJsonFile(file, settings);
|
|
console.log(`Updated ${file}\nhttp.proxy: ${LOCAL_PROXY_URL}\nhttp.proxySupport: override\nhttp.proxyStrictSSL: false\nhttp.systemCertificates: true`);
|
|
console.log(`local proxy reachable: ${await checkPort() ? 'yes' : 'no'}`);
|
|
}
|
|
|
|
async function vscode(args) {
|
|
const action = args[0] || 'status';
|
|
switch (action) {
|
|
case 'status': await vscodeStatus(); break;
|
|
case 'setup': await vscodeSetup(); break;
|
|
case 'launch':
|
|
switch (args[1] || 'status') {
|
|
case 'status': vscodeLaunchStatus(); break;
|
|
case 'setup': vscodeLaunchSetup(); break;
|
|
default: console.error('Usage: proxy-bridge vscode launch [status|setup]'); process.exit(1);
|
|
}
|
|
break;
|
|
default: console.error('Usage: proxy-bridge vscode [status|setup|launch]'); process.exit(1);
|
|
}
|
|
}
|
|
|
|
function help() {
|
|
console.log(`Usage: proxy-bridge <command>
|
|
|
|
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
|
|
enable Enable the user service at login
|
|
disable Disable the user service at login
|
|
logs Follow service logs
|
|
setup Store or update keyring credentials and upstream config
|
|
config Print effective non-secret configuration
|
|
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
|
|
proxy-bridge vscode launch status
|
|
proxy-bridge vscode launch setup
|
|
`);
|
|
}
|
|
|
|
async function main() {
|
|
const command = process.argv[2] || 'status';
|
|
switch (command) {
|
|
case 'status': await status(); break;
|
|
case 'profile':
|
|
if (process.argv[3]) setProfile(process.argv[3]);
|
|
else console.log(getProfile());
|
|
break;
|
|
case 'toggle':
|
|
const current = getProfile();
|
|
const profiles = getAvailableProfiles();
|
|
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':
|
|
case 'enable':
|
|
case 'disable': control(command); break;
|
|
case 'logs': logs(); break;
|
|
case 'setup': setup(); break;
|
|
case 'config': config(); break;
|
|
case 'vscode': await vscode(process.argv.slice(3)); break;
|
|
default: help(); process.exit(1);
|
|
}
|
|
}
|
|
|
|
main().catch((error) => { console.error(error.message); process.exit(1); });
|