feat(proxy): apt proxy audit + doctor mode; reinstall-from-clone = update

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.
This commit is contained in:
tke committed 2026-09-23 16:07:53 +02:00
1 parent a626392eb1
commit a23fa00dfb
4 files changed
+339 -15

No files matched your search

+125 -14
View File
@@ -20,16 +20,20 @@ const os = require('os');
const path = require('path');
const readline = require('readline');
const { spawnSync, execFileSync } = require('child_process');
const { scanAptProxies, summarizeAptFindings } = require('./apt-audit.js');
// ---------------------------------------------------------------------------
// CLI parsing + paths
// ---------------------------------------------------------------------------
const args = process.argv.slice(2);
const COMMAND = args[0] === 'doctor' ? 'doctor' : 'install';
if (COMMAND === 'doctor') args.shift();
const FLAGS = {
dryRun: args.includes('--dry-run'),
nonInteractive: args.includes('--non-interactive'),
noSystemd: args.includes('--no-systemd'),
fix: args.includes('--fix'), // doctor --fix
};
const BRIDGE_DIR = process.env.PROXY_BRIDGE_DIR || '/opt/proxy-bridge';
@@ -39,6 +43,7 @@ const SRC_DIR = __dirname; // install.js lives next to bridge.js/proxyctl.js in
const BRIDGE_URL = 'http://127.0.0.1:8888/';
const BASHRC = process.env.PROXY_BRIDGE_BASHRC || path.join(os.homedir(), '.bashrc');
const APT_CONF = process.env.PROXY_BRIDGE_APT_CONF || '/etc/apt/apt.conf.d/80proxy-bridge';
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 CODE_FILES = ['bridge.js', 'proxyctl.js'];
@@ -133,9 +138,29 @@ function sectionPreflight() {
}
// ---------------------------------------------------------------------------
// Section: deploy code to BRIDGE_DIR
// Section: deploy code to BRIDGE_DIR (update semantics)
// ---------------------------------------------------------------------------
// Marker written next to the deployed code so reinstalls behave like updates:
// a clone install NEVER downgrades an installed newer build without the user
// saying so explicitly.
const MARKER_FILE = '.installed-from';
function sourceVersion() {
// Source version = git describe of the clone, falling back to file hashes.
const r = spawnSync('git', ['-C', SRC_DIR, 'log', '-1', '--format=%h %cI'], { encoding: 'utf8' });
const tag = r.status === 0 ? r.stdout.trim() : '';
const sums = CODE_FILES.map(f => {
const b = fs.readFileSync(path.join(SRC_DIR, f));
return `${f}:${require('crypto').createHash('sha256').update(b).digest('hex').slice(0, 12)}`;
});
return { tag, sums: sums.join(' '), raw: `${tag} ${sums.join(' ')}`.trim() };
}
function installedVersion() {
try { return fs.readFileSync(path.join(BRIDGE_DIR, MARKER_FILE), 'utf8').trim(); } catch { return null; }
}
function sectionDeploy() {
const S = 'Deploy code';
let changed = false;
@@ -157,6 +182,9 @@ function sectionDeploy() {
}
}
}
if (changed && !FLAGS.dryRun) {
fs.writeFileSync(path.join(BRIDGE_DIR, MARKER_FILE), sourceVersion().raw + '\n');
}
const cliLink = path.join(BIN_DIR, 'proxy-bridge');
const wantTarget = path.join(BRIDGE_DIR, 'proxyctl.js');
let current = null;
@@ -169,7 +197,10 @@ function sectionDeploy() {
fs.symlinkSync(wantTarget, cliLink);
}
}
report(S, changed ? 'CHANGED' : 'OK', changed ? 'code deployed to ' + BRIDGE_DIR : 'bridge.js/proxyctl.js identical, CLI symlink in place');
const ver = sourceVersion();
report(S, changed ? 'CHANGED' : 'OK', changed
? `code deployed to ${BRIDGE_DIR} (${ver.tag || ver.sums})`
: `code identical to installed (${installedVersion() || ver.tag || 'unknown version'}), CLI symlink in place`);
return true;
}
@@ -314,14 +345,13 @@ async function sectionLegacy() {
findings.push({ what: 'bashrc #PROXY_A93JK2 block' + (credFiles.length ? ` + ${credFiles.join(', ')} (contain PLAINTEXT credentials)` : ''), fix: 'remove legacy line' + (credFiles.length ? ' and delete cred files' : '') });
}
// apt legacy file with userinfo
const aptLegacy = '/etc/apt/apt.conf.d/80proxy';
let aptLegacyHasCreds = false;
try {
const content = fs.readFileSync(aptLegacy, 'utf8');
aptLegacyHasCreds = /:\/\/[^/\s]+:[^@\s]+@/.test(content); // presence only
} catch {}
if (aptLegacyHasCreds) findings.push({ what: `${aptLegacy} (contains embedded credentials)`, fix: 'delete (80proxy-bridge supersedes)' });
// apt proxy audit: ALL files in the apt conf dir, not just the one legacy name.
const { findings: aptFindings, error: aptErr } = scanAptProxies(APT_CONF_DIR, path.basename(APT_CONF));
const aptThreats = aptErr ? [] : aptFindings.filter(f => f.severity !== 'ok');
for (const f of aptThreats) {
const label = { creds: 'contains embedded credentials', overrides: 'sorts after our snippet and REPLACES the bridge URL (apt last-wins)', external: 'points at a non-bridge proxy' }[f.severity];
findings.push({ what: `${f.file} (${label})`, fix: f.severity === 'creds' ? 'back up + delete' : 'review: rename/remove after confirmation', aptFinding: f });
}
// snap proxy with userinfo
const snapGet = run('snap', ['get', 'system', 'proxy.http', 'proxy.https'], { readOnly: true });
@@ -340,7 +370,7 @@ async function sectionLegacy() {
} catch {}
}
if (findings.length === 0) { report(S, 'OK', 'no legacy credential-embedding artifacts found'); return true; }
if (findings.length === 0) { report(S, 'OK', 'no legacy/credential-embedding/override artifacts found'); return true; }
console.log('\nLegacy artifacts found:');
findings.forEach((f, i) => console.log(` ${i + 1}. ${f.what}\n fix: ${f.fix}`));
@@ -363,9 +393,22 @@ async function sectionLegacy() {
if (del && !FLAGS.dryRun) credFiles.forEach(f => fs.rmSync(f, { force: true }));
changed = changed || del;
}
if (aptLegacyHasCreds) {
if (!FLAGS.dryRun) run('sudo', ['rm', '-f', aptLegacy]);
changed = true;
for (const f of aptThreats) {
if (f.severity === 'creds' || f.severity === 'overrides') {
if (!FLAGS.dryRun) {
// Back up locally before any removal; never destroy without a copy.
run('sudo', ['cp', '-a', f.file, `${f.file}.disabled-by-proxy-bridge`]);
run('sudo', ['rm', '-f', f.file]);
}
changed = true;
} else {
const del = await askConfirm(`Remove non-bridge apt proxy file ${f.file} (backup kept)?`, false);
if (del && !FLAGS.dryRun) {
run('sudo', ['cp', '-a', f.file, `${f.file}.disabled-by-proxy-bridge`]);
run('sudo', ['rm', '-f', f.file]);
}
changed = changed || del;
}
}
if (findings.some(f => f.what.startsWith('snap system'))) {
if (!FLAGS.dryRun) {
@@ -536,11 +579,79 @@ async function sectionVerify() {
return allCore;
}
// ---------------------------------------------------------------------------
// doctor: report-only audit (or --fix) of proxy-related system state
// ---------------------------------------------------------------------------
async function doctor() {
console.log(`=== Proxy Bridge doctor ${FLAGS.fix ? '(FIX MODE) ' : ''}===`);
let issues = 0;
// 1. apt proxy audit
const { findings, error } = scanAptProxies(APT_CONF_DIR, path.basename(APT_CONF));
if (error) { console.log(`[apt] ${error}`); }
const summary = summarizeAptFindings(findings);
if (!summary.bridgePresent) { console.log(`[apt] ✗ bridge snippet ${APT_CONF} missing`); issues++; }
for (const f of summary.threats) {
issues++;
const label = { creds: 'CREDENTIALS EMBEDDED', overrides: 'OVERRIDES the bridge (apt last-wins)', external: 'non-bridge proxy' }[f.severity];
console.log(`[apt] ✗ ${f.file}: ${label} (${f.directives} proxy directive(s))`);
if (FLAGS.fix && (f.severity === 'creds' || f.severity === 'overrides')) {
run('sudo', ['cp', '-a', f.file, `${f.file}.disabled-by-proxy-bridge`]);
const rm = run('sudo', ['rm', '-f', f.file]);
console.log(rm.status === 0 ? ` → fixed (backup: ${f.file}.disabled-by-proxy-bridge)` : ' → fix FAILED (sudo)');
}
}
if (summary.threats.length === 0 && summary.bridgePresent) console.log('[apt] ✓ bridge snippet present, no conflicting proxy directives');
// 2. bashrc
let bashrc = '';
try { bashrc = fs.readFileSync(BASHRC, 'utf8'); } catch {}
if (bashrc.includes('#PROXY_A93JK2')) { console.log(`[bashrc] ✗ legacy #PROXY_A93JK2 line present in ${BASHRC}`); issues++; }
else if (!bashrc.includes('#PROXY_BRIDGE_CONFIG')) { console.log('[bashrc] ✗ bridge config block missing'); issues++; }
else console.log('[bashrc] ✓ bridge export block present, no legacy marker');
const credFiles = ['.http_proxy', '.http_proxy.off', '.http_noproxy'].map(f => path.join(os.homedir(), f)).filter(fileExists);
for (const f of credFiles) { console.log(`[bashrc] ✗ plaintext-credential file exists: ${f}`); issues++; }
// 3. snap
const snapGet = run('snap', ['get', 'system', 'proxy.http', 'proxy.https'], { readOnly: true });
if (snapGet.status === 0) {
if (/:\/\/[^/\s]+:[^@\s]+@/.test(snapGet.stdout)) { console.log('[snap] ✗ system proxy contains embedded credentials'); issues++; }
else if (snapGet.stdout.includes('127.0.0.1:8888')) console.log('[snap] ✓ points at bridge');
}
// 4. systemd service drop-ins
for (const svc of ['docker', 'snapd', 'ollama']) {
const f = `/etc/systemd/system/${svc}.service.d/http-proxy.conf`;
try {
const content = fs.readFileSync(f, 'utf8');
if (/:\/\/[^/\s]+:[^@\s]+@/.test(content)) { console.log(`[svc] ✗ ${f}: embedded credentials`); issues++; }
else console.log(`[svc] ✓ ${f}: bridge URL`);
} catch {}
}
// 5. bridge service + keyring (structure only)
if (!FLAGS.noSystemd) {
const state = run('systemctl', ['--user', 'is-active', 'proxy-bridge'], { readOnly: true }).stdout.trim();
console.log(`[service] ${state === 'active' ? '✓' : '✗'} proxy-bridge: ${state || 'unknown'}`);
if (state !== 'active') issues++;
}
const user = (readJson(path.join(BRIDGE_DIR, 'user.json')) || {}).username;
if (user) {
console.log(`[keyring] ${keyringHasPassword(user) ? '✓ password entry present' : '✗ NO password entry (re-run installer)'}`);
if (!keyringHasPassword(user)) issues++;
}
console.log(`\n${issues === 0 ? 'All checks passed.' : `${issues} issue(s) found${FLAGS.fix ? ' (fix mode attempted cred/override removals)' : '; re-run with --fix or node install.js to repair'}`}`);
process.exit(issues === 0 ? 0 : 1);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
if (COMMAND === 'doctor') return doctor();
console.log(`=== Proxy Bridge installer ${FLAGS.dryRun ? '(DRY RUN) ' : ''}===`);
console.log(`target: ${BRIDGE_DIR}`);
const missingInfo = [];