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.
107 lines
4.9 KiB
JavaScript
107 lines
4.9 KiB
JavaScript
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');
|
|
});
|