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

+11 -28
View File
@@ -46,7 +46,7 @@ const APT_CONF = process.env.PROXY_BRIDGE_APT_CONF || '/etc/apt/apt.conf.d/80pro
const APT_CONF_DIR = process.env.PROXY_BRIDGE_APT_CONF_DIR || path.dirname(APT_CONF);
const RUNTIME_FILES = ['config.json', 'user.json', 'profile.json', 'overrides.json', 'remotes.json'];
const CODE_FILES = ['bridge.js', 'proxyctl.js'];
const CODE_FILES = ['bridge.js', 'proxyctl.js', 'apt-audit.js', 'verify-creds.js'];
const sectionResults = []; // { section, status: CHANGED|OK|SKIPPED|FAILED, detail }
function report(section, status, detail = '') {
@@ -263,32 +263,15 @@ async function sectionConfig() {
// Section: credentials — verify before storing (anti-lockout)
// ---------------------------------------------------------------------------
// One single CONNECT attempt with candidate creds directly against the upstream.
// NEVER retries, NEVER prints the password, resolves: ok | authfail | unreachable
function verifyCredentials(host, port, username, password) {
return new Promise((resolve) => {
const net = require('net');
const socket = net.connect(Number(port), host);
let buf = Buffer.alloc(0);
const finish = (r) => { try { socket.destroy(); } catch {} resolve(r); };
socket.setTimeout(10000, () => finish('unreachable'));
socket.once('connect', () => {
const auth = 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64');
socket.write(`CONNECT 127.0.0.1:443 HTTP/1.1\r\nHost: 127.0.0.1:443\r\nProxy-Authorization: ${auth}\r\n\r\n`);
});
socket.on('data', (d) => {
buf = Buffer.concat([buf, d]);
const head = buf.toString('latin1').split('\r\n')[0] || '';
const m = head.match(/^HTTP\/\d\.\d\s+(\d{3})/i);
if (m || buf.includes('\r\n\r\n')) {
const code = m ? Number(m[1]) : 0;
// 200/4xx non-407 (e.g. 403 blocked target) = creds accepted; 407 = rejected.
finish(code === 407 ? 'authfail' : (code > 0 ? 'ok' : 'unreachable'));
}
});
socket.once('error', () => finish('unreachable'));
socket.once('end', () => { if (buf.length === 0) finish('unreachable'); });
});
// verifyProxyCredentials: shared one-shot upstream check (see verify-creds.js).
const { verifyProxyCredentials: verifyCredentials } =
(() => { try { return require('./verify-creds.js'); } catch { return {}; } })();
// Fallback inline copy if deploys predate the shared module (kept minimal):
async function verifyWithFallback(host, port, user, pass) {
if (verifyCredentials) return verifyCredentials(host, port, user, pass);
const { verifyProxyCredentials } = require(path.join(BRIDGE_DIR, 'verify-creds.js'));
return verifyProxyCredentials(host, port, user, pass);
}
async function sectionCredentials(config) {
@@ -311,7 +294,7 @@ async function sectionCredentials(config) {
for (let attempt = 1; attempt <= 3; attempt++) {
const pass = await askSecret(`Corporate password for ${user} (attempt ${attempt}/3)`);
if (!pass) { console.log(' empty password, try again'); continue; }
const verdict = await verifyCredentials(config.host, config.port, user, pass);
const verdict = await verifyWithFallback(config.host, config.port, user, pass);
if (verdict === 'ok') {
if (!FLAGS.dryRun) {
execFileSync('secret-tool', ['store', '--label=Proxy Bridge Credentials', 'service', 'proxy-bridge', 'account', user], { input: pass });