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
@@ -62,7 +62,9 @@ async function bridge(t, upstreamPort, upstreamCfg) {
|
||||
// Start the bridge with env overrides and return the ephemeral port of a chosen
|
||||
// listenerProfile ('dynamic', 'internet', or a PAC profile name). All listeners
|
||||
// are tracked for cleanup; only the requested one is bound to a random port.
|
||||
async function bridgeOnProfile(t, listenerProfile, { upstreamCfg, env = {}, withCreds = false } = {}) {
|
||||
// spawnCalls collects every child_process.spawn call the bridge makes (e.g. the
|
||||
// lockout desktop notification).
|
||||
async function bridgeOnProfile(t, listenerProfile, { upstreamCfg, env = {}, withCreds = false, spawnCalls = null } = {}) {
|
||||
const servers = [];
|
||||
const fullEnv = { ...process.env, ...env };
|
||||
vm.runInNewContext(source, {
|
||||
@@ -100,6 +102,10 @@ async function bridgeOnProfile(t, listenerProfile, { upstreamCfg, env = {}, with
|
||||
if (Array.isArray(args) && args[0] === 'lookup' && withCreds) return 'test-password\n';
|
||||
return '';
|
||||
},
|
||||
spawn: (cmd, args, opts) => {
|
||||
if (spawnCalls) spawnCalls.push({ cmd, args, opts });
|
||||
return { on() {}, unref() {} };
|
||||
},
|
||||
};
|
||||
if (name === 'http') return {
|
||||
...http,
|
||||
@@ -351,6 +357,85 @@ function connectWithAuthCapture(t, port) {
|
||||
});
|
||||
}
|
||||
|
||||
// Variant with a REAL fs.watchFile passthrough and a dynamic keyring closure —
|
||||
// for tests that exercise the bridge's config/stamp watchers and reauth refresh.
|
||||
// Watchers are tracked and stopped at test end so node --test can exit.
|
||||
function bridgeWithRealWatch(t, listenerProfile, { upstreamCfg, env = {}, withCreds = false, spawnCalls = null, passwordRef = null } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const servers = [];
|
||||
const watched = [];
|
||||
const fullEnv = { ...process.env, ...env };
|
||||
let userLoadedOnce = false;
|
||||
vm.runInNewContext(source, {
|
||||
Buffer, URL, console: { log() {} }, setTimeout, clearTimeout,
|
||||
process: { env: fullEnv },
|
||||
require(name) {
|
||||
if (name === 'fs') return {
|
||||
readFileSync: (p) => {
|
||||
if (typeof p === 'string' && p.includes('config.json')) {
|
||||
return JSON.stringify(upstreamCfg || { enabled: false });
|
||||
}
|
||||
if (typeof p === 'string' && p.includes('user.json')) {
|
||||
if (!withCreds) throw new Error('ENOENT: no user.json in test');
|
||||
if (userLoadedOnce) throw new Error('ENOENT: one-shot'); // reauth clears user file
|
||||
userLoadedOnce = true;
|
||||
return JSON.stringify({ username: 'test-user' });
|
||||
}
|
||||
return fs.readFileSync(p, 'utf8');
|
||||
},
|
||||
existsSync: (p) => {
|
||||
if (typeof p === 'string') {
|
||||
if (p.includes('user.json')) return withCreds && !userLoadedOnce;
|
||||
if (p.includes('profile.json') || p.includes('overrides.json') || p.includes('remotes.json')) return false;
|
||||
}
|
||||
return fs.existsSync(p);
|
||||
},
|
||||
readdirSync: fs.readdirSync,
|
||||
watchFile: (p, opts, listener) => {
|
||||
watched.push(String(p));
|
||||
return fs.watchFile(p, opts, listener);
|
||||
},
|
||||
};
|
||||
if (name === 'child_process') return {
|
||||
execFileSync: (cmd, args) => {
|
||||
if (Array.isArray(args) && args[0] === 'lookup' && withCreds) {
|
||||
return (passwordRef ? passwordRef.current : 'test-password') + '\n';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
spawn: (cmd, args, opts) => {
|
||||
if (spawnCalls) spawnCalls.push({ cmd, args, opts });
|
||||
return { on() {}, unref() {} };
|
||||
},
|
||||
};
|
||||
if (name === 'http') return {
|
||||
...http,
|
||||
createServer(...args) {
|
||||
const server = http.createServer(...args);
|
||||
server.listen = () => { servers.push(server); return server; };
|
||||
return server;
|
||||
},
|
||||
};
|
||||
return require(name);
|
||||
},
|
||||
}, { filename: 'bridge.js' });
|
||||
|
||||
const names = ['dynamic'];
|
||||
if (!upstreamCfg || upstreamCfg.enabled !== false) names.push('internet');
|
||||
names.push('direct', 'off');
|
||||
const pacDir = env.PROXY_BRIDGE_PAC_DIR || path.join(require('os').homedir(), '.mozilla');
|
||||
if (fs.existsSync(pacDir)) {
|
||||
for (const f of fs.readdirSync(pacDir)) if (f.endsWith('.pac')) names.push(f.slice(0, -4));
|
||||
}
|
||||
const idx = names.indexOf(listenerProfile);
|
||||
if (idx === -1 || !servers[idx]) return reject(new Error(`listener '${listenerProfile}' not created; have ${names.join(',')}`));
|
||||
const server = servers[idx];
|
||||
server.listen = net.Server.prototype.listen;
|
||||
t.after(() => watched.forEach(p => { try { fs.unwatchFile(p); } catch {} }));
|
||||
resolve(listen(t, server));
|
||||
});
|
||||
}
|
||||
|
||||
// Wait until the client socket has seen one full proxy response head, then settle:
|
||||
// covers both graceful end and destroy paths without depending on FIN timing.
|
||||
function awaitProxyResponse(socket) {
|
||||
@@ -447,3 +532,102 @@ test('missing credentials never produce an auth header and do not crash the brid
|
||||
}
|
||||
assert.deepEqual(authHeadersSeen, [false, false, false]);
|
||||
});
|
||||
|
||||
test('lockout entry fires exactly one desktop notification per episode', { timeout: 8000 }, async t => {
|
||||
const spawnCalls = [];
|
||||
const upstream = net.createServer(socket => {
|
||||
socket.once('data', () => socket.end('HTTP/1.1 407 Proxy Authentication Required\r\n\r\n'));
|
||||
});
|
||||
const upstreamPort = await listen(t, upstream);
|
||||
const proxyPort = await bridgeOnProfile(t, 'internet', {
|
||||
upstreamCfg: { enabled: true, host: '127.0.0.1', port: upstreamPort },
|
||||
withCreds: true,
|
||||
spawnCalls,
|
||||
env: { DBUS_SESSION_BUS_ADDRESS: 'unix:path=/tmp/test-bus' }, // session present -> notify attempted
|
||||
});
|
||||
|
||||
for (let i = 0; i < 5; i++) { // 5 failures: lock at 3, stay locked for 4-5
|
||||
const socket = await connectWithAuthCapture(t, proxyPort);
|
||||
await awaitProxyResponse(socket);
|
||||
}
|
||||
const notifications = spawnCalls.filter(c => /notify-send|gdbus|busctl/.test(c.cmd));
|
||||
assert.equal(notifications.length, 1, `expected one notification per lockout episode, got ${notifications.map(n => n.cmd).join(',')}`);
|
||||
const argsJoined = notifications[0] ? notifications[0].args.join(' ') : '';
|
||||
assert.match(argsJoined, /407|credentials|expired|authentication/i, 'notification mentions the cause');
|
||||
assert.match(argsJoined, /proxy-bridge reauth|install\.js/, 'notification tells the user how to fix it');
|
||||
});
|
||||
|
||||
test('no notification without a desktop session (headless stays silent)', { timeout: 8000 }, async t => {
|
||||
const spawnCalls = [];
|
||||
const upstream = net.createServer(socket => {
|
||||
socket.once('data', () => socket.end('HTTP/1.1 407 Proxy Authentication Required\r\n\r\n'));
|
||||
});
|
||||
const upstreamPort = await listen(t, upstream);
|
||||
const proxyPort = await bridgeOnProfile(t, 'internet', {
|
||||
upstreamCfg: { enabled: true, host: '127.0.0.1', port: upstreamPort },
|
||||
withCreds: true,
|
||||
spawnCalls,
|
||||
// Empty strings override process.env in the harness merge — simulate headless.
|
||||
env: { DBUS_SESSION_BUS_ADDRESS: '', DISPLAY: '', WAYLAND_DISPLAY: '' },
|
||||
});
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const socket = await connectWithAuthCapture(t, proxyPort);
|
||||
await awaitProxyResponse(socket);
|
||||
}
|
||||
const notifications = spawnCalls.filter(c => /notify-send|gdbus|busctl/.test(c.cmd));
|
||||
assert.equal(notifications.length, 0, 'no desktop session -> no notification attempts');
|
||||
});
|
||||
|
||||
test('reauth stamp clears the lockout without a service restart', { timeout: 8000 }, async t => {
|
||||
const stampDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'bridge-reauth-'));
|
||||
t.after(() => fs.rmSync(stampDir, { recursive: true, force: true }));
|
||||
const stampFile = path.join(stampDir, 'reauth-stamp.json');
|
||||
|
||||
let upstream407 = true;
|
||||
const authHeadersSeen = [];
|
||||
const upstream = net.createServer(socket => {
|
||||
socket.once('data', d => {
|
||||
authHeadersSeen.push(/^Proxy-Authorization:/mi.test(d.toString()));
|
||||
if (upstream407) {
|
||||
socket.end('HTTP/1.1 407 Proxy Authentication Required\r\n\r\n');
|
||||
} else {
|
||||
socket.write('HTTP/1.1 200 OK\r\n\r\n');
|
||||
socket.pipe(socket);
|
||||
}
|
||||
});
|
||||
});
|
||||
const upstreamPort = await listen(t, upstream);
|
||||
// watchFile() in the vm stub is a no-op -> drive the stamp change via real fs.watchFile:
|
||||
// the bridge polls with 1s interval, but our stub swallows watchers. Instead of hacking
|
||||
// the stub, this test uses a SHORT backoff so expiry itself is observable... no: the point
|
||||
// is reauth clears BEFORE backoff expiry. Keep backoff long; the bridge must react to the
|
||||
// stamp file via its fs.watchFile — so give the vm stub a REAL watchFile passthrough.
|
||||
const proxyPort = await bridgeWithRealWatch(t, 'internet', {
|
||||
upstreamCfg: { enabled: true, host: '127.0.0.1', port: upstreamPort },
|
||||
withCreds: true,
|
||||
env: {
|
||||
PROXY_BRIDGE_REAUTH_FILE: stampFile,
|
||||
PROXY_BRIDGE_AUTH_LOCK_THRESHOLD: '3',
|
||||
PROXY_BRIDGE_AUTH_LOCK_BACKOFF_MS: '600000', // 10 min: must NOT wait for this
|
||||
},
|
||||
});
|
||||
|
||||
// Lock the upstream (3 consecutive 407s) then confirm 4th goes unauthenticated
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const s = await connectWithAuthCapture(t, proxyPort);
|
||||
await awaitProxyResponse(s);
|
||||
}
|
||||
assert.deepEqual(authHeadersSeen, [true, true, true, false], 'precondition: lockout engaged');
|
||||
assert.equal(upstream407, true);
|
||||
|
||||
// Simulate `proxy-bridge reauth`: upstream accepts creds now + stamp file updated
|
||||
upstream407 = false;
|
||||
fs.writeFileSync(stampFile, JSON.stringify({ at: Date.now() }));
|
||||
await new Promise(r => setTimeout(r, 1200)); // fs.watchFile poll interval is 1000ms
|
||||
|
||||
const s = await connectWithAuthCapture(t, proxyPort);
|
||||
const data = await readUntil(s, d => d.includes('200'));
|
||||
assert.equal(authHeadersSeen.length, 5, 'one more request made');
|
||||
assert.equal(authHeadersSeen[4], true, 'auth immediately resumed after reauth stamp (lockout cleared)');
|
||||
assert.ok(data.toString().includes('200 Connection Established'), 'tunnel established');
|
||||
});
|
||||
Reference in new issue
Block a user