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
|
deploy*.sh
|
||||||
ecosystem.config.*
|
ecosystem.config.*
|
||||||
|
|
||||||
|
scripts/agSniffer/*
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "9router-app",
|
"name": "9router-app",
|
||||||
"version": "0.4.9",
|
"version": "0.4.10",
|
||||||
"description": "9Router web dashboard",
|
"description": "9Router web dashboard",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const BINARY_NAME = "cloudflared";
|
|||||||
const IS_WINDOWS = os.platform() === "win32";
|
const IS_WINDOWS = os.platform() === "win32";
|
||||||
const BIN_NAME = IS_WINDOWS ? `${BINARY_NAME}.exe` : BINARY_NAME;
|
const BIN_NAME = IS_WINDOWS ? `${BINARY_NAME}.exe` : BINARY_NAME;
|
||||||
const BIN_PATH = path.join(BIN_DIR, BIN_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";
|
const GITHUB_BASE_URL = "https://github.com/cloudflare/cloudflared/releases/latest/download";
|
||||||
|
|
||||||
@@ -367,7 +368,7 @@ function killCloudflaredByPort(port) {
|
|||||||
try {
|
try {
|
||||||
if (IS_WINDOWS) {
|
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 }`;
|
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 {
|
} else {
|
||||||
execSync(`pkill -f "cloudflared.*:${port}([^0-9]|$)" 2>/dev/null || true`, { stdio: "ignore", windowsHide: true });
|
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 crypto = require("crypto");
|
||||||
const { exec } = require("child_process");
|
const { exec } = require("child_process");
|
||||||
const { execWithPassword, isSudoAvailable } = require("../dns/dnsConfig.js");
|
const { execWithPassword, isSudoAvailable } = require("../dns/dnsConfig.js");
|
||||||
|
const { runElevatedPowerShell, quotePs } = require("../winElevated.js");
|
||||||
const { log, err } = require("../logger");
|
const { log, err } = require("../logger");
|
||||||
|
|
||||||
const IS_WIN = process.platform === "win32";
|
const IS_WIN = process.platform === "win32";
|
||||||
const IS_MAC = process.platform === "darwin";
|
const IS_MAC = process.platform === "darwin";
|
||||||
const LINUX_CERT_DIR = "/usr/local/share/ca-certificates";
|
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
|
// Get SHA1 fingerprint from cert file using Node.js crypto
|
||||||
function getCertFingerprint(certPath) {
|
function getCertFingerprint(certPath) {
|
||||||
@@ -44,8 +46,14 @@ function checkCertInstalledMac(certPath) {
|
|||||||
|
|
||||||
function checkCertInstalledWindows(certPath) {
|
function checkCertInstalledWindows(certPath) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
// Check Root store for our Root CA by common name
|
// Check by SHA1 fingerprint — detects stale cert with same CN but different key
|
||||||
exec("certutil -store Root \"9Router MITM Root CA\"", { windowsHide: true }, (error) => {
|
let fingerprint;
|
||||||
|
try {
|
||||||
|
fingerprint = getCertFingerprint(certPath).replace(/:/g, "");
|
||||||
|
} catch {
|
||||||
|
return resolve(false);
|
||||||
|
}
|
||||||
|
exec(`certutil -store Root ${fingerprint}`, { windowsHide: true }, (error) => {
|
||||||
resolve(!error);
|
resolve(!error);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -88,17 +96,19 @@ async function installCertMac(sudoPassword, certPath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function installCertWindows(certPath) {
|
async function installCertWindows(certPath) {
|
||||||
// Process already has admin rights — run certutil directly, no UAC needed
|
// Auto-elevate via UAC popup if not admin (zero popup if already admin).
|
||||||
return new Promise((resolve, reject) => {
|
// Delete any stale cert with same CN before adding to avoid duplicates.
|
||||||
exec(
|
const script = `
|
||||||
`certutil -addstore Root "${certPath}"`,
|
certutil -delstore Root ${quotePs(ROOT_CA_CN)} 2>$null | Out-Null
|
||||||
{ windowsHide: true },
|
$exit = & certutil -addstore Root ${quotePs(certPath)} 2>&1
|
||||||
(error) => {
|
if ($LASTEXITCODE -ne 0) { throw "certutil exit $LASTEXITCODE" }
|
||||||
if (error) reject(new Error(`Failed to install certificate: ${error.message}`));
|
`;
|
||||||
else { log("🔐 Cert: ✅ installed to Windows Root store"); resolve(); }
|
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() {
|
async function uninstallCertWindows() {
|
||||||
// Process already has admin rights — run certutil directly, no UAC needed
|
// Auto-elevate via UAC popup if not admin
|
||||||
return new Promise((resolve, reject) => {
|
const script = `certutil -delstore Root ${quotePs(ROOT_CA_CN)}`;
|
||||||
exec(
|
try {
|
||||||
`certutil -delstore Root "9Router MITM Root CA"`,
|
await runElevatedPowerShell(script);
|
||||||
{ windowsHide: true },
|
log("🔐 Cert: ✅ uninstalled from Windows Root store");
|
||||||
(error) => {
|
} catch (e) {
|
||||||
if (error) reject(new Error(`Failed to uninstall certificate: ${error.message}`));
|
throw new Error(`Failed to uninstall certificate: ${e.message}`);
|
||||||
else { log("🔐 Cert: ✅ uninstalled from Windows Root store"); resolve(); }
|
}
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkCertInstalledLinux() {
|
function checkCertInstalledLinux() {
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ const MODEL_SYNONYMS = {
|
|||||||
antigravity: { "gemini-default": "gemini-3-flash" },
|
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) {
|
function getToolForHost(host) {
|
||||||
const h = (host || "").split(":")[0];
|
const h = (host || "").split(":")[0];
|
||||||
if (h === "api.individual.githubcopilot.com") return "copilot";
|
if (h === "api.individual.githubcopilot.com") return "copilot";
|
||||||
@@ -29,4 +38,4 @@ function getToolForHost(host) {
|
|||||||
return null;
|
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 os = require("os");
|
||||||
const { log, err } = require("../logger");
|
const { log, err } = require("../logger");
|
||||||
const { TOOL_HOSTS } = require("../../shared/constants/mitmToolHosts");
|
const { TOOL_HOSTS } = require("../../shared/constants/mitmToolHosts");
|
||||||
|
const { runElevatedPowerShell, quotePs, isAdmin } = require("../winElevated.js");
|
||||||
|
|
||||||
const IS_WIN = process.platform === "win32";
|
const IS_WIN = process.platform === "win32";
|
||||||
const IS_MAC = process.platform === "darwin";
|
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")
|
? path.join(process.env.SystemRoot || "C:\\Windows", "System32", "drivers", "etc", "hosts")
|
||||||
: "/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). */
|
/** True when `sudo` exists (e.g. missing on minimal Docker images like Alpine). */
|
||||||
function isSudoAvailable() {
|
function isSudoAvailable() {
|
||||||
if (IS_WIN) return false;
|
if (IS_WIN) return false;
|
||||||
@@ -150,17 +110,20 @@ async function addDNSEntry(tool, sudoPassword) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (IS_WIN) {
|
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");
|
||||||
const toAppend = entriesToAdd.map(h => `127.0.0.1 ${h}`).join("\r\n") + "\r\n";
|
// Single elevated script: append to hosts + flush DNS (1 UAC popup, or zero if admin)
|
||||||
fs.appendFileSync(HOSTS_FILE, toAppend, "utf8");
|
const script = `
|
||||||
require("child_process").execSync("ipconfig /flushdns", { windowsHide: true });
|
Add-Content -LiteralPath ${quotePs(HOSTS_FILE)} -Value ${quotePs(toAppend)}
|
||||||
|
ipconfig /flushdns | Out-Null
|
||||||
|
`;
|
||||||
|
await runElevatedPowerShell(script);
|
||||||
} else {
|
} else {
|
||||||
await execWithPassword(`echo "${entries}" >> ${HOSTS_FILE}`, sudoPassword);
|
await execWithPassword(`echo "${entries}" >> ${HOSTS_FILE}`, sudoPassword);
|
||||||
await flushDNS(sudoPassword);
|
await flushDNS(sudoPassword);
|
||||||
}
|
}
|
||||||
log(`🌐 DNS ${tool}: ✅ added ${entriesToAdd.join(", ")}`);
|
log(`🌐 DNS ${tool}: ✅ added ${entriesToAdd.join(", ")}`);
|
||||||
} catch (error) {
|
} 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);
|
throw new Error(msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,11 +143,19 @@ async function removeDNSEntry(tool, sudoPassword) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (IS_WIN) {
|
if (IS_WIN) {
|
||||||
// Process already has admin rights — edit hosts file directly
|
// Build PowerShell list literal of hosts to strip
|
||||||
const content = fs.readFileSync(HOSTS_FILE, "utf8");
|
const hostsList = entriesToRemove.map(quotePs).join(",");
|
||||||
const filtered = content.split(/\r?\n/).filter(l => !entriesToRemove.some(h => l.includes(h))).join("\r\n");
|
const script = `
|
||||||
fs.writeFileSync(HOSTS_FILE, filtered, "utf8");
|
$hosts = @(${hostsList})
|
||||||
require("child_process").execSync("ipconfig /flushdns", { windowsHide: true });
|
$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 {
|
} else {
|
||||||
for (const host of entriesToRemove) {
|
for (const host of entriesToRemove) {
|
||||||
const sedCmd = IS_MAC
|
const sedCmd = IS_MAC
|
||||||
@@ -196,7 +167,7 @@ async function removeDNSEntry(tool, sudoPassword) {
|
|||||||
}
|
}
|
||||||
log(`🌐 DNS ${tool}: ✅ removed ${entriesToRemove.join(", ")}`);
|
log(`🌐 DNS ${tool}: ✅ removed ${entriesToRemove.join(", ")}`);
|
||||||
} catch (error) {
|
} 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);
|
throw new Error(msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,7 +216,6 @@ module.exports = {
|
|||||||
removeAllDNSEntriesSync,
|
removeAllDNSEntriesSync,
|
||||||
execWithPassword,
|
execWithPassword,
|
||||||
isSudoAvailable,
|
isSudoAvailable,
|
||||||
executeElevatedPowerShell,
|
|
||||||
checkDNSEntry,
|
checkDNSEntry,
|
||||||
checkAllDNSStatus,
|
checkAllDNSStatus,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,19 +1,31 @@
|
|||||||
const { err } = require("../logger");
|
const { err, createResponseDumper } = require("../logger");
|
||||||
const { fetchRouter, pipeSSE } = require("./base");
|
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) {
|
async function intercept(req, res, bodyBuffer, mappedModel) {
|
||||||
|
const dumper = createResponseDumper(req, "intercept-antigravity");
|
||||||
|
const isStream = req.url.includes(":streamGenerateContent");
|
||||||
try {
|
try {
|
||||||
const body = JSON.parse(bodyBuffer.toString());
|
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);
|
const routerRes = await fetchRouter(body, "/v1/chat/completions", req.headers);
|
||||||
await pipeSSE(routerRes, res);
|
await pipeSSE(routerRes, res, dumper);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
err(`[antigravity] ${error.message}`);
|
err(`[antigravity] ${error.message}`);
|
||||||
if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" });
|
if (dumper) { dumper.writeChunk(`\n[ERROR] ${error.message}\n`); dumper.end(); }
|
||||||
res.end(JSON.stringify({ error: { message: error.message, type: "mitm_error" } }));
|
// 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" } }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,25 +32,26 @@ async function fetchRouter(openaiBody, path = "/v1/chat/completions", clientHead
|
|||||||
body: JSON.stringify(openaiBody)
|
body: JSON.stringify(openaiBody)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
// Forward response as-is (status + body). pipeSSE will propagate status.
|
||||||
const errText = await response.text().catch(() => "");
|
|
||||||
throw new Error(`[${response.status}]: ${errText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
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 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" };
|
const resHeaders = { "Content-Type": ct, "Cache-Control": "no-cache", "Connection": "keep-alive" };
|
||||||
if (ct.includes("text/event-stream")) resHeaders["X-Accel-Buffering"] = "no";
|
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) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +59,8 @@ async function pipeSSE(routerRes, res) {
|
|||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
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 }));
|
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() {
|
function time() {
|
||||||
return new Date().toLocaleTimeString("en-US", { hour12: false });
|
return new Date().toLocaleTimeString("en-US", { hour12: false });
|
||||||
}
|
}
|
||||||
@@ -5,4 +11,86 @@ function time() {
|
|||||||
const log = (msg) => console.log(`[${time()}] [MITM] ${msg}`);
|
const log = (msg) => console.log(`[${time()}] [MITM] ${msg}`);
|
||||||
const err = (msg) => console.error(`[${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;
|
serverPid = null;
|
||||||
|
|
||||||
if (IS_WIN) {
|
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 hostsFile = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "drivers", "etc", "hosts");
|
||||||
const allHosts = Object.values(TOOL_HOSTS).flat();
|
const allHosts = Object.values(TOOL_HOSTS).flat();
|
||||||
try {
|
try {
|
||||||
const hostsContent = fs.readFileSync(hostsFile, "utf8");
|
const { isAdmin, runElevatedPowerShell, quotePs } = require("./winElevated.js");
|
||||||
const filtered = hostsContent.split(/\r?\n/).filter(l => !allHosts.some(h => l.includes(h))).join("\r\n");
|
if (isAdmin()) {
|
||||||
fs.writeFileSync(hostsFile, filtered, "utf8");
|
// Direct fs write — bypass PowerShell to avoid parser pitfalls
|
||||||
require("child_process").execSync("ipconfig /flushdns", { windowsHide: true });
|
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}`); }
|
} catch (e) { err(`Failed to clean hosts: ${e.message}`); }
|
||||||
} else {
|
} else {
|
||||||
await removeAllDNSEntries(sudoPassword);
|
await removeAllDNSEntries(sudoPassword);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const path = require("path");
|
|||||||
const dns = require("dns");
|
const dns = require("dns");
|
||||||
const { promisify } = require("util");
|
const { promisify } = require("util");
|
||||||
const { execSync } = require("child_process");
|
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 { TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, getToolForHost } = require("./config");
|
||||||
const { DATA_DIR, MITM_DIR } = require("./paths");
|
const { DATA_DIR, MITM_DIR } = require("./paths");
|
||||||
const { getCertForDomain } = require("./cert/generate");
|
const { getCertForDomain } = require("./cert/generate");
|
||||||
@@ -12,12 +12,9 @@ const { getCertForDomain } = require("./cert/generate");
|
|||||||
const DB_FILE = path.join(DATA_DIR, "db.json");
|
const DB_FILE = path.join(DATA_DIR, "db.json");
|
||||||
const LOCAL_PORT = 443;
|
const LOCAL_PORT = 443;
|
||||||
const IS_WIN = process.platform === "win32";
|
const IS_WIN = process.platform === "win32";
|
||||||
const ENABLE_FILE_LOG = true;
|
const ENABLE_FILE_LOG = false;
|
||||||
const LOG_DIR = path.join(DATA_DIR, "logs", "mitm");
|
|
||||||
const INTERNAL_REQUEST_HEADER = { name: "x-request-source", value: "local" };
|
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
|
// Load handlers — dev/ overrides handlers/ for private implementations
|
||||||
function loadHandler(name) {
|
function loadHandler(name) {
|
||||||
try { return require(`./dev/${name}`); } catch {}
|
try { return require(`./dev/${name}`); } catch {}
|
||||||
@@ -34,13 +31,17 @@ const handlers = {
|
|||||||
// ── SSL / SNI ─────────────────────────────────────────────────
|
// ── SSL / SNI ─────────────────────────────────────────────────
|
||||||
|
|
||||||
const certCache = new Map();
|
const certCache = new Map();
|
||||||
|
let rootCAPem;
|
||||||
|
|
||||||
function sniCallback(servername, cb) {
|
function sniCallback(servername, cb) {
|
||||||
try {
|
try {
|
||||||
if (certCache.has(servername)) return cb(null, certCache.get(servername));
|
if (certCache.has(servername)) return cb(null, certCache.get(servername));
|
||||||
const certData = getCertForDomain(servername);
|
const certData = getCertForDomain(servername);
|
||||||
if (!certData) return cb(new Error(`Failed to generate cert for ${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);
|
certCache.set(servername, ctx);
|
||||||
log(`🔐 Cert generated: ${servername}`);
|
log(`🔐 Cert generated: ${servername}`);
|
||||||
cb(null, ctx);
|
cb(null, ctx);
|
||||||
@@ -52,11 +53,10 @@ function sniCallback(servername, cb) {
|
|||||||
|
|
||||||
let sslOptions;
|
let sslOptions;
|
||||||
try {
|
try {
|
||||||
sslOptions = {
|
const rootKey = fs.readFileSync(path.join(MITM_DIR, "rootCA.key"));
|
||||||
key: fs.readFileSync(path.join(MITM_DIR, "rootCA.key")),
|
const rootCert = fs.readFileSync(path.join(MITM_DIR, "rootCA.crt"));
|
||||||
cert: fs.readFileSync(path.join(MITM_DIR, "rootCA.crt")),
|
rootCAPem = rootCert.toString("utf8");
|
||||||
SNICallback: sniCallback
|
sslOptions = { key: rootKey, cert: rootCert, SNICallback: sniCallback };
|
||||||
};
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
err(`Root CA not found: ${e.message}`);
|
err(`Root CA not found: ${e.message}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -116,24 +116,16 @@ function getMappedModel(tool, model) {
|
|||||||
} catch { return null; }
|
} 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.
|
* Forward request to real upstream.
|
||||||
* Optional onResponse(rawBuffer) callback — if provided, tees the response
|
* Optional onResponse(rawBuffer) callback — if provided, tees the response
|
||||||
* so it's both forwarded to client AND passed to the callback for inspection.
|
* 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) {
|
async function passthrough(req, res, bodyBuffer, onResponse) {
|
||||||
const targetHost = (req.headers.host || TARGET_HOSTS[0]).split(":")[0];
|
const targetHost = (req.headers.host || TARGET_HOSTS[0]).split(":")[0];
|
||||||
const targetIP = await resolveTargetIP(targetHost);
|
const targetIP = await resolveTargetIP(targetHost);
|
||||||
|
const dumper = ENABLE_FILE_LOG ? createResponseDumper(req, "passthrough") : null;
|
||||||
|
|
||||||
const forwardReq = https.request({
|
const forwardReq = https.request({
|
||||||
hostname: targetIP,
|
hostname: targetIP,
|
||||||
@@ -145,23 +137,30 @@ async function passthrough(req, res, bodyBuffer, onResponse) {
|
|||||||
rejectUnauthorized: false
|
rejectUnauthorized: false
|
||||||
}, (forwardRes) => {
|
}, (forwardRes) => {
|
||||||
res.writeHead(forwardRes.statusCode, forwardRes.headers);
|
res.writeHead(forwardRes.statusCode, forwardRes.headers);
|
||||||
|
if (dumper) dumper.writeHeader(forwardRes.statusCode, forwardRes.headers);
|
||||||
|
|
||||||
if (!onResponse) {
|
if (!onResponse && !dumper) {
|
||||||
forwardRes.pipe(res);
|
forwardRes.pipe(res);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tee: forward to client AND buffer for callback
|
// Tee: forward to client AND optionally buffer + dump
|
||||||
const chunks = [];
|
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", () => {
|
forwardRes.on("end", () => {
|
||||||
|
if (dumper) dumper.end();
|
||||||
res.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) => {
|
forwardReq.on("error", (e) => {
|
||||||
err(`Passthrough error: ${e.message}`);
|
err(`Passthrough error: ${e.message}`);
|
||||||
|
if (dumper) { dumper.writeChunk(`\n[ERROR] ${e.message}\n`); dumper.end(); }
|
||||||
if (!res.headersSent) res.writeHead(502);
|
if (!res.headersSent) res.writeHead(502);
|
||||||
res.end("Bad Gateway");
|
res.end("Bad Gateway");
|
||||||
});
|
});
|
||||||
@@ -181,7 +180,7 @@ const server = https.createServer(sslOptions, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bodyBuffer = await collectBodyRaw(req);
|
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
|
// Anti-loop: skip requests from 9Router
|
||||||
if (req.headers[INTERNAL_REQUEST_HEADER.name] === INTERNAL_REQUEST_HEADER.value) {
|
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