Files
OmniRoute/src/mitm/server.cjs
Diego Rodrigues de Sa e Souza 3ec9ca11b1 Release v3.7.6 (#1803)
* feat(api-keys): add rename support in permissions modal

Add an editable key name field at the top of the permissions modal,
allowing users to rename API keys alongside existing permission settings.

The backend already supported name updates via PATCH /api/keys/:id — this
wires the UI to send the name field and refreshes the key list on success.

Changes:
- Add keyName state and text input to PermissionsModal
- Update handleUpdatePermissions to validate and send name in PATCH body
- Add integration test for rename via PATCH (valid, empty, too-long names)
- Update E2E mock to handle PATCH requests

* chore(release): bump version to 3.7.6

* chore(release): v3.7.6 — merge API key rename feature and sync docs

* chore(release): expand contributor credits to 155 PRs across full project history

- Expanded acknowledgment table from 29 to 53 contributors
- Added 100+ previously uncredited PRs from project inception through v3.7.5
- Moved contributor credits section to v3.7.6 (current release)
- Synced llm.txt version to 3.7.6

* fix: resolve security ReDoS in codex and bugs #1797 #1789

* feat(dashboard): implement remaining v3.7.6 dashboard features and fixes

* fix(xiaomi-mimo): update models to V2.5, fix Token Plan validation and default region (#1823)

Integrated into release/v3.7.6

* fix(dashboard): correct loadPresets ReferenceError in CostOverviewTab

* fix(codex): omit compact client metadata (#1822)

Integrated into release/v3.7.6

* feat(chatgpt-web): support thinking_effort (Standard/Extended) for thinking-capable models (#1821)

Integrated into release/v3.7.6

* Fix endpoint visibility, A2A status, and API catalog (#1806)

Integrated into release/v3.7.6

* fix(analytics): use pure SQL aggregations — no history rows loaded (#1802)

Integrated into release/v3.7.6

* fix(stability): resolve codex input validation, enable combo circuit breaker, and fix broken unit tests

* docs(changelog): update for stability bug fixes #1804 #1805

* fix: clear active requests and recover providers (#1824)

Integrated into release/v3.7.6

* feat: inject fallback tool names to prevent upstream 400 errors (#1775)

* feat: auto-restore probe-failed database to prevent data loss (#1810)

* fix: safely cast inputs to strings before calling trim() to avoid crashes on numeric fields in proxy modal (#1825)

* chore(release): v3.7.6 — final stability patches for production

* test: update expected db probe-failure error message for auto-restore feature

* chore(workflow): mandate implementation plan generation in resolve-issues

* docs(changelog): rewrite v3.7.6 with complete commit-accurate entries

* feat(analytics): add cost-based usage insights and activity streaks

Expand usage analytics to report total cost, per-series cost totals,
API key counts, and current activity streaks using pricing-aware token
calculations.

Also make probe-failed database recovery choose the newest backup by
its embedded timestamp instead of filesystem mtime so auto-restore
selects the intended snapshot reliably.

* fix(mitm): enforce transparent interception on port 443 only

Reject non-443 MITM port updates in the settings API and normalize
stored configuration back to the required transparent interception
port.

Lock the dashboard port field to 443, update the validation copy, and
add integration coverage to prevent stale custom ports from being
accepted or surfaced.

* docs(changelog): update for analytics and mitm features

---------

Co-authored-by: Andrew Munsell <andrew@wizardapps.net>
Co-authored-by: Antigravity Assistant <bot@antigravity.local>
Co-authored-by: Gi99lin <74502520+Gi99lin@users.noreply.github.com>
Co-authored-by: Sergey Morozov <tr0st@bk.ru>
Co-authored-by: payne <baboialex95@gmail.com>
Co-authored-by: Randi <55005611+rdself@users.noreply.github.com>
Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com>
Co-authored-by: ipanghu <bypanghu@163.com>
2026-04-30 14:08:50 -03:00

340 lines
9.4 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_HOST = "daily-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)
let cachedTargetIP = null;
async function resolveTargetIP() {
if (cachedTargetIP) return cachedTargetIP;
const resolver = new dns.Resolver();
resolver.setServers(["8.8.8.8"]);
const resolve4 = promisify(resolver.resolve4.bind(resolver));
const addresses = await resolve4(TARGET_HOST);
cachedTargetIP = addresses[0];
return cachedTargetIP;
}
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 targetIP = await resolveTargetIP();
// 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: TARGET_HOST },
servername: TARGET_HOST,
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));
});