Fix : Antigravity MITM

This commit is contained in:
decolua
2026-04-29 17:28:38 +07:00
parent 512e3de371
commit 34da52f144
12 changed files with 313 additions and 128 deletions

1
.gitignore vendored
View File

@@ -68,3 +68,4 @@ README1.md
deploy*.sh
ecosystem.config.*
scripts/agSniffer/*

View File

@@ -1,6 +1,6 @@
{
"name": "9router-app",
"version": "0.4.9",
"version": "0.4.10",
"description": "9Router web dashboard",
"private": true,
"scripts": {

View File

@@ -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 });
}

View File

@@ -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() {

View File

@@ -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 };

View File

@@ -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,
};

View File

@@ -1,20 +1,32 @@
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 (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" } }));
}
}
}
module.exports = { intercept };

View File

@@ -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 }));
}
}

View File

@@ -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 };

View File

@@ -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);

View File

@@ -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) {

79
src/mitm/winElevated.js Normal file
View File

@@ -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 };