mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 21:02:12 +03:00
353 lines
9.8 KiB
JavaScript
353 lines
9.8 KiB
JavaScript
const https = require("https");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const dns = require("dns");
|
|
const { promisify } = require("util");
|
|
const os = require("os");
|
|
|
|
// Resolve data directory — mirrors src/lib/dataPaths.ts logic.
|
|
// This file runs as a standalone CommonJS process and cannot import the ES module.
|
|
function getDataDir() {
|
|
if (process.env.DATA_DIR) return path.resolve(process.env.DATA_DIR.trim());
|
|
return path.join(os.homedir(), ".omniroute");
|
|
}
|
|
|
|
// Configuration
|
|
const TARGET_HOSTS = new Set([
|
|
"daily-cloudcode-pa.sandbox.googleapis.com",
|
|
"daily-cloudcode-pa.googleapis.com",
|
|
"cloudcode-pa.googleapis.com",
|
|
]);
|
|
const parsedLocalPort = Number.parseInt(process.env.MITM_LOCAL_PORT || "443", 10);
|
|
const LOCAL_PORT =
|
|
Number.isInteger(parsedLocalPort) && parsedLocalPort > 0 && parsedLocalPort <= 65535
|
|
? parsedLocalPort
|
|
: 443;
|
|
const ROUTER_BASE_URL = (
|
|
process.env.OMNIROUTE_BASE_URL ||
|
|
process.env.BASE_URL ||
|
|
"http://localhost:20128"
|
|
)
|
|
.trim()
|
|
.replace(/\/+$/, "");
|
|
const ROUTER_URL = `${ROUTER_BASE_URL}/v1/chat/completions`;
|
|
const API_KEY = process.env.ROUTER_API_KEY;
|
|
const DATA_DIR = getDataDir();
|
|
const DB_FILE = path.join(DATA_DIR, "db.json");
|
|
const SQLITE_FILE = path.join(DATA_DIR, "storage.sqlite");
|
|
|
|
let _sqliteDb = null;
|
|
|
|
// Toggle logging (set true to enable file logging for debugging)
|
|
const ENABLE_FILE_LOG = false;
|
|
|
|
if (!API_KEY) {
|
|
console.error("❌ ROUTER_API_KEY required");
|
|
process.exit(1);
|
|
}
|
|
|
|
// Load SSL certificates
|
|
const certDir = path.join(DATA_DIR, "mitm");
|
|
const STATS_FILE = path.join(certDir, "stats.json");
|
|
const stats = {
|
|
startedAt: null,
|
|
totalRequests: 0,
|
|
interceptedRequests: 0,
|
|
activeConnections: 0,
|
|
lastRequestAt: null,
|
|
lastInterceptAt: null,
|
|
};
|
|
|
|
function writeStats() {
|
|
try {
|
|
fs.writeFileSync(STATS_FILE, JSON.stringify(stats, null, 2));
|
|
} catch {
|
|
// Stats are best-effort and should not affect proxy traffic.
|
|
}
|
|
}
|
|
|
|
const sslOptions = {
|
|
key: fs.readFileSync(path.join(certDir, "server.key")),
|
|
cert: fs.readFileSync(path.join(certDir, "server.crt")),
|
|
};
|
|
|
|
// Chat endpoints that should be intercepted
|
|
const CHAT_URL_PATTERNS = [":generateContent", ":streamGenerateContent"];
|
|
|
|
// Log directory for request/response dumps
|
|
const LOG_DIR = path.join(__dirname, "../../logs/mitm");
|
|
if (ENABLE_FILE_LOG && !fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
|
|
// Safe log filename: only alphanumeric + hyphens, anchored inside LOG_DIR
|
|
function safeLogPath(name) {
|
|
const safe = name.replace(/[^a-zA-Z0-9_\-]/g, "_").substring(0, 80);
|
|
const resolved = path.resolve(LOG_DIR, safe);
|
|
if (!resolved.startsWith(path.resolve(LOG_DIR) + path.sep)) {
|
|
throw new Error("Path traversal attempt detected in log filename");
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
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 = safeLogPath(`${ts}_${urlSlug}.json`);
|
|
const body = JSON.parse(bodyBuffer.toString());
|
|
fs.writeFileSync(filePath, JSON.stringify(body, null, 2));
|
|
console.log(`💾 Saved request: ${filePath}`);
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
}
|
|
|
|
function saveResponseLog(url, data) {
|
|
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 = safeLogPath(`${ts}_${urlSlug}_response.txt`);
|
|
fs.writeFileSync(filePath, data);
|
|
console.log(`💾 Saved response: ${filePath}`);
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
}
|
|
|
|
// Resolve real IP of target host (bypass /etc/hosts)
|
|
const cachedTargetIPs = new Map();
|
|
function getTargetHost(req) {
|
|
const host = String(req.headers.host || "")
|
|
.split(":")[0]
|
|
.toLowerCase();
|
|
return TARGET_HOSTS.has(host) ? host : "daily-cloudcode-pa.sandbox.googleapis.com";
|
|
}
|
|
|
|
async function resolveTargetIP(targetHost) {
|
|
if (cachedTargetIPs.has(targetHost)) return cachedTargetIPs.get(targetHost);
|
|
const resolver = new dns.Resolver();
|
|
resolver.setServers(["8.8.8.8"]);
|
|
const resolve4 = promisify(resolver.resolve4.bind(resolver));
|
|
const addresses = await resolve4(targetHost);
|
|
const targetIP = addresses[0];
|
|
cachedTargetIPs.set(targetHost, targetIP);
|
|
return targetIP;
|
|
}
|
|
|
|
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(body) {
|
|
try {
|
|
return JSON.parse(body.toString()).model || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a lazy SQLite connection for reading MITM aliases.
|
|
* Falls back to null if better-sqlite3 is unavailable.
|
|
*/
|
|
function getSqliteDb() {
|
|
if (_sqliteDb) return _sqliteDb;
|
|
try {
|
|
const Database = require("better-sqlite3");
|
|
if (fs.existsSync(SQLITE_FILE)) {
|
|
_sqliteDb = new Database(SQLITE_FILE, { readonly: true });
|
|
return _sqliteDb;
|
|
}
|
|
} catch {
|
|
// better-sqlite3 not available in this process
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getMappedModel(model) {
|
|
if (!model) return null;
|
|
|
|
// Primary: read from SQLite key_value table
|
|
try {
|
|
const db = getSqliteDb();
|
|
if (db) {
|
|
const row = db
|
|
.prepare(
|
|
"SELECT value FROM key_value WHERE namespace = 'mitmAlias' AND key = 'antigravity'"
|
|
)
|
|
.get();
|
|
if (row) {
|
|
const mappings = JSON.parse(row.value);
|
|
return mappings[model] || null;
|
|
}
|
|
}
|
|
} catch {
|
|
// Fall through to JSON fallback
|
|
}
|
|
|
|
// Fallback: read from db.json (legacy installs not yet migrated)
|
|
try {
|
|
if (fs.existsSync(DB_FILE)) {
|
|
const db = JSON.parse(fs.readFileSync(DB_FILE, "utf-8"));
|
|
return db.mitmAlias?.antigravity?.[model] || null;
|
|
}
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function passthrough(req, res, bodyBuffer) {
|
|
const targetHost = getTargetHost(req);
|
|
const targetIP = await resolveTargetIP(targetHost);
|
|
|
|
// TLS validation is enabled by default. Set MITM_DISABLE_TLS_VERIFY=1 only
|
|
// in controlled local environments where the target uses a self-signed cert.
|
|
const rejectUnauthorized = process.env.MITM_DISABLE_TLS_VERIFY !== "1";
|
|
|
|
const forwardReq = https.request(
|
|
{
|
|
hostname: targetIP,
|
|
port: 443,
|
|
path: req.url,
|
|
method: req.method,
|
|
headers: { ...req.headers, host: targetHost },
|
|
servername: targetHost,
|
|
rejectUnauthorized,
|
|
},
|
|
(forwardRes) => {
|
|
res.writeHead(forwardRes.statusCode, forwardRes.headers);
|
|
forwardRes.pipe(res);
|
|
}
|
|
);
|
|
|
|
forwardReq.on("error", (err) => {
|
|
console.error(`❌ Passthrough error: ${err.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(`OmniRoute ${response.status}: ${errText}`);
|
|
}
|
|
|
|
res.writeHead(200, {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache",
|
|
Connection: "keep-alive",
|
|
"X-Accel-Buffering": "no",
|
|
});
|
|
|
|
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) {
|
|
console.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) => {
|
|
stats.totalRequests++;
|
|
stats.lastRequestAt = new Date().toISOString();
|
|
writeStats();
|
|
|
|
const bodyBuffer = await collectBodyRaw(req);
|
|
|
|
// Save request log if enabled
|
|
if (bodyBuffer.length > 0) saveRequestLog(req.url, bodyBuffer);
|
|
|
|
// Anti-loop: requests from OmniRoute bypass interception
|
|
if (req.headers["x-omniroute-source"] === "omniroute") {
|
|
return passthrough(req, res, bodyBuffer);
|
|
}
|
|
|
|
const isChatRequest = CHAT_URL_PATTERNS.some((p) => req.url.includes(p));
|
|
|
|
if (!isChatRequest) {
|
|
return passthrough(req, res, bodyBuffer);
|
|
}
|
|
|
|
const model = extractModel(bodyBuffer);
|
|
const mappedModel = getMappedModel(model);
|
|
|
|
if (!mappedModel) {
|
|
return passthrough(req, res, bodyBuffer);
|
|
}
|
|
|
|
stats.interceptedRequests++;
|
|
stats.lastInterceptAt = new Date().toISOString();
|
|
writeStats();
|
|
|
|
console.log(`🔀 ${model} → ${mappedModel}`);
|
|
return intercept(req, res, bodyBuffer, mappedModel);
|
|
});
|
|
|
|
server.listen(LOCAL_PORT, () => {
|
|
stats.startedAt = new Date().toISOString();
|
|
writeStats();
|
|
console.log(`🚀 MITM ready on :${LOCAL_PORT} → ${ROUTER_URL}`);
|
|
});
|
|
|
|
server.on("connection", (socket) => {
|
|
stats.activeConnections++;
|
|
writeStats();
|
|
socket.on("close", () => {
|
|
stats.activeConnections = Math.max(0, stats.activeConnections - 1);
|
|
writeStats();
|
|
});
|
|
});
|
|
|
|
server.on("error", (error) => {
|
|
if (error.code === "EADDRINUSE") {
|
|
console.error(`❌ Port ${LOCAL_PORT} already in use`);
|
|
} else if (error.code === "EACCES") {
|
|
console.error(`❌ Permission denied for port ${LOCAL_PORT}`);
|
|
} else {
|
|
console.error(`❌ ${error.message}`);
|
|
}
|
|
process.exit(1);
|
|
});
|
|
|
|
process.on("SIGTERM", () => {
|
|
server.close(() => process.exit(0));
|
|
});
|
|
process.on("SIGINT", () => {
|
|
server.close(() => process.exit(0));
|
|
});
|