apt-audit.js scans EVERY file in apt.conf.d for Acquire::*::Proxy, classifying: - creds: embedded userinfo (plaintext leak + stale-cred hammering risk) - overrides: sorts after 80proxy-bridge so apt last-wins REPLACES the bridge URL - external: points at a non-bridge proxy Installer Legacy section alerts + fixes (backup kept: *.disabled-by-proxy-bridge). New 'install.js doctor [--fix]': audits apt, bashrc, snap, service drop-ins, service state, keyring presence; exit 1 when issues found. Update semantics: deploy writes .installed-from (git commit + file hashes); runtime JSONs are never created if present and never overwritten; tests prove 3 consecutive installs leave runtime state byte-identical. Downgrade protection comes from the visible marker + diff reporting, never silent clobbering. Tests: 6 audit + 2 installer (update-semantics, doctor) — suite now 23/23.
226 lines
12 KiB
JavaScript
226 lines
12 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const { once } = require('node:events');
|
|
const { spawnSync, 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 INSTALLER = path.join(__dirname, 'install.js');
|
|
const PROXY_DIR = __dirname;
|
|
|
|
function mkTmp(t) {
|
|
const dirs = {
|
|
bridge: fs.mkdtempSync(path.join(os.tmpdir(), 'pi-bridge-')),
|
|
bin: fs.mkdtempSync(path.join(os.tmpdir(), 'pi-bin-')),
|
|
systemd: fs.mkdtempSync(path.join(os.tmpdir(), 'pi-systemd-')),
|
|
home: fs.mkdtempSync(path.join(os.tmpdir(), 'pi-home-')),
|
|
apt: fs.mkdtempSync(path.join(os.tmpdir(), 'pi-apt-')),
|
|
};
|
|
t.after(() => Object.values(dirs).forEach(d => fs.rmSync(d, { recursive: true, force: true })));
|
|
return dirs;
|
|
}
|
|
|
|
function envFor(dirs) {
|
|
return {
|
|
...process.env,
|
|
PROXY_BRIDGE_DIR: dirs.bridge,
|
|
PROXY_BRIDGE_BIN_DIR: dirs.bin,
|
|
PROXY_BRIDGE_SYSTEMD_USER_DIR: dirs.systemd,
|
|
PROXY_BRIDGE_BASHRC: path.join(dirs.home, '.bashrc'),
|
|
PROXY_BRIDGE_APT_CONF: path.join(dirs.apt, '80proxy-bridge'),
|
|
};
|
|
}
|
|
|
|
function runInstaller(t, dirs, extraArgs = []) {
|
|
// Subcommand (e.g. 'doctor') must come first, flags after — install.js parses
|
|
// args[0] as the command.
|
|
extraArgs.sort((a, b) => (a === 'doctor' ? -1 : b === 'doctor' ? 1 : 0));
|
|
const r = spawnSync('node', [INSTALLER, ...extraArgs, '--no-systemd'], {
|
|
env: envFor(dirs),
|
|
encoding: 'utf8',
|
|
timeout: 60000,
|
|
input: '', // no stdin: any interactive prompt must not be reached in these non-interactive paths
|
|
});
|
|
r.stdout = String(r.stdout).replace(/\x1b\[[0-9;]*m/g, '');
|
|
r.stderr = String(r.stderr || '').replace(/\x1b\[[0-9;]*m/g, '');
|
|
return r;
|
|
}
|
|
|
|
// Async variant: use whenever the test process itself hosts a TCP stub server —
|
|
// spawnSync would block the event loop and the stub would never answer.
|
|
function runInstallerAsync(t, dirs, { args = [], stdinLines = [] } = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn('node', [INSTALLER, '--no-systemd', ...args], { env: envFor(dirs) });
|
|
let stdout = '';
|
|
let stderr = '';
|
|
child.stdout.on('data', d => { stdout += d; });
|
|
child.stderr.on('data', d => { stderr += d; });
|
|
const timer = setTimeout(() => { child.kill(); reject(new Error('installer hung')); }, 55000);
|
|
child.on('exit', code => {
|
|
clearTimeout(timer);
|
|
resolve({
|
|
status: code,
|
|
stdout: stdout.replace(/\x1b\[[0-9;]*m/g, ''),
|
|
stderr: stderr.replace(/\x1b\[[0-9;]*m/g, ''),
|
|
});
|
|
});
|
|
// Feed stdin with a small delay so readline has attached before lines arrive.
|
|
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, 300);
|
|
});
|
|
}
|
|
|
|
test('dry-run changes nothing on disk', { timeout: 30000 }, async t => {
|
|
const dirs = mkTmp(t);
|
|
const r = runInstaller(t, dirs, ['--dry-run', '--non-interactive']);
|
|
// non-interactive with no imported config -> CONFIG section reports FAILED by design; fine for this test
|
|
assert.equal(fs.readdirSync(dirs.bridge).length, 0, 'bridge dir must stay empty in dry-run');
|
|
assert.equal(fs.readdirSync(dirs.bin).length, 0, 'bin dir must stay empty in dry-run');
|
|
assert.equal(fs.readdirSync(dirs.systemd).length, 0, 'systemd dir must stay empty in dry-run');
|
|
assert.ok(!fs.existsSync(path.join(dirs.home, '.bashrc')), 'bashrc must not be created in dry-run');
|
|
assert.match(r.stdout, /Dry run: no changes were made\./);
|
|
});
|
|
|
|
test('import-only run is idempotent: second run reports zero CHANGED', { timeout: 60000 }, async t => {
|
|
const dirs = mkTmp(t);
|
|
// Seed an 'imported' config the way the live /opt has it
|
|
fs.writeFileSync(path.join(dirs.bridge, 'config.json'), JSON.stringify({ enabled: true, host: '127.0.0.1', port: '1' }));
|
|
fs.writeFileSync(path.join(dirs.bridge, 'user.json'), JSON.stringify({ username: 'seeded-user' }));
|
|
// Seed bashrc with the EXACT bridge block the installer would write (idempotency target)
|
|
fs.writeFileSync(path.join(dirs.home, '.bashrc'),
|
|
`\n# Local Proxy Bridge Configuration #PROXY_BRIDGE_CONFIG\nexport http_proxy="http://127.0.0.1:8888/"\nexport https_proxy="http://127.0.0.1:8888/"\nexport HTTP_PROXY="http://127.0.0.1:8888/"\nexport HTTPS_PROXY="http://127.0.0.1:8888/"\nexport NO_PROXY="localhost,127.0.0.1,::1"\nexport no_proxy="localhost,127.0.0.1,::1"\n`);
|
|
// Seed the apt conf the installer would want (no sudo needed: our APT_CONF is a tmp path)
|
|
fs.writeFileSync(path.join(dirs.apt, '80proxy-bridge'),
|
|
`Acquire::http::Proxy "http://127.0.0.1:8888/";\nAcquire::https::Proxy "http://127.0.0.1:8888/";\n`);
|
|
|
|
const r1 = runInstaller(t, dirs, ['--non-interactive']);
|
|
assert.equal(r1.status, 1, JSON.stringify(r1.stdout + r1.stderr)); // creds missing -> expected FAILED in this seed
|
|
assert.match(r1.stdout, /\[OK\] Runtime config/);
|
|
assert.match(r1.stdout, /\[FAILED\] Credentials/); // no keyring entry for seeded-user; correct
|
|
|
|
// Second run must show zero CHANGED in the sections that did run
|
|
const r2 = runInstaller(t, dirs, ['--non-interactive']);
|
|
const changedLines = r2.stdout.split('\n').filter(l => l.includes('[CHANGED]'));
|
|
assert.deepEqual(changedLines, [], `second run must be a no-op, got CHANGED: ${changedLines.join(' | ')}`);
|
|
});
|
|
|
|
test('credential verification: 200 from upstream stores the password (junk test account)', { timeout: 60000 }, async t => {
|
|
const dirs = mkTmp(t);
|
|
const account = `bridge-test-${process.pid}`;
|
|
// Stub "upstream" that accepts any creds once
|
|
const upstream = net.createServer(socket => {
|
|
socket.once('data', () => {
|
|
socket.end('HTTP/1.1 200 Connection Established\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(path.join(dirs.bridge, 'config.json'), JSON.stringify({ enabled: true, host: '127.0.0.1', port: String(port) }));
|
|
fs.writeFileSync(path.join(dirs.bridge, 'user.json'), JSON.stringify({ username: account }));
|
|
fs.writeFileSync(path.join(dirs.home, '.bashrc'), '');
|
|
|
|
const r = await runInstallerAsync(t, dirs, { stdinLines: ['junk-test-password-not-real', 'n'] });
|
|
// creds stored via real secret-tool; clean up regardless of outcome
|
|
spawnSync('secret-tool', ['clear', 'service', 'proxy-bridge', 'account', account], { encoding: 'utf8' });
|
|
assert.match(r.stdout, /\[OK\] Credentials|\[CHANGED\] Credentials/);
|
|
assert.match(r.stdout, /credentials verified against upstream and stored/);
|
|
});
|
|
|
|
test('credential verification: 407 from upstream does NOT store and reports FAILED', { timeout: 60000 }, async t => {
|
|
const dirs = mkTmp(t);
|
|
const account = `bridge-test407-${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());
|
|
const port = upstream.address().port;
|
|
|
|
fs.writeFileSync(path.join(dirs.bridge, 'config.json'), JSON.stringify({ enabled: true, host: '127.0.0.1', port: String(port) }));
|
|
fs.writeFileSync(path.join(dirs.bridge, 'user.json'), JSON.stringify({ username: account }));
|
|
fs.writeFileSync(path.join(dirs.home, '.bashrc'), '');
|
|
|
|
const r = await runInstallerAsync(t, dirs, { stdinLines: ['wrong1', 'wrong2', 'wrong3', 'n'] });
|
|
// make sure nothing was stored for this account
|
|
const check = spawnSync('secret-tool', ['lookup', 'service', 'proxy-bridge', 'account', account], { encoding: 'utf8' });
|
|
assert.notEqual(check.status, 0, 'no keyring entry must exist for rejected creds');
|
|
assert.match(r.stdout, /\[FAILED\] Credentials/);
|
|
assert.match(r.stdout, /nothing stored/i);
|
|
});
|
|
|
|
test('reinstall from a clone behaves as an update: runtime state never clobbered', { timeout: 60000 }, async t => {
|
|
const dirs = mkTmp(t);
|
|
// First install: fresh, upstream disabled to skip creds entirely.
|
|
const configPath = path.join(dirs.bridge, 'config.json');
|
|
fs.writeFileSync(configPath, JSON.stringify({ enabled: false }));
|
|
fs.writeFileSync(path.join(dirs.home, '.bashrc'), '');
|
|
|
|
const r1 = runInstaller(t, dirs, ['--non-interactive']);
|
|
assert.match(r1.stdout, /Deploy code — code deployed/);
|
|
assert.ok(fs.readFileSync(path.join(dirs.bridge, 'bridge.js')).length > 1000);
|
|
|
|
// Simulate the RUNNING SYSTEM writing runtime state after install 1:
|
|
fs.writeFileSync(path.join(dirs.bridge, 'user.json'), JSON.stringify({ username: 'live-user' }));
|
|
fs.writeFileSync(path.join(dirs.bridge, 'profile.json'), JSON.stringify({ profile: 'proxy-tke' }));
|
|
fs.writeFileSync(path.join(dirs.bridge, 'overrides.json'), JSON.stringify({ rules: [{ match: 'x', profile: 'direct' }] }));
|
|
const configAfter = fs.readFileSync(configPath, 'utf8');
|
|
|
|
// Second install (same source): must be a no-op for state files.
|
|
const r2 = runInstaller(t, dirs, ['--non-interactive']);
|
|
assert.ok(!/\[CHANGED\] Runtime config/.test(r2.stdout), 'runtime config import must not touch existing files');
|
|
assert.equal(fs.readFileSync(configPath, 'utf8'), configAfter, 'config.json untouched');
|
|
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dirs.bridge, 'profile.json'), 'utf8')), { profile: 'proxy-tke' });
|
|
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dirs.bridge, 'overrides.json'), 'utf8')), { rules: [{ match: 'x', profile: 'direct' }] });
|
|
// Version marker exists and records source hashes.
|
|
const marker = fs.readFileSync(path.join(dirs.bridge, '.installed-from'), 'utf8');
|
|
assert.ok(marker.includes('bridge.js:'), 'version marker records source hashes');
|
|
|
|
// Third install simulating an UPDATE: installed copy older than source → redeploy, state untouched.
|
|
fs.appendFileSync(path.join(dirs.bridge, 'bridge.js'), '\n// pretend installed copy is older\n');
|
|
const r3 = runInstaller(t, dirs, ['--non-interactive']);
|
|
assert.match(r3.stdout, /\[CHANGED\] Deploy code/, 'newer source must redeploy');
|
|
// Marker identifies the SOURCE build (git commit + source file hashes), so it is
|
|
// stable across identical sources — and present; the installed bridge.js must now
|
|
// equal the source again (the fake "older" line gone).
|
|
assert.equal(fs.readFileSync(path.join(dirs.bridge, '.installed-from'), 'utf8'), marker);
|
|
assert.ok(!fs.readFileSync(path.join(dirs.bridge, 'bridge.js'), 'utf8').includes('pretend installed copy is older'));
|
|
assert.equal(fs.readFileSync(configPath, 'utf8'), configAfter, 'update must not touch runtime config');
|
|
});
|
|
|
|
test('doctor reports apt threats and exits non-zero; clean tree exits zero', { timeout: 30000 }, async t => {
|
|
const dirs = mkTmp(t);
|
|
// Threat: file sorting after 80proxy-bridge with creds
|
|
fs.writeFileSync(path.join(dirs.apt, '80proxy-bridge'),
|
|
'Acquire::http::Proxy "http://127.0.0.1:8888/";\nAcquire::https::Proxy "http://127.0.0.1:8888/";\n');
|
|
fs.writeFileSync(path.join(dirs.apt, '99bad'), 'Acquire::http::Proxy "http://u:p@proxy.corp:8080/";\n');
|
|
fs.writeFileSync(path.join(dirs.home, '.bashrc'),
|
|
'\n# Local Proxy Bridge Configuration #PROXY_BRIDGE_CONFIG\nexport http_proxy="http://127.0.0.1:8888/"\n');
|
|
fs.writeFileSync(path.join(dirs.bridge, 'user.json'), JSON.stringify({ username: `doc-test-${process.pid}` }));
|
|
|
|
const r = runInstaller(t, dirs, ['doctor', '--no-systemd']);
|
|
assert.equal(r.status, 1);
|
|
assert.match(r.stdout, /99bad.*CREDENTIALS EMBEDDED/);
|
|
assert.match(r.stdout, /NO password entry/);
|
|
|
|
// Clean tree: doctor passes
|
|
const dirs2 = mkTmp(t);
|
|
fs.writeFileSync(path.join(dirs2.apt, '80proxy-bridge'),
|
|
'Acquire::http::Proxy "http://127.0.0.1:8888/";\nAcquire::https::Proxy "http://127.0.0.1:8888/";\n');
|
|
fs.writeFileSync(path.join(dirs2.home, '.bashrc'),
|
|
'\n# Local Proxy Bridge Configuration #PROXY_BRIDGE_CONFIG\nexport http_proxy="http://127.0.0.1:8888/"\n');
|
|
const r2 = runInstaller(t, dirs2, ['doctor', '--no-systemd']);
|
|
assert.equal(r2.status, 0, JSON.stringify(r2.stdout + r2.stderr));
|
|
assert.match(r2.stdout, /All checks passed\./);
|
|
});
|