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:
1 parent
a626392eb1
commit
a23fa00dfb
4 files changed
+338
-14
No files matched your search
@@ -0,0 +1,61 @@
|
||||
// Shared apt proxy audit logic — used by install.js (Legacy section) and doctor mode.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const BRIDGE_MARK = '127.0.0.1:8888';
|
||||
|
||||
// One finding per file that declares Acquire proxy directives.
|
||||
// severity: 'ok' (bridge URL, sorted before-or-at our file, no creds)
|
||||
// 'overrides' (proxy directives in a file sorting AFTER 80proxy-bridge → apt last-wins)
|
||||
// 'creds' (embedded userinfo — plaintext-leak + stale-cred hammering risk)
|
||||
// 'external' (points at a non-bridge proxy; may be intentional, but defeats the bridge)
|
||||
function scanAptProxies(aptConfDir, bridgeFileName = '80proxy-bridge') {
|
||||
const findings = [];
|
||||
const files = [];
|
||||
try {
|
||||
for (const f of fs.readdirSync(aptConfDir)) {
|
||||
const p = path.join(aptConfDir, f);
|
||||
try { if (fs.statSync(p).isFile()) files.push(f); } catch {}
|
||||
}
|
||||
} catch {
|
||||
return { findings, error: `cannot read ${aptConfDir}` };
|
||||
}
|
||||
const bridgeRank = bridgeFileName;
|
||||
const bridgeFilePresent = files.includes(bridgeRank);
|
||||
for (const f of files.sort()) {
|
||||
const p = path.join(aptConfDir, f);
|
||||
let content = '';
|
||||
try { content = fs.readFileSync(p, 'utf8'); } catch { continue; }
|
||||
const proxyLines = content.split('\n')
|
||||
.map(l => l.trim())
|
||||
.filter(l => /^(Acquire::(http|https)::Proxy)\s/i.test(l));
|
||||
if (proxyLines.length === 0) continue;
|
||||
|
||||
const hasCreds = proxyLines.some(l => /:\/\/[^/\s"]+:[^@\s"]+@/.test(l));
|
||||
const allBridge = proxyLines.every(l => l.includes(BRIDGE_MARK));
|
||||
const sortsAfterBridge = f > bridgeRank; // apt merges alphabetically; later wins
|
||||
|
||||
let severity = 'ok';
|
||||
if (f === bridgeRank) severity = allBridge && !hasCreds ? 'ok' : (hasCreds ? 'creds' : 'external');
|
||||
else if (hasCreds) severity = 'creds';
|
||||
else if (sortsAfterBridge && bridgeFilePresent) severity = 'overrides';
|
||||
else if (sortsAfterBridge && allBridge) severity = 'ok'; // redundant bridge copy, harmless
|
||||
else if (!allBridge) severity = 'external';
|
||||
// sorted BEFORE us and external -> harmless but noteworthy; we still report as external
|
||||
|
||||
findings.push({
|
||||
file: p, name: f, severity,
|
||||
directives: proxyLines.length,
|
||||
creds: hasCreds, bridge: allBridge, sortsAfterBridge,
|
||||
});
|
||||
}
|
||||
return { findings };
|
||||
}
|
||||
|
||||
function summarizeAptFindings(findings) {
|
||||
const bad = findings.filter(f => f.severity !== 'ok');
|
||||
const bridgeFile = findings.find(f => f.severity === 'ok' && f.bridge);
|
||||
return { bridgePresent: !!bridgeFile, threats: bad };
|
||||
}
|
||||
|
||||
module.exports = { scanAptProxies, summarizeAptFindings, BRIDGE_MARK };
|
||||
@@ -0,0 +1,84 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const { scanAptProxies, summarizeAptFindings } = require('./apt-audit.js');
|
||||
|
||||
function mkAptDir(t, files) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'apt-audit-'));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
fs.writeFileSync(path.join(dir, name), content);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
const BRIDGE_CONF = 'Acquire::http::Proxy "http://127.0.0.1:8888/";\nAcquire::https::Proxy "http://127.0.0.1:8888/";\n';
|
||||
|
||||
test('clean setup: only 80proxy-bridge with bridge URL → no threats', t => {
|
||||
const dir = mkAptDir(t, {
|
||||
'20archive': '// no proxies here\n',
|
||||
'80proxy-bridge': BRIDGE_CONF,
|
||||
'99update-notifier': '// unrelated\n',
|
||||
});
|
||||
const { findings } = scanAptProxies(dir);
|
||||
const summary = summarizeAptFindings(findings);
|
||||
assert.equal(summary.bridgePresent, true);
|
||||
assert.deepEqual(summary.threats, []);
|
||||
});
|
||||
|
||||
test('legacy 80proxy with embedded creds is flagged creds even when sorting before ours', t => {
|
||||
const dir = mkAptDir(t, {
|
||||
'80proxy': 'Acquire::http::Proxy "http://user:p%40ss@proxy.corp:8080/";\n',
|
||||
'80proxy-bridge': BRIDGE_CONF,
|
||||
});
|
||||
const { findings } = scanAptProxies(dir);
|
||||
const credFinding = findings.find(f => f.name === '80proxy');
|
||||
assert.equal(credFinding.severity, 'creds');
|
||||
assert.equal(summarizeAptFindings(findings).threats.length, 1);
|
||||
});
|
||||
|
||||
test('a later-sorting file with an external proxy overrides the bridge (apt last-wins)', t => {
|
||||
const dir = mkAptDir(t, {
|
||||
'80proxy-bridge': BRIDGE_CONF,
|
||||
'99custom': 'Acquire::http::Proxy "http://other.proxy:3128/";\n',
|
||||
});
|
||||
const { findings } = scanAptProxies(dir);
|
||||
const f99 = findings.find(f => f.name === '99custom');
|
||||
assert.equal(f99.severity, 'overrides');
|
||||
assert.equal(f99.sortsAfterBridge, true);
|
||||
assert.equal(summarizeAptFindings(findings).threats.length, 1);
|
||||
});
|
||||
|
||||
test('earlier-sorting external proxy does not override but is still reported external', t => {
|
||||
const dir = mkAptDir(t, {
|
||||
'10proxy': 'Acquire::https::Proxy "http://other.proxy:3128/";\n',
|
||||
'80proxy-bridge': BRIDGE_CONF,
|
||||
});
|
||||
const { findings } = scanAptProxies(dir);
|
||||
const f10 = findings.find(f => f.name === '10proxy');
|
||||
assert.equal(f10.severity, 'external');
|
||||
assert.equal(f10.sortsAfterBridge, false);
|
||||
});
|
||||
|
||||
test('missing directory returns an error instead of crashing', t => {
|
||||
const { findings, error } = scanAptProxies('/nonexistent-apt-dir-xyz');
|
||||
assert.deepEqual(findings, []);
|
||||
assert.ok(error);
|
||||
});
|
||||
|
||||
test('bridge file missing entirely: bridgePresent false, all proxy files reported', t => {
|
||||
const dir = mkAptDir(t, {
|
||||
'50proxy': 'Acquire::http::Proxy "http://some.proxy:8080/";\n',
|
||||
});
|
||||
const { findings } = scanAptProxies(dir);
|
||||
const summary = summarizeAptFindings(findings);
|
||||
assert.equal(summary.bridgePresent, false);
|
||||
assert.equal(summary.threats.length, 1);
|
||||
assert.equal(summary.threats[0].severity, 'external'); // effective config; not "overriding" a bridge that isn't there
|
||||
// A cred-bearing file must still be flagged creds even without a bridge file:
|
||||
const dir2 = mkAptDir(t, { '99bad': 'Acquire::http::Proxy "http://u:p@proxy:8080/";\n' });
|
||||
const { findings: f2 } = scanAptProxies(dir2);
|
||||
assert.equal(f2[0].severity, 'creds');
|
||||
});
|
||||
+124
-13
@@ -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]);
|
||||
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 = [];
|
||||
|
||||
@@ -34,7 +34,10 @@ function envFor(dirs) {
|
||||
}
|
||||
|
||||
function runInstaller(t, dirs, extraArgs = []) {
|
||||
const r = spawnSync('node', [INSTALLER, '--no-systemd', ...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,
|
||||
@@ -155,3 +158,68 @@ test('credential verification: 407 from upstream does NOT store and reports FAIL
|
||||
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\./);
|
||||
});
|
||||
Reference in new issue
Block a user