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.
This commit is contained in:
tke committed 2026-09-24 13:10:42 +02:00
1 parent e49b4af14a
commit be0e2d84cc
6 files changed
+409 -31

No files matched your search

+27 -1
View File
@@ -2,7 +2,7 @@ const http = require('http');
const https = require('https');
const net = require('net');
const tls = require('tls');
const { execFileSync } = require('child_process');
const { execFileSync, spawn } = require('child_process');
const fs = require('fs');
const vm = require('vm');
const path = require('path');
@@ -13,6 +13,7 @@ const USER_FILE = process.env.PROXY_BRIDGE_USER_FILE || '/opt/proxy-bridge/user.
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);
@@ -129,9 +130,23 @@ function recordAuthFailure(key) {
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)) {
@@ -294,6 +309,17 @@ function matchOverride(host) {
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();