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

+27 -1
View File
@@ -2,7 +2,7 @@ const http = require('http');
const https = require('https'); const https = require('https');
const net = require('net'); const net = require('net');
const tls = require('tls'); const tls = require('tls');
const { execFileSync } = require('child_process'); const { execFileSync, spawn } = require('child_process');
const fs = require('fs'); const fs = require('fs');
const vm = require('vm'); const vm = require('vm');
const path = require('path'); const path = require('path');
@@ -13,6 +13,7 @@ const USER_FILE = process.env.PROXY_BRIDGE_USER_FILE || '/opt/proxy-bridge/user.
const PROFILE_FILE = process.env.PROXY_BRIDGE_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 OVERRIDES_FILE = process.env.PROXY_BRIDGE_OVERRIDES_FILE || '/opt/proxy-bridge/overrides.json';
const REMOTES_FILE = process.env.PROXY_BRIDGE_REMOTES_FILE || '/opt/proxy-bridge/remotes.json'; const REMOTES_FILE = process.env.PROXY_BRIDGE_REMOTES_FILE || '/opt/proxy-bridge/remotes.json';
const REAUTH_FILE = process.env.PROXY_BRIDGE_REAUTH_FILE || '/opt/proxy-bridge/reauth-stamp.json';
const PAC_DIR = process.env.PROXY_BRIDGE_PAC_DIR || path.join(os.homedir(), '.mozilla'); const PAC_DIR = process.env.PROXY_BRIDGE_PAC_DIR || path.join(os.homedir(), '.mozilla');
const LISTEN_HOST = process.env.PROXY_BRIDGE_LISTEN_HOST || '127.0.0.1'; const LISTEN_HOST = process.env.PROXY_BRIDGE_LISTEN_HOST || '127.0.0.1';
const BASE_PORT = Number(process.env.PROXY_BRIDGE_BASE_PORT || 8888); const BASE_PORT = Number(process.env.PROXY_BRIDGE_BASE_PORT || 8888);
@@ -129,9 +130,23 @@ function recordAuthFailure(key) {
if (st.failures.length >= AUTH_LOCK_THRESHOLD && st.lockedUntil <= now) { if (st.failures.length >= AUTH_LOCK_THRESHOLD && st.lockedUntil <= now) {
st.lockedUntil = now + AUTH_LOCK_BACKOFF_MS; st.lockedUntil = now + AUTH_LOCK_BACKOFF_MS;
log('WARN', 'auth', 'repeated 407 from upstream; withholding credentials until backoff expires (check keyring / password expiry)', { upstream: key, failures: st.failures.length, backoffMs: AUTH_LOCK_BACKOFF_MS }); log('WARN', 'auth', 'repeated 407 from upstream; withholding credentials until backoff expires (check keyring / password expiry)', { upstream: key, failures: st.failures.length, backoffMs: AUTH_LOCK_BACKOFF_MS });
notifyAuthLockout(key);
} }
} }
// Desktop notification on lockout entry. One per episode (callers gate on the
// lockedUntil transition). Silent no-op without a desktop session; never throws.
function notifyAuthLockout(key) {
if (!process.env.DBUS_SESSION_BUS_ADDRESS && !process.env.DISPLAY) return;
const summary = 'Proxy Bridge: upstream authentication rejected';
const body = `Proxy ${key} keeps answering 407 (expired credentials?). Auth withheld for ${Math.round(AUTH_LOCK_BACKOFF_MS / 60000)} min to protect the account. Fix: run 'proxy-bridge reauth' or 'node install.js'.`;
try {
const child = spawn('notify-send', ['--urgency=critical', '--app-name=proxy-bridge', summary, body], { detached: true, stdio: 'ignore' });
child.on('error', () => {});
child.unref();
} catch {}
}
function recordAuthSuccess(key) { function recordAuthSuccess(key) {
const st = authState.get(key); const st = authState.get(key);
if (st && (st.failures.length > 0 || st.lockedUntil !== 0)) { if (st && (st.failures.length > 0 || st.lockedUntil !== 0)) {
@@ -294,6 +309,17 @@ function matchOverride(host) {
return null; return null;
} }
// A `proxy-bridge reauth` run drops this stamp file; on change we reload the
// credentials from the keyring and clear EVERY lockout — no service restart.
function onReauthStamp() {
try { fs.readFileSync(REAUTH_FILE, 'utf8'); } catch { return; }
const hadLocks = authState.size > 0;
authState.clear();
refreshAuth();
log('INFO', 'auth', 'reauth stamp observed; credentials reloaded, lockout state cleared', { cleared: hadLocks });
}
fs.watchFile(REAUTH_FILE, { interval: 1000 }, onReauthStamp);
fs.watchFile(PROFILE_FILE, { interval: 1000 }, updateDynamicProfile); fs.watchFile(PROFILE_FILE, { interval: 1000 }, updateDynamicProfile);
fs.watchFile(OVERRIDES_FILE, { interval: 1000 }, loadOverrides); fs.watchFile(OVERRIDES_FILE, { interval: 1000 }, loadOverrides);
updateDynamicProfile(); updateDynamicProfile();
+185 -1
View File
@@ -62,7 +62,9 @@ async function bridge(t, upstreamPort, upstreamCfg) {
// Start the bridge with env overrides and return the ephemeral port of a chosen // Start the bridge with env overrides and return the ephemeral port of a chosen
// listenerProfile ('dynamic', 'internet', or a PAC profile name). All listeners // listenerProfile ('dynamic', 'internet', or a PAC profile name). All listeners
// are tracked for cleanup; only the requested one is bound to a random port. // 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 servers = [];
const fullEnv = { ...process.env, ...env }; const fullEnv = { ...process.env, ...env };
vm.runInNewContext(source, { 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'; if (Array.isArray(args) && args[0] === 'lookup' && withCreds) return 'test-password\n';
return ''; return '';
}, },
spawn: (cmd, args, opts) => {
if (spawnCalls) spawnCalls.push({ cmd, args, opts });
return { on() {}, unref() {} };
},
}; };
if (name === 'http') return { if (name === 'http') return {
...http, ...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: // 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. // covers both graceful end and destroy paths without depending on FIN timing.
function awaitProxyResponse(socket) { 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]); 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');
});
+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 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 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 } const sectionResults = []; // { section, status: CHANGED|OK|SKIPPED|FAILED, detail }
function report(section, status, detail = '') { function report(section, status, detail = '') {
@@ -263,32 +263,15 @@ async function sectionConfig() {
// Section: credentials — verify before storing (anti-lockout) // Section: credentials — verify before storing (anti-lockout)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// One single CONNECT attempt with candidate creds directly against the upstream. // verifyProxyCredentials: shared one-shot upstream check (see verify-creds.js).
// NEVER retries, NEVER prints the password, resolves: ok | authfail | unreachable const { verifyProxyCredentials: verifyCredentials } =
function verifyCredentials(host, port, username, password) { (() => { try { return require('./verify-creds.js'); } catch { return {}; } })();
return new Promise((resolve) => {
const net = require('net'); // Fallback inline copy if deploys predate the shared module (kept minimal):
const socket = net.connect(Number(port), host); async function verifyWithFallback(host, port, user, pass) {
let buf = Buffer.alloc(0); if (verifyCredentials) return verifyCredentials(host, port, user, pass);
const finish = (r) => { try { socket.destroy(); } catch {} resolve(r); }; const { verifyProxyCredentials } = require(path.join(BRIDGE_DIR, 'verify-creds.js'));
socket.setTimeout(10000, () => finish('unreachable')); return verifyProxyCredentials(host, port, user, pass);
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'); });
});
} }
async function sectionCredentials(config) { async function sectionCredentials(config) {
@@ -311,7 +294,7 @@ async function sectionCredentials(config) {
for (let attempt = 1; attempt <= 3; attempt++) { for (let attempt = 1; attempt <= 3; attempt++) {
const pass = await askSecret(`Corporate password for ${user} (attempt ${attempt}/3)`); const pass = await askSecret(`Corporate password for ${user} (attempt ${attempt}/3)`);
if (!pass) { console.log(' empty password, try again'); continue; } 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 (verdict === 'ok') {
if (!FLAGS.dryRun) { if (!FLAGS.dryRun) {
execFileSync('secret-tool', ['store', '--label=Proxy Bridge Credentials', 'service', 'proxy-bridge', 'account', user], { input: pass }); execFileSync('secret-tool', ['store', '--label=Proxy Bridge Credentials', 'service', 'proxy-bridge', 'account', user], { input: pass });
+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 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 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 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 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 LOCAL_PROXY_URL = `http://${LISTEN_HOST}:${LISTEN_PORT}`;
const VSCODE_CERT_FLAG = ''; // e.g. '--ignore-certificate-errors' const VSCODE_CERT_FLAG = ''; // e.g. '--ignore-certificate-errors'
@@ -211,6 +212,51 @@ function setup() {
child.on('exit', (code) => process.exit(code || 0)); 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() { function config() {
const all = getAvailableProfiles(); const all = getAvailableProfiles();
console.log(`listen_host: ${LISTEN_HOST}`); console.log(`listen_host: ${LISTEN_HOST}`);
@@ -360,6 +406,7 @@ Commands:
enable Enable the user service at login enable Enable the user service at login
disable Disable the user service at login disable Disable the user service at login
logs Follow service logs logs Follow service logs
reauth Replace upstream password (verified once, no service restart)
setup Store or update keyring credentials and upstream config setup Store or update keyring credentials and upstream config
config Print effective non-secret configuration config Print effective non-secret configuration
vscode Manage VS Code proxy settings vscode Manage VS Code proxy settings
@@ -402,6 +449,7 @@ async function main() {
case 'enable': case 'enable':
case 'disable': control(command); break; case 'disable': control(command); break;
case 'logs': logs(); break; case 'logs': logs(); break;
case 'reauth': await reauth(); break;
case 'setup': setup(); break; case 'setup': setup(); break;
case 'config': config(); break; case 'config': config(); break;
case 'vscode': await vscode(process.argv.slice(3)); break; case 'vscode': await vscode(process.argv.slice(3)); break;
+106
View File
@@ -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');
});
+31
View File
@@ -0,0 +1,31 @@
// One single CONNECT attempt with candidate creds against the upstream proxy.
// Used by install.js (credential setup) and proxyctl.js (reauth). NEVER retries,
// NEVER logs the password, resolves: 'ok' | 'authfail' | 'unreachable'
const net = require('net');
function verifyProxyCredentials(host, port, username, password, timeoutMs = 10000) {
return new Promise((resolve) => {
const socket = net.connect(Number(port), host);
let buf = Buffer.alloc(0);
const finish = (r) => { try { socket.destroy(); } catch {} resolve(r); };
socket.setTimeout(timeoutMs, () => 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 for the probe 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'); });
});
}
module.exports = { verifyProxyCredentials };