| const { exec, spawn, execSync } = require("child_process"); |
| const path = require("path"); |
| const fs = require("fs"); |
| const os = require("os"); |
| const net = require("net"); |
| const https = require("https"); |
| const crypto = require("crypto"); |
| const { addDNSEntry, removeDNSEntry, removeAllDNSEntries, removeAllDNSEntriesSync, checkAllDNSStatus, TOOL_HOSTS, isSudoAvailable, isSudoPasswordRequired } = require("./dns/dnsConfig"); |
| const { isAdmin } = require("./winElevated.js"); |
|
|
| const IS_WIN = process.platform === "win32"; |
| const IS_MAC = process.platform === "darwin"; |
| const { generateCert } = require("./cert/generate"); |
| const { installCert, uninstallCert } = require("./cert/install"); |
| const { isCertExpired } = require("./cert/rootCA"); |
| const { DATA_DIR, MITM_DIR } = require("./paths"); |
| const { log, err } = require("./logger"); |
| const { LSOF_BIN } = require("./config"); |
|
|
| const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128"; |
|
|
| function shellQuoteSingle(str) { |
| if (str == null || str === "") return "''"; |
| return `'${String(str).replace(/'/g, "'\\''")}'`; |
| } |
| |
| async function resolveMitmRouterBaseUrl() { |
| if (!_getSettings) return DEFAULT_MITM_ROUTER_BASE; |
| try { |
| const s = await _getSettings(); |
| const raw = s && s.mitmRouterBaseUrl != null ? String(s.mitmRouterBaseUrl).trim() : ""; |
| if (!raw) return DEFAULT_MITM_ROUTER_BASE; |
| const u = new URL(raw); |
| if (u.protocol !== "http:" && u.protocol !== "https:") return DEFAULT_MITM_ROUTER_BASE; |
| return raw.replace(/\/+$/, ""); |
| } catch { |
| return DEFAULT_MITM_ROUTER_BASE; |
| } |
| } |
| |
| const MITM_PORT = 443; |
| const MITM_WIN_NODE_PORT = 8443; |
| const PID_FILE = path.join(MITM_DIR, ".mitm.pid"); |
| |
| const MITM_MAX_RESTARTS = 5; |
| const MITM_RESTART_DELAYS_MS = [5000, 10000, 20000, 30000, 60000]; |
| const MITM_RESTART_RESET_MS = 60000; |
| |
| let mitmRestartCount = 0; |
| let mitmLastStartTime = 0; |
| let mitmIsRestarting = false; |
| |
| function resolveBundledServerPath() { |
| if (process.env.MITM_SERVER_PATH) return process.env.MITM_SERVER_PATH; |
| const sibling = path.join(__dirname, "server.js"); |
| if (fs.existsSync(sibling)) return sibling; |
| const fromCwd = path.join(process.cwd(), "src", "mitm", "server.js"); |
| if (fs.existsSync(fromCwd)) return fromCwd; |
| const fromNext = path.join(process.cwd(), "..", "src", "mitm", "server.js"); |
| if (fs.existsSync(fromNext)) return fromNext; |
| return fromCwd; |
| } |
| |
| // Copy bundled server.js into DATA_DIR so MITM doesn't lock node_modules |
| // (prevents EBUSY on `npm i -g 9router@latest` while MITM is running). |
| function ensureRuntimeServer(bundledPath) { |
| try { |
| if (!bundledPath || !fs.existsSync(bundledPath)) return bundledPath; |
| |
| // Dev mode: source file has relative requires (./logger, ./config...), |
| // only the bundled file inside node_modules is self-contained + safe to copy. |
| if (!bundledPath.includes(`${path.sep}node_modules${path.sep}`)) { |
| return bundledPath; |
| } |
| |
| const runtimeDir = path.join(DATA_DIR, "runtime", "mitm"); |
| const runtimeServer = path.join(runtimeDir, "server.js"); |
| |
| // Skip copy if sizes match (bundle unchanged since last run) |
| if (fs.existsSync(runtimeServer)) { |
| try { |
| if (fs.statSync(bundledPath).size === fs.statSync(runtimeServer).size) return runtimeServer; |
| } catch { /* recopy */ } |
| } |
| |
| fs.mkdirSync(runtimeDir, { recursive: true }); |
| fs.copyFileSync(bundledPath, runtimeServer); |
| return runtimeServer; |
| } catch (e) { |
| try { log(`[MITM] runtime copy failed: ${e.message}`); } catch { /* ignore */ } |
| return bundledPath; |
| } |
| } |
| |
| const SERVER_PATH = ensureRuntimeServer(resolveBundledServerPath()); |
| const ENCRYPT_ALGO = "aes-256-gcm"; |
| const ENCRYPT_SALT = "9router-mitm-pwd"; |
| |
| function getProcessUsingPort443() { |
| try { |
| if (IS_WIN) { |
| const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command ` + |
| `"$c = Get-NetTCPConnection -LocalPort 443 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; if ($c) { $c.OwningProcess } else { 0 }"`; |
| const pidStr = execSync(psCmd, { encoding: "utf8", windowsHide: true }).trim(); |
| const pid = parseInt(pidStr, 10); |
| if (pid && pid > 4) { |
| const tasklistResult = execSync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { encoding: "utf8", windowsHide: true }); |
| const processMatch = tasklistResult.match(/"([^"]+)"/); |
| if (processMatch) return processMatch[1].replace(".exe", ""); |
| } |
| } else { |
| const result = execSync(`${LSOF_BIN} -i :443`, { encoding: "utf8", windowsHide: true }); |
| const lines = result.trim().split("\n"); |
| if (lines.length > 1) return lines[1].split(/\s+/)[0]; |
| } |
| } catch { |
| return null; |
| } |
| return null; |
| } |
| |
| let serverProcess = null; |
| let serverPid = null; |
| |
| function getCachedPassword() { return globalThis.__mitmSudoPassword || null; } |
| function setCachedPassword(pwd) { globalThis.__mitmSudoPassword = pwd; } |
| |
| function isProcessAlive(pid) { |
| try { |
| process.kill(pid, 0); |
| return true; |
| } catch (err) { |
| return err.code === "EACCES"; |
| } |
| } |
| |
| function killProcess(pid, force = false, sudoPassword = null) { |
| if (IS_WIN) { |
| const flag = force ? "/F " : ""; |
| exec(`taskkill ${flag}/PID ${pid}`, { windowsHide: true }, () => { }); |
| } else { |
| const sig = force ? "SIGKILL" : "SIGTERM"; |
| const cmd = `pkill -${sig} -P ${pid} 2>/dev/null; kill -${sig} ${pid} 2>/dev/null`; |
| if (sudoPassword || isSudoAvailable()) { |
| const { execWithPassword } = require("./dns/dnsConfig"); |
| execWithPassword(cmd, sudoPassword || "").catch(() => exec(cmd, { windowsHide: true }, () => { })); |
| } else { |
| exec(cmd, { windowsHide: true }, () => { }); |
| } |
| } |
| } |
| |
| function deriveKey() { |
| try { |
| const { machineIdSync } = require("node-machine-id"); |
| const raw = machineIdSync(); |
| return crypto.createHash("sha256").update(raw + ENCRYPT_SALT).digest(); |
| } catch { |
| return crypto.createHash("sha256").update(ENCRYPT_SALT).digest(); |
| } |
| } |
| |
| function encryptPassword(plaintext) { |
| const key = deriveKey(); |
| const iv = crypto.randomBytes(12); |
| const cipher = crypto.createCipheriv(ENCRYPT_ALGO, key, iv); |
| const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); |
| const tag = cipher.getAuthTag(); |
| return `${iv.toString("hex")}:${tag.toString("hex")}:${encrypted.toString("hex")}`; |
| } |
| |
| function decryptPassword(stored) { |
| try { |
| const [ivHex, tagHex, dataHex] = stored.split(":"); |
| if (!ivHex || !tagHex || !dataHex) return null; |
| const key = deriveKey(); |
| const decipher = crypto.createDecipheriv(ENCRYPT_ALGO, key, Buffer.from(ivHex, "hex")); |
| decipher.setAuthTag(Buffer.from(tagHex, "hex")); |
| return decipher.update(Buffer.from(dataHex, "hex")) + decipher.final("utf8"); |
| } catch { |
| return null; |
| } |
| } |
| |
| let _getSettings = null; |
| let _updateSettings = null; |
| |
| function initDbHooks(getSettingsFn, updateSettingsFn) { |
| _getSettings = getSettingsFn; |
| _updateSettings = updateSettingsFn; |
| } |
| |
| async function saveMitmSettings(enabled, password) { |
| if (!_updateSettings) return; |
| try { |
| const updates = { mitmEnabled: enabled }; |
| if (password) updates.mitmSudoEncrypted = encryptPassword(password); |
| await _updateSettings(updates); |
| } catch (e) { |
| err(`Failed to save settings: ${e.message}`); |
| } |
| } |
| |
| async function clearEncryptedPassword() { |
| if (!_updateSettings) return; |
| try { |
| await _updateSettings({ mitmSudoEncrypted: null }); |
| } catch (e) { |
| err(`Failed to clear encrypted password: ${e.message}`); |
| } |
| } |
| |
| async function loadEncryptedPassword() { |
| if (!_getSettings) return null; |
| try { |
| const settings = await _getSettings(); |
| if (!settings.mitmSudoEncrypted) return null; |
| return decryptPassword(settings.mitmSudoEncrypted); |
| } catch { |
| return null; |
| } |
| } |
| |
| async function saveDnsToolState(tool, enabled) { |
| if (!_updateSettings || !_getSettings) return; |
| try { |
| const s = await _getSettings(); |
| const next = { ...(s.dnsToolEnabled || {}), [tool]: enabled }; |
| await _updateSettings({ dnsToolEnabled: next }); |
| } catch (e) { |
| err(`Failed to save DNS state: ${e.message}`); |
| } |
| } |
| |
| async function loadDnsToolState() { |
| if (!_getSettings) return {}; |
| try { |
| const s = await _getSettings(); |
| return s.dnsToolEnabled || {}; |
| } catch { |
| return {}; |
| } |
| } |
| |
| /** |
| * Re-apply DNS for tools previously enabled β called on app startup after MITM running. |
| */ |
| async function restoreToolDNS(sudoPassword) { |
| const state = await loadDnsToolState(); |
| const password = sudoPassword || getCachedPassword() || await loadEncryptedPassword(); |
| for (const [tool, enabled] of Object.entries(state)) { |
| if (!enabled || !TOOL_HOSTS[tool]) continue; |
| try { |
| await addDNSEntry(tool, password); |
| } catch (e) { |
| err(`DNS ${tool}: restore failed β ${e.message}`); |
| } |
| } |
| } |
| |
| /** |
| * Check if user has privilege to mutate hosts file. |
| * Win: needs admin. Mac/Linux: root OR cached/encrypted sudo password. |
| */ |
| async function hasDnsPrivilege() { |
| if (IS_WIN) return isAdmin(); |
| if (isAdmin()) return true; |
| if (!isSudoPasswordRequired()) return true; |
| const pwd = getCachedPassword() || await loadEncryptedPassword(); |
| return !!pwd; |
| } |
| |
| function checkPort443Free() { |
| return new Promise((resolve) => { |
| const tester = net.createServer(); |
| tester.once("error", (err) => { |
| if (err.code === "EADDRINUSE") resolve("in-use"); |
| else resolve("no-permission"); |
| }); |
| tester.once("listening", () => { tester.close(() => resolve("free")); }); |
| tester.listen(MITM_PORT, "127.0.0.1"); |
| }); |
| } |
| |
| function getPort443Owner(sudoPassword) { |
| return new Promise((resolve) => { |
| if (IS_WIN) { |
| const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "` + |
| `$c = Get-NetTCPConnection -LocalPort 443 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; ` + |
| `if ($c) { $c.OwningProcess } else { 0 }"`; |
| exec(psCmd, { windowsHide: true }, (err, stdout) => { |
| if (err) return resolve(null); |
| const pid = parseInt(stdout.trim(), 10); |
| if (!pid || pid <= 4) return resolve(null); |
| exec(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { windowsHide: true }, (e2, out2) => { |
| const m = out2?.match(/"([^"]+)"/); |
| resolve({ pid, name: m ? m[1] : "unknown" }); |
| }); |
| }); |
| } else { |
| // Only find process actually LISTENING on TCP port 443 |
| exec(`${LSOF_BIN} -nP -iTCP:443 -sTCP:LISTEN -t`, { windowsHide: true }, (err, stdout) => { |
| if (err || !stdout?.trim()) return resolve(null); |
| const pid = parseInt(stdout.trim().split("\n")[0], 10); |
| if (!pid || isNaN(pid)) return resolve(null); |
| exec(`ps -p ${pid} -o comm=`, { windowsHide: true }, (e2, out2) => { |
| resolve({ pid, name: (out2?.trim() || "unknown") }); |
| }); |
| }); |
| } |
| }); |
| } |
| |
| async function killLeftoverMitm(sudoPassword) { |
| if (serverProcess && !serverProcess.killed) { |
| try { serverProcess.kill("SIGKILL"); } catch { /* ignore */ } |
| serverProcess = null; |
| serverPid = null; |
| } |
| try { |
| if (fs.existsSync(PID_FILE)) { |
| const savedPid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10); |
| if (savedPid && isProcessAlive(savedPid)) { |
| killProcess(savedPid, true, sudoPassword); |
| await new Promise(r => setTimeout(r, 500)); |
| } |
| fs.unlinkSync(PID_FILE); |
| } |
| } catch { /* ignore */ } |
| if (!IS_WIN && SERVER_PATH) { |
| try { |
| const escaped = SERVER_PATH.replace(/'/g, "'\\''"); |
| if (sudoPassword || isSudoAvailable()) { |
| const { execWithPassword } = require("./dns/dnsConfig"); |
| await execWithPassword(`pkill -SIGKILL -f "${escaped}" 2>/dev/null || true`, sudoPassword || "").catch(() => { }); |
| } else { |
| exec(`pkill -SIGKILL -f "${escaped}" 2>/dev/null || true`, { windowsHide: true }, () => { }); |
| } |
| await new Promise(r => setTimeout(r, 500)); |
| } catch { /* ignore */ } |
| } |
| } |
| |
| function pollMitmHealth(timeoutMs, port = MITM_PORT) { |
| return new Promise((resolve) => { |
| const deadline = Date.now() + timeoutMs; |
| const check = () => { |
| const req = https.request( |
| { hostname: "127.0.0.1", port, path: "/_mitm_health", method: "GET", rejectUnauthorized: false }, |
| (res) => { |
| let body = ""; |
| res.on("data", (d) => { body += d; }); |
| res.on("end", () => { |
| try { |
| const json = JSON.parse(body); |
| resolve(json.ok === true ? { ok: true, pid: json.pid || null } : null); |
| } catch { resolve(null); } |
| }); |
| } |
| ); |
| req.on("error", () => { |
| if (Date.now() < deadline) setTimeout(check, 500); |
| else resolve(null); |
| }); |
| req.end(); |
| }; |
| check(); |
| }); |
| } |
| |
| /** |
| * Get full MITM status including per-tool DNS status |
| */ |
| async function getMitmStatus() { |
| let running = serverProcess !== null && !serverProcess.killed; |
| let pid = serverPid; |
| |
| if (!running) { |
| try { |
| if (fs.existsSync(PID_FILE)) { |
| const savedPid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10); |
| if (savedPid && isProcessAlive(savedPid)) { |
| running = true; |
| pid = savedPid; |
| } else { |
| fs.unlinkSync(PID_FILE); |
| } |
| } |
| } catch { /* ignore */ } |
| } |
| |
| const dnsStatus = checkAllDNSStatus(); |
| const rootCACertPath = path.join(MITM_DIR, "rootCA.crt"); |
| const certExists = fs.existsSync(rootCACertPath); |
| const { checkCertInstalled } = require("./cert/install"); |
| const certTrusted = certExists ? await checkCertInstalled(rootCACertPath) : false; |
| |
| return { running, pid, certExists, certTrusted, dnsStatus }; |
| } |
| |
| async function scheduleMitmRestart(apiKey) { |
| if (mitmIsRestarting) return; |
| |
| const aliveMs = Date.now() - mitmLastStartTime; |
| if (aliveMs >= MITM_RESTART_RESET_MS) mitmRestartCount = 0; |
| |
| if (mitmRestartCount >= MITM_MAX_RESTARTS) { |
| err("Max restart attempts reached. Giving up."); |
| return; |
| } |
| |
| const attempt = mitmRestartCount; |
| const delay = MITM_RESTART_DELAYS_MS[Math.min(attempt, MITM_RESTART_DELAYS_MS.length - 1)]; |
| mitmRestartCount++; |
| mitmIsRestarting = true; |
| |
| log(`Restarting in ${delay / 1000}s... (${mitmRestartCount}/${MITM_MAX_RESTARTS})`); |
| await new Promise((r) => setTimeout(r, delay)); |
| |
| try { |
| const settings = _getSettings ? await _getSettings() : null; |
| if (settings && !settings.mitmEnabled) { |
| log("MITM disabled, skipping restart"); |
| mitmIsRestarting = false; |
| return; |
| } |
| const password = getCachedPassword() || await loadEncryptedPassword(); |
| if (!password && !IS_WIN) { |
| err("No cached password, cannot auto-restart"); |
| mitmIsRestarting = false; |
| return; |
| } |
| await startServer(apiKey, password); |
| log("π Restarted successfully"); |
| mitmRestartCount = 0; |
| mitmIsRestarting = false; |
| } catch (e) { |
| err(`Restart attempt ${mitmRestartCount}/${MITM_MAX_RESTARTS} failed: ${e.message}`); |
| mitmIsRestarting = false; |
| // Schedule next retry |
| scheduleMitmRestart(apiKey); |
| } |
| } |
| |
| /** |
| * Start MITM server only (cert + server, no DNS) |
| */ |
| async function killPort443Owner(owner, sudoPassword) { |
| if (!owner || !owner.pid) return; |
| if (IS_WIN) { |
| try { |
| execSync(`powershell -NonInteractive -WindowStyle Hidden -Command "Stop-Process -Id ${owner.pid} -Force -ErrorAction SilentlyContinue"`, { windowsHide: true }); |
| } catch { /* best effort */ } |
| } else { |
| try { |
| const { execWithPassword } = require("./dns/dnsConfig"); |
| if (sudoPassword || isSudoAvailable()) { |
| await execWithPassword(`kill -9 ${owner.pid}`, sudoPassword || ""); |
| } else { |
| execSync(`kill -9 ${owner.pid}`, { windowsHide: true }); |
| } |
| } catch { /* best effort */ } |
| } |
| await new Promise(r => setTimeout(r, 800)); |
| } |
| |
| async function startServer(apiKey, sudoPassword, forceKillPort443 = false) { |
| if (!serverProcess || serverProcess.killed) { |
| try { |
| if (fs.existsSync(PID_FILE)) { |
| const savedPid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10); |
| if (savedPid && isProcessAlive(savedPid)) { |
| serverPid = savedPid; |
| log(`β»οΈ Reusing existing process (PID: ${savedPid})`); |
| await saveMitmSettings(true, sudoPassword); |
| if (sudoPassword) setCachedPassword(sudoPassword); |
| return { running: true, pid: savedPid }; |
| } else { |
| fs.unlinkSync(PID_FILE); |
| } |
| } |
| } catch { /* ignore */ } |
| } |
| |
| if (serverProcess && !serverProcess.killed) { |
| throw new Error("MITM server is already running"); |
| } |
| |
| await killLeftoverMitm(sudoPassword); |
| |
| if (!IS_WIN) { |
| const portStatus = await checkPort443Free(); |
| if (portStatus === "in-use" || portStatus === "no-permission") { |
| const owner = await getPort443Owner(sudoPassword); |
| if (owner) { |
| const shortName = owner.name.includes("/") |
| ? owner.name.split("/").filter(Boolean).pop() |
| : owner.name; |
| if (forceKillPort443) { |
| log(`Killing process on port 443 (PID ${owner.pid}, name=${shortName})...`); |
| await killPort443Owner(owner, sudoPassword); |
| } else { |
| const e = new Error(`Port 443 is already in use by "${shortName}" (PID ${owner.pid}).`); |
| e.code = "PORT_443_BUSY"; |
| e.portOwner = { pid: owner.pid, name: shortName }; |
| throw e; |
| } |
| } |
| } |
| } |
| |
| // Step 1: Generate Root CA if missing or expired |
| const rootCACertPath = path.join(MITM_DIR, "rootCA.crt"); |
| const rootCAKeyPath = path.join(MITM_DIR, "rootCA.key"); |
| const certExists = fs.existsSync(rootCACertPath) && fs.existsSync(rootCAKeyPath); |
| |
| if (!certExists || isCertExpired(rootCACertPath)) { |
| if (certExists) { |
| // Uninstall expired cert from system store before regenerating |
| log("π Cert expired β uninstalling old cert..."); |
| const password = sudoPassword || getCachedPassword() || await loadEncryptedPassword(); |
| try { await uninstallCert(password, rootCACertPath); } catch { /* best effort */ } |
| } |
| log("π Generating Root CA..."); |
| await generateCert(); |
| } |
| |
| // Step 1.5: Auto-install Root CA if not trusted yet |
| const { checkCertInstalled } = require("./cert/install"); |
| const rootCATrusted = await checkCertInstalled(rootCACertPath); |
| const linuxNoSystemTrust = !IS_WIN && !IS_MAC && !isSudoAvailable(); |
| if (!rootCATrusted) { |
| log("π Cert: not trusted β installing..."); |
| const password = sudoPassword || getCachedPassword() || await loadEncryptedPassword(); |
| if (linuxNoSystemTrust) { |
| log(`π Cert: skipping system trust (no sudo). Install ${rootCACertPath} as a trusted CA on machines that use this proxy.`); |
| } else { |
| if (!password && isSudoPasswordRequired()) { |
| throw new Error("Sudo password required to install Root CA certificate"); |
| } |
| try { |
| await installCert(password, rootCACertPath); |
| log("π Cert: β
trusted"); |
| } catch (e) { |
| throw new Error(`Failed to trust certificate: ${e.message}`); |
| } |
| } |
| } else { |
| log("π Cert: already trusted β
"); |
| } |
| |
| // Step 2: Spawn server (Root CA already installed in Step 1.5) |
| // Verify server.js exists β recopy if runtime file was deleted (antivirus/cleanup) |
| let effectiveServerPath = SERVER_PATH; |
| if (!effectiveServerPath || !fs.existsSync(effectiveServerPath)) { |
| log(`[MITM] server.js missing at ${effectiveServerPath} β recopying`); |
| effectiveServerPath = ensureRuntimeServer(resolveBundledServerPath()); |
| if (!effectiveServerPath || !fs.existsSync(effectiveServerPath)) { |
| throw new Error(`MITM server.js not found at ${effectiveServerPath}. Reinstall 9router.`); |
| } |
| } |
| const mitmRouterBase = await resolveMitmRouterBaseUrl(); |
| log(`π Starting server... (router: ${mitmRouterBase})`); |
| if (IS_WIN) { |
| // Check port 443 β ask user before killing |
| const winOwner = await getPort443Owner(sudoPassword); |
| if (winOwner) { |
| if (forceKillPort443) { |
| log(`Killing process on port 443 (PID ${winOwner.pid}, name=${winOwner.name})...`); |
| await killPort443Owner(winOwner, sudoPassword); |
| } else { |
| const e = new Error(`Port 443 is already in use by "${winOwner.name}" (PID ${winOwner.pid}).`); |
| e.code = "PORT_443_BUSY"; |
| e.portOwner = { pid: winOwner.pid, name: winOwner.name }; |
| throw e; |
| } |
| } |
| |
| // Spawn directly β process already has admin rights |
| // cwd=tmpdir so process doesn't lock the install dir on Windows (EBUSY on update) |
| serverProcess = spawn( |
| process.execPath, |
| [effectiveServerPath], |
| { |
| detached: false, |
| windowsHide: true, |
| cwd: os.tmpdir(), |
| stdio: ["ignore", "pipe", "pipe"], |
| env: { |
| ...process.env, |
| ROUTER_API_KEY: apiKey, |
| NODE_ENV: "production", |
| MITM_ROUTER_BASE: mitmRouterBase, |
| }, |
| } |
| ); |
| |
| if (_updateSettings) await _updateSettings({ mitmCertInstalled: true }).catch(() => { }); |
| } else if (isSudoAvailable()) { |
| // Pass HOME explicitly so os.homedir() resolves to the unprivileged user's home |
| // instead of /root when sudo resets the environment. |
| const inlineCmd = [ |
| `HOME=${shellQuoteSingle(os.homedir())}`, |
| `ROUTER_API_KEY=${shellQuoteSingle(apiKey)}`, |
| `MITM_ROUTER_BASE=${shellQuoteSingle(mitmRouterBase)}`, |
| "NODE_ENV=production", |
| shellQuoteSingle(process.execPath), |
| shellQuoteSingle(effectiveServerPath), |
| ].join(" "); |
| serverProcess = spawn( |
| "sudo", ["-S", "-E", "sh", "-c", inlineCmd], |
| { detached: false, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] } |
| ); |
| serverProcess.stdin.write(`${sudoPassword}\n`); |
| serverProcess.stdin.end(); |
| } else { |
| // Docker/minimal images: no sudo β same as Windows-style direct spawn |
| serverProcess = spawn(process.execPath, [effectiveServerPath], { |
| detached: false, |
| windowsHide: true, |
| cwd: os.tmpdir(), |
| stdio: ["ignore", "pipe", "pipe"], |
| env: { |
| ...process.env, |
| ROUTER_API_KEY: apiKey, |
| NODE_ENV: "production", |
| MITM_ROUTER_BASE: mitmRouterBase, |
| }, |
| }); |
| } |
| |
| if (serverProcess) { |
| serverPid = serverProcess.pid; |
| fs.writeFileSync(PID_FILE, String(serverPid)); |
| mitmLastStartTime = Date.now(); |
| } |
| |
| // Set NODE_EXTRA_CA_CERTS so Node-based GUI apps (Electron/AG language_server) trust MITM cert |
| if (IS_MAC) { |
| const rootCAPath = path.join(MITM_DIR, "rootCA.crt"); |
| if (fs.existsSync(rootCAPath)) { |
| exec(`launchctl setenv NODE_EXTRA_CA_CERTS "${rootCAPath}"`, { windowsHide: true }, (e) => { |
| if (e) log(`[launchctl] Failed to set NODE_EXTRA_CA_CERTS: ${e.message}`); |
| else log(`[launchctl] NODE_EXTRA_CA_CERTS set to ${rootCAPath}`); |
| }); |
| } |
| } else if (IS_WIN) { |
| const rootCAPath = path.join(MITM_DIR, "rootCA.crt"); |
| if (fs.existsSync(rootCAPath)) { |
| exec(`setx NODE_EXTRA_CA_CERTS "${rootCAPath}"`, { windowsHide: true }, (e) => { |
| if (e) log(`[setx] Failed to set NODE_EXTRA_CA_CERTS: ${e.message}`); |
| else log(`[setx] NODE_EXTRA_CA_CERTS set for current user`); |
| }); |
| } |
| } |
| |
| let startError = null; |
| if (serverProcess) { |
| serverProcess.stdout.on("data", (data) => { |
| // server.js already formats its own logs β print as-is |
| process.stdout.write(data); |
| }); |
| serverProcess.stderr.on("data", (data) => { |
| const msg = data.toString().trim(); |
| // Mac/Linux: filter sudo password prompt noise |
| if (msg && (IS_WIN || (!msg.includes("Password:") && !msg.includes("password for")))) { |
| err(msg); |
| startError = msg; |
| } |
| // Detect wrong/missing password β clear cache and stop retry loop |
| if (!IS_WIN && (msg.includes("incorrect password") || msg.includes("no password was provided"))) { |
| setCachedPassword(null); |
| clearEncryptedPassword(); |
| mitmIsRestarting = true; // prevent scheduleMitmRestart from firing |
| } |
| }); |
| serverProcess.on("exit", (code) => { |
| log(`Server exited (code: ${code})`); |
| serverProcess = null; |
| serverPid = null; |
| try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ } |
| // Auto-restart on unexpected exit |
| if (code !== 0 && !mitmIsRestarting) scheduleMitmRestart(apiKey); |
| }); |
| } |
| |
| const health = await pollMitmHealth(8000, MITM_PORT); |
| if (!health) { |
| if (serverProcess && !serverProcess.killed) { try { serverProcess.kill(); } catch { /* ignore */ } serverProcess = null; } |
| const processUsing443 = getProcessUsingPort443(); |
| const portInfo = processUsing443 ? ` Port 443 already in use by ${processUsing443}.` : ""; |
| const reason = startError || `Check sudo password or port 443 access.${portInfo}`; |
| throw new Error(`MITM server failed to start. ${reason}`); |
| } |
| |
| if (_updateSettings) await _updateSettings({ mitmCertInstalled: true }).catch(() => { }); |
| |
| log(`β
Server healthy (PID: ${serverPid || health.pid})`); |
| |
| // Log DNS status per tool |
| const dnsStatus = checkAllDNSStatus(); |
| for (const [tool, active] of Object.entries(dnsStatus)) { |
| log(`π DNS ${tool}: ${active ? "β
active" : "β inactive"}`); |
| } |
| |
| await saveMitmSettings(true, sudoPassword); |
| if (sudoPassword) setCachedPassword(sudoPassword); |
| |
| return { running: true, pid: serverPid }; |
| } |
| |
| /** |
| * Stop MITM server β removes ALL tool DNS entries first, then kills server |
| */ |
| async function stopServer(sudoPassword) { |
| // Prevent auto-restart from triggering on intentional stop |
| mitmIsRestarting = true; |
| mitmRestartCount = 0; |
| log("βΉ Stopping server..."); |
| |
| // Kill server process |
| const proc = serverProcess; |
| const pidToKill = proc && !proc.killed |
| ? proc.pid |
| : (() => { try { return parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10); } catch { return null; } })(); |
| |
| if (pidToKill && isProcessAlive(pidToKill)) { |
| log(`Killing server (PID: ${pidToKill})...`); |
| killProcess(pidToKill, false, sudoPassword); |
| await new Promise(r => setTimeout(r, 1000)); |
| if (isProcessAlive(pidToKill)) killProcess(pidToKill, true, sudoPassword); |
| } |
| serverProcess = null; |
| serverPid = null; |
| |
| if (IS_WIN) { |
| const hostsFile = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "drivers", "etc", "hosts"); |
| const allHosts = Object.values(TOOL_HOSTS).flat(); |
| try { |
| const { isAdmin, runElevatedPowerShell, quotePs } = require("./winElevated.js"); |
| if (isAdmin()) { |
| // Direct fs write β bypass PowerShell to avoid parser pitfalls |
| const content = fs.readFileSync(hostsFile, "utf8"); |
| const filtered = content.split(/\r?\n/).filter(l => !allHosts.some(h => l.includes(h))).join("\r\n"); |
| const next = filtered.replace(/[\r\n\s]+$/g, "") + "\r\n"; |
| if (next !== content) fs.writeFileSync(hostsFile, next, "utf8"); |
| try { require("child_process").execSync("ipconfig /flushdns", { windowsHide: true, stdio: "ignore" }); } catch { /* ignore */ } |
| log("π DNS: β
all tool hosts removed"); |
| } else { |
| const hostsList = allHosts.map(quotePs).join(","); |
| const script = ` |
| $hosts = @(${hostsList}) |
| $lines = Get-Content -LiteralPath ${quotePs(hostsFile)} |
| $filtered = $lines | Where-Object { |
| $line = $_ |
| -not ($hosts | Where-Object { $line -match [regex]::Escape($_) }) |
| } |
| Set-Content -LiteralPath ${quotePs(hostsFile)} -Value $filtered |
| ipconfig /flushdns | Out-Null |
| `; |
| await runElevatedPowerShell(script); |
| } |
| } catch (e) { err(`Failed to clean hosts: ${e.message}`); } |
| } else { |
| await removeAllDNSEntries(sudoPassword); |
| } |
| |
| // Unset NODE_EXTRA_CA_CERTS so apps don't keep trusting stale MITM cert |
| if (IS_MAC) { |
| exec(`launchctl unsetenv NODE_EXTRA_CA_CERTS`, { windowsHide: true }, (e) => { |
| if (e) log(`[launchctl] Failed to unset NODE_EXTRA_CA_CERTS: ${e.message}`); |
| else log(`[launchctl] NODE_EXTRA_CA_CERTS unset`); |
| }); |
| } else if (IS_WIN) { |
| exec(`reg delete HKCU\\Environment /F /V NODE_EXTRA_CA_CERTS`, { windowsHide: true }, (e) => { |
| if (e) log(`[reg] Failed to unset NODE_EXTRA_CA_CERTS: ${e.message}`); |
| else log(`[reg] NODE_EXTRA_CA_CERTS unset`); |
| }); |
| } |
| |
| try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ } |
| await saveMitmSettings(false, null); |
| mitmIsRestarting = false; |
| |
| return { running: false, pid: null }; |
| } |
| |
| /** |
| * Enable DNS for a specific tool (requires server running) |
| */ |
| async function enableToolDNS(tool, sudoPassword) { |
| const status = await getMitmStatus(); |
| if (!status.running) throw new Error("MITM server is not running. Start the server first."); |
| |
| const password = sudoPassword || getCachedPassword() || await loadEncryptedPassword(); |
| await addDNSEntry(tool, password); |
| await saveDnsToolState(tool, true); |
| return { success: true }; |
| } |
| |
| /** |
| * Disable DNS for a specific tool |
| */ |
| async function disableToolDNS(tool, sudoPassword) { |
| const password = sudoPassword || getCachedPassword() || await loadEncryptedPassword(); |
| await removeDNSEntry(tool, password); |
| await saveDnsToolState(tool, false); |
| return { success: true }; |
| } |
| |
| /** |
| * Install Root CA to system trust store (standalone, no server start) |
| */ |
| async function trustCert(sudoPassword) { |
| const rootCACertPath = path.join(MITM_DIR, "rootCA.crt"); |
| if (!fs.existsSync(rootCACertPath)) throw new Error("Root CA not found. Start server first to generate it."); |
| const { installCert } = require("./cert/install"); |
| if (!IS_WIN && !IS_MAC && !isSudoAvailable()) { |
| log(`π Cert: system trust unavailable (no sudo). Use file: ${rootCACertPath}`); |
| return; |
| } |
| const password = sudoPassword || getCachedPassword() || await loadEncryptedPassword(); |
| if (!password && isSudoPasswordRequired()) throw new Error("Sudo password required to trust certificate"); |
| await installCert(password, rootCACertPath); |
| if (password) setCachedPassword(password); |
| } |
| |
| // Legacy aliases for backward compatibility |
| const startMitm = startServer; |
| const stopMitm = stopServer; |
| |
| module.exports = { |
| getMitmStatus, |
| startServer, |
| stopServer, |
| enableToolDNS, |
| disableToolDNS, |
| trustCert, |
| // Legacy |
| startMitm, |
| stopMitm, |
| getCachedPassword, |
| setCachedPassword, |
| loadEncryptedPassword, |
| clearEncryptedPassword, |
| isSudoPasswordRequired, |
| initDbHooks, |
| restoreToolDNS, |
| hasDnsPrivilege, |
| removeAllDNSEntriesSync, |
| }; |
| |