refactor: Fix MITM

This commit is contained in:
decolua
2026-03-14 16:38:44 +07:00
parent 877eea8ebe
commit b98f4ce20e
5 changed files with 91 additions and 60 deletions

View File

@@ -173,7 +173,7 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
)}
{/* Action button */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-2" data-i18n-skip="true">
{isRunning ? (
<button
onClick={() => handleAction("stop")}

View File

@@ -11,6 +11,7 @@ const DEFAULT_BATCH_SIZE = 20;
const DEFAULT_FLUSH_INTERVAL_MS = 5000;
const DEFAULT_MAX_JSON_SIZE = 5 * 1024; // 5KB default, configurable via settings
const CONFIG_CACHE_TTL_MS = 5000;
const MAX_TOTAL_DB_SIZE = 50 * 1024 * 1024; // 50MB hard limit for total DB file
function getAppName() {
return "9router";
@@ -181,6 +182,13 @@ async function flushToDatabase() {
db.data.records = db.data.records.slice(0, config.maxRecords);
}
// Shrink records until total serialized size is within safe limit
while (db.data.records.length > 1) {
const totalSize = Buffer.byteLength(JSON.stringify(db.data), "utf8");
if (totalSize <= MAX_TOTAL_DB_SIZE) break;
db.data.records = db.data.records.slice(0, Math.floor(db.data.records.length / 2));
}
await db.write();
} catch (error) {
console.error("[requestDetailsDb] Batch write failed:", error);

View File

@@ -74,9 +74,11 @@ async function installCert(sudoPassword, certPath) {
}
async function installCertMac(sudoPassword, certPath) {
const command = `security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${certPath}"`;
// Remove all old certs with same name first to avoid duplicate/stale cert conflict
const deleteOld = `security delete-certificate -c "9Router MITM Root CA" /Library/Keychains/System.keychain 2>/dev/null || true`;
const install = `security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${certPath}"`;
try {
await execWithPassword(command, sudoPassword);
await execWithPassword(`${deleteOld} && ${install}`, sudoPassword);
console.log(`✅ Installed certificate to system keychain: ${certPath}`);
} catch (error) {
const msg = error.message?.includes("canceled") ? "User canceled authorization" : "Certificate install failed";

View File

@@ -4,6 +4,9 @@ const path = require("path");
const dns = require("dns");
const { promisify } = require("util");
// Allow self-signed certs from MITM root CA when fetching external hosts
const INTERNAL_REQUEST_HEADER = { name: "x-request-source", value: "local" };
// All intercepted domains across all tools
@@ -198,6 +201,13 @@ async function intercept(req, res, bodyBuffer, mappedModel) {
if (ct.includes("text/event-stream")) resHeaders["X-Accel-Buffering"] = "no";
res.writeHead(200, resHeaders);
// Guard: some responses have no body (e.g. errors, empty replies)
if (!response.body) {
const text = await response.text().catch(() => "");
res.end(text);
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
@@ -213,37 +223,44 @@ async function intercept(req, res, bodyBuffer, mappedModel) {
}
const server = https.createServer(sslOptions, async (req, res) => {
if (req.url === "/_mitm_health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, pid: process.pid }));
return;
// Top-level catch to prevent uncaughtException from crashing the server
try {
if (req.url === "/_mitm_health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, pid: process.pid }));
return;
}
const bodyBuffer = await collectBodyRaw(req);
if (bodyBuffer.length > 0) saveRequestLog(req.url, bodyBuffer);
// Anti-loop: requests originating from 9Router bypass interception
if (req.headers[INTERNAL_REQUEST_HEADER.name] === INTERNAL_REQUEST_HEADER.value) {
return passthrough(req, res, bodyBuffer);
}
const tool = getToolForHost(req.headers.host);
if (!tool) return passthrough(req, res, bodyBuffer);
// Check if this URL should be intercepted based on tool
const isChat = tool === "antigravity"
? ANTIGRAVITY_URL_PATTERNS.some(p => req.url.includes(p))
: COPILOT_URL_PATTERNS.some(p => req.url.includes(p));
if (!isChat) return passthrough(req, res, bodyBuffer);
const model = extractModel(req.url, bodyBuffer);
console.log("Extracted model:", model);
const mappedModel = getMappedModel(tool, model);
if (!mappedModel) return passthrough(req, res, bodyBuffer);
return intercept(req, res, bodyBuffer, mappedModel);
} catch (err) {
console.error(`❌ Unhandled request error: ${err.message}`);
if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: { message: err.message, type: "mitm_error" } }));
}
const bodyBuffer = await collectBodyRaw(req);
if (bodyBuffer.length > 0) saveRequestLog(req.url, bodyBuffer);
// Anti-loop: requests originating from 9Router bypass interception
if (req.headers[INTERNAL_REQUEST_HEADER.name] === INTERNAL_REQUEST_HEADER.value) {
return passthrough(req, res, bodyBuffer);
}
const tool = getToolForHost(req.headers.host);
if (!tool) return passthrough(req, res, bodyBuffer);
// Check if this URL should be intercepted based on tool
const isChat = tool === "antigravity"
? ANTIGRAVITY_URL_PATTERNS.some(p => req.url.includes(p))
: COPILOT_URL_PATTERNS.some(p => req.url.includes(p));
if (!isChat) return passthrough(req, res, bodyBuffer);
const model = extractModel(req.url, bodyBuffer);
console.log("Extracted model:", model)
const mappedModel = getMappedModel(tool, model);
if (!mappedModel) return passthrough(req, res, bodyBuffer);
return intercept(req, res, bodyBuffer, mappedModel);
});
server.listen(LOCAL_PORT, () => {

View File

@@ -33,13 +33,17 @@ import os from "os";
// Multiple modules register SIGINT/SIGTERM handlers legitimately
process.setMaxListeners(20);
let signalHandlersRegistered = false;
let watchdogInterval = null;
let networkMonitorInterval = null;
let lastNetworkFingerprint = null;
let lastWatchdogTick = Date.now();
let lastTunnelRestartAt = 0;
let tunnelRestartInProgress = false;
// Use global to survive Next.js hot reload — prevents duplicate intervals
const g = global.__appSingleton ??= {
signalHandlersRegistered: false,
watchdogInterval: null,
networkMonitorInterval: null,
lastNetworkFingerprint: null,
lastWatchdogTick: Date.now(),
lastTunnelRestartAt: 0,
tunnelRestartInProgress: false,
};
const WATCHDOG_INTERVAL_MS = 60000;
const NETWORK_CHECK_INTERVAL_MS = 5000;
const NETWORK_RESTART_COOLDOWN_MS = 30000;
@@ -68,14 +72,14 @@ export async function initializeApp() {
}
// Kill cloudflared on process exit (register once only)
if (!signalHandlersRegistered) {
if (!g.signalHandlersRegistered) {
const cleanup = () => {
killCloudflared();
process.exit();
};
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
signalHandlersRegistered = true;
g.signalHandlersRegistered = true;
}
// Pre-download cloudflared binary in background
@@ -127,8 +131,8 @@ async function autoStartMitm() {
/** Periodically check tunnel process health and reconnect if crashed */
function startWatchdog() {
if (watchdogInterval) return;
watchdogInterval = setInterval(async () => {
if (g.watchdogInterval) return;
g.watchdogInterval = setInterval(async () => {
try {
const settings = await getSettings();
if (!settings.tunnelEnabled) return;
@@ -141,7 +145,7 @@ function startWatchdog() {
}
}, WATCHDOG_INTERVAL_MS);
if (watchdogInterval.unref) watchdogInterval.unref();
if (g.watchdogInterval.unref) g.watchdogInterval.unref();
}
/** Get network fingerprint from active interfaces (IPv4 only) */
@@ -161,53 +165,53 @@ function getNetworkFingerprint() {
/** Monitor network changes + sleep/wake → kill and reconnect tunnel */
function startNetworkMonitor() {
if (networkMonitorInterval) return;
if (g.networkMonitorInterval) return;
lastNetworkFingerprint = getNetworkFingerprint();
lastWatchdogTick = Date.now();
g.lastNetworkFingerprint = getNetworkFingerprint();
g.lastWatchdogTick = Date.now();
networkMonitorInterval = setInterval(async () => {
g.networkMonitorInterval = setInterval(async () => {
try {
const settings = await getSettings();
if (!settings.tunnelEnabled) return;
const now = Date.now();
const elapsed = now - lastWatchdogTick;
lastWatchdogTick = now;
const elapsed = now - g.lastWatchdogTick;
g.lastWatchdogTick = now;
const currentFingerprint = getNetworkFingerprint();
const networkChanged = currentFingerprint !== lastNetworkFingerprint;
const networkChanged = currentFingerprint !== g.lastNetworkFingerprint;
const wasSleep = elapsed > NETWORK_CHECK_INTERVAL_MS * 3;
if (networkChanged) lastNetworkFingerprint = currentFingerprint;
if (networkChanged) g.lastNetworkFingerprint = currentFingerprint;
if (!networkChanged && !wasSleep) return;
// Skip if restart already in progress or restarted recently
if (tunnelRestartInProgress) return;
if (now - lastTunnelRestartAt < NETWORK_RESTART_COOLDOWN_MS) return;
if (g.tunnelRestartInProgress) return;
if (now - g.lastTunnelRestartAt < NETWORK_RESTART_COOLDOWN_MS) return;
const reason = wasSleep && networkChanged ? "sleep/wake + network change"
: wasSleep ? "sleep/wake" : "network change";
console.log(`[NetworkMonitor] ${reason} detected, restarting tunnel...`);
tunnelRestartInProgress = true;
lastTunnelRestartAt = now;
g.tunnelRestartInProgress = true;
g.lastTunnelRestartAt = now;
try {
killCloudflared();
await new Promise(r => setTimeout(r, 2000));
await enableTunnel();
console.log("[NetworkMonitor] Tunnel restarted");
lastNetworkFingerprint = getNetworkFingerprint();
g.lastNetworkFingerprint = getNetworkFingerprint();
} finally {
tunnelRestartInProgress = false;
g.tunnelRestartInProgress = false;
}
} catch (err) {
console.log("[NetworkMonitor] Tunnel restart failed:", err.message);
}
}, NETWORK_CHECK_INTERVAL_MS);
if (networkMonitorInterval.unref) networkMonitorInterval.unref();
if (g.networkMonitorInterval.unref) g.networkMonitorInterval.unref();
}
export default initializeApp;