Files
gists/scripts/proxy/proxyctl.js
T
tke be0e2d84cc feat(proxy): desktop lockout notification + 'proxy-bridge reauth' command
Notification path (bridge.js): entering auth lockout fires ONE desktop
notification per episode (notify-send, critical urgency), only when a session
is detectable (DBUS_SESSION_BUS_ADDRESS or DISPLAY) — headless stays silent.
Message explains the 407 cause and points at 'proxy-bridge reauth'.

Recovery path (proxyctl.js): 'proxy-bridge reauth' prompts masked, verifies the
new password with a SINGLE upstream CONNECT (407 -> NOT stored), stores via
secret-tool on success, then writes a reauth-stamp.json the bridge watches
(1s poll): clears in-memory authState and reloads keyring creds — no restart.

Shared one-shot verifier extracted to verify-creds.js (install.js + proxyctl.js
use the same code; also fixes proxyctl 'setup' shelling to the retired
/opt/setup.js — 'reauth' is its replacement for the password-expiry flow).

Tests: +3 bridge (notify once-per-episode incl. remediation hint; headless
silent; stamp clears lockout without restart) +2 proxyctl (verified stored +
stamped; rejected never stored/stamped). Suite: 28/28.
2026-09-24 13:10:42 +02:00

461 lines
18 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 = process.env.PROXY_BRIDGE_PROFILE_FILE || '/opt/proxy-bridge/profile.json';
const OVERRIDES_FILE = process.env.PROXY_BRIDGE_OVERRIDES_FILE || '/opt/proxy-bridge/overrides.json';
const REAUTH_FILE = process.env.PROXY_BRIDGE_REAUTH_FILE || '/opt/proxy-bridge/reauth-stamp.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));
}
// reauth: replace the stored upstream password after an expiry/407 lockout.
// The new password is verified ONCE against the upstream before it ever touches
// the keyring; on success we drop a stamp file the bridge watches (clears the
// auth-lockout state within ~1s, no service restart).
async function reauth() {
const readline = require('readline');
const config = (() => { try { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); } catch { return null; } })();
if (!config || config.enabled === false || !config.host) {
console.error('reauth: upstream proxy is disabled or unconfigured.');
process.exit(1);
}
const username = (() => { try { return JSON.parse(fs.readFileSync(USER_FILE, 'utf8')).username; } catch { return null; } })();
if (!username) {
console.error(`reauth: no username in ${USER_FILE}. Run 'node install.js' for initial setup.`);
process.exit(1);
}
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const password = await new Promise((resolve) => {
const oldWrite = rl._writeToOutput;
rl._writeToOutput = function (s) {
if (s === '\n' || s === '\r' || s === '\r\n') rl.output.write(s);
else if (!s.includes(username)) rl.output.write('*');
else rl.output.write(s);
};
rl.question(`New password for ${username}: `, (a) => { rl._writeToOutput = oldWrite; rl.output.write('\n'); rl.close(); resolve(a.trim()); });
});
if (!password) { console.error('reauth: empty password, aborted. Nothing was stored.'); process.exit(1); }
const { verifyProxyCredentials } = require('./verify-creds.js');
const verdict = await verifyProxyCredentials(config.host, Number(config.port), username, password);
if (verdict === 'ok') {
spawnSync('secret-tool', ['store', '--label=Proxy Bridge Credentials', 'service', 'proxy-bridge', 'account', username], { input: password });
fs.writeFileSync(REAUTH_FILE, JSON.stringify({ at: Date.now(), user: username }));
console.log(`Credentials verified by ${config.host}:${config.port}, stored in keyring, and the bridge was notified (lockout clears within ~1s — no restart).`);
return;
}
if (verdict === 'authfail') {
console.error('reauth: upstream rejected these credentials (407). NOT stored — check the password and try again.');
process.exit(1);
}
console.error(`reauth: could not reach ${config.host}:${config.port} to verify. Credentials NOT stored (retry when online).`);
process.exit(1);
}
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
reauth Replace upstream password (verified once, no service restart)
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 'reauth': await reauth(); 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); });