mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
Fix : Antigravity MITM
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -68,3 +68,4 @@ README1.md
|
||||
deploy*.sh
|
||||
ecosystem.config.*
|
||||
|
||||
scripts/agSniffer/*
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "9router-app",
|
||||
"version": "0.4.9",
|
||||
"version": "0.4.10",
|
||||
"description": "9Router web dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
79
src/mitm/winElevated.js
Normal 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 };
|
||||
Reference in New Issue
Block a user