mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
refactor: Fix MITM
This commit is contained in:
@@ -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")}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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, () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user