const https = require("https"); const http2 = require("http2"); const tls = require("tls"); const fs = require("fs"); const path = require("path"); const dns = require("dns"); const { promisify } = require("util"); const { execSync } = require("child_process"); const { log, err, dumpRequest, createResponseDumper, clearDumpDir } = require("./logger"); const { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, getToolForHost } = require("./config"); const { DATA_DIR, MITM_DIR } = require("./paths"); const { getCertForDomain } = require("./cert/generate"); const { getMitmAlias } = require("./dbReader"); const { applyAntigravityIdeVersionOverride } = require("./antigravityIdeVersion"); const LOCAL_PORT = 443; const IS_WIN = process.platform === "win32"; const ENABLE_FILE_LOG = IS_DEV; // Clear stale dump files on every MITM start (prevents unbounded disk usage) clearDumpDir(); const INTERNAL_REQUEST_HEADER = { name: "x-request-source", value: "local" }; // Host rewrite for upstream forward: PROD cloudcode-pa is rate-limited (429), // daily-cloudcode-pa (dev endpoint) accepts same body+token. Same trick as open-sse. const HOST_REWRITE = { "cloudcode-pa.googleapis.com": "daily-cloudcode-pa.googleapis.com", }; const handlers = { antigravity: require("./handlers/antigravity"), copilot: require("./handlers/copilot"), kiro: require("./handlers/kiro"), cursor: require("./handlers/cursor"), }; // ── 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}\n${rootCAPem}` }); certCache.set(servername, ctx); cb(null, ctx); } catch (e) { err(`SNI error for ${servername}: ${e.message}`); cb(e); } } let sslOptions; try { 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); } // ── Helpers ─────────────────────────────────────────────────── const cachedTargetIPs = {}; const CACHE_TTL_MS = 5 * 60 * 1000; async function resolveTargetIP(hostname) { const cached = cachedTargetIPs[hostname]; if (cached && Date.now() - cached.ts < CACHE_TTL_MS) return cached.ip; const resolver = new dns.Resolver(); resolver.setServers(["8.8.8.8"]); const resolve4 = promisify(resolver.resolve4.bind(resolver)); const addresses = await resolve4(hostname); cachedTargetIPs[hostname] = { ip: addresses[0], ts: Date.now() }; return cachedTargetIPs[hostname].ip; } function collectBodyRaw(req) { return new Promise((resolve, reject) => { const chunks = []; req.on("data", chunk => chunks.push(chunk)); req.on("end", () => resolve(Buffer.concat(chunks))); req.on("error", reject); }); } // Extract model from URL path (Gemini), body (OpenAI/Anthropic), or Kiro conversationState function extractModel(url, body) { const urlMatch = url.match(/\/models\/([^/:]+)/); if (urlMatch) return urlMatch[1]; // Skip parsing if body is binary (AWS EventStream, Protocol Buffers, etc.) if (isBinaryData(body)) return null; try { const parsed = JSON.parse(body.toString()); if (parsed.conversationState) { return parsed.conversationState.currentMessage?.userInputMessage?.modelId || null; } return parsed.model || null; } catch { return null; } } // Detect binary data vs JSON text function isBinaryData(buffer) { if (!buffer || buffer.length === 0) return false; // AWS EventStream signature: first 4 bytes = frame length (big-endian uint32) // Check for non-printable chars in first 100 bytes (common in binary protocols) const sample = buffer.slice(0, Math.min(100, buffer.length)); let nonPrintable = 0; for (let i = 0; i < sample.length; i++) { const byte = sample[i]; // Count non-ASCII printable chars (excluding whitespace) if (byte < 0x20 && byte !== 0x09 && byte !== 0x0A && byte !== 0x0D) { nonPrintable++; } if (byte > 0x7E) nonPrintable++; } // If >30% non-printable, treat as binary return (nonPrintable / sample.length) > 0.3; } function getMappedModel(tool, model) { if (!model) return null; try { const aliases = getMitmAlias(tool); if (!aliases) return null; // Normalize via synonym map (e.g., public AG names -> backend model ids) const normalizedModel = String(model).replace(/^models\//, ""); const lookup = MODEL_SYNONYMS?.[tool]?.[normalizedModel] || normalizedModel; if (aliases[lookup]) return aliases[lookup]; // Prefix match fallback const prefixKey = Object.keys(aliases).find(k => k && aliases[k] && (lookup.startsWith(k) || k.startsWith(lookup))); if (prefixKey) return aliases[prefixKey]; // Pattern fallback: catches AG renamed variants (e.g. deprecated pro IDs → gemini-pro-agent) const patterns = MODEL_PATTERNS?.[tool] || []; for (const { match, alias } of patterns) { if (match.test(lookup) && aliases[alias]) return aliases[alias]; } return null; } catch { return null; } } /** * 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 originalHost = (req.headers.host || TARGET_HOSTS[0]).split(":")[0]; // Only rewrite host for chat endpoints — daily-cloudcode-pa rejects auth/login requests const isChatEndpoint = req.url.includes(":generateContent") || req.url.includes(":streamGenerateContent"); const targetHost = isChatEndpoint ? (HOST_REWRITE[originalHost] || originalHost) : originalHost; const dumper = ENABLE_FILE_LOG ? createResponseDumper(req, "passthrough") : null; const tool = getToolForHost(req.headers.host); const versionOverride = tool === "antigravity" ? applyAntigravityIdeVersionOverride(bodyBuffer, req.headers) : { bodyBuffer, headers: req.headers }; const bodyForForwarding = versionOverride.bodyBuffer; const headersForForwarding = { ...versionOverride.headers, host: targetHost }; if (bodyForForwarding !== bodyBuffer) { headersForForwarding["content-length"] = String(bodyForForwarding.length); } // ALPN negotiate: try HTTP/2 first (like browsers/mitmweb), fallback HTTP/1.1 try { const proto = await negotiateAlpn(targetHost); if (proto === "h2") { return await passthroughHttp2(req, res, bodyForForwarding, headersForForwarding, targetHost, onResponse, dumper); } } catch (e) { err(`[mitm] ALPN negotiate failed: ${e.message}, fallback to HTTP/1.1`); } return passthroughHttps(req, res, bodyForForwarding, headersForForwarding, targetHost, onResponse, dumper); } // ── ALPN negotiation cache ──────────────────────────────────── const alpnCache = new Map(); // host → "h2" | "http/1.1" async function negotiateAlpn(host) { if (alpnCache.has(host)) return alpnCache.get(host); const ip = await resolveTargetIP(host); return new Promise((resolve, reject) => { const socket = tls.connect({ host: ip, port: 443, servername: host, ALPNProtocols: ["h2", "http/1.1"], rejectUnauthorized: false, }, () => { const proto = socket.alpnProtocol || "http/1.1"; alpnCache.set(host, proto); log(`🔗 [mitm] ALPN ${host} → ${proto}`); socket.end(); resolve(proto); }); socket.once("error", reject); socket.setTimeout(5000, () => { socket.destroy(new Error("ALPN timeout")); }); }); } // HTTP/2 passthrough using node:http2 native async function passthroughHttp2(req, res, bodyBuffer, headers, targetHost, onResponse, dumper) { const targetIP = await resolveTargetIP(targetHost); // HTTP/2 pseudo-headers required; strip HTTP/1.1-only headers const h2Headers = {}; for (const [k, v] of Object.entries(headers)) { const lk = k.toLowerCase(); if (lk === "host" || lk === "connection" || lk === "keep-alive" || lk === "transfer-encoding" || lk === "upgrade" || lk === "proxy-connection") continue; h2Headers[lk] = v; } h2Headers[":method"] = req.method; h2Headers[":path"] = req.url; h2Headers[":scheme"] = "https"; h2Headers[":authority"] = targetHost; return new Promise((resolve) => { const client = http2.connect(`https://${targetHost}`, { createConnection: () => tls.connect({ host: targetIP, port: 443, servername: targetHost, ALPNProtocols: ["h2"], rejectUnauthorized: false, }), }); client.once("error", (e) => { err(`[mitm] http2 client error: ${e.message}`); if (dumper) { dumper.writeChunk(`\n[ERROR h2] ${e.message}\n`); dumper.end(); } if (!res.headersSent) res.writeHead(502); if (!res.writableEnded) res.end("Bad Gateway"); try { client.close(); } catch {} resolve(); }); const stream = client.request(h2Headers, { endStream: bodyBuffer.length === 0 }); if (bodyBuffer.length > 0) stream.end(bodyBuffer); stream.once("response", (responseHeaders) => { const status = responseHeaders[":status"]; // Filter pseudo-headers + connection-specific const outHeaders = {}; for (const [k, v] of Object.entries(responseHeaders)) { if (k.startsWith(":")) continue; if (k === "connection" || k === "keep-alive" || k === "transfer-encoding") continue; outHeaders[k] = v; } res.writeHead(status, outHeaders); if (dumper) dumper.writeHeader(status, outHeaders); const chunks = []; stream.on("data", chunk => { if (dumper) dumper.writeChunk(chunk); if (onResponse) chunks.push(chunk); res.write(chunk); }); stream.on("end", () => { if (dumper) dumper.end(); if (!res.writableEnded) res.end(); if (onResponse) try { onResponse(Buffer.concat(chunks), outHeaders); } catch {} try { client.close(); } catch {} resolve(); }); }); stream.once("error", (e) => { err(`[mitm] http2 stream error: ${e.message}`); if (dumper) { dumper.writeChunk(`\n[ERROR h2-stream] ${e.message}\n`); dumper.end(); } if (!res.headersSent) res.writeHead(502); if (!res.writableEnded) res.end(); try { client.close(); } catch {} resolve(); }); }); } // Fallback: raw https.request HTTP/1.1 with custom DNS (bypasses /etc/hosts MITM loop) async function passthroughHttps(req, res, bodyBuffer, headers, targetHost, onResponse, dumper) { const targetIP = await resolveTargetIP(targetHost); const forwardReq = https.request({ hostname: targetIP, port: 443, path: req.url, method: req.method, headers, servername: targetHost, rejectUnauthorized: false }, (forwardRes) => { res.writeHead(forwardRes.statusCode, forwardRes.headers); if (dumper) dumper.writeHeader(forwardRes.statusCode, forwardRes.headers); if (!onResponse && !dumper) { forwardRes.pipe(res); return; } const chunks = []; 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(); 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"); }); if (bodyBuffer.length > 0) forwardReq.write(bodyBuffer); forwardReq.end(); } // ── Request handler ─────────────────────────────────────────── const server = https.createServer(sslOptions, async (req, res) => { 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 (ENABLE_FILE_LOG) dumpRequest(req, bodyBuffer, "raw"); // Anti-loop: skip requests from 9Router 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); const patterns = URL_PATTERNS[tool] || []; const isChat = patterns.some(p => req.url.includes(p)); if (!isChat) return passthrough(req, res, bodyBuffer); // Cursor uses binary proto — model extraction not possible at this layer. // Delegate directly to handler which decodes proto internally. if (tool === "cursor") { return handlers[tool].intercept(req, res, bodyBuffer, null, passthrough); } const model = extractModel(req.url, bodyBuffer); // Intentional passthrough: some models must never be re-routed (e.g. Antigravity // tab-autocomplete) so latency-critical inline completion stays native. Silent — this // is by design, not a leak, and fires per keystroke. See MODEL_NO_MAP in config.js. if (model && (MODEL_NO_MAP[tool] || []).some((re) => re.test(model))) { return passthrough(req, res, bodyBuffer); } const mappedModel = getMappedModel(tool, model); if (!mappedModel) { return passthrough(req, res, bodyBuffer); } return handlers[tool].intercept(req, res, bodyBuffer, mappedModel, passthrough); } catch (e) { err(`Unhandled error: ${e.message}`); if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: { message: e.message, type: "mitm_error" } })); } }); // Kill only processes LISTENING on LOCAL_PORT (not outbound connections) function killPort(port) { try { let pidList = []; if (IS_WIN) { const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command ` + `"Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess"`; const out = execSync(psCmd, { encoding: "utf-8", windowsHide: true }).trim(); if (!out) return; pidList = out.split(/\r?\n/).map(s => s.trim()).filter(p => p && Number(p) !== process.pid && Number(p) > 4); } else { const out = execSync(`${LSOF_BIN} -nP -iTCP:${port} -sTCP:LISTEN -t`, { encoding: "utf-8", windowsHide: true }).trim(); if (!out) return; pidList = out.split("\n").filter(p => p && Number(p) !== process.pid); } if (pidList.length === 0) return; pidList.forEach(pid => { try { if (IS_WIN) execSync(`taskkill /F /PID ${pid}`, { windowsHide: true }); else process.kill(Number(pid), "SIGKILL"); } catch (e) { err(`Failed to kill PID ${pid}: ${e.message}`); } }); log(`Killed ${pidList.length} process(es) on port ${port}`); } catch (e) { if (e.status !== 1) throw e; } } try { killPort(LOCAL_PORT); } catch (e) { err(`Cannot kill process on port ${LOCAL_PORT}: ${e.message}`); process.exit(1); } server.listen(LOCAL_PORT, () => log(`🚀 Server ready on :${LOCAL_PORT}`)); server.on("error", (e) => { if (e.code === "EADDRINUSE") err(`Port ${LOCAL_PORT} already in use`); else if (e.code === "EACCES") err(`Permission denied for port ${LOCAL_PORT}`); else err(e.message); process.exit(1); }); const { removeAllDNSEntriesSync } = require("./dns/dnsConfig"); let isShuttingDown = false; const shutdown = () => { if (isShuttingDown) return; isShuttingDown = true; // Strip tool hosts from /etc/hosts so other apps aren't broken after exit removeAllDNSEntriesSync(); const forceExit = setTimeout(() => process.exit(0), 1500); server.close(() => { clearTimeout(forceExit); process.exit(0); }); }; process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown); if (process.platform === "win32") process.on("SIGBREAK", shutdown);