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.
690 lines
33 KiB
JavaScript
690 lines
33 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
// Proxy Bridge installer — idempotent, interactive where information is missing.
|
|
// Replaces the retired legacy scripts (install_proxy.sh, update_apt_proxy.sh,
|
|
// update_bashrc_proxy.sh, update_service_proxy.sh, get_proxy.sh, setup.js).
|
|
//
|
|
// node install.js interactive install/repair (re-runnable)
|
|
// node install.js --dry-run print the plan, change nothing
|
|
// node install.js --non-interactive use existing/defaults; fail listing missing info
|
|
// node install.js --no-systemd skip systemd user service + system service steps
|
|
//
|
|
// Hard rules honored here:
|
|
// - Passwords are NEVER printed, logged, or stored in a file. Username goes to
|
|
// user.json; the password goes to the keyring via secret-tool only AFTER a
|
|
// single successful verification against the upstream (never on failure).
|
|
// - Keyring presence is probed by `secret-tool lookup`'s exit code only.
|
|
|
|
const fs = require('fs');
|
|
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';
|
|
const BIN_DIR = process.env.PROXY_BRIDGE_BIN_DIR || path.join(os.homedir(), '.local', 'bin');
|
|
const SYSTEMD_USER_DIR = process.env.PROXY_BRIDGE_SYSTEMD_USER_DIR || path.join(os.homedir(), '.config', 'systemd', 'user');
|
|
const SRC_DIR = __dirname; // install.js lives next to bridge.js/proxyctl.js in the repo
|
|
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'];
|
|
|
|
const sectionResults = []; // { section, status: CHANGED|OK|SKIPPED|FAILED, detail }
|
|
function report(section, status, detail = '') {
|
|
sectionResults.push({ section, status, detail });
|
|
const color = { CHANGED: '\x1b[33m', OK: '\x1b[32m', SKIPPED: '\x1b[36m', FAILED: '\x1b[31m' }[status] || '';
|
|
console.log(`${color}[${status}]\x1b[0m ${section}${detail ? ` — ${detail}` : ''}`);
|
|
}
|
|
|
|
function run(cmd, args, opts = {}) {
|
|
if (FLAGS.dryRun && !opts.readOnly) {
|
|
return { status: 0, stdout: '', stderr: '', dryRun: true };
|
|
}
|
|
return spawnSync(cmd, args, { encoding: 'utf8', ...opts });
|
|
}
|
|
|
|
function sudoOk() {
|
|
const r = run('sudo', ['-n', 'true'], { readOnly: true });
|
|
return r.status === 0;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Interactive prompting
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let rl = null;
|
|
function getRl() {
|
|
if (!rl) rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
return rl;
|
|
}
|
|
|
|
function ask(question, defaultValue = '') {
|
|
return new Promise((resolve) => {
|
|
getRl().question(question + (defaultValue ? ` [${defaultValue}]: ` : ': '), (a) => {
|
|
resolve(a.trim() || defaultValue);
|
|
});
|
|
});
|
|
}
|
|
|
|
function askSecret(question) {
|
|
return new Promise((resolve) => {
|
|
const r = getRl();
|
|
const oldWrite = r._writeToOutput;
|
|
r._writeToOutput = function (s) {
|
|
if (s === '\n' || s === '\r' || s === '\r\n') r.output.write(s);
|
|
else if (s.includes(question)) r.output.write(s); // initial prompt
|
|
else r.output.write('*');
|
|
};
|
|
r.question(question + ': ', (a) => { r._writeToOutput = oldWrite; r.output.write('\n'); resolve(a.trim()); });
|
|
});
|
|
}
|
|
|
|
async function askConfirm(question, defaultYes = true) {
|
|
if (FLAGS.dryRun) { console.log(`(dry-run) would ask: ${question} -> default ${defaultYes ? 'y' : 'n'}`); return defaultYes; }
|
|
if (FLAGS.nonInteractive) return defaultYes;
|
|
const a = (await ask(`${question} (y/n)`, defaultYes ? 'y' : 'n')).toLowerCase();
|
|
return a === 'y';
|
|
}
|
|
|
|
function requireInfo(name, value) {
|
|
if (value) return value;
|
|
throw new Error(`MISSING_INFO:${name}`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Section: preflight
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function sectionPreflight() {
|
|
const S = 'Preflight';
|
|
if (process.getuid && process.getuid() === 0) {
|
|
report(S, 'FAILED', 'run as your user, not root — sudo is invoked per-step when needed');
|
|
return false;
|
|
}
|
|
const nodeV = run('node', ['--version'], { readOnly: true });
|
|
if (nodeV.status !== 0) { report(S, 'FAILED', 'node not on PATH'); return false; }
|
|
const st = run('which', ['secret-tool'], { readOnly: true });
|
|
if (st.status !== 0) {
|
|
if (FLAGS.nonInteractive) { report(S, 'FAILED', 'libsecret-tools missing (apt install -y libsecret-tools)'); return false; }
|
|
report(S, 'CHANGED', 'libsecret-tools missing — will attempt install (sudo apt-get install -y libsecret-tools)');
|
|
run('sudo', ['apt-get', 'install', '-y', 'libsecret-tools', '-qq']);
|
|
if (run('which', ['secret-tool'], { readOnly: true }).status !== 0) {
|
|
report(S, 'FAILED', 'libsecret-tools install failed');
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
report(S, 'OK', `node ${nodeV.stdout.trim()}, secret-tool present`);
|
|
return true;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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;
|
|
|
|
if (!FLAGS.dryRun) {
|
|
fs.mkdirSync(BRIDGE_DIR, { recursive: true });
|
|
}
|
|
for (const f of CODE_FILES) {
|
|
const src = path.join(SRC_DIR, f);
|
|
const dst = path.join(BRIDGE_DIR, f);
|
|
const srcBuf = fs.readFileSync(src);
|
|
let dstBuf = null;
|
|
try { dstBuf = fs.readFileSync(dst); } catch {}
|
|
if (!dstBuf || !srcBuf.equals(dstBuf)) {
|
|
changed = true;
|
|
if (!FLAGS.dryRun) {
|
|
fs.copyFileSync(src, dst);
|
|
if (f === 'proxyctl.js') fs.chmodSync(dst, 0o755);
|
|
}
|
|
}
|
|
}
|
|
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;
|
|
try { current = fs.readlinkSync(cliLink); } catch {}
|
|
if (current !== wantTarget) {
|
|
changed = true;
|
|
if (!FLAGS.dryRun) {
|
|
fs.mkdirSync(BIN_DIR, { recursive: true });
|
|
try { fs.unlinkSync(cliLink); } catch {}
|
|
fs.symlinkSync(wantTarget, cliLink);
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Section: import / create runtime config (non-destructive)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function readJson(p) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } }
|
|
function fileExists(p) { try { fs.accessSync(p); return true; } catch { return false; } }
|
|
|
|
function keyringHasPassword(username) {
|
|
if (!username) return false;
|
|
const r = run('secret-tool', ['lookup', 'service', 'proxy-bridge', 'account', username], { readOnly: true });
|
|
// Never inspect r.stdout — just the exit code.
|
|
return r.status === 0 && !r.error;
|
|
}
|
|
|
|
async function sectionConfig() {
|
|
const S = 'Runtime config';
|
|
const configPath = path.join(BRIDGE_DIR, 'config.json');
|
|
const userPath = path.join(BRIDGE_DIR, 'user.json');
|
|
const existingConfig = readJson(configPath);
|
|
const existingUser = readJson(userPath);
|
|
|
|
// Import existing — show, ask before changing.
|
|
if (existingConfig && existingConfig.host) {
|
|
const enabled = existingConfig.enabled !== false;
|
|
report(S, 'OK', `imported existing: ${enabled ? 'enabled' : 'disabled'} upstream ${existingConfig.host}:${existingConfig.port} (kept)`);
|
|
if (!enabled) return { enabled: false };
|
|
const username = existingUser && existingUser.username ? existingUser.username : null;
|
|
return { enabled: true, host: existingConfig.host, port: String(existingConfig.port), username };
|
|
}
|
|
|
|
// Nothing to import — ask.
|
|
if (FLAGS.nonInteractive) {
|
|
report(S, 'FAILED', 'no existing config.json to import and --non-interactive set (missing: upstream host/port or profiles mode)');
|
|
return null;
|
|
}
|
|
console.log('\n--- Upstream proxy setup (no existing configuration found) ---');
|
|
const enable = await askConfirm('Enable corporate upstream proxy?', true);
|
|
if (!enable) {
|
|
const cfg = { enabled: false };
|
|
if (!FLAGS.dryRun) fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));
|
|
report(S, 'CHANGED', 'wrote config.json with upstream disabled');
|
|
return { enabled: false };
|
|
}
|
|
const host = requireInfo('upstream-host', await ask('Corporate proxy hostname'));
|
|
const port = await ask('Corporate proxy port', '8080');
|
|
const username = requireInfo('username', await ask('Corporate username'));
|
|
const cfg = { enabled: true, host, port };
|
|
if (!FLAGS.dryRun) {
|
|
fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));
|
|
fs.writeFileSync(userPath, JSON.stringify({ username }));
|
|
}
|
|
report(S, 'CHANGED', `wrote config.json (${host}:${port}) + user.json`);
|
|
return { enabled: true, host, port: String(port), username };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Section: credentials — verify before storing (anti-lockout)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// One single CONNECT attempt with candidate creds directly against the upstream.
|
|
// NEVER retries, NEVER prints the password, resolves: ok | authfail | unreachable
|
|
function verifyCredentials(host, port, username, password) {
|
|
return new Promise((resolve) => {
|
|
const net = require('net');
|
|
const socket = net.connect(Number(port), host);
|
|
let buf = Buffer.alloc(0);
|
|
const finish = (r) => { try { socket.destroy(); } catch {} resolve(r); };
|
|
socket.setTimeout(10000, () => 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 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) {
|
|
const S = 'Credentials';
|
|
if (!config || !config.enabled) { report(S, 'SKIPPED', 'upstream disabled'); return true; }
|
|
|
|
const username = config.username;
|
|
if (username && keyringHasPassword(username)) {
|
|
report(S, 'OK', `keyring entry present for account (not displayed)`);
|
|
return true;
|
|
}
|
|
|
|
if (FLAGS.nonInteractive) {
|
|
report(S, 'FAILED', `keyring has no password for the configured account and --non-interactive set (missing: password)`);
|
|
return false;
|
|
}
|
|
|
|
console.log('\n--- Credential setup (one verification round-trip, result never stored on failure) ---');
|
|
const user = username || requireInfo('username', await ask('Corporate username'));
|
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
const pass = await askSecret(`Corporate password for ${user} (attempt ${attempt}/3)`);
|
|
if (!pass) { console.log(' empty password, try again'); continue; }
|
|
const verdict = await verifyCredentials(config.host, config.port, user, pass);
|
|
if (verdict === 'ok') {
|
|
if (!FLAGS.dryRun) {
|
|
execFileSync('secret-tool', ['store', '--label=Proxy Bridge Credentials', 'service', 'proxy-bridge', 'account', user], { input: pass });
|
|
}
|
|
report(S, attempt > 1 ? 'CHANGED' : 'OK', 'credentials verified against upstream and stored in keyring');
|
|
return true;
|
|
}
|
|
if (verdict === 'authfail') console.log(' upstream rejected these credentials (407). Not stored. Check the password.');
|
|
else console.log(' could not reach the upstream proxy to verify. Credentials NOT stored (retry when online).');
|
|
if (verdict === 'unreachable') return false; // don't re-prompt if we can't even verify
|
|
}
|
|
report(S, 'FAILED', 'credentials could not be verified after 3 attempts — nothing stored');
|
|
return false;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Section: legacy artifact migration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function sectionLegacy() {
|
|
const S = 'Legacy migration';
|
|
const findings = [];
|
|
|
|
// bashrc: legacy marker sourcing ~/.http_proxy (cred-bearing file from update_bashrc_proxy.sh)
|
|
let bashrc = '';
|
|
try { bashrc = fs.readFileSync(BASHRC, 'utf8'); } catch {}
|
|
const legacyLineRe = /^.*#PROXY_A93JK2.*$/m;
|
|
const legacyMatch = bashrc.match(legacyLineRe);
|
|
const credFiles = ['.http_proxy', '.http_proxy.off', '.http_noproxy'].map(f => path.join(os.homedir(), f)).filter(fileExists);
|
|
if (legacyMatch || credFiles.length > 0) {
|
|
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 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 });
|
|
if (snapGet.status === 0 && /:\/\/[^/\s]+:[^@\s]+@/.test(snapGet.stdout)) {
|
|
findings.push({ what: 'snap system proxy.* (contain embedded credentials)', fix: 'point at bridge URL without creds (needs sudo)' });
|
|
}
|
|
|
|
// systemd service drop-ins with userinfo
|
|
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)) {
|
|
findings.push({ what: `${f} (contains embedded credentials)`, fix: `rewrite to bridge URL (needs sudo)` });
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
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}`));
|
|
const proceed = await askConfirm('Migrate/remove these now?', true);
|
|
if (!proceed) { report(S, 'SKIPPED', 'user deferred legacy migration'); return true; }
|
|
|
|
let changed = false;
|
|
if (legacyMatch) {
|
|
if (!FLAGS.dryRun) {
|
|
const cleaned = bashrc.split('\n').filter((line, idx, arr) => {
|
|
if (line.includes('#PROXY_A93JK2')) return false;
|
|
return true;
|
|
}).join('\n');
|
|
fs.writeFileSync(BASHRC, cleaned);
|
|
}
|
|
changed = true;
|
|
}
|
|
if (credFiles.length > 0) {
|
|
const del = await askConfirm(`Delete plaintext-credential file(s) ${credFiles.join(', ')}?`, true);
|
|
if (del && !FLAGS.dryRun) credFiles.forEach(f => fs.rmSync(f, { force: true }));
|
|
changed = changed || del;
|
|
}
|
|
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) {
|
|
run('sudo', ['snap', 'set', 'system', `proxy.http=${BRIDGE_URL}`, `proxy.https=${BRIDGE_URL}`]);
|
|
}
|
|
changed = true;
|
|
}
|
|
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)) {
|
|
if (!FLAGS.dryRun) {
|
|
const clean = `[Service]\nEnvironment="HTTP_PROXY=${BRIDGE_URL}"\nEnvironment="HTTPS_PROXY=${BRIDGE_URL}"\nEnvironment="NO_PROXY=localhost,127.0.0.1,::1"\n`;
|
|
fs.writeFileSync(`/tmp/proxy-bridge-dropin.tmp`, clean);
|
|
run('sudo', ['cp', `/tmp/proxy-bridge-dropin.tmp`, f]);
|
|
fs.rmSync(`/tmp/proxy-bridge-dropin.tmp`, { force: true });
|
|
}
|
|
changed = true;
|
|
}
|
|
} catch {}
|
|
}
|
|
report(S, changed ? 'CHANGED' : 'OK', changed ? `migrated ${findings.length} artifact(s)` : 'no changes needed');
|
|
return true;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Section: systemd user service
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function sectionUnit() {
|
|
const S = 'systemd service';
|
|
if (FLAGS.noSystemd) { report(S, 'SKIPPED', '--no-systemd'); return true; }
|
|
const unitPath = path.join(SYSTEMD_USER_DIR, 'proxy-bridge.service');
|
|
// The service must not run on an agent-bundled runtime: prefer (1) the node
|
|
// already pinned in an existing unit (stable, known-good), (2) the newest
|
|
// nvm-installed node, (3) PATH lookup that does NOT resolve into the hermes
|
|
// agent dir, (4) process.execPath as a last resort.
|
|
let nodeBin = null;
|
|
try {
|
|
const existing = fs.readFileSync(unitPath, 'utf8');
|
|
const m = existing.match(/^ExecStart=(\S+)\s+/m);
|
|
if (m && fs.existsSync(m[1])) nodeBin = m[1];
|
|
} catch {}
|
|
if (!nodeBin) {
|
|
const nvmRoot = path.join(os.homedir(), '.nvm', 'versions', 'node');
|
|
try {
|
|
const versions = fs.readdirSync(nvmRoot).filter(v => /^v\d+/.test(v)).sort((a, b) =>
|
|
a.localeCompare(b, undefined, { numeric: true }));
|
|
if (versions.length > 0) nodeBin = path.join(nvmRoot, versions[versions.length - 1], 'bin', 'node');
|
|
} catch {}
|
|
}
|
|
if (!nodeBin) {
|
|
const loginNode = run('bash', ['--noprofile', '--norc', '-c', 'command -v node'], { readOnly: true });
|
|
const cand = loginNode.status === 0 ? loginNode.stdout.trim() : '';
|
|
if (cand && !fs.realpathSync(cand).includes('/.hermes/')) nodeBin = cand;
|
|
}
|
|
if (!nodeBin) nodeBin = process.execPath;
|
|
const want = `[Unit]\nDescription=Local Proxy Bridge (Keyring Auth)\nAfter=network.target\n\n[Service]\nType=simple\nExecStart=${nodeBin} ${BRIDGE_DIR}/bridge.js\nRestart=on-failure\nRestartSec=5\n\n[Install]\nWantedBy=default.target\n`;
|
|
let current = null;
|
|
try { current = fs.readFileSync(unitPath, 'utf8'); } catch {}
|
|
if (current === want) {
|
|
report(S, 'OK', 'unit file up to date');
|
|
return true;
|
|
}
|
|
if (!FLAGS.dryRun) {
|
|
fs.mkdirSync(SYSTEMD_USER_DIR, { recursive: true });
|
|
fs.writeFileSync(unitPath, want);
|
|
run('systemctl', ['--user', 'daemon-reload']);
|
|
run('systemctl', ['--user', 'enable', 'proxy-bridge.service']);
|
|
}
|
|
report(S, 'CHANGED', 'unit file written + enabled (restart happens in verify section with your consent)');
|
|
return true;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Section: system-wide proxy point-at-bridge (idempotent)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function sectionSystemWide() {
|
|
const S = 'System-wide proxy';
|
|
let changed = false;
|
|
|
|
// bashrc bridge block (marker-guarded)
|
|
let bashrc = '';
|
|
try { bashrc = fs.readFileSync(BASHRC, 'utf8'); } catch {}
|
|
const marker = '#PROXY_BRIDGE_CONFIG';
|
|
const block = `\n# Local Proxy Bridge Configuration ${marker}\nexport http_proxy="${BRIDGE_URL}"\nexport https_proxy="${BRIDGE_URL}"\nexport HTTP_PROXY="${BRIDGE_URL}"\nexport HTTPS_PROXY="${BRIDGE_URL}"\nexport NO_PROXY="localhost,127.0.0.1,::1"\nexport no_proxy="localhost,127.0.0.1,::1"\n`;
|
|
if (!bashrc.includes(marker)) {
|
|
changed = true;
|
|
if (!FLAGS.dryRun) fs.writeFileSync(BASHRC, bashrc.replace(/\n*$/, '\n') + block);
|
|
}
|
|
|
|
// apt conf (needs sudo; content-compare via sudo cat is awkward — check existence + no-userinfo suffices for OK)
|
|
let aptOk = false;
|
|
try {
|
|
const c = fs.readFileSync(APT_CONF, 'utf8');
|
|
aptOk = c.includes('127.0.0.1:8888') && !/:\/\/[^/\s]+:[^@\s]+@/.test(c);
|
|
} catch {}
|
|
if (!aptOk) {
|
|
const doIt = FLAGS.nonInteractive ? true : await askConfirm(`Write ${APT_CONF} with bridge URL (sudo)?`, true);
|
|
if (doIt) {
|
|
changed = true;
|
|
if (!FLAGS.dryRun) {
|
|
const tmpW = `/tmp/proxy-bridge-apt.tmp`;
|
|
fs.writeFileSync(tmpW, `Acquire::http::Proxy "${BRIDGE_URL}";\nAcquire::https::Proxy "${BRIDGE_URL}";\n`);
|
|
run('sudo', ['cp', tmpW, APT_CONF]);
|
|
fs.rmSync(tmpW, { force: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
// service drop-ins (docker/snapd/ollama) — bridge URL only
|
|
for (const svc of ['docker', 'snapd', 'ollama']) {
|
|
const f = `/etc/systemd/system/${svc}.service.d/http-proxy.conf`;
|
|
let ok = false;
|
|
try {
|
|
const c = fs.readFileSync(f, 'utf8');
|
|
ok = c.includes('127.0.0.1:8888') && !/:\/\/[^/\s]+:[^@\s]+@/.test(c);
|
|
} catch {}
|
|
if (!ok) {
|
|
const svcExists = run('systemctl', ['list-unit-files', `${svc}.service`], { readOnly: true });
|
|
if (svcExists.status !== 0 || !svcExists.stdout.includes(svc)) continue; // service not installed
|
|
const doIt = FLAGS.nonInteractive ? true : await askConfirm(`Point ${svc} at the bridge (sudo + restart ${svc})?`, true);
|
|
if (doIt) {
|
|
changed = true;
|
|
if (!FLAGS.dryRun) {
|
|
const tmpW = `/tmp/proxy-bridge-dropin.tmp`;
|
|
fs.writeFileSync(tmpW, `[Service]\nEnvironment="HTTP_PROXY=${BRIDGE_URL}"\nEnvironment="HTTPS_PROXY=${BRIDGE_URL}"\nEnvironment="NO_PROXY=localhost,127.0.0.1,::1"\n`);
|
|
run('sudo', ['mkdir', '-p', path.dirname(f)]);
|
|
run('sudo', ['cp', tmpW, f]);
|
|
fs.rmSync(tmpW, { force: true });
|
|
run('sudo', ['systemctl', 'daemon-reload']);
|
|
run('sudo', ['systemctl', 'restart', svc]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
report(S, changed ? 'CHANGED' : 'OK', changed ? 'system-wide proxy config updated' : 'bashrc/apt/services already point at the bridge');
|
|
return true;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Section: verify (service restart happens here, with consent)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function sectionVerify() {
|
|
const S = 'Verify';
|
|
if (FLAGS.noSystemd) { report(S, 'SKIPPED', '--no-systemd (no restart/checks)'); return true; }
|
|
|
|
const active = run('systemctl', ['--user', 'is-active', 'proxy-bridge'], { readOnly: true }).stdout.trim();
|
|
if (active !== 'active') {
|
|
const doIt = FLAGS.nonInteractive ? true : await askConfirm('Start/restart proxy-bridge now? (drops bridged connectivity briefly)', true);
|
|
if (doIt && !FLAGS.dryRun) {
|
|
run('systemctl', ['--user', 'restart', 'proxy-bridge']);
|
|
await new Promise(r => setTimeout(r, 1500));
|
|
}
|
|
}
|
|
const state = run('systemctl', ['--user', 'is-active', 'proxy-bridge'], { readOnly: true }).stdout.trim();
|
|
if (state !== 'active') { report(S, 'FAILED', `service state: ${state || 'unknown'}`); return false; }
|
|
|
|
// ports listening
|
|
const ss = run('ss', ['-tln'], { readOnly: true });
|
|
const ports = [8888, 8889, 8890, 8891];
|
|
const listening = ports.filter(p => ss.stdout.includes(`127.0.0.1:${p}`));
|
|
const allCore = listening.length === ports.length;
|
|
report(S, allCore ? 'OK' : 'FAILED', `service active; core ports listening: ${listening.join(',') || 'none'}`);
|
|
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 = [];
|
|
let credsFailed = false;
|
|
try {
|
|
if (!sectionPreflight()) throw new Error('PREFLIGHT_FAILED');
|
|
if (!sectionDeploy()) throw new Error('DEPLOY_FAILED');
|
|
const config = await sectionConfig();
|
|
if (config === null) throw new Error('CONFIG_FAILED');
|
|
credsFailed = !(await sectionCredentials(config));
|
|
// Do not abort on credential failure: remaining sections show/prep what
|
|
// else needs doing, and the summary still exits non-zero.
|
|
if (!await sectionLegacy()) throw new Error('LEGACY_FAILED');
|
|
if (!sectionUnit()) throw new Error('UNIT_FAILED');
|
|
if (!await sectionSystemWide()) throw new Error('SYSWIDE_FAILED');
|
|
if (!FLAGS.dryRun && !credsFailed) await sectionVerify();
|
|
else report('Verify', 'SKIPPED', FLAGS.dryRun ? 'dry run' : 'credentials not verified; skipping restart');
|
|
} catch (e) {
|
|
if (String(e.message).startsWith('MISSING_INFO:')) {
|
|
missingInfo.push(e.message.split(':')[1]);
|
|
} else if (!['PREFLIGHT_FAILED', 'DEPLOY_FAILED', 'CONFIG_FAILED', 'CREDS_FAILED', 'LEGACY_FAILED', 'UNIT_FAILED', 'SYSWIDE_FAILED'].includes(e.message)) {
|
|
throw e;
|
|
}
|
|
} finally {
|
|
if (rl) rl.close();
|
|
}
|
|
const failed = sectionResults.filter(r => r.status === 'FAILED');
|
|
console.log('\n=== Summary ===');
|
|
sectionResults.forEach(r => console.log(` ${r.status.padEnd(8)} ${r.section}`));
|
|
if (missingInfo.length) console.log(`\nMissing information for --non-interactive: ${missingInfo.join(', ')}`);
|
|
if (FLAGS.dryRun) console.log('\nDry run: no changes were made.');
|
|
process.exit(failed.length > 0 || missingInfo.length > 0 ? 1 : 0);
|
|
}
|
|
|
|
main().catch(e => { console.error('installer error:', e.message); process.exit(1); });
|