mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
Feat : Kiro MITM
This commit is contained in:
+1
-1
@@ -67,4 +67,4 @@ README1.md
|
|||||||
deploy.sh
|
deploy.sh
|
||||||
ecosystem.config.*
|
ecosystem.config.*
|
||||||
start.sh
|
start.sh
|
||||||
src/mitm/server2.js
|
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "9router-app",
|
"name": "9router-app",
|
||||||
"version": "0.3.54",
|
"version": "0.3.57",
|
||||||
"description": "9Router web dashboard",
|
"description": "9Router web dashboard",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -40,8 +40,10 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
|
"esbuild": "^0.27.4",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
|
"javascript-obfuscator": "^5.3.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^4"
|
"tailwindcss": "^4"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { getModelsByProviderId } from "@/shared/constants/models";
|
|||||||
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||||
import { MitmServerCard, MitmToolCard } from "@/app/(dashboard)/dashboard/cli-tools/components";
|
import { MitmServerCard, MitmToolCard } from "@/app/(dashboard)/dashboard/cli-tools/components";
|
||||||
|
|
||||||
const MITM_TOOL_IDS = ["antigravity", "copilot"];
|
const MITM_TOOL_IDS = ["antigravity", "copilot", "kiro"];
|
||||||
|
|
||||||
export default function MitmPageClient() {
|
export default function MitmPageClient() {
|
||||||
const [connections, setConnections] = useState([]);
|
const [connections, setConnections] = useState([]);
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ export async function ensureAppInitialized() {
|
|||||||
return initialized;
|
return initialized;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-initialize when module loads
|
// Auto-initialize at runtime only, not during next build
|
||||||
ensureAppInitialized().catch(console.log);
|
if (process.env.NEXT_PHASE !== "phase-production-build") {
|
||||||
|
ensureAppInitialized().catch(console.log);
|
||||||
|
}
|
||||||
|
|
||||||
export default ensureAppInitialized;
|
export default ensureAppInitialized;
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// All intercepted domains + URL patterns per tool
|
||||||
|
|
||||||
|
const TARGET_HOSTS = [
|
||||||
|
"daily-cloudcode-pa.googleapis.com",
|
||||||
|
"cloudcode-pa.googleapis.com",
|
||||||
|
"api.individual.githubcopilot.com",
|
||||||
|
"q.us-east-1.amazonaws.com",
|
||||||
|
];
|
||||||
|
|
||||||
|
const URL_PATTERNS = {
|
||||||
|
antigravity: [":generateContent", ":streamGenerateContent"],
|
||||||
|
copilot: ["/chat/completions", "/v1/messages", "/responses"],
|
||||||
|
kiro: ["/generateAssistantResponse"],
|
||||||
|
};
|
||||||
|
|
||||||
|
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";
|
||||||
|
if (h === "q.us-east-1.amazonaws.com") return "kiro";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { TARGET_HOSTS, URL_PATTERNS, getToolForHost };
|
||||||
@@ -8,6 +8,7 @@ const { log, err } = require("../logger");
|
|||||||
const TOOL_HOSTS = {
|
const TOOL_HOSTS = {
|
||||||
antigravity: ["daily-cloudcode-pa.googleapis.com", "cloudcode-pa.googleapis.com"],
|
antigravity: ["daily-cloudcode-pa.googleapis.com", "cloudcode-pa.googleapis.com"],
|
||||||
copilot: ["api.individual.githubcopilot.com"],
|
copilot: ["api.individual.githubcopilot.com"],
|
||||||
|
kiro: ["q.us-east-1.amazonaws.com", "codewhisperer.us-east-1.amazonaws.com"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const IS_WIN = process.platform === "win32";
|
const IS_WIN = process.platform === "win32";
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
const { err } = require("../logger");
|
||||||
|
const { fetchRouter, pipeSSE } = require("./base");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercept Antigravity (Gemini) request — replace model and forward to router
|
||||||
|
*/
|
||||||
|
async function intercept(req, res, bodyBuffer, mappedModel) {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(bodyBuffer.toString());
|
||||||
|
body.model = mappedModel;
|
||||||
|
const routerRes = await fetchRouter(body);
|
||||||
|
await pipeSSE(routerRes, res);
|
||||||
|
} catch (error) {
|
||||||
|
err(`[antigravity] ${error.message}`);
|
||||||
|
if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: { message: error.message, type: "mitm_error" } }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { intercept };
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
const { log, err } = require("../logger");
|
||||||
|
|
||||||
|
const ROUTER_BASE = "http://localhost:20128";
|
||||||
|
const API_KEY = process.env.ROUTER_API_KEY;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send body to 9Router at the given path and return the fetch Response object
|
||||||
|
*/
|
||||||
|
async function fetchRouter(openaiBody, path = "/v1/chat/completions") {
|
||||||
|
const response = await fetch(`${ROUTER_BASE}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(API_KEY && { "Authorization": `Bearer ${API_KEY}` })
|
||||||
|
},
|
||||||
|
body: JSON.stringify(openaiBody)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text().catch(() => "");
|
||||||
|
throw new Error(`[${response.status}]: ${errText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pipe SSE stream from router directly to client response
|
||||||
|
*/
|
||||||
|
async function pipeSSE(routerRes, res) {
|
||||||
|
const ct = routerRes.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);
|
||||||
|
|
||||||
|
if (!routerRes.body) {
|
||||||
|
res.end(await routerRes.text().catch(() => ""));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = routerRes.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 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { fetchRouter, pipeSSE };
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
const { err } = require("../logger");
|
||||||
|
const { fetchRouter, pipeSSE } = require("./base");
|
||||||
|
|
||||||
|
// Map Copilot endpoint → 9Router path
|
||||||
|
const URL_MAP = {
|
||||||
|
"/chat/completions": "/v1/chat/completions",
|
||||||
|
"/v1/messages": "/v1/messages",
|
||||||
|
"/responses": "/v1/responses",
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveRouterPath(reqUrl) {
|
||||||
|
for (const [pattern, routerPath] of Object.entries(URL_MAP)) {
|
||||||
|
if (reqUrl.includes(pattern)) return routerPath;
|
||||||
|
}
|
||||||
|
return "/v1/chat/completions";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercept Copilot request — replace model and forward to matching 9Router endpoint
|
||||||
|
*/
|
||||||
|
async function intercept(req, res, bodyBuffer, mappedModel) {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(bodyBuffer.toString());
|
||||||
|
body.model = mappedModel;
|
||||||
|
const routerPath = resolveRouterPath(req.url);
|
||||||
|
const routerRes = await fetchRouter(body, routerPath);
|
||||||
|
await pipeSSE(routerRes, res);
|
||||||
|
} catch (error) {
|
||||||
|
err(`[copilot] ${error.message}`);
|
||||||
|
if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: { message: error.message, type: "mitm_error" } }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { intercept };
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Cursor MITM handler — coming soon
|
||||||
|
* This feature is currently under development.
|
||||||
|
*/
|
||||||
|
async function intercept(req, res) {
|
||||||
|
res.writeHead(501, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
error: {
|
||||||
|
message: "Cursor MITM support is coming soon.",
|
||||||
|
type: "not_implemented"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { intercept };
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
const { err } = require("../logger");
|
||||||
|
const { fetchRouter, pipeSSE } = require("./base");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercept Kiro request — replace model and forward to router
|
||||||
|
*/
|
||||||
|
async function intercept(req, res, bodyBuffer, mappedModel) {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(bodyBuffer.toString());
|
||||||
|
body.model = mappedModel;
|
||||||
|
const routerRes = await fetchRouter(body);
|
||||||
|
await pipeSSE(routerRes, res);
|
||||||
|
} catch (error) {
|
||||||
|
err(`[Kiro] ${error.message}`);
|
||||||
|
if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: { message: error.message, type: "mitm_error" } }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { intercept };
|
||||||
+75
-163
@@ -4,100 +4,62 @@ const path = require("path");
|
|||||||
const dns = require("dns");
|
const dns = require("dns");
|
||||||
const { promisify } = require("util");
|
const { promisify } = require("util");
|
||||||
const { log, err } = require("./logger");
|
const { log, err } = require("./logger");
|
||||||
|
const { TARGET_HOSTS, URL_PATTERNS, getToolForHost } = require("./config");
|
||||||
// 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 { DATA_DIR, MITM_DIR } = require("./paths");
|
||||||
const DB_FILE = path.join(DATA_DIR, "db.json");
|
|
||||||
|
|
||||||
const ENABLE_FILE_LOG = false;
|
|
||||||
|
|
||||||
|
|
||||||
const { getCertForDomain } = require("./cert/generate");
|
const { getCertForDomain } = require("./cert/generate");
|
||||||
|
|
||||||
// Certificate cache for performance
|
const DB_FILE = path.join(DATA_DIR, "db.json");
|
||||||
const certCache = new Map();
|
const LOCAL_PORT = 443;
|
||||||
|
const ENABLE_FILE_LOG = false;
|
||||||
|
const LOG_DIR = path.join(DATA_DIR, "logs", "mitm");
|
||||||
|
const INTERNAL_REQUEST_HEADER = { name: "x-request-source", value: "local" };
|
||||||
|
|
||||||
// SNI callback for dynamic certificate generation
|
if (ENABLE_FILE_LOG && !fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||||
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
|
// Load handlers — dev/ overrides handlers/ for private implementations
|
||||||
const certData = getCertForDomain(servername);
|
function loadHandler(name) {
|
||||||
if (!certData) {
|
try { return require(`./dev/${name}`); } catch {}
|
||||||
return cb(new Error(`Failed to generate cert for ${servername}`));
|
return require(`./handlers/${name}`);
|
||||||
}
|
|
||||||
|
|
||||||
// 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 handlers = {
|
||||||
const certDir = MITM_DIR;
|
antigravity: loadHandler("antigravity"),
|
||||||
const rootCAKeyPath = path.join(certDir, "rootCA.key");
|
copilot: loadHandler("copilot"),
|
||||||
const rootCACertPath = path.join(certDir, "rootCA.crt");
|
kiro: loadHandler("kiro"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── SSL / SNI ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const certCache = new Map();
|
||||||
|
|
||||||
|
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 });
|
||||||
|
certCache.set(servername, ctx);
|
||||||
|
log(`🔐 Cert generated: ${servername}`);
|
||||||
|
cb(null, ctx);
|
||||||
|
} catch (e) {
|
||||||
|
err(`SNI error for ${servername}: ${e.message}`);
|
||||||
|
cb(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let sslOptions;
|
let sslOptions;
|
||||||
try {
|
try {
|
||||||
sslOptions = {
|
sslOptions = {
|
||||||
key: fs.readFileSync(rootCAKeyPath),
|
key: fs.readFileSync(path.join(MITM_DIR, "rootCA.key")),
|
||||||
cert: fs.readFileSync(rootCACertPath),
|
cert: fs.readFileSync(path.join(MITM_DIR, "rootCA.crt")),
|
||||||
SNICallback: sniCallback
|
SNICallback: sniCallback
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
err(`Root CA not found in ${certDir}: ${e.message}`);
|
err(`Root CA not found: ${e.message}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Antigravity: Gemini generateContent endpoints
|
// ── Helpers ───────────────────────────────────────────────────
|
||||||
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 = {};
|
const cachedTargetIPs = {};
|
||||||
async function resolveTargetIP(hostname) {
|
async function resolveTargetIP(hostname) {
|
||||||
@@ -119,11 +81,17 @@ function collectBodyRaw(req) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract model from URL path (Gemini) or body (OpenAI/Anthropic)
|
// Extract model from URL path (Gemini), body (OpenAI/Anthropic), or Kiro conversationState
|
||||||
function extractModel(url, body) {
|
function extractModel(url, body) {
|
||||||
const urlMatch = url.match(/\/models\/([^/:]+)/);
|
const urlMatch = url.match(/\/models\/([^/:]+)/);
|
||||||
if (urlMatch) return urlMatch[1];
|
if (urlMatch) return urlMatch[1];
|
||||||
try { return JSON.parse(body.toString()).model || null; } catch { 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 getMappedModel(tool, model) {
|
function getMappedModel(tool, model) {
|
||||||
@@ -133,24 +101,21 @@ function getMappedModel(tool, model) {
|
|||||||
const db = JSON.parse(fs.readFileSync(DB_FILE, "utf-8"));
|
const db = JSON.parse(fs.readFileSync(DB_FILE, "utf-8"));
|
||||||
const aliases = db.mitmAlias?.[tool];
|
const aliases = db.mitmAlias?.[tool];
|
||||||
if (!aliases) return null;
|
if (!aliases) return null;
|
||||||
// Exact match first
|
|
||||||
if (aliases[model]) return aliases[model];
|
if (aliases[model]) return aliases[model];
|
||||||
// Prefix match fallback: find alias key that starts with model or model starts with key
|
// Prefix match fallback
|
||||||
const prefixKey = Object.keys(aliases).find(k => k && aliases[k] && (model.startsWith(k) || k.startsWith(model)));
|
const prefixKey = Object.keys(aliases).find(k => k && aliases[k] && (model.startsWith(k) || k.startsWith(model)));
|
||||||
return prefixKey ? aliases[prefixKey] : null;
|
return prefixKey ? aliases[prefixKey] : null;
|
||||||
} catch {
|
} catch { return null; }
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function saveRequestLog(url, bodyBuffer) {
|
||||||
* Determine which tool this request belongs to based on hostname
|
if (!ENABLE_FILE_LOG) return;
|
||||||
*/
|
try {
|
||||||
function getToolForHost(host) {
|
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
const h = (host || "").split(":")[0];
|
const slug = url.replace(/[^a-zA-Z0-9]/g, "_").substring(0, 60);
|
||||||
if (h === "api.individual.githubcopilot.com") return "copilot";
|
const body = JSON.parse(bodyBuffer.toString());
|
||||||
if (h === "daily-cloudcode-pa.googleapis.com" || h === "cloudcode-pa.googleapis.com") return "antigravity";
|
fs.writeFileSync(path.join(LOG_DIR, `${ts}_${slug}.json`), JSON.stringify(body, null, 2));
|
||||||
return null;
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function passthrough(req, res, bodyBuffer) {
|
async function passthrough(req, res, bodyBuffer) {
|
||||||
@@ -180,53 +145,9 @@ async function passthrough(req, res, bodyBuffer) {
|
|||||||
forwardReq.end();
|
forwardReq.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function intercept(req, res, bodyBuffer, mappedModel) {
|
// ── Request handler ───────────────────────────────────────────
|
||||||
try {
|
|
||||||
const body = JSON.parse(bodyBuffer.toString());
|
|
||||||
body.model = mappedModel;
|
|
||||||
|
|
||||||
const response = await fetch(ROUTER_URL, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(API_KEY && { "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) => {
|
const server = https.createServer(sslOptions, async (req, res) => {
|
||||||
// Top-level catch to prevent uncaughtException from crashing the server
|
|
||||||
try {
|
try {
|
||||||
if (req.url === "/_mitm_health") {
|
if (req.url === "/_mitm_health") {
|
||||||
res.writeHead(200, { "Content-Type": "application/json" });
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
@@ -237,7 +158,7 @@ const server = https.createServer(sslOptions, async (req, res) => {
|
|||||||
const bodyBuffer = await collectBodyRaw(req);
|
const bodyBuffer = await collectBodyRaw(req);
|
||||||
if (bodyBuffer.length > 0) saveRequestLog(req.url, bodyBuffer);
|
if (bodyBuffer.length > 0) saveRequestLog(req.url, bodyBuffer);
|
||||||
|
|
||||||
// Anti-loop: requests originating from 9Router bypass interception
|
// Anti-loop: skip requests from 9Router
|
||||||
if (req.headers[INTERNAL_REQUEST_HEADER.name] === INTERNAL_REQUEST_HEADER.value) {
|
if (req.headers[INTERNAL_REQUEST_HEADER.name] === INTERNAL_REQUEST_HEADER.value) {
|
||||||
return passthrough(req, res, bodyBuffer);
|
return passthrough(req, res, bodyBuffer);
|
||||||
}
|
}
|
||||||
@@ -245,49 +166,40 @@ const server = https.createServer(sslOptions, async (req, res) => {
|
|||||||
const tool = getToolForHost(req.headers.host);
|
const tool = getToolForHost(req.headers.host);
|
||||||
if (!tool) return passthrough(req, res, bodyBuffer);
|
if (!tool) return passthrough(req, res, bodyBuffer);
|
||||||
|
|
||||||
// Check if this URL should be intercepted based on tool
|
const patterns = URL_PATTERNS[tool] || [];
|
||||||
const isChat = tool === "antigravity"
|
const isChat = patterns.some(p => req.url.includes(p));
|
||||||
? 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);
|
if (!isChat) return passthrough(req, res, bodyBuffer);
|
||||||
|
|
||||||
const model = extractModel(req.url, bodyBuffer);
|
log(`🔍 [${tool}] url=${req.url} | bodyLen=${bodyBuffer.length}`);
|
||||||
log(`🔍 model="${model}" url=${req.url}`);
|
|
||||||
const mappedModel = getMappedModel(tool, model);
|
|
||||||
|
|
||||||
|
const model = extractModel(req.url, bodyBuffer);
|
||||||
|
log(`🔍 [${tool}] model="${model}"`);
|
||||||
|
|
||||||
|
const mappedModel = getMappedModel(tool, model);
|
||||||
if (!mappedModel) {
|
if (!mappedModel) {
|
||||||
log(`⏩ passthrough | no mapping | ${tool} | ${model || "unknown"}`);
|
log(`⏩ passthrough | no mapping | ${tool} | ${model || "unknown"}`);
|
||||||
return passthrough(req, res, bodyBuffer);
|
return passthrough(req, res, bodyBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
log(`⚡ intercept | ${tool} | ${model} → ${mappedModel}`);
|
log(`⚡ intercept | ${tool} | ${model} → ${mappedModel}`);
|
||||||
return intercept(req, res, bodyBuffer, mappedModel);
|
return handlers[tool].intercept(req, res, bodyBuffer, mappedModel, passthrough);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
err(`Unhandled request error: ${e.message}`);
|
err(`Unhandled error: ${e.message}`);
|
||||||
if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" });
|
if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" });
|
||||||
res.end(JSON.stringify({ error: { message: e.message, type: "mitm_error" } }));
|
res.end(JSON.stringify({ error: { message: e.message, type: "mitm_error" } }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(LOCAL_PORT, () => {
|
server.listen(LOCAL_PORT, () => log(`🚀 Server ready on :${LOCAL_PORT}`));
|
||||||
log(`🚀 Server ready on :${LOCAL_PORT}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
server.on("error", (error) => {
|
server.on("error", (e) => {
|
||||||
if (error.code === "EADDRINUSE") {
|
if (e.code === "EADDRINUSE") err(`Port ${LOCAL_PORT} already in use`);
|
||||||
err(`Port ${LOCAL_PORT} already in use`);
|
else if (e.code === "EACCES") err(`Permission denied for port ${LOCAL_PORT}`);
|
||||||
} else if (error.code === "EACCES") {
|
else err(e.message);
|
||||||
err(`Permission denied for port ${LOCAL_PORT}`);
|
|
||||||
} else {
|
|
||||||
err(error.message);
|
|
||||||
}
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
const shutdown = () => { server.close(() => process.exit(0)); };
|
const shutdown = () => server.close(() => process.exit(0));
|
||||||
process.on("SIGTERM", shutdown);
|
process.on("SIGTERM", shutdown);
|
||||||
process.on("SIGINT", shutdown);
|
process.on("SIGINT", shutdown);
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") process.on("SIGBREAK", shutdown);
|
||||||
process.on("SIGBREAK", shutdown);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -76,10 +76,26 @@ export const CLI_TOOLS = {
|
|||||||
defaultModels: [
|
defaultModels: [
|
||||||
{ id: "gpt-4o", name: "GPT-4o", alias: "gpt-4o" },
|
{ id: "gpt-4o", name: "GPT-4o", alias: "gpt-4o" },
|
||||||
{ id: "gpt-4.1", name: "GPT-4.1", alias: "gpt-4.1" },
|
{ id: "gpt-4.1", name: "GPT-4.1", alias: "gpt-4.1" },
|
||||||
{ id: "gpt-5-mini", name: "GPT-5 Mini", alias: "gpt-5-mini" },
|
|
||||||
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5", alias: "claude-haiku-4.5" },
|
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5", alias: "claude-haiku-4.5" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
kiro: {
|
||||||
|
id: "kiro",
|
||||||
|
name: "Kiro",
|
||||||
|
image: "/providers/kiro.png",
|
||||||
|
color: "#FF6B00",
|
||||||
|
description: "Kiro IDE with MITM",
|
||||||
|
configType: "mitm",
|
||||||
|
mitmDomain: "q.us-east-1.amazonaws.com",
|
||||||
|
defaultModels: [
|
||||||
|
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", alias: "claude-sonnet-4.5" },
|
||||||
|
{ id: "claude-sonnet-4", name: "Claude Sonnet 4", alias: "claude-sonnet-4" },
|
||||||
|
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5", alias: "claude-haiku-4.5" },
|
||||||
|
{ id: "deepseek-3.2", name: "DeepSeek 3.2", alias: "deepseek-3.2" },
|
||||||
|
{ id: "minimax-m2.1", name: "MiniMax M2.1", alias: "minimax-m2.1" },
|
||||||
|
{ id: "simple-task", name: "Qwen3 Coder Next", alias: "simple-task" },
|
||||||
|
],
|
||||||
|
},
|
||||||
droid: {
|
droid: {
|
||||||
id: "droid",
|
id: "droid",
|
||||||
name: "Factory Droid",
|
name: "Factory Droid",
|
||||||
|
|||||||
Reference in New Issue
Block a user