From 34da52f144c6c979b75f6d68a5a1255fa98bb4b2 Mon Sep 17 00:00:00 2001 From: decolua Date: Wed, 29 Apr 2026 17:28:38 +0700 Subject: [PATCH] Fix : Antigravity MITM --- .gitignore | 1 + package.json | 2 +- src/lib/tunnel/cloudflared.js | 3 +- src/mitm/cert/install.js | 55 ++++++++++--------- src/mitm/config.js | 11 +++- src/mitm/dns/dnsConfig.js | 76 ++++++++------------------- src/mitm/handlers/antigravity.js | 24 ++++++--- src/mitm/handlers/base.js | 22 ++++---- src/mitm/logger.js | 90 +++++++++++++++++++++++++++++++- src/mitm/manager.js | 27 ++++++++-- src/mitm/server.js | 51 +++++++++--------- src/mitm/winElevated.js | 79 ++++++++++++++++++++++++++++ 12 files changed, 313 insertions(+), 128 deletions(-) create mode 100644 src/mitm/winElevated.js diff --git a/.gitignore b/.gitignore index c6310e25..06d10b2d 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,4 @@ README1.md deploy*.sh ecosystem.config.* +scripts/agSniffer/* diff --git a/package.json b/package.json index cfe1017a..f8039b45 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.4.9", + "version": "0.4.10", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/src/lib/tunnel/cloudflared.js b/src/lib/tunnel/cloudflared.js index 5a808802..1384e991 100644 --- a/src/lib/tunnel/cloudflared.js +++ b/src/lib/tunnel/cloudflared.js @@ -11,6 +11,7 @@ const BINARY_NAME = "cloudflared"; const IS_WINDOWS = os.platform() === "win32"; const BIN_NAME = IS_WINDOWS ? `${BINARY_NAME}.exe` : BINARY_NAME; const BIN_PATH = path.join(BIN_DIR, BIN_NAME); +const POWERSHELL_HIDDEN_COMMAND = "powershell -NoProfile -NonInteractive -WindowStyle Hidden -Command"; const GITHUB_BASE_URL = "https://github.com/cloudflare/cloudflared/releases/latest/download"; @@ -367,7 +368,7 @@ function killCloudflaredByPort(port) { try { if (IS_WINDOWS) { const psCmd = `Get-CimInstance Win32_Process -Filter \\"Name='cloudflared.exe'\\" | Where-Object { $_.CommandLine -match ':${port}(\\D|$)' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }`; - execSync(`powershell -NoProfile -Command "${psCmd}"`, { stdio: "ignore", windowsHide: true }); + execSync(`${POWERSHELL_HIDDEN_COMMAND} "${psCmd}"`, { stdio: "ignore", windowsHide: true }); } else { execSync(`pkill -f "cloudflared.*:${port}([^0-9]|$)" 2>/dev/null || true`, { stdio: "ignore", windowsHide: true }); } diff --git a/src/mitm/cert/install.js b/src/mitm/cert/install.js index 115a6924..bf986e01 100644 --- a/src/mitm/cert/install.js +++ b/src/mitm/cert/install.js @@ -2,11 +2,13 @@ const fs = require("fs"); const crypto = require("crypto"); const { exec } = require("child_process"); const { execWithPassword, isSudoAvailable } = require("../dns/dnsConfig.js"); +const { runElevatedPowerShell, quotePs } = require("../winElevated.js"); const { log, err } = require("../logger"); const IS_WIN = process.platform === "win32"; const IS_MAC = process.platform === "darwin"; const LINUX_CERT_DIR = "/usr/local/share/ca-certificates"; +const ROOT_CA_CN = "9Router MITM Root CA"; // Get SHA1 fingerprint from cert file using Node.js crypto function getCertFingerprint(certPath) { @@ -44,8 +46,14 @@ function checkCertInstalledMac(certPath) { function checkCertInstalledWindows(certPath) { return new Promise((resolve) => { - // Check Root store for our Root CA by common name - exec("certutil -store Root \"9Router MITM Root CA\"", { windowsHide: true }, (error) => { + // Check by SHA1 fingerprint β€” detects stale cert with same CN but different key + let fingerprint; + try { + fingerprint = getCertFingerprint(certPath).replace(/:/g, ""); + } catch { + return resolve(false); + } + exec(`certutil -store Root ${fingerprint}`, { windowsHide: true }, (error) => { resolve(!error); }); }); @@ -88,17 +96,19 @@ async function installCertMac(sudoPassword, certPath) { } async function installCertWindows(certPath) { - // Process already has admin rights β€” run certutil directly, no UAC needed - return new Promise((resolve, reject) => { - exec( - `certutil -addstore Root "${certPath}"`, - { windowsHide: true }, - (error) => { - if (error) reject(new Error(`Failed to install certificate: ${error.message}`)); - else { log("πŸ” Cert: βœ… installed to Windows Root store"); resolve(); } - } - ); - }); + // Auto-elevate via UAC popup if not admin (zero popup if already admin). + // Delete any stale cert with same CN before adding to avoid duplicates. + const script = ` + certutil -delstore Root ${quotePs(ROOT_CA_CN)} 2>$null | Out-Null + $exit = & certutil -addstore Root ${quotePs(certPath)} 2>&1 + if ($LASTEXITCODE -ne 0) { throw "certutil exit $LASTEXITCODE" } + `; + try { + await runElevatedPowerShell(script); + log("πŸ” Cert: βœ… installed to Windows Root store"); + } catch (e) { + throw new Error(`Failed to install certificate: ${e.message}`); + } } /** @@ -132,17 +142,14 @@ async function uninstallCertMac(sudoPassword, certPath) { } async function uninstallCertWindows() { - // Process already has admin rights β€” run certutil directly, no UAC needed - return new Promise((resolve, reject) => { - exec( - `certutil -delstore Root "9Router MITM Root CA"`, - { windowsHide: true }, - (error) => { - if (error) reject(new Error(`Failed to uninstall certificate: ${error.message}`)); - else { log("πŸ” Cert: βœ… uninstalled from Windows Root store"); resolve(); } - } - ); - }); + // Auto-elevate via UAC popup if not admin + const script = `certutil -delstore Root ${quotePs(ROOT_CA_CN)}`; + try { + await runElevatedPowerShell(script); + log("πŸ” Cert: βœ… uninstalled from Windows Root store"); + } catch (e) { + throw new Error(`Failed to uninstall certificate: ${e.message}`); + } } function checkCertInstalledLinux() { diff --git a/src/mitm/config.js b/src/mitm/config.js index f8dc7a0b..b1396e0d 100644 --- a/src/mitm/config.js +++ b/src/mitm/config.js @@ -20,6 +20,15 @@ const MODEL_SYNONYMS = { antigravity: { "gemini-default": "gemini-3-flash" }, }; +// URL substrings whose request/response should NOT be dumped to file (telemetry, polling, empty) +const LOG_BLACKLIST_URL_PARTS = [ + "recordCodeAssistMetrics", + "recordTrajectoryAnalytics", + "fetchAdminControls", + "listExperiments", + "fetchUserInfo", +]; + function getToolForHost(host) { const h = (host || "").split(":")[0]; if (h === "api.individual.githubcopilot.com") return "copilot"; @@ -29,4 +38,4 @@ function getToolForHost(host) { return null; } -module.exports = { TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, getToolForHost }; +module.exports = { TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, LOG_BLACKLIST_URL_PARTS, getToolForHost }; diff --git a/src/mitm/dns/dnsConfig.js b/src/mitm/dns/dnsConfig.js index 39af3364..f0a58cbd 100644 --- a/src/mitm/dns/dnsConfig.js +++ b/src/mitm/dns/dnsConfig.js @@ -4,6 +4,7 @@ const path = require("path"); const os = require("os"); const { log, err } = require("../logger"); const { TOOL_HOSTS } = require("../../shared/constants/mitmToolHosts"); +const { runElevatedPowerShell, quotePs, isAdmin } = require("../winElevated.js"); const IS_WIN = process.platform === "win32"; const IS_MAC = process.platform === "darwin"; @@ -11,47 +12,6 @@ const HOSTS_FILE = IS_WIN ? path.join(process.env.SystemRoot || "C:\\Windows", "System32", "drivers", "etc", "hosts") : "/etc/hosts"; -/** - * Execute elevated PowerShell script on Windows via Start-Process -Verb RunAs. - * Only UAC consent dialog appears, no CMD/PS window popup. - */ -function executeElevatedPowerShell(psScriptPath, timeoutMs = 30000) { - const flagFile = path.join(os.tmpdir(), `ps_done_${Date.now()}.flag`); - const psSQ = (s) => s.replace(/'/g, "''"); - - let psContent = fs.readFileSync(psScriptPath, "utf8"); - psContent += `\nSet-Content -Path '${psSQ(flagFile)}' -Value 'done' -Encoding UTF8\n`; - fs.writeFileSync(psScriptPath, psContent, "utf8"); - - const outerCmd = `Start-Process powershell -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-WindowStyle','Hidden','-File','${psSQ(psScriptPath)}' -Verb RunAs -WindowStyle Hidden`; - - return new Promise((resolve, reject) => { - let settled = false; - const settle = (fn, arg) => { if (!settled) { settled = true; fn(arg); } }; - - exec( - `powershell -NoProfile -NonInteractive -WindowStyle Hidden -Command "${outerCmd}"`, - { windowsHide: true }, - () => {} - ); - - const deadline = Date.now() + timeoutMs; - const poll = () => { - if (settled) return; - if (fs.existsSync(flagFile)) { - try { fs.unlinkSync(flagFile); fs.unlinkSync(psScriptPath); } catch { /* ignore */ } - return settle(resolve); - } - if (Date.now() > deadline) { - try { fs.unlinkSync(psScriptPath); } catch { /* ignore */ } - return settle(reject, new Error("Timed out waiting for UAC confirmation")); - } - setTimeout(poll, 500); - }; - setTimeout(poll, 300); - }); -} - /** True when `sudo` exists (e.g. missing on minimal Docker images like Alpine). */ function isSudoAvailable() { if (IS_WIN) return false; @@ -150,17 +110,20 @@ async function addDNSEntry(tool, sudoPassword) { try { if (IS_WIN) { - // Process already has admin rights β€” edit hosts file directly - const toAppend = entriesToAdd.map(h => `127.0.0.1 ${h}`).join("\r\n") + "\r\n"; - fs.appendFileSync(HOSTS_FILE, toAppend, "utf8"); - require("child_process").execSync("ipconfig /flushdns", { windowsHide: true }); + const toAppend = entriesToAdd.map(h => `127.0.0.1 ${h}`).join("`r`n"); + // Single elevated script: append to hosts + flush DNS (1 UAC popup, or zero if admin) + const script = ` + Add-Content -LiteralPath ${quotePs(HOSTS_FILE)} -Value ${quotePs(toAppend)} + ipconfig /flushdns | Out-Null + `; + await runElevatedPowerShell(script); } else { await execWithPassword(`echo "${entries}" >> ${HOSTS_FILE}`, sudoPassword); await flushDNS(sudoPassword); } log(`🌐 DNS ${tool}: βœ… added ${entriesToAdd.join(", ")}`); } catch (error) { - const msg = error.message?.includes("incorrect password") ? "Wrong sudo password" : "Failed to add DNS entry"; + const msg = error.message?.includes("incorrect password") ? "Wrong sudo password" : `Failed to add DNS entry: ${error.message}`; throw new Error(msg); } } @@ -180,11 +143,19 @@ async function removeDNSEntry(tool, sudoPassword) { try { if (IS_WIN) { - // Process already has admin rights β€” edit hosts file directly - const content = fs.readFileSync(HOSTS_FILE, "utf8"); - const filtered = content.split(/\r?\n/).filter(l => !entriesToRemove.some(h => l.includes(h))).join("\r\n"); - fs.writeFileSync(HOSTS_FILE, filtered, "utf8"); - require("child_process").execSync("ipconfig /flushdns", { windowsHide: true }); + // Build PowerShell list literal of hosts to strip + const hostsList = entriesToRemove.map(quotePs).join(","); + const script = ` + $hosts = @(${hostsList}) + $lines = Get-Content -LiteralPath ${quotePs(HOSTS_FILE)} + $filtered = $lines | Where-Object { + $line = $_ + -not ($hosts | Where-Object { $line -match [regex]::Escape($_) }) + } + Set-Content -LiteralPath ${quotePs(HOSTS_FILE)} -Value $filtered + ipconfig /flushdns | Out-Null + `; + await runElevatedPowerShell(script); } else { for (const host of entriesToRemove) { const sedCmd = IS_MAC @@ -196,7 +167,7 @@ async function removeDNSEntry(tool, sudoPassword) { } log(`🌐 DNS ${tool}: βœ… removed ${entriesToRemove.join(", ")}`); } catch (error) { - const msg = error.message?.includes("incorrect password") ? "Wrong sudo password" : "Failed to remove DNS entry"; + const msg = error.message?.includes("incorrect password") ? "Wrong sudo password" : `Failed to remove DNS entry: ${error.message}`; throw new Error(msg); } } @@ -245,7 +216,6 @@ module.exports = { removeAllDNSEntriesSync, execWithPassword, isSudoAvailable, - executeElevatedPowerShell, checkDNSEntry, checkAllDNSStatus, }; diff --git a/src/mitm/handlers/antigravity.js b/src/mitm/handlers/antigravity.js index 21695e37..a93f7a50 100644 --- a/src/mitm/handlers/antigravity.js +++ b/src/mitm/handlers/antigravity.js @@ -1,19 +1,31 @@ -const { err } = require("../logger"); +const { err, createResponseDumper } = require("../logger"); const { fetchRouter, pipeSSE } = require("./base"); /** - * Intercept Antigravity (Gemini) request β€” replace model and forward to router + * Intercept Antigravity request β€” forward Gemini body as-is to /v1/chat/completions. + * Router auto-detects format via body.userAgent==="antigravity" + body.request.contents, + * runs antigravityβ†’openaiβ†’providerβ†’openaiβ†’antigravity translators internally. */ async function intercept(req, res, bodyBuffer, mappedModel) { + const dumper = createResponseDumper(req, "intercept-antigravity"); + const isStream = req.url.includes(":streamGenerateContent"); try { const body = JSON.parse(bodyBuffer.toString()); - body.model = mappedModel; + if (body.model) body.model = mappedModel; + const routerRes = await fetchRouter(body, "/v1/chat/completions", req.headers); - await pipeSSE(routerRes, res); + await pipeSSE(routerRes, res, dumper); } catch (error) { err(`[antigravity] ${error.message}`); - if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: { message: error.message, type: "mitm_error" } })); + if (dumper) { dumper.writeChunk(`\n[ERROR] ${error.message}\n`); dumper.end(); } + // For stream endpoint, send SSE error chunk so SDK doesn't hang waiting + if (isStream) { + if (!res.headersSent) res.writeHead(200, { "Content-Type": "text/event-stream" }); + res.end(`data: ${JSON.stringify({ error: { message: error.message } })}\r\n\r\n`); + } else { + if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: { message: error.message, type: "mitm_error" } })); + } } } diff --git a/src/mitm/handlers/base.js b/src/mitm/handlers/base.js index 07f9d57d..74ac35bb 100644 --- a/src/mitm/handlers/base.js +++ b/src/mitm/handlers/base.js @@ -32,25 +32,26 @@ async function fetchRouter(openaiBody, path = "/v1/chat/completions", clientHead body: JSON.stringify(openaiBody) }); - if (!response.ok) { - const errText = await response.text().catch(() => ""); - throw new Error(`[${response.status}]: ${errText}`); - } - + // Forward response as-is (status + body). pipeSSE will propagate status. return response; } /** - * Pipe SSE stream from router directly to client response + * Pipe SSE stream from router directly to client response. + * Optional dumper tees the stream into a debug file. */ -async function pipeSSE(routerRes, res) { +async function pipeSSE(routerRes, res, dumper) { const ct = routerRes.headers.get("content-type") || "application/json"; + const status = routerRes.status || 200; const resHeaders = { "Content-Type": ct, "Cache-Control": "no-cache", "Connection": "keep-alive" }; if (ct.includes("text/event-stream")) resHeaders["X-Accel-Buffering"] = "no"; - res.writeHead(200, resHeaders); + res.writeHead(status, resHeaders); + if (dumper) dumper.writeHeader(routerRes.status, Object.fromEntries(routerRes.headers)); if (!routerRes.body) { - res.end(await routerRes.text().catch(() => "")); + const text = await routerRes.text().catch(() => ""); + if (dumper) { dumper.writeChunk(text); dumper.end(); } + res.end(text); return; } @@ -58,7 +59,8 @@ async function pipeSSE(routerRes, res) { const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); - if (done) { res.end(); break; } + if (done) { if (dumper) dumper.end(); res.end(); break; } + if (dumper) dumper.writeChunk(value); res.write(decoder.decode(value, { stream: true })); } } diff --git a/src/mitm/logger.js b/src/mitm/logger.js index 5e067fb5..c1fd9b08 100644 --- a/src/mitm/logger.js +++ b/src/mitm/logger.js @@ -1,3 +1,9 @@ +const fs = require("fs"); +const path = require("path"); +const zlib = require("zlib"); +const { DATA_DIR } = require("./paths"); +const { LOG_BLACKLIST_URL_PARTS } = require("./config"); + function time() { return new Date().toLocaleTimeString("en-US", { hour12: false }); } @@ -5,4 +11,86 @@ function time() { const log = (msg) => console.log(`[${time()}] [MITM] ${msg}`); const err = (msg) => console.error(`[${time()}] ❌ [MITM] ${msg}`); -module.exports = { log, err }; +const DUMP_DIR = path.join(DATA_DIR, "logs", "mitm"); +if (!fs.existsSync(DUMP_DIR)) fs.mkdirSync(DUMP_DIR, { recursive: true }); + +const EMPTY_BODY_RE = /^\s*(\{\s*\}|\[\s*\]|null)?\s*$/; + +function slugify(s, max = 80) { + return String(s).replace(/[^a-zA-Z0-9]/g, "_").substring(0, max); +} + +function isBlacklisted(url) { + if (!url) return false; + return LOG_BLACKLIST_URL_PARTS.some(part => url.includes(part)); +} + +// Decode body buffer based on content-encoding header +function decodeBody(buf, encoding) { + if (!buf || buf.length === 0) return buf; + try { + const enc = (encoding || "").toLowerCase(); + if (enc.includes("gzip")) return zlib.gunzipSync(buf); + if (enc.includes("br")) return zlib.brotliDecompressSync(buf); + if (enc.includes("deflate")) return zlib.inflateSync(buf); + } catch { /* return raw on failure */ } + return buf; +} + +// Save raw request: method + url + headers + body +function dumpRequest(req, bodyBuffer, tag = "raw") { + if (isBlacklisted(req.url)) return null; + try { + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + const slug = slugify((req.headers.host || "") + req.url); + const file = path.join(DUMP_DIR, `${ts}_${tag}_${slug}.req.json`); + let parsed = null; + try { parsed = JSON.parse(bodyBuffer.toString()); } catch { /* not JSON */ } + fs.writeFileSync(file, JSON.stringify({ + method: req.method, + url: req.url, + host: req.headers.host, + headers: req.headers, + body: parsed ?? bodyBuffer.toString("utf8") + }, null, 2)); + return file; + } catch { return null; } +} + +// Buffer-based response dumper β€” collects chunks then decodes + writes once on end() +// Trade-off: holds response in RAM, but enables gzip/br decoding for readable output. +function createResponseDumper(req, tag = "raw") { + if (isBlacklisted(req.url)) return null; + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + const slug = slugify((req.headers.host || "") + req.url); + const file = path.join(DUMP_DIR, `${ts}_${tag}_${slug}.res.txt`); + let status = 0; + let headers = {}; + const chunks = []; + return { + writeHeader: (s, h) => { status = s; headers = h || {}; }, + writeChunk: (chunk) => { + if (chunk == null) return; + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }, + end: () => { + try { + const raw = Buffer.concat(chunks); + const enc = headers["content-encoding"] || headers["Content-Encoding"]; + const decoded = decodeBody(raw, enc); + const text = decoded.toString("utf8"); + // Skip empty / trivially-empty bodies + if (EMPTY_BODY_RE.test(text)) return; + // Strip content-encoding since body is now decoded + const cleanHeaders = { ...headers }; + delete cleanHeaders["content-encoding"]; + delete cleanHeaders["Content-Encoding"]; + const out = `STATUS: ${status}\nHEADERS: ${JSON.stringify(cleanHeaders, null, 2)}\n---BODY---\n${text}`; + fs.writeFileSync(file, out); + } catch { /* ignore */ } + }, + file + }; +} + +module.exports = { log, err, dumpRequest, createResponseDumper }; diff --git a/src/mitm/manager.js b/src/mitm/manager.js index 031a0eda..6eb0815e 100644 --- a/src/mitm/manager.js +++ b/src/mitm/manager.js @@ -626,14 +626,31 @@ async function stopServer(sudoPassword) { serverPid = null; if (IS_WIN) { - // Process already has admin rights β€” edit hosts file directly const hostsFile = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "drivers", "etc", "hosts"); const allHosts = Object.values(TOOL_HOSTS).flat(); try { - const hostsContent = fs.readFileSync(hostsFile, "utf8"); - const filtered = hostsContent.split(/\r?\n/).filter(l => !allHosts.some(h => l.includes(h))).join("\r\n"); - fs.writeFileSync(hostsFile, filtered, "utf8"); - require("child_process").execSync("ipconfig /flushdns", { windowsHide: true }); + 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"); + if (filtered !== content) fs.writeFileSync(hostsFile, filtered, "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); diff --git a/src/mitm/server.js b/src/mitm/server.js index cc814979..7c97c866 100644 --- a/src/mitm/server.js +++ b/src/mitm/server.js @@ -4,7 +4,7 @@ const path = require("path"); const dns = require("dns"); const { promisify } = require("util"); const { execSync } = require("child_process"); -const { log, err } = require("./logger"); +const { log, err, dumpRequest, createResponseDumper } = require("./logger"); const { TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, getToolForHost } = require("./config"); const { DATA_DIR, MITM_DIR } = require("./paths"); const { getCertForDomain } = require("./cert/generate"); @@ -12,12 +12,9 @@ const { getCertForDomain } = require("./cert/generate"); const DB_FILE = path.join(DATA_DIR, "db.json"); const LOCAL_PORT = 443; const IS_WIN = process.platform === "win32"; -const ENABLE_FILE_LOG = true; -const LOG_DIR = path.join(DATA_DIR, "logs", "mitm"); +const ENABLE_FILE_LOG = false; const INTERNAL_REQUEST_HEADER = { name: "x-request-source", value: "local" }; -if (ENABLE_FILE_LOG && !fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true }); - // Load handlers β€” dev/ overrides handlers/ for private implementations function loadHandler(name) { try { return require(`./dev/${name}`); } catch {} @@ -34,13 +31,17 @@ const handlers = { // ── SSL / SNI ───────────────────────────────────────────────── const certCache = new Map(); +let rootCAPem; function sniCallback(servername, cb) { try { if (certCache.has(servername)) return cb(null, certCache.get(servername)); const certData = getCertForDomain(servername); if (!certData) return cb(new Error(`Failed to generate cert for ${servername}`)); - const ctx = require("tls").createSecureContext({ key: certData.key, cert: certData.cert }); + const ctx = require("tls").createSecureContext({ + key: certData.key, + cert: `${certData.cert}\n${rootCAPem}` + }); certCache.set(servername, ctx); log(`πŸ” Cert generated: ${servername}`); cb(null, ctx); @@ -52,11 +53,10 @@ function sniCallback(servername, cb) { let sslOptions; try { - sslOptions = { - key: fs.readFileSync(path.join(MITM_DIR, "rootCA.key")), - cert: fs.readFileSync(path.join(MITM_DIR, "rootCA.crt")), - SNICallback: sniCallback - }; + const rootKey = fs.readFileSync(path.join(MITM_DIR, "rootCA.key")); + const rootCert = fs.readFileSync(path.join(MITM_DIR, "rootCA.crt")); + rootCAPem = rootCert.toString("utf8"); + sslOptions = { key: rootKey, cert: rootCert, SNICallback: sniCallback }; } catch (e) { err(`Root CA not found: ${e.message}`); process.exit(1); @@ -116,24 +116,16 @@ function getMappedModel(tool, model) { } catch { return null; } } -function saveRequestLog(url, bodyBuffer) { - if (!ENABLE_FILE_LOG) return; - try { - const ts = new Date().toISOString().replace(/[:.]/g, "-"); - const slug = url.replace(/[^a-zA-Z0-9]/g, "_").substring(0, 60); - const body = JSON.parse(bodyBuffer.toString()); - fs.writeFileSync(path.join(LOG_DIR, `${ts}_${slug}.json`), JSON.stringify(body, null, 2)); - } catch { /* ignore */ } -} - /** * Forward request to real upstream. * Optional onResponse(rawBuffer) callback β€” if provided, tees the response * so it's both forwarded to client AND passed to the callback for inspection. + * Also tees full stream into a dump file when ENABLE_FILE_LOG is on. */ async function passthrough(req, res, bodyBuffer, onResponse) { const targetHost = (req.headers.host || TARGET_HOSTS[0]).split(":")[0]; const targetIP = await resolveTargetIP(targetHost); + const dumper = ENABLE_FILE_LOG ? createResponseDumper(req, "passthrough") : null; const forwardReq = https.request({ hostname: targetIP, @@ -145,23 +137,30 @@ async function passthrough(req, res, bodyBuffer, onResponse) { rejectUnauthorized: false }, (forwardRes) => { res.writeHead(forwardRes.statusCode, forwardRes.headers); + if (dumper) dumper.writeHeader(forwardRes.statusCode, forwardRes.headers); - if (!onResponse) { + if (!onResponse && !dumper) { forwardRes.pipe(res); return; } - // Tee: forward to client AND buffer for callback + // Tee: forward to client AND optionally buffer + dump const chunks = []; - forwardRes.on("data", chunk => { chunks.push(chunk); res.write(chunk); }); + forwardRes.on("data", chunk => { + if (dumper) dumper.writeChunk(chunk); + if (onResponse) chunks.push(chunk); + res.write(chunk); + }); forwardRes.on("end", () => { + if (dumper) dumper.end(); res.end(); - try { onResponse(Buffer.concat(chunks), forwardRes.headers); } catch { /* ignore */ } + if (onResponse) try { onResponse(Buffer.concat(chunks), forwardRes.headers); } catch { /* ignore */ } }); }); forwardReq.on("error", (e) => { err(`Passthrough error: ${e.message}`); + if (dumper) { dumper.writeChunk(`\n[ERROR] ${e.message}\n`); dumper.end(); } if (!res.headersSent) res.writeHead(502); res.end("Bad Gateway"); }); @@ -181,7 +180,7 @@ const server = https.createServer(sslOptions, async (req, res) => { } const bodyBuffer = await collectBodyRaw(req); - if (bodyBuffer.length > 0) saveRequestLog(req.url, bodyBuffer); + if (ENABLE_FILE_LOG) dumpRequest(req, bodyBuffer, "raw"); // Anti-loop: skip requests from 9Router if (req.headers[INTERNAL_REQUEST_HEADER.name] === INTERNAL_REQUEST_HEADER.value) { diff --git a/src/mitm/winElevated.js b/src/mitm/winElevated.js new file mode 100644 index 00000000..e2746ff7 --- /dev/null +++ b/src/mitm/winElevated.js @@ -0,0 +1,79 @@ +const { exec, execSync } = require("child_process"); + +const IS_WIN = process.platform === "win32"; + +/** + * Detect if current Windows process has admin rights (no UAC popup needed). + * Uses `net session` which only succeeds when elevated. + */ +function isAdmin() { + if (!IS_WIN) return false; + try { + execSync("net session >nul 2>&1", { windowsHide: true, stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +/** + * Quote a string safely for PowerShell single-quoted literal. + */ +function quotePs(value) { + return `'${String(value).replace(/'/g, "''")}'`; +} + +/** + * Run PowerShell script β€” escalated via UAC popup if not already admin. + * Returns Promise resolving on exit code 0, rejecting otherwise. + * + * IMPORTANT: each call triggers ONE UAC popup. Batch multiple admin tasks + * into a single script string to minimize popups. + */ +function runElevatedPowerShell(script) { + if (!IS_WIN) return Promise.reject(new Error("Windows-only")); + + const encoded = Buffer.from(script, "utf16le").toString("base64"); + + // If already admin, run directly β€” zero popup + if (isAdmin()) { + return new Promise((resolve, reject) => { + exec( + `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encoded}`, + { windowsHide: true }, + (error, stdout, stderr) => { + if (error) reject(new Error(stderr || error.message)); + else resolve(stdout); + } + ); + }); + } + + // Not admin β€” wrap with Start-Process -Verb RunAs (UAC popup) + const wrapper = ` + $proc = Start-Process powershell -ArgumentList @( + '-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass', + '-WindowStyle','Hidden','-EncodedCommand','${encoded}' + ) -Verb RunAs -Wait -PassThru -WindowStyle Hidden; + if ($proc.ExitCode -ne 0) { throw "Elevated command exited with code $($proc.ExitCode)" } + `; + + return new Promise((resolve, reject) => { + exec( + `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command ${quotePs(wrapper)}`, + { windowsHide: true }, + (error, stdout, stderr) => { + if (error) { + const msg = stderr || error.message; + if (msg.includes("canceled by the user") || msg.includes("operation was canceled")) { + reject(new Error("User canceled UAC prompt")); + } else { + reject(new Error(msg)); + } + } else resolve(stdout); + } + ); + }); +} + +module.exports = { isAdmin, runElevatedPowerShell, quotePs };