Files
gists/scripts/proxy/bridge.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

550 lines
23 KiB
JavaScript

const http = require('http');
const https = require('https');
const net = require('net');
const tls = require('tls');
const { execFileSync, spawn } = require('child_process');
const fs = require('fs');
const vm = require('vm');
const path = require('path');
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 = 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 REMOTES_FILE = process.env.PROXY_BRIDGE_REMOTES_FILE || '/opt/proxy-bridge/remotes.json';
const REAUTH_FILE = process.env.PROXY_BRIDGE_REAUTH_FILE || '/opt/proxy-bridge/reauth-stamp.json';
const PAC_DIR = process.env.PROXY_BRIDGE_PAC_DIR || path.join(os.homedir(), '.mozilla');
const LISTEN_HOST = process.env.PROXY_BRIDGE_LISTEN_HOST || '127.0.0.1';
const BASE_PORT = Number(process.env.PROXY_BRIDGE_BASE_PORT || 8888);
let INTERNET_PROXY_HOST, INTERNET_PROXY_PORT, UPSTREAM_ENABLED = true;
try {
const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
INTERNET_PROXY_HOST = config.host;
INTERNET_PROXY_PORT = Number(config.port);
UPSTREAM_ENABLED = (config.enabled !== false);
} catch (e) {
INTERNET_PROXY_HOST = 'your-proxy.example.com';
INTERNET_PROXY_PORT = 8080;
}
let requestCounter = 0;
let dynamicProfile = UPSTREAM_ENABLED ? 'internet' : 'direct';
const pacContexts = new Map();
function now() { return new Date().toISOString(); }
function log(level, id, message, fields = {}) {
const suffix = Object.entries(fields)
.filter(([, value]) => value !== undefined && value !== null && value !== '')
.map(([key, value]) => `${key}=${JSON.stringify(value)}`)
.join(' ');
console.log(`${now()} ${level} [${id}] ${message}${suffix ? ` ${suffix}` : ''}`);
}
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 {
const user = JSON.parse(fs.readFileSync(USER_FILE, 'utf8')).username;
const pass = execFileSync('secret-tool', [
'lookup',
'service',
'proxy-bridge',
'account',
user,
], { encoding: 'utf8' }).trim();
return pass ? { user, pass } : null;
} catch { return null; }
}
let AUTH_HEADER;
function refreshAuth() {
const creds = loadCredentials();
if (creds) {
AUTH_HEADER = 'Basic ' + Buffer.from(`${creds.user}:${creds.pass}`).toString('base64');
return true;
}
return false;
}
refreshAuth();
// --- Credential-failure backoff (anti-lockout) ---
// If the corp proxy keeps answering 407 to our stored credentials, retrying them
// on every request can lock the account. Track consecutive failures per upstream
// and stop sending Proxy-Authorization once the threshold is reached; re-check
// the keyring once after a backoff window before trying authenticated again.
const AUTH_LOCK_THRESHOLD = Number(process.env.PROXY_BRIDGE_AUTH_LOCK_THRESHOLD || 3);
const AUTH_LOCK_WINDOW_MS = Number(process.env.PROXY_BRIDGE_AUTH_LOCK_WINDOW_MS || 120_000);
const AUTH_LOCK_BACKOFF_MS = Number(process.env.PROXY_BRIDGE_AUTH_LOCK_BACKOFF_MS || 300_000);
const authState = new Map(); // upstreamKey -> { failures:[ts,...], lockedUntil }
function authStateFor(key) {
let st = authState.get(key);
if (!st) { st = { failures: [], lockedUntil: 0 }; authState.set(key, st); }
return st;
}
// Should we attach Proxy-Authorization for this upstream right now?
// May refresh creds once from the keyring when a lock window just expired.
function authHeaderForUpstream(key) {
const st = authStateFor(key);
const now = Date.now();
if (st.lockedUntil > now) return null;
if (st.lockedUntil !== 0) {
// Backoff expired: one optimistic retry with freshly loaded credentials.
st.lockedUntil = 0;
st.failures = [];
refreshAuth();
log('INFO', 'auth', 'backoff expired; retrying with refreshed credentials', { upstream: key });
}
if (!AUTH_HEADER && !refreshAuth()) return null;
return AUTH_HEADER;
}
function recordAuthFailure(key) {
const st = authStateFor(key);
const now = Date.now();
st.failures = st.failures.filter(ts => now - ts < AUTH_LOCK_WINDOW_MS);
st.failures.push(now);
if (st.failures.length >= AUTH_LOCK_THRESHOLD && st.lockedUntil <= now) {
st.lockedUntil = now + AUTH_LOCK_BACKOFF_MS;
log('WARN', 'auth', 'repeated 407 from upstream; withholding credentials until backoff expires (check keyring / password expiry)', { upstream: key, failures: st.failures.length, backoffMs: AUTH_LOCK_BACKOFF_MS });
notifyAuthLockout(key);
}
}
// Desktop notification on lockout entry. One per episode (callers gate on the
// lockedUntil transition). Silent no-op without a desktop session; never throws.
function notifyAuthLockout(key) {
if (!process.env.DBUS_SESSION_BUS_ADDRESS && !process.env.DISPLAY) return;
const summary = 'Proxy Bridge: upstream authentication rejected';
const body = `Proxy ${key} keeps answering 407 (expired credentials?). Auth withheld for ${Math.round(AUTH_LOCK_BACKOFF_MS / 60000)} min to protect the account. Fix: run 'proxy-bridge reauth' or 'node install.js'.`;
try {
const child = spawn('notify-send', ['--urgency=critical', '--app-name=proxy-bridge', summary, body], { detached: true, stdio: 'ignore' });
child.on('error', () => {});
child.unref();
} catch {}
}
function recordAuthSuccess(key) {
const st = authState.get(key);
if (st && (st.failures.length > 0 || st.lockedUntil !== 0)) {
authState.delete(key);
log('INFO', 'auth', 'upstream accepted credentials; auth-failure state cleared', { upstream: key });
}
}
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.includes('.') && hostdom.split('.')[0] === host),
isResolvable: (host) => {
try { execFileSync('getent', ['hosts', host]); return true; } catch { return false; }
},
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) => {
const p = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.');
return new RegExp('^' + p + '$').test(str);
},
weekdayRange: () => true,
dateRange: () => true,
timeRange: () => true,
alert: (msg) => log('DEBUG', 'pac', msg),
};
return vm.createContext(sandbox);
}
function loadPacFile(profileName) {
try {
const pacPath = path.join(PAC_DIR, `${profileName}.pac`);
if (!fs.existsSync(pacPath)) return null;
const script = fs.readFileSync(pacPath, 'utf8');
const context = createPacSandbox();
vm.runInContext(script, context);
return typeof context.FindProxyForURL === 'function' ? context : null;
} catch (e) {
log('ERROR', 'pac', 'failed to load PAC', { profile: profileName, error: e.message });
return null;
}
}
function getAvailableProfiles() {
const profiles = [];
if (UPSTREAM_ENABLED) profiles.push('internet');
profiles.push('direct', 'off');
const pacDir = PAC_DIR;
if (fs.existsSync(pacDir)) {
fs.readdirSync(pacDir).forEach(f => {
if (f.endsWith('.pac')) profiles.push(f.slice(0, -4));
});
}
return profiles;
}
function refreshPacContexts() {
getAvailableProfiles().forEach(name => {
if (!['internet', 'direct', 'off'].includes(name)) {
pacContexts.set(name, loadPacFile(name));
}
});
}
function updateDynamicProfile() {
try {
if (fs.existsSync(PROFILE_FILE)) {
const data = JSON.parse(fs.readFileSync(PROFILE_FILE, 'utf8'));
const newProfile = data.profile;
const valid = getAvailableProfiles();
if (newProfile && valid.includes(newProfile) && newProfile !== dynamicProfile) {
log('INFO', 'profile', 'dynamic profile switch', { old: dynamicProfile, new: newProfile });
dynamicProfile = newProfile;
} else if (newProfile && !valid.includes(newProfile)) {
log('WARN', 'profile', 'saved profile is invalid/disabled', { profile: newProfile });
dynamicProfile = UPSTREAM_ENABLED ? 'internet' : 'direct';
}
}
} 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;
}
// A `proxy-bridge reauth` run drops this stamp file; on change we reload the
// credentials from the keyring and clear EVERY lockout — no service restart.
function onReauthStamp() {
try { fs.readFileSync(REAUTH_FILE, 'utf8'); } catch { return; }
const hadLocks = authState.size > 0;
authState.clear();
refreshAuth();
log('INFO', 'auth', 'reauth stamp observed; credentials reloaded, lockout state cleared', { cleared: hadLocks });
}
fs.watchFile(REAUTH_FILE, { interval: 1000 }, onReauthStamp);
fs.watchFile(PROFILE_FILE, { interval: 1000 }, updateDynamicProfile);
fs.watchFile(OVERRIDES_FILE, { interval: 1000 }, loadOverrides);
updateDynamicProfile();
refreshPacContexts();
loadOverrides();
function getUpstream(profileName, url, host) {
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 { config, overrideProfile };
}
function parseProxyConfig(config) {
const first = config.split(';')[0].trim();
if (first.startsWith('PROXY ')) {
const [host, port] = first.substring(6).split(':');
return { type: 'PROXY', host, port: Number(port) };
}
if (first.startsWith('HTTPS ')) {
const [host, port] = first.substring(6).split(':');
return { type: 'HTTPS', host, port: Number(port) };
}
return { type: first === 'OFF' ? 'OFF' : 'DIRECT' };
}
function sanitizeProxyHeaders(headers) {
const clean = { ...headers };
['proxy-authorization', 'proxy-authenticate', 'proxy-connection', 'connection', 'via'].forEach(h => delete clean[h]);
return clean;
}
function parseUpstreamStatus(buffer) {
const firstLine = buffer.toString('latin1').split('\r\n', 1)[0];
const match = firstLine.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})\s*(.*)$/i);
return { line: firstLine, code: match ? Number(match[1]) : undefined, text: match ? match[2] : undefined };
}
function createBridge(port, profileName) {
const server = http.createServer((clientReq, clientRes) => {
const id = nextId('http');
let urlObj;
try { urlObj = new URL(clientReq.url); } catch { urlObj = new URL(clientReq.url, `http://${clientReq.headers.host}`); }
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), override: overrideProfile, target: urlObj.href, upstream: upstreamConfig });
if (upstream.type === 'OFF') {
clientRes.writeHead(403); return clientRes.end('Forbidden by proxy profile\n');
}
const isProxied = (upstream.type === 'PROXY' || upstream.type === 'HTTPS');
const upstreamAuthKey = isProxied ? `${upstream.host}:${upstream.port}` : null;
const options = {
method: clientReq.method,
headers: sanitizeProxyHeaders(clientReq.headers),
host: isProxied ? upstream.host : urlObj.hostname,
port: isProxied ? upstream.port : (urlObj.port || 80),
path: isProxied ? urlObj.href : (urlObj.pathname + urlObj.search)
};
if (isProxied) {
const authHeader = authHeaderForUpstream(upstreamAuthKey);
if (authHeader) options.headers['Proxy-Authorization'] = authHeader;
}
const requestModule = upstream.type === 'HTTPS' ? https : http;
const upstreamReq = requestModule.request(options, (upstreamRes) => {
if (isProxied) {
if (upstreamRes.statusCode === 407) recordAuthFailure(upstreamAuthKey);
else recordAuthSuccess(upstreamAuthKey);
}
clientRes.writeHead(upstreamRes.statusCode, sanitizeProxyHeaders(upstreamRes.headers));
upstreamRes.pipe(clientRes);
upstreamRes.on('error', (err) => {
clientRes.destroy(err);
});
upstreamRes.on('close', () => {
if (!upstreamRes.complete) {
clientRes.destroy(new Error('Upstream response truncated'));
}
});
});
upstreamReq.on('error', (e) => {
if (!clientRes.headersSent) clientRes.writeHead(502);
clientRes.end('Bad Gateway\n');
});
clientReq.on('close', () => {
if (!clientReq.complete) upstreamReq.destroy();
});
clientReq.pipe(upstreamReq);
});
server.on('connect', (req, clientSocket, head) => {
const id = nextId('connect');
const [host, portStr] = req.url.split(':');
const portNum = Number(portStr || 443);
const { config: upstreamConfig, overrideProfile } = getUpstream(profileName, `https://${req.url}/`, host);
const upstream = parseProxyConfig(upstreamConfig);
let upstreamSocket;
let connected = false;
clientSocket.setKeepAlive(true, 10000);
clientSocket.setNoDelay(true);
clientSocket.on('error', () => {
if (upstreamSocket) upstreamSocket.destroy();
});
clientSocket.on('close', () => {
if (upstreamSocket) upstreamSocket.destroy();
});
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');
}
const isProxiedConnect = (upstream.type === 'PROXY' || upstream.type === 'HTTPS');
const upstreamAuthKey = isProxiedConnect ? `${upstream.host}:${upstream.port}` : null;
const establishDirect = () => {
upstreamSocket.setKeepAlive(true, 10000);
upstreamSocket.setNoDelay(true);
connected = true;
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
if (head && head.length > 0) upstreamSocket.write(head);
upstreamSocket.pipe(clientSocket);
clientSocket.pipe(upstreamSocket);
};
const sendConnectToProxy = () => {
upstreamSocket.setKeepAlive(true, 10000);
upstreamSocket.setNoDelay(true);
const authHeader = authHeaderForUpstream(upstreamAuthKey);
upstreamSocket.write(`CONNECT ${req.url} HTTP/1.1\r\nHost: ${req.url}\r\n`);
if (authHeader) upstreamSocket.write(`Proxy-Authorization: ${authHeader}\r\n`);
upstreamSocket.write('\r\n');
};
if (upstream.type === 'HTTPS') {
upstreamSocket = tls.connect(
{ host: upstream.host, port: upstream.port, servername: upstream.host, rejectUnauthorized: false },
sendConnectToProxy
);
} else if (upstream.type === 'PROXY') {
upstreamSocket = net.connect(upstream.port, upstream.host, sendConnectToProxy);
} else { // DIRECT
upstreamSocket = net.connect(portNum, host, establishDirect);
}
upstreamSocket.on('error', () => {
if (!connected) {
try { clientSocket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n'); } catch {}
} else {
clientSocket.destroy();
}
});
upstreamSocket.on('close', () => {
if (!connected) {
try { clientSocket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n'); } catch {}
} else {
clientSocket.destroy();
}
});
let connectBuffer = Buffer.alloc(0);
function onUpstreamData(data) {
if (connected) return;
if (upstream.type === 'PROXY' || upstream.type === 'HTTPS') {
connectBuffer = Buffer.concat([connectBuffer, data]);
const headerEnd = connectBuffer.indexOf('\r\n\r\n');
if (headerEnd === -1) return;
connected = true;
upstreamSocket.removeListener('data', onUpstreamData);
const status = parseUpstreamStatus(connectBuffer);
if (status.code === 200) {
recordAuthSuccess(upstreamAuthKey);
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
const remaining = connectBuffer.subarray(headerEnd + 4);
if (remaining.length > 0) clientSocket.write(remaining);
if (head && head.length > 0) upstreamSocket.write(head);
upstreamSocket.pipe(clientSocket);
clientSocket.pipe(upstreamSocket);
} else {
if (status.code === 407) recordAuthFailure(upstreamAuthKey);
clientSocket.write(connectBuffer);
clientSocket.end();
upstreamSocket.end();
}
}
// DIRECT is established in the socket's connect callback (establishDirect);
// nothing to do here — piped streams carry the bytes from here on.
}
upstreamSocket.on('data', onUpstreamData);
});
server.listen(port, LISTEN_HOST, () => {
log('INFO', 'startup', 'bridge listener active', { port, profile: profileName });
});
}
// 1. Dynamic bridge on 8888
createBridge(BASE_PORT, 'dynamic');
// 2. Static bridges on 8889+
getAvailableProfiles().forEach((name, i) => {
createBridge(BASE_PORT + 1 + i, name);
});