| 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; |
|
|
| |
| clearDumpDir(); |
| const INTERNAL_REQUEST_HEADER = { name: "x-request-source", value: "local" }; |
|
|
| |
| |
| 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"), |
| }; |
|
|
| |
|
|
| 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); |
| } |
|
|
| |
|
|
| 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); |
| }); |
| } |
|
|
| |
| function extractModel(url, body) { |
| const urlMatch = url.match(/\/models\/([^/:]+)/); |
| if (urlMatch) return urlMatch[1]; |
| |
| |
| 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; } |
| } |
|
|
| |
| function isBinaryData(buffer) { |
| if (!buffer || buffer.length === 0) return false; |
| |
| |
| 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]; |
| |
| if (byte < 0x20 && byte !== 0x09 && byte !== 0x0A && byte !== 0x0D) { |
| nonPrintable++; |
| } |
| if (byte > 0x7E) nonPrintable++; |
| } |
| |
| return (nonPrintable / sample.length) > 0.3; |
| } |
|
|
| function getMappedModel(tool, model) { |
| if (!model) return null; |
| try { |
| const aliases = getMitmAlias(tool); |
| if (!aliases) return null; |
| |
| const normalizedModel = String(model).replace(/^models\//, ""); |
| const lookup = MODEL_SYNONYMS?.[tool]?.[normalizedModel] || normalizedModel; |
| if (aliases[lookup]) return aliases[lookup]; |
| |
| const prefixKey = Object.keys(aliases).find(k => k && aliases[k] && (lookup.startsWith(k) || k.startsWith(lookup))); |
| if (prefixKey) return aliases[prefixKey]; |
| |
| 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; } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async function passthrough(req, res, bodyBuffer, onResponse) { |
| const originalHost = (req.headers.host || TARGET_HOSTS[0]).split(":")[0]; |
| |
| 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); |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| const alpnCache = new Map(); |
| 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")); }); |
| }); |
| } |
|
|
| |
| async function passthroughHttp2(req, res, bodyBuffer, headers, targetHost, onResponse, dumper) { |
| const targetIP = await resolveTargetIP(targetHost); |
| |
| 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"]; |
| |
| 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(); |
| }); |
| }); |
| } |
|
|
| |
| 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 { } |
| }); |
| }); |
|
|
| 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(); |
| } |
|
|
| |
|
|
| 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"); |
|
|
| |
| 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); |
|
|
| |
| |
| if (tool === "cursor") { |
| return handlers[tool].intercept(req, res, bodyBuffer, null, passthrough); |
| } |
|
|
| const model = extractModel(req.url, bodyBuffer); |
|
|
| |
| |
| |
| 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" } })); |
| } |
| }); |
|
|
| |
| 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; |
| |
| 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); |
|
|