refactor: Fix MITM
This commit is contained in:
@@ -173,7 +173,7 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action button */}
|
{/* Action button */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2" data-i18n-skip="true">
|
||||||
{isRunning ? (
|
{isRunning ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAction("stop")}
|
onClick={() => handleAction("stop")}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const DEFAULT_BATCH_SIZE = 20;
|
|||||||
const DEFAULT_FLUSH_INTERVAL_MS = 5000;
|
const DEFAULT_FLUSH_INTERVAL_MS = 5000;
|
||||||
const DEFAULT_MAX_JSON_SIZE = 5 * 1024; // 5KB default, configurable via settings
|
const DEFAULT_MAX_JSON_SIZE = 5 * 1024; // 5KB default, configurable via settings
|
||||||
const CONFIG_CACHE_TTL_MS = 5000;
|
const CONFIG_CACHE_TTL_MS = 5000;
|
||||||
|
const MAX_TOTAL_DB_SIZE = 50 * 1024 * 1024; // 50MB hard limit for total DB file
|
||||||
|
|
||||||
function getAppName() {
|
function getAppName() {
|
||||||
return "9router";
|
return "9router";
|
||||||
@@ -181,6 +182,13 @@ async function flushToDatabase() {
|
|||||||
db.data.records = db.data.records.slice(0, config.maxRecords);
|
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();
|
await db.write();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[requestDetailsDb] Batch write failed:", error);
|
console.error("[requestDetailsDb] Batch write failed:", error);
|
||||||
|
|||||||
@@ -74,9 +74,11 @@ async function installCert(sudoPassword, certPath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function installCertMac(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 {
|
try {
|
||||||
await execWithPassword(command, sudoPassword);
|
await execWithPassword(`${deleteOld} && ${install}`, sudoPassword);
|
||||||
console.log(`✅ Installed certificate to system keychain: ${certPath}`);
|
console.log(`✅ Installed certificate to system keychain: ${certPath}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = error.message?.includes("canceled") ? "User canceled authorization" : "Certificate install failed";
|
const msg = error.message?.includes("canceled") ? "User canceled authorization" : "Certificate install failed";
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ const path = require("path");
|
|||||||
const dns = require("dns");
|
const dns = require("dns");
|
||||||
const { promisify } = require("util");
|
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" };
|
const INTERNAL_REQUEST_HEADER = { name: "x-request-source", value: "local" };
|
||||||
|
|
||||||
// All intercepted domains across all tools
|
// 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";
|
if (ct.includes("text/event-stream")) resHeaders["X-Accel-Buffering"] = "no";
|
||||||
res.writeHead(200, resHeaders);
|
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 reader = response.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -213,37 +223,44 @@ async function intercept(req, res, bodyBuffer, mappedModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const server = https.createServer(sslOptions, async (req, res) => {
|
const server = https.createServer(sslOptions, async (req, res) => {
|
||||||
if (req.url === "/_mitm_health") {
|
// Top-level catch to prevent uncaughtException from crashing the server
|
||||||
res.writeHead(200, { "Content-Type": "application/json" });
|
try {
|
||||||
res.end(JSON.stringify({ ok: true, pid: process.pid }));
|
if (req.url === "/_mitm_health") {
|
||||||
return;
|
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, () => {
|
server.listen(LOCAL_PORT, () => {
|
||||||
|
|||||||
@@ -33,13 +33,17 @@ import os from "os";
|
|||||||
// Multiple modules register SIGINT/SIGTERM handlers legitimately
|
// Multiple modules register SIGINT/SIGTERM handlers legitimately
|
||||||
process.setMaxListeners(20);
|
process.setMaxListeners(20);
|
||||||
|
|
||||||
let signalHandlersRegistered = false;
|
// Use global to survive Next.js hot reload — prevents duplicate intervals
|
||||||
let watchdogInterval = null;
|
const g = global.__appSingleton ??= {
|
||||||
let networkMonitorInterval = null;
|
signalHandlersRegistered: false,
|
||||||
let lastNetworkFingerprint = null;
|
watchdogInterval: null,
|
||||||
let lastWatchdogTick = Date.now();
|
networkMonitorInterval: null,
|
||||||
let lastTunnelRestartAt = 0;
|
lastNetworkFingerprint: null,
|
||||||
let tunnelRestartInProgress = false;
|
lastWatchdogTick: Date.now(),
|
||||||
|
lastTunnelRestartAt: 0,
|
||||||
|
tunnelRestartInProgress: false,
|
||||||
|
};
|
||||||
|
|
||||||
const WATCHDOG_INTERVAL_MS = 60000;
|
const WATCHDOG_INTERVAL_MS = 60000;
|
||||||
const NETWORK_CHECK_INTERVAL_MS = 5000;
|
const NETWORK_CHECK_INTERVAL_MS = 5000;
|
||||||
const NETWORK_RESTART_COOLDOWN_MS = 30000;
|
const NETWORK_RESTART_COOLDOWN_MS = 30000;
|
||||||
@@ -68,14 +72,14 @@ export async function initializeApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Kill cloudflared on process exit (register once only)
|
// Kill cloudflared on process exit (register once only)
|
||||||
if (!signalHandlersRegistered) {
|
if (!g.signalHandlersRegistered) {
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
killCloudflared();
|
killCloudflared();
|
||||||
process.exit();
|
process.exit();
|
||||||
};
|
};
|
||||||
process.on("SIGINT", cleanup);
|
process.on("SIGINT", cleanup);
|
||||||
process.on("SIGTERM", cleanup);
|
process.on("SIGTERM", cleanup);
|
||||||
signalHandlersRegistered = true;
|
g.signalHandlersRegistered = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-download cloudflared binary in background
|
// Pre-download cloudflared binary in background
|
||||||
@@ -127,8 +131,8 @@ async function autoStartMitm() {
|
|||||||
|
|
||||||
/** Periodically check tunnel process health and reconnect if crashed */
|
/** Periodically check tunnel process health and reconnect if crashed */
|
||||||
function startWatchdog() {
|
function startWatchdog() {
|
||||||
if (watchdogInterval) return;
|
if (g.watchdogInterval) return;
|
||||||
watchdogInterval = setInterval(async () => {
|
g.watchdogInterval = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
if (!settings.tunnelEnabled) return;
|
if (!settings.tunnelEnabled) return;
|
||||||
@@ -141,7 +145,7 @@ function startWatchdog() {
|
|||||||
}
|
}
|
||||||
}, WATCHDOG_INTERVAL_MS);
|
}, WATCHDOG_INTERVAL_MS);
|
||||||
|
|
||||||
if (watchdogInterval.unref) watchdogInterval.unref();
|
if (g.watchdogInterval.unref) g.watchdogInterval.unref();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get network fingerprint from active interfaces (IPv4 only) */
|
/** Get network fingerprint from active interfaces (IPv4 only) */
|
||||||
@@ -161,53 +165,53 @@ function getNetworkFingerprint() {
|
|||||||
|
|
||||||
/** Monitor network changes + sleep/wake → kill and reconnect tunnel */
|
/** Monitor network changes + sleep/wake → kill and reconnect tunnel */
|
||||||
function startNetworkMonitor() {
|
function startNetworkMonitor() {
|
||||||
if (networkMonitorInterval) return;
|
if (g.networkMonitorInterval) return;
|
||||||
|
|
||||||
lastNetworkFingerprint = getNetworkFingerprint();
|
g.lastNetworkFingerprint = getNetworkFingerprint();
|
||||||
lastWatchdogTick = Date.now();
|
g.lastWatchdogTick = Date.now();
|
||||||
|
|
||||||
networkMonitorInterval = setInterval(async () => {
|
g.networkMonitorInterval = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
if (!settings.tunnelEnabled) return;
|
if (!settings.tunnelEnabled) return;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const elapsed = now - lastWatchdogTick;
|
const elapsed = now - g.lastWatchdogTick;
|
||||||
lastWatchdogTick = now;
|
g.lastWatchdogTick = now;
|
||||||
|
|
||||||
const currentFingerprint = getNetworkFingerprint();
|
const currentFingerprint = getNetworkFingerprint();
|
||||||
const networkChanged = currentFingerprint !== lastNetworkFingerprint;
|
const networkChanged = currentFingerprint !== g.lastNetworkFingerprint;
|
||||||
const wasSleep = elapsed > NETWORK_CHECK_INTERVAL_MS * 3;
|
const wasSleep = elapsed > NETWORK_CHECK_INTERVAL_MS * 3;
|
||||||
|
|
||||||
if (networkChanged) lastNetworkFingerprint = currentFingerprint;
|
if (networkChanged) g.lastNetworkFingerprint = currentFingerprint;
|
||||||
|
|
||||||
if (!networkChanged && !wasSleep) return;
|
if (!networkChanged && !wasSleep) return;
|
||||||
|
|
||||||
// Skip if restart already in progress or restarted recently
|
// Skip if restart already in progress or restarted recently
|
||||||
if (tunnelRestartInProgress) return;
|
if (g.tunnelRestartInProgress) return;
|
||||||
if (now - lastTunnelRestartAt < NETWORK_RESTART_COOLDOWN_MS) return;
|
if (now - g.lastTunnelRestartAt < NETWORK_RESTART_COOLDOWN_MS) return;
|
||||||
|
|
||||||
const reason = wasSleep && networkChanged ? "sleep/wake + network change"
|
const reason = wasSleep && networkChanged ? "sleep/wake + network change"
|
||||||
: wasSleep ? "sleep/wake" : "network change";
|
: wasSleep ? "sleep/wake" : "network change";
|
||||||
console.log(`[NetworkMonitor] ${reason} detected, restarting tunnel...`);
|
console.log(`[NetworkMonitor] ${reason} detected, restarting tunnel...`);
|
||||||
|
|
||||||
tunnelRestartInProgress = true;
|
g.tunnelRestartInProgress = true;
|
||||||
lastTunnelRestartAt = now;
|
g.lastTunnelRestartAt = now;
|
||||||
try {
|
try {
|
||||||
killCloudflared();
|
killCloudflared();
|
||||||
await new Promise(r => setTimeout(r, 2000));
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
await enableTunnel();
|
await enableTunnel();
|
||||||
console.log("[NetworkMonitor] Tunnel restarted");
|
console.log("[NetworkMonitor] Tunnel restarted");
|
||||||
lastNetworkFingerprint = getNetworkFingerprint();
|
g.lastNetworkFingerprint = getNetworkFingerprint();
|
||||||
} finally {
|
} finally {
|
||||||
tunnelRestartInProgress = false;
|
g.tunnelRestartInProgress = false;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log("[NetworkMonitor] Tunnel restart failed:", err.message);
|
console.log("[NetworkMonitor] Tunnel restart failed:", err.message);
|
||||||
}
|
}
|
||||||
}, NETWORK_CHECK_INTERVAL_MS);
|
}, NETWORK_CHECK_INTERVAL_MS);
|
||||||
|
|
||||||
if (networkMonitorInterval.unref) networkMonitorInterval.unref();
|
if (g.networkMonitorInterval.unref) g.networkMonitorInterval.unref();
|
||||||
}
|
}
|
||||||
|
|
||||||
export default initializeApp;
|
export default initializeApp;
|
||||||
|
|||||||
Reference in New Issue
Block a user