Files
gists/scripts/proxy/proxyctl.js
T
tobias 401b3e1781 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>
2026-05-28 21:58:11 +02:00

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); });