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:
1 parent
e49b4af14a
commit
be0e2d84cc
6 files changed
+409
-31
No files matched your search
@@ -0,0 +1,106 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { once } = require('node:events');
|
||||
const { spawn } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const net = require('node:net');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const PROXYCTL = path.join(__dirname, 'proxyctl.js');
|
||||
|
||||
function mkState(t) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'proxyctl-'));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
return {
|
||||
bridgeDir: dir,
|
||||
config: path.join(dir, 'config.json'),
|
||||
user: path.join(dir, 'user.json'),
|
||||
stamp: path.join(dir, 'reauth-stamp.json'),
|
||||
};
|
||||
}
|
||||
|
||||
function runProxyctl(st, { args, stdinLines = [], upstreamPort = null }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const env = {
|
||||
...process.env,
|
||||
PROXY_BRIDGE_CONFIG_FILE: st.config,
|
||||
PROXY_BRIDGE_USER_FILE: st.user,
|
||||
PROXY_BRIDGE_REAUTH_FILE: st.stamp,
|
||||
};
|
||||
const child = spawn('node', [PROXYCTL, ...args], { env });
|
||||
let stdout = '', stderr = '';
|
||||
child.stdout.on('data', d => { stdout += d; });
|
||||
child.stderr.on('data', d => { stderr += d; });
|
||||
const timer = setTimeout(() => { child.kill(); reject(new Error('proxyctl hung')); }, 20000);
|
||||
child.on('exit', code => {
|
||||
clearTimeout(timer);
|
||||
resolve({ status: code, stdout: stdout.replace(/\x1b\[[0-9;]*m/g, ''), stderr });
|
||||
});
|
||||
let i = 0;
|
||||
const feed = () => {
|
||||
if (i >= stdinLines.length) { try { child.stdin.end(); } catch {} return; }
|
||||
child.stdin.write(stdinLines[i++] + '\n');
|
||||
setTimeout(feed, 150);
|
||||
};
|
||||
setTimeout(feed, 250);
|
||||
});
|
||||
}
|
||||
|
||||
test('reauth: verified new password is stored and stamp file dropped', { timeout: 30000 }, async t => {
|
||||
const st = mkState(t);
|
||||
const account = `reauth-test-${process.pid}`;
|
||||
// Upstream accepts exactly the password 'correct-new-pw'
|
||||
const upstream = net.createServer(socket => {
|
||||
socket.once('data', d => {
|
||||
const auth = /^Proxy-Authorization: (.+)$/mi.exec(d.toString());
|
||||
const decoded = auth ? Buffer.from(auth[1].replace(/^Basic\s+/i, ''), 'base64').toString() : '';
|
||||
const ok = decoded === `${account}:correct-new-pw`;
|
||||
socket.end(ok
|
||||
? 'HTTP/1.1 200 Connection Established\r\n\r\n'
|
||||
: 'HTTP/1.1 407 Proxy Authentication Required\r\n\r\n');
|
||||
});
|
||||
});
|
||||
upstream.listen(0, '127.0.0.1');
|
||||
await once(upstream, 'listening');
|
||||
t.after(() => upstream.close());
|
||||
const port = upstream.address().port;
|
||||
|
||||
fs.writeFileSync(st.config, JSON.stringify({ enabled: true, host: '127.0.0.1', port: String(port) }));
|
||||
fs.writeFileSync(st.user, JSON.stringify({ username: account }));
|
||||
|
||||
const r = await runProxyctl(st, { args: ['reauth'], stdinLines: ['correct-new-pw'] });
|
||||
assert.equal(r.status, 0, JSON.stringify(r.stderr + r.stdout));
|
||||
assert.match(r.stdout, /verified|accepted/i);
|
||||
// keyring got the password (real secret-tool with a junk account; clean up after)
|
||||
const lookup = await runProxyctl(st, { args: ['config'] }).catch(() => null); // not the check; real lookup below
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const kr = spawnSync('secret-tool', ['lookup', 'service', 'proxy-bridge', 'account', account], { encoding: 'utf8' });
|
||||
assert.equal(kr.stdout.trim(), 'correct-new-pw');
|
||||
spawnSync('secret-tool', ['clear', 'service', 'proxy-bridge', 'account', account], { encoding: 'utf8' });
|
||||
// stamp file written so the running bridge picks it up within ~1s
|
||||
const stamp = JSON.parse(fs.readFileSync(st.stamp, 'utf8'));
|
||||
assert.ok(stamp.at > 0);
|
||||
});
|
||||
|
||||
test('reauth: rejected password is NOT stored and no stamp is dropped', { timeout: 30000 }, async t => {
|
||||
const st = mkState(t);
|
||||
const account = `reauth-407-${process.pid}`;
|
||||
const upstream = net.createServer(socket => {
|
||||
socket.once('data', () => socket.end('HTTP/1.1 407 Proxy Authentication Required\r\n\r\n'));
|
||||
});
|
||||
upstream.listen(0, '127.0.0.1');
|
||||
await once(upstream, 'listening');
|
||||
t.after(() => upstream.close());
|
||||
|
||||
fs.writeFileSync(st.config, JSON.stringify({ enabled: true, host: '127.0.0.1', port: String(upstream.address().port) }));
|
||||
fs.writeFileSync(st.user, JSON.stringify({ username: account }));
|
||||
|
||||
const r = await runProxyctl(st, { args: ['reauth'], stdinLines: ['wrong'] });
|
||||
assert.notEqual(r.status, 0, 'reauth of a rejected password must fail');
|
||||
assert.match(r.stdout + r.stderr, /407|rejected/i);
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const kr = spawnSync('secret-tool', ['lookup', 'service', 'proxy-bridge', 'account', account], { encoding: 'utf8' });
|
||||
assert.notEqual(kr.status, 0, 'rejected password must not reach the keyring');
|
||||
assert.equal(fs.existsSync(st.stamp), false, 'no stamp on failed reauth');
|
||||
});
|
||||
Reference in new issue
Block a user