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

+49 -1
View File
@@ -11,8 +11,9 @@ 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 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'
@@ -211,6 +212,51 @@ function setup() {
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}`);
@@ -360,6 +406,7 @@ Commands:
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
@@ -402,6 +449,7 @@ async function main() {
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;