297 lines
9.3 KiB
JavaScript
297 lines
9.3 KiB
JavaScript
const https = require("https");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const dns = require("dns");
|
|
const { promisify } = require("util");
|
|
const { log, err } = require("./logger");
|
|
|
|
// 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
|
|
const TARGET_HOSTS = [
|
|
"daily-cloudcode-pa.googleapis.com",
|
|
"cloudcode-pa.googleapis.com",
|
|
"api.individual.githubcopilot.com",
|
|
];
|
|
|
|
const LOCAL_PORT = 443;
|
|
const ROUTER_URL = "http://localhost:20128/v1/chat/completions";
|
|
const API_KEY = process.env.ROUTER_API_KEY;
|
|
const { DATA_DIR, MITM_DIR } = require("./paths");
|
|
const DB_FILE = path.join(DATA_DIR, "db.json");
|
|
|
|
const ENABLE_FILE_LOG = false;
|
|
|
|
if (!API_KEY) {
|
|
err("ROUTER_API_KEY required");
|
|
process.exit(1);
|
|
}
|
|
|
|
const { getCertForDomain } = require("./cert/generate");
|
|
|
|
// Certificate cache for performance
|
|
const certCache = new Map();
|
|
|
|
// SNI callback for dynamic certificate generation
|
|
function sniCallback(servername, cb) {
|
|
try {
|
|
// Check cache first
|
|
if (certCache.has(servername)) {
|
|
const cached = certCache.get(servername);
|
|
return cb(null, cached);
|
|
}
|
|
|
|
// Generate new cert for this domain
|
|
const certData = getCertForDomain(servername);
|
|
if (!certData) {
|
|
return cb(new Error(`Failed to generate cert for ${servername}`));
|
|
}
|
|
|
|
// Create secure context
|
|
const ctx = require("tls").createSecureContext({
|
|
key: certData.key,
|
|
cert: certData.cert
|
|
});
|
|
|
|
// Cache it
|
|
certCache.set(servername, ctx);
|
|
log(`🔐 Cert generated: ${servername}`);
|
|
|
|
cb(null, ctx);
|
|
} catch (error) {
|
|
err(`SNI error for ${servername}: ${error.message}`);
|
|
cb(error);
|
|
}
|
|
}
|
|
|
|
// Load Root CA for default context
|
|
const certDir = MITM_DIR;
|
|
const rootCAKeyPath = path.join(certDir, "rootCA.key");
|
|
const rootCACertPath = path.join(certDir, "rootCA.crt");
|
|
|
|
let sslOptions;
|
|
try {
|
|
sslOptions = {
|
|
key: fs.readFileSync(rootCAKeyPath),
|
|
cert: fs.readFileSync(rootCACertPath),
|
|
SNICallback: sniCallback
|
|
};
|
|
} catch (e) {
|
|
err(`Root CA not found in ${certDir}: ${e.message}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Antigravity: Gemini generateContent endpoints
|
|
const ANTIGRAVITY_URL_PATTERNS = [":generateContent", ":streamGenerateContent"];
|
|
// Copilot: OpenAI-compatible + Anthropic endpoints
|
|
const COPILOT_URL_PATTERNS = ["/chat/completions", "/v1/messages", "/responses"];
|
|
|
|
const LOG_DIR = path.join(DATA_DIR, "logs", "mitm");
|
|
if (ENABLE_FILE_LOG && !fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
|
|
function saveRequestLog(url, bodyBuffer) {
|
|
if (!ENABLE_FILE_LOG) return;
|
|
try {
|
|
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
const urlSlug = url.replace(/[^a-zA-Z0-9]/g, "_").substring(0, 60);
|
|
const filePath = path.join(LOG_DIR, `${ts}_${urlSlug}.json`);
|
|
const body = JSON.parse(bodyBuffer.toString());
|
|
fs.writeFileSync(filePath, JSON.stringify(body, null, 2));
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
const cachedTargetIPs = {};
|
|
async function resolveTargetIP(hostname) {
|
|
if (cachedTargetIPs[hostname]) return cachedTargetIPs[hostname];
|
|
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] = addresses[0];
|
|
return cachedTargetIPs[hostname];
|
|
}
|
|
|
|
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) or body (OpenAI/Anthropic)
|
|
function extractModel(url, body) {
|
|
const urlMatch = url.match(/\/models\/([^/:]+)/);
|
|
if (urlMatch) return urlMatch[1];
|
|
try { return JSON.parse(body.toString()).model || null; } catch { return null; }
|
|
}
|
|
|
|
function getMappedModel(tool, model) {
|
|
if (!model) return null;
|
|
try {
|
|
if (!fs.existsSync(DB_FILE)) return null;
|
|
const db = JSON.parse(fs.readFileSync(DB_FILE, "utf-8"));
|
|
const aliases = db.mitmAlias?.[tool];
|
|
if (!aliases) return null;
|
|
// Exact match first
|
|
if (aliases[model]) return aliases[model];
|
|
// Prefix match fallback: find alias key that starts with model or model starts with key
|
|
const prefixKey = Object.keys(aliases).find(k => k && aliases[k] && (model.startsWith(k) || k.startsWith(model)));
|
|
return prefixKey ? aliases[prefixKey] : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Determine which tool this request belongs to based on hostname
|
|
*/
|
|
function getToolForHost(host) {
|
|
const h = (host || "").split(":")[0];
|
|
if (h === "api.individual.githubcopilot.com") return "copilot";
|
|
if (h === "daily-cloudcode-pa.googleapis.com" || h === "cloudcode-pa.googleapis.com") return "antigravity";
|
|
return null;
|
|
}
|
|
|
|
async function passthrough(req, res, bodyBuffer) {
|
|
const targetHost = (req.headers.host || TARGET_HOSTS[0]).split(":")[0];
|
|
const targetIP = await resolveTargetIP(targetHost);
|
|
|
|
const forwardReq = https.request({
|
|
hostname: targetIP,
|
|
port: 443,
|
|
path: req.url,
|
|
method: req.method,
|
|
headers: { ...req.headers, host: targetHost },
|
|
servername: targetHost,
|
|
rejectUnauthorized: false
|
|
}, (forwardRes) => {
|
|
res.writeHead(forwardRes.statusCode, forwardRes.headers);
|
|
forwardRes.pipe(res);
|
|
});
|
|
|
|
forwardReq.on("error", (e) => {
|
|
err(`Passthrough error: ${e.message}`);
|
|
if (!res.headersSent) res.writeHead(502);
|
|
res.end("Bad Gateway");
|
|
});
|
|
|
|
if (bodyBuffer.length > 0) forwardReq.write(bodyBuffer);
|
|
forwardReq.end();
|
|
}
|
|
|
|
async function intercept(req, res, bodyBuffer, mappedModel) {
|
|
try {
|
|
const body = JSON.parse(bodyBuffer.toString());
|
|
body.model = mappedModel;
|
|
|
|
const response = await fetch(ROUTER_URL, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Authorization": `Bearer ${API_KEY}`
|
|
},
|
|
body: JSON.stringify(body)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errText = await response.text().catch(() => "");
|
|
throw new Error(`9Router ${response.status}: ${errText}`);
|
|
}
|
|
|
|
const ct = response.headers.get("content-type") || "application/json";
|
|
const resHeaders = { "Content-Type": ct, "Cache-Control": "no-cache", "Connection": "keep-alive" };
|
|
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) {
|
|
const { done, value } = await reader.read();
|
|
if (done) { res.end(); break; }
|
|
res.write(decoder.decode(value, { stream: true }));
|
|
}
|
|
} catch (error) {
|
|
err(`Intercept error: ${error.message}`);
|
|
if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ error: { message: error.message, type: "mitm_error" } }));
|
|
}
|
|
}
|
|
|
|
const server = https.createServer(sslOptions, async (req, res) => {
|
|
// 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);
|
|
log(`🔍 model="${model}" url=${req.url}`);
|
|
const mappedModel = getMappedModel(tool, model);
|
|
|
|
if (!mappedModel) {
|
|
log(`⏩ passthrough | no mapping | ${tool} | ${model || "unknown"}`);
|
|
return passthrough(req, res, bodyBuffer);
|
|
}
|
|
|
|
log(`⚡ intercept | ${tool} | ${model} → ${mappedModel}`);
|
|
return intercept(req, res, bodyBuffer, mappedModel);
|
|
} catch (e) {
|
|
err(`Unhandled request error: ${e.message}`);
|
|
if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ error: { message: e.message, type: "mitm_error" } }));
|
|
}
|
|
});
|
|
|
|
server.listen(LOCAL_PORT, () => {
|
|
log(`🚀 Server ready on :${LOCAL_PORT}`);
|
|
});
|
|
|
|
server.on("error", (error) => {
|
|
if (error.code === "EADDRINUSE") {
|
|
err(`Port ${LOCAL_PORT} already in use`);
|
|
} else if (error.code === "EACCES") {
|
|
err(`Permission denied for port ${LOCAL_PORT}`);
|
|
} else {
|
|
err(error.message);
|
|
}
|
|
process.exit(1);
|
|
});
|
|
|
|
const shutdown = () => { server.close(() => process.exit(0)); };
|
|
process.on("SIGTERM", shutdown);
|
|
process.on("SIGINT", shutdown);
|
|
if (process.platform === "win32") {
|
|
process.on("SIGBREAK", shutdown);
|
|
}
|