responses-proxy 0.1.4 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.js +366 -92
- package/dist/client/assets/{index-DAy1ivku.js → index-D4Ktr4qL.js} +1 -1
- package/dist/client/index.html +1 -1
- package/dist/mitm/cert.js +111 -0
- package/dist/mitm/dns.js +150 -0
- package/dist/mitm/server.js +229 -0
- package/dist/mitm/start-server.js +13 -0
- package/dist/package.json +1 -1
- package/dist/server.js +111 -22
- package/package.json +1 -1
package/dist/client/index.html
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
margin: 0;
|
|
23
23
|
}
|
|
24
24
|
</style>
|
|
25
|
-
<script type="module" crossorigin src="/assets/index-
|
|
25
|
+
<script type="module" crossorigin src="/assets/index-D4Ktr4qL.js"></script>
|
|
26
26
|
<link rel="stylesheet" crossorigin href="/assets/index-DpqgYK3L.css">
|
|
27
27
|
</head>
|
|
28
28
|
<body>
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MITM Certificate Management — generates root CA + per-domain leaf certs.
|
|
3
|
+
* Uses node-forge for certificate generation (no native deps).
|
|
4
|
+
*/
|
|
5
|
+
import { execSync } from "node:child_process";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
const MITM_DIR = path.join(process.env.HOME || "", ".responses-proxy", "mitm");
|
|
10
|
+
const ROOT_CA_KEY = path.join(MITM_DIR, "rootCA.key");
|
|
11
|
+
const ROOT_CA_CERT = path.join(MITM_DIR, "rootCA.crt");
|
|
12
|
+
export function getMitmDir() {
|
|
13
|
+
mkdirSync(MITM_DIR, { recursive: true });
|
|
14
|
+
return MITM_DIR;
|
|
15
|
+
}
|
|
16
|
+
export function rootCaExists() {
|
|
17
|
+
return existsSync(ROOT_CA_KEY) && existsSync(ROOT_CA_CERT);
|
|
18
|
+
}
|
|
19
|
+
export function isRootCaTrusted() {
|
|
20
|
+
if (process.platform !== "darwin")
|
|
21
|
+
return false;
|
|
22
|
+
try {
|
|
23
|
+
const result = execSync(`security verify-cert -c "${ROOT_CA_CERT}" 2>&1`, { encoding: "utf8", timeout: 5000 });
|
|
24
|
+
return result.includes("successful") || !result.includes("CSSMERR");
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Generate a self-signed Root CA using openssl CLI (available on macOS/Linux).
|
|
32
|
+
* Outputs rootCA.key + rootCA.crt to MITM_DIR.
|
|
33
|
+
*/
|
|
34
|
+
export function generateRootCA() {
|
|
35
|
+
mkdirSync(MITM_DIR, { recursive: true });
|
|
36
|
+
if (rootCaExists()) {
|
|
37
|
+
return { keyPath: ROOT_CA_KEY, certPath: ROOT_CA_CERT };
|
|
38
|
+
}
|
|
39
|
+
// Generate RSA 2048 key
|
|
40
|
+
execSync(`openssl genrsa -out "${ROOT_CA_KEY}" 2048`, { stdio: "ignore", timeout: 10000 });
|
|
41
|
+
// Generate self-signed CA cert (valid 10 years)
|
|
42
|
+
execSync(`openssl req -x509 -new -nodes -key "${ROOT_CA_KEY}" -sha256 -days 3650 ` +
|
|
43
|
+
`-subj "/CN=responses-proxy MITM CA/O=responses-proxy/C=US" ` +
|
|
44
|
+
`-out "${ROOT_CA_CERT}"`, { stdio: "ignore", timeout: 10000 });
|
|
45
|
+
return { keyPath: ROOT_CA_KEY, certPath: ROOT_CA_CERT };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Trust the root CA in the macOS system keychain (requires sudo).
|
|
49
|
+
*/
|
|
50
|
+
export async function trustRootCA(sudoPassword) {
|
|
51
|
+
if (process.platform !== "darwin") {
|
|
52
|
+
throw new Error("Trust cert is only supported on macOS");
|
|
53
|
+
}
|
|
54
|
+
if (!rootCaExists()) {
|
|
55
|
+
throw new Error("Root CA does not exist. Generate it first.");
|
|
56
|
+
}
|
|
57
|
+
const cmd = `security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${ROOT_CA_CERT}"`;
|
|
58
|
+
if (sudoPassword) {
|
|
59
|
+
const { execWithPassword } = await import("./dns.js");
|
|
60
|
+
await execWithPassword(cmd, sudoPassword);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
execSync(`sudo ${cmd}`, { stdio: "ignore", timeout: 15000 });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Generate a leaf certificate for a specific domain, signed by the root CA.
|
|
68
|
+
* Returns { key, cert } as PEM strings.
|
|
69
|
+
*/
|
|
70
|
+
export function generateLeafCert(domain) {
|
|
71
|
+
if (!rootCaExists())
|
|
72
|
+
return null;
|
|
73
|
+
const tmpDir = path.join(MITM_DIR, "tmp");
|
|
74
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
75
|
+
const serial = randomBytes(8).toString("hex");
|
|
76
|
+
const leafKey = path.join(tmpDir, `${serial}.key`);
|
|
77
|
+
const leafCsr = path.join(tmpDir, `${serial}.csr`);
|
|
78
|
+
const leafCert = path.join(tmpDir, `${serial}.crt`);
|
|
79
|
+
const extFile = path.join(tmpDir, `${serial}.ext`);
|
|
80
|
+
try {
|
|
81
|
+
// Generate leaf key
|
|
82
|
+
execSync(`openssl genrsa -out "${leafKey}" 2048`, { stdio: "ignore", timeout: 5000 });
|
|
83
|
+
// Generate CSR
|
|
84
|
+
execSync(`openssl req -new -key "${leafKey}" -subj "/CN=${domain}" -out "${leafCsr}"`, { stdio: "ignore", timeout: 5000 });
|
|
85
|
+
// Write SAN extension file
|
|
86
|
+
writeFileSync(extFile, `authorityKeyIdentifier=keyid,issuer\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,nonRepudiation,keyEncipherment,dataEncipherment\nsubjectAltName=DNS:${domain}\n`);
|
|
87
|
+
// Sign with root CA
|
|
88
|
+
execSync(`openssl x509 -req -in "${leafCsr}" -CA "${ROOT_CA_CERT}" -CAkey "${ROOT_CA_KEY}" ` +
|
|
89
|
+
`-CAcreateserial -out "${leafCert}" -days 825 -sha256 -extfile "${extFile}"`, { stdio: "ignore", timeout: 5000 });
|
|
90
|
+
const key = readFileSync(leafKey, "utf8");
|
|
91
|
+
const cert = readFileSync(leafCert, "utf8");
|
|
92
|
+
// Cleanup temp files
|
|
93
|
+
try {
|
|
94
|
+
for (const f of [leafKey, leafCsr, leafCert, extFile]) {
|
|
95
|
+
if (existsSync(f))
|
|
96
|
+
require("node:fs").unlinkSync(f);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch { /* best effort */ }
|
|
100
|
+
return { key, cert };
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export function loadRootCA() {
|
|
107
|
+
return {
|
|
108
|
+
key: readFileSync(ROOT_CA_KEY, "utf8"),
|
|
109
|
+
cert: readFileSync(ROOT_CA_CERT, "utf8"),
|
|
110
|
+
};
|
|
111
|
+
}
|
package/dist/mitm/dns.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MITM DNS Management — manages /etc/hosts entries to redirect tool domains to localhost.
|
|
3
|
+
*/
|
|
4
|
+
import { execSync, spawn } from "node:child_process";
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
const IS_WIN = process.platform === "win32";
|
|
7
|
+
const IS_MAC = process.platform === "darwin";
|
|
8
|
+
const HOSTS_FILE = IS_WIN
|
|
9
|
+
? `${process.env.SystemRoot || "C:\\Windows"}\\System32\\drivers\\etc\\hosts`
|
|
10
|
+
: "/etc/hosts";
|
|
11
|
+
// Tool domains to intercept
|
|
12
|
+
export const TOOL_HOSTS = {
|
|
13
|
+
antigravity: ["daily-cloudcode-pa.googleapis.com", "cloudcode-pa.googleapis.com"],
|
|
14
|
+
copilot: ["api.individual.githubcopilot.com"],
|
|
15
|
+
kiro: ["q.us-east-1.amazonaws.com"],
|
|
16
|
+
};
|
|
17
|
+
export function isSudoAvailable() {
|
|
18
|
+
if (IS_WIN)
|
|
19
|
+
return false;
|
|
20
|
+
try {
|
|
21
|
+
execSync("command -v sudo", { stdio: "ignore", timeout: 3000 });
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function canRunSudoWithoutPassword() {
|
|
29
|
+
if (IS_WIN || !isSudoAvailable())
|
|
30
|
+
return true;
|
|
31
|
+
try {
|
|
32
|
+
execSync("sudo -n true", { stdio: "ignore", timeout: 3000 });
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function isSudoPasswordRequired() {
|
|
40
|
+
return !IS_WIN && isSudoAvailable() && !canRunSudoWithoutPassword();
|
|
41
|
+
}
|
|
42
|
+
export function execWithPassword(command, password) {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const useSudo = isSudoAvailable();
|
|
45
|
+
const child = useSudo
|
|
46
|
+
? spawn("sudo", ["-S", "sh", "-c", command], { stdio: ["pipe", "pipe", "pipe"] })
|
|
47
|
+
: spawn("sh", ["-c", command], { stdio: ["ignore", "pipe", "pipe"] });
|
|
48
|
+
let stdout = "";
|
|
49
|
+
let stderr = "";
|
|
50
|
+
child.stdout?.on("data", (d) => { stdout += d; });
|
|
51
|
+
child.stderr?.on("data", (d) => { stderr += d; });
|
|
52
|
+
child.on("close", (code) => {
|
|
53
|
+
if (code === 0)
|
|
54
|
+
resolve(stdout);
|
|
55
|
+
else
|
|
56
|
+
reject(new Error(stderr || `Exit code ${code}`));
|
|
57
|
+
});
|
|
58
|
+
if (useSudo && child.stdin) {
|
|
59
|
+
child.stdin.write(`${password}\n`);
|
|
60
|
+
child.stdin.end();
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function checkDNSEntry(host) {
|
|
65
|
+
try {
|
|
66
|
+
const content = readFileSync(HOSTS_FILE, "utf8");
|
|
67
|
+
return content.includes(host);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function checkAllDNSStatus() {
|
|
74
|
+
try {
|
|
75
|
+
const content = readFileSync(HOSTS_FILE, "utf8");
|
|
76
|
+
const result = {};
|
|
77
|
+
for (const [tool, hosts] of Object.entries(TOOL_HOSTS)) {
|
|
78
|
+
result[tool] = hosts.every((h) => content.includes(h));
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return Object.fromEntries(Object.keys(TOOL_HOSTS).map((t) => [t, false]));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function flushDNS(sudoPassword) {
|
|
87
|
+
if (IS_WIN)
|
|
88
|
+
return;
|
|
89
|
+
if (IS_MAC) {
|
|
90
|
+
await execWithPassword("dscacheutil -flushcache && killall -HUP mDNSResponder", sudoPassword);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
await execWithPassword("resolvectl flush-caches 2>/dev/null || true", sudoPassword);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export async function addDNSEntry(tool, sudoPassword) {
|
|
97
|
+
const hosts = TOOL_HOSTS[tool];
|
|
98
|
+
if (!hosts)
|
|
99
|
+
throw new Error(`Unknown tool: ${tool}`);
|
|
100
|
+
const entriesToAdd = hosts.filter((h) => !checkDNSEntry(h));
|
|
101
|
+
if (entriesToAdd.length === 0)
|
|
102
|
+
return;
|
|
103
|
+
const current = readFileSync(HOSTS_FILE, "utf8");
|
|
104
|
+
const trimmed = current.replace(/[\r\n\s]+$/g, "");
|
|
105
|
+
const toAppend = entriesToAdd.map((h) => `127.0.0.1 ${h}`).join("\n");
|
|
106
|
+
const next = `${trimmed}\n${toAppend}\n`;
|
|
107
|
+
const escaped = next.replace(/'/g, "'\\''");
|
|
108
|
+
await execWithPassword(`printf '%s' '${escaped}' | tee ${HOSTS_FILE} > /dev/null`, sudoPassword);
|
|
109
|
+
await flushDNS(sudoPassword);
|
|
110
|
+
}
|
|
111
|
+
export async function removeDNSEntry(tool, sudoPassword) {
|
|
112
|
+
const hosts = TOOL_HOSTS[tool];
|
|
113
|
+
if (!hosts)
|
|
114
|
+
throw new Error(`Unknown tool: ${tool}`);
|
|
115
|
+
const entriesToRemove = hosts.filter((h) => checkDNSEntry(h));
|
|
116
|
+
if (entriesToRemove.length === 0)
|
|
117
|
+
return;
|
|
118
|
+
const current = readFileSync(HOSTS_FILE, "utf8");
|
|
119
|
+
const filtered = current
|
|
120
|
+
.split(/\r?\n/)
|
|
121
|
+
.filter((l) => !entriesToRemove.some((h) => l.includes(h)))
|
|
122
|
+
.join("\n");
|
|
123
|
+
const next = filtered.replace(/[\r\n\s]+$/g, "") + "\n";
|
|
124
|
+
const escaped = next.replace(/'/g, "'\\''");
|
|
125
|
+
await execWithPassword(`printf '%s' '${escaped}' | tee ${HOSTS_FILE} > /dev/null`, sudoPassword);
|
|
126
|
+
await flushDNS(sudoPassword);
|
|
127
|
+
}
|
|
128
|
+
export function removeAllDNSEntriesSync() {
|
|
129
|
+
try {
|
|
130
|
+
if (!existsSync(HOSTS_FILE))
|
|
131
|
+
return;
|
|
132
|
+
const allHosts = Object.values(TOOL_HOSTS).flat();
|
|
133
|
+
const content = readFileSync(HOSTS_FILE, "utf8");
|
|
134
|
+
const filtered = content
|
|
135
|
+
.split(/\r?\n/)
|
|
136
|
+
.filter((l) => !allHosts.some((h) => l.includes(h)))
|
|
137
|
+
.join("\n");
|
|
138
|
+
const next = filtered.replace(/[\r\n\s]+$/g, "") + "\n";
|
|
139
|
+
if (next === content)
|
|
140
|
+
return;
|
|
141
|
+
writeFileSync(HOSTS_FILE, next, "utf8");
|
|
142
|
+
if (IS_MAC) {
|
|
143
|
+
try {
|
|
144
|
+
execSync("dscacheutil -flushcache && killall -HUP mDNSResponder", { stdio: "ignore" });
|
|
145
|
+
}
|
|
146
|
+
catch { /* ignore */ }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch { /* best effort during shutdown */ }
|
|
150
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MITM Proxy Server — intercepts HTTPS on port 443 with dynamic SSL certs.
|
|
3
|
+
*
|
|
4
|
+
* Cloned from 9Router's src/mitm/server.js approach:
|
|
5
|
+
* - HTTPS server on :443 with SNI callback for per-domain certs
|
|
6
|
+
* - Intercepts requests to tool domains (Antigravity, Copilot, Kiro)
|
|
7
|
+
* - Rewrites model in request body to mapped model from proxy
|
|
8
|
+
* - Forwards to real upstream via HTTPS with custom DNS (bypass /etc/hosts loop)
|
|
9
|
+
*/
|
|
10
|
+
import * as https from "node:https";
|
|
11
|
+
import * as tls from "node:tls";
|
|
12
|
+
import * as dns from "node:dns";
|
|
13
|
+
import { promisify } from "node:util";
|
|
14
|
+
import { readFileSync, existsSync, writeFileSync } from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { execSync } from "node:child_process";
|
|
17
|
+
import { generateLeafCert, loadRootCA, rootCaExists, getMitmDir } from "./cert.js";
|
|
18
|
+
import { TOOL_HOSTS, removeAllDNSEntriesSync } from "./dns.js";
|
|
19
|
+
const LOCAL_PORT = 443;
|
|
20
|
+
const MITM_DIR = getMitmDir();
|
|
21
|
+
const PID_FILE = path.join(MITM_DIR, ".mitm.pid");
|
|
22
|
+
// Tool detection from host header
|
|
23
|
+
const HOST_TO_TOOL = {};
|
|
24
|
+
for (const [tool, hosts] of Object.entries(TOOL_HOSTS)) {
|
|
25
|
+
for (const h of hosts)
|
|
26
|
+
HOST_TO_TOOL[h] = tool;
|
|
27
|
+
}
|
|
28
|
+
// Proxy base URL — requests are forwarded to responses-proxy for model routing
|
|
29
|
+
const ROUTER_BASE_URL = process.env.MITM_ROUTER_BASE_URL || "http://localhost:8318";
|
|
30
|
+
// Internal header to prevent loop
|
|
31
|
+
const INTERNAL_HEADER = "x-request-source";
|
|
32
|
+
const INTERNAL_VALUE = "mitm-local";
|
|
33
|
+
// ─── SSL / SNI ───────────────────────────────────────────────────────────────
|
|
34
|
+
const certCache = new Map();
|
|
35
|
+
let rootCAPem;
|
|
36
|
+
function sniCallback(servername, cb) {
|
|
37
|
+
try {
|
|
38
|
+
if (certCache.has(servername))
|
|
39
|
+
return cb(null, certCache.get(servername));
|
|
40
|
+
const certData = generateLeafCert(servername);
|
|
41
|
+
if (!certData)
|
|
42
|
+
return cb(new Error(`Failed to generate cert for ${servername}`));
|
|
43
|
+
const ctx = tls.createSecureContext({
|
|
44
|
+
key: certData.key,
|
|
45
|
+
cert: `${certData.cert}\n${rootCAPem}`,
|
|
46
|
+
});
|
|
47
|
+
certCache.set(servername, ctx);
|
|
48
|
+
cb(null, ctx);
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
cb(e);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
55
|
+
const resolve4 = promisify((() => {
|
|
56
|
+
const resolver = new dns.Resolver();
|
|
57
|
+
resolver.setServers(["8.8.8.8", "1.1.1.1"]);
|
|
58
|
+
return resolver.resolve4.bind(resolver);
|
|
59
|
+
})());
|
|
60
|
+
const ipCache = {};
|
|
61
|
+
async function resolveTargetIP(hostname) {
|
|
62
|
+
const cached = ipCache[hostname];
|
|
63
|
+
if (cached && Date.now() - cached.ts < 5 * 60 * 1000)
|
|
64
|
+
return cached.ip;
|
|
65
|
+
const addresses = await resolve4(hostname);
|
|
66
|
+
ipCache[hostname] = { ip: addresses[0], ts: Date.now() };
|
|
67
|
+
return addresses[0];
|
|
68
|
+
}
|
|
69
|
+
function collectBody(req) {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
const chunks = [];
|
|
72
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
73
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
74
|
+
req.on("error", reject);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
// ─── Request handler ─────────────────────────────────────────────────────────
|
|
78
|
+
async function handleRequest(req, res) {
|
|
79
|
+
try {
|
|
80
|
+
// Health check
|
|
81
|
+
if (req.url === "/_mitm_health") {
|
|
82
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
83
|
+
res.end(JSON.stringify({ ok: true, pid: process.pid }));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const bodyBuffer = await collectBody(req);
|
|
87
|
+
// Anti-loop: skip our own requests
|
|
88
|
+
if (req.headers[INTERNAL_HEADER] === INTERNAL_VALUE) {
|
|
89
|
+
return passthrough(req, res, bodyBuffer);
|
|
90
|
+
}
|
|
91
|
+
const host = (req.headers.host || "").split(":")[0];
|
|
92
|
+
const tool = HOST_TO_TOOL[host];
|
|
93
|
+
// Not a tool domain — passthrough
|
|
94
|
+
if (!tool) {
|
|
95
|
+
return passthrough(req, res, bodyBuffer);
|
|
96
|
+
}
|
|
97
|
+
// Forward to our proxy router instead of upstream
|
|
98
|
+
// This lets the proxy handle model routing, RTK, combos, etc.
|
|
99
|
+
return forwardToRouter(req, res, bodyBuffer, tool);
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
if (!res.headersSent)
|
|
103
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
104
|
+
res.end(JSON.stringify({ error: { message: e.message, type: "mitm_error" } }));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Forward intercepted request to the local responses-proxy router.
|
|
109
|
+
* The proxy will handle model selection, provider routing, RTK, etc.
|
|
110
|
+
*/
|
|
111
|
+
function forwardToRouter(req, res, bodyBuffer, tool) {
|
|
112
|
+
const url = new URL(ROUTER_BASE_URL);
|
|
113
|
+
const http = require("node:http");
|
|
114
|
+
const proxyReq = http.request({
|
|
115
|
+
hostname: url.hostname,
|
|
116
|
+
port: url.port || 8318,
|
|
117
|
+
path: "/v1/chat/completions",
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: {
|
|
120
|
+
"content-type": "application/json",
|
|
121
|
+
"content-length": String(bodyBuffer.length),
|
|
122
|
+
[INTERNAL_HEADER]: INTERNAL_VALUE,
|
|
123
|
+
"x-mitm-tool": tool,
|
|
124
|
+
"x-mitm-original-host": req.headers.host || "",
|
|
125
|
+
"x-mitm-original-path": req.url || "",
|
|
126
|
+
},
|
|
127
|
+
}, (proxyRes) => {
|
|
128
|
+
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
|
129
|
+
proxyRes.pipe(res);
|
|
130
|
+
});
|
|
131
|
+
proxyReq.on("error", (e) => {
|
|
132
|
+
// If proxy is unreachable, passthrough to real upstream
|
|
133
|
+
passthrough(req, res, bodyBuffer);
|
|
134
|
+
});
|
|
135
|
+
proxyReq.write(bodyBuffer);
|
|
136
|
+
proxyReq.end();
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Forward request to the real upstream (bypass /etc/hosts via custom DNS).
|
|
140
|
+
*/
|
|
141
|
+
async function passthrough(req, res, bodyBuffer) {
|
|
142
|
+
const originalHost = (req.headers.host || "").split(":")[0];
|
|
143
|
+
const targetIP = await resolveTargetIP(originalHost);
|
|
144
|
+
const forwardReq = https.request({
|
|
145
|
+
hostname: targetIP,
|
|
146
|
+
port: 443,
|
|
147
|
+
path: req.url,
|
|
148
|
+
method: req.method,
|
|
149
|
+
headers: { ...req.headers, host: originalHost },
|
|
150
|
+
servername: originalHost,
|
|
151
|
+
rejectUnauthorized: false,
|
|
152
|
+
}, (forwardRes) => {
|
|
153
|
+
res.writeHead(forwardRes.statusCode || 502, forwardRes.headers);
|
|
154
|
+
forwardRes.pipe(res);
|
|
155
|
+
});
|
|
156
|
+
forwardReq.on("error", (e) => {
|
|
157
|
+
if (!res.headersSent)
|
|
158
|
+
res.writeHead(502);
|
|
159
|
+
res.end("Bad Gateway");
|
|
160
|
+
});
|
|
161
|
+
if (bodyBuffer.length > 0)
|
|
162
|
+
forwardReq.write(bodyBuffer);
|
|
163
|
+
forwardReq.end();
|
|
164
|
+
}
|
|
165
|
+
// ─── Server startup ──────────────────────────────────────────────────────────
|
|
166
|
+
export function startMitmServer() {
|
|
167
|
+
if (!rootCaExists()) {
|
|
168
|
+
throw new Error("Root CA not found. Generate it first via the dashboard.");
|
|
169
|
+
}
|
|
170
|
+
const rootCA = loadRootCA();
|
|
171
|
+
rootCAPem = rootCA.cert;
|
|
172
|
+
const sslOptions = {
|
|
173
|
+
key: rootCA.key,
|
|
174
|
+
cert: rootCA.cert,
|
|
175
|
+
SNICallback: sniCallback,
|
|
176
|
+
};
|
|
177
|
+
// Kill existing process on port 443
|
|
178
|
+
try {
|
|
179
|
+
const pids = execSync(`lsof -nP -iTCP:${LOCAL_PORT} -sTCP:LISTEN -t`, { encoding: "utf8", timeout: 3000 }).trim();
|
|
180
|
+
if (pids) {
|
|
181
|
+
pids.split("\n").forEach((pid) => {
|
|
182
|
+
try {
|
|
183
|
+
process.kill(Number(pid), "SIGKILL");
|
|
184
|
+
}
|
|
185
|
+
catch { /* ignore */ }
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
catch { /* port is free */ }
|
|
190
|
+
const server = https.createServer(sslOptions, handleRequest);
|
|
191
|
+
server.listen(LOCAL_PORT, () => {
|
|
192
|
+
console.log(`[mitm] 🚀 MITM server running on :${LOCAL_PORT}`);
|
|
193
|
+
writeFileSync(PID_FILE, String(process.pid));
|
|
194
|
+
});
|
|
195
|
+
server.on("error", (e) => {
|
|
196
|
+
if (e.code === "EADDRINUSE") {
|
|
197
|
+
throw new Error(`Port ${LOCAL_PORT} already in use`);
|
|
198
|
+
}
|
|
199
|
+
if (e.code === "EACCES") {
|
|
200
|
+
throw new Error(`Permission denied for port ${LOCAL_PORT}. Run with sudo.`);
|
|
201
|
+
}
|
|
202
|
+
throw e;
|
|
203
|
+
});
|
|
204
|
+
// Graceful shutdown
|
|
205
|
+
const shutdown = () => {
|
|
206
|
+
removeAllDNSEntriesSync();
|
|
207
|
+
server.close(() => process.exit(0));
|
|
208
|
+
setTimeout(() => process.exit(0), 1500);
|
|
209
|
+
};
|
|
210
|
+
process.on("SIGTERM", shutdown);
|
|
211
|
+
process.on("SIGINT", shutdown);
|
|
212
|
+
return { pid: process.pid };
|
|
213
|
+
}
|
|
214
|
+
export function getMitmPid() {
|
|
215
|
+
try {
|
|
216
|
+
if (!existsSync(PID_FILE))
|
|
217
|
+
return null;
|
|
218
|
+
const pid = Number(readFileSync(PID_FILE, "utf8").trim());
|
|
219
|
+
// Check if process is alive
|
|
220
|
+
process.kill(pid, 0);
|
|
221
|
+
return pid;
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
export function isMitmRunning() {
|
|
228
|
+
return getMitmPid() !== null;
|
|
229
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MITM Server entry point — spawned with sudo by the main proxy.
|
|
3
|
+
* Runs the HTTPS interception server on port 443.
|
|
4
|
+
*/
|
|
5
|
+
import { startMitmServer } from "./server.js";
|
|
6
|
+
try {
|
|
7
|
+
const { pid } = startMitmServer();
|
|
8
|
+
console.log(`[mitm] Server started (PID: ${pid})`);
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
console.error(`[mitm] Failed to start:`, error instanceof Error ? error.message : error);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
package/dist/package.json
CHANGED
package/dist/server.js
CHANGED
|
@@ -1972,33 +1972,122 @@ app.delete("/api/cli-tools/codex-settings", async (_request, reply) => {
|
|
|
1972
1972
|
return reply.code(500).send({ error: error instanceof Error ? error.message : "Failed to reset settings" });
|
|
1973
1973
|
}
|
|
1974
1974
|
});
|
|
1975
|
-
// ─── MITM Server Status
|
|
1975
|
+
// ─── MITM Server Status ─────────────────────────────────────────────────────
|
|
1976
1976
|
app.get("/api/cli-tools/mitm-status", async (_request, reply) => {
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1977
|
+
try {
|
|
1978
|
+
const { rootCaExists, isRootCaTrusted } = await import("./mitm/cert.js");
|
|
1979
|
+
const { isMitmRunning } = await import("./mitm/server.js");
|
|
1980
|
+
const { checkAllDNSStatus, isSudoPasswordRequired } = await import("./mitm/dns.js");
|
|
1981
|
+
return reply.send({
|
|
1982
|
+
running: isMitmRunning(),
|
|
1983
|
+
certExists: rootCaExists(),
|
|
1984
|
+
certTrusted: isRootCaTrusted(),
|
|
1985
|
+
dnsStatus: checkAllDNSStatus(),
|
|
1986
|
+
needsSudoPassword: isSudoPasswordRequired(),
|
|
1987
|
+
available: true,
|
|
1988
|
+
isWin: process.platform === "win32",
|
|
1989
|
+
});
|
|
1990
|
+
}
|
|
1991
|
+
catch (error) {
|
|
1992
|
+
return reply.send({
|
|
1993
|
+
running: false,
|
|
1994
|
+
certExists: false,
|
|
1995
|
+
certTrusted: false,
|
|
1996
|
+
dnsStatus: {},
|
|
1997
|
+
available: false,
|
|
1998
|
+
reason: error instanceof Error ? error.message : "MITM module not available",
|
|
1999
|
+
});
|
|
2000
|
+
}
|
|
1987
2001
|
});
|
|
1988
|
-
app.post("/api/cli-tools/mitm-start", async (
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
2002
|
+
app.post("/api/cli-tools/mitm-start", async (request, reply) => {
|
|
2003
|
+
const body = request.body;
|
|
2004
|
+
try {
|
|
2005
|
+
const { generateRootCA, trustRootCA, rootCaExists: caExists } = await import("./mitm/cert.js");
|
|
2006
|
+
const { spawn } = await import("node:child_process");
|
|
2007
|
+
// Ensure CA exists
|
|
2008
|
+
if (!caExists()) {
|
|
2009
|
+
generateRootCA();
|
|
2010
|
+
}
|
|
2011
|
+
// Start MITM server as a child process (needs root for port 443)
|
|
2012
|
+
const mitmScript = path.resolve(import.meta.dirname || __dirname, "mitm", "start-server.js");
|
|
2013
|
+
const sudoPassword = body?.sudoPassword || "";
|
|
2014
|
+
// Use sudo to start the server process
|
|
2015
|
+
const env = {
|
|
2016
|
+
...process.env,
|
|
2017
|
+
MITM_ROUTER_BASE_URL: `http://127.0.0.1:${config.PORT}`,
|
|
2018
|
+
};
|
|
2019
|
+
const child = spawn("sudo", ["-S", process.execPath, mitmScript], {
|
|
2020
|
+
env,
|
|
2021
|
+
stdio: ["pipe", "ignore", "pipe"],
|
|
2022
|
+
detached: true,
|
|
2023
|
+
});
|
|
2024
|
+
if (sudoPassword && child.stdin) {
|
|
2025
|
+
child.stdin.write(`${sudoPassword}\n`);
|
|
2026
|
+
child.stdin.end();
|
|
2027
|
+
}
|
|
2028
|
+
child.unref();
|
|
2029
|
+
// Wait a moment to check if it started
|
|
2030
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
2031
|
+
return reply.send({ ok: true });
|
|
2032
|
+
}
|
|
2033
|
+
catch (error) {
|
|
2034
|
+
return reply.code(500).send({
|
|
2035
|
+
error: error instanceof Error ? error.message : "Failed to start MITM server",
|
|
2036
|
+
});
|
|
2037
|
+
}
|
|
1992
2038
|
});
|
|
1993
2039
|
app.post("/api/cli-tools/mitm-stop", async (_request, reply) => {
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
2040
|
+
try {
|
|
2041
|
+
const { getMitmPid } = await import("./mitm/server.js");
|
|
2042
|
+
const pid = getMitmPid();
|
|
2043
|
+
if (pid) {
|
|
2044
|
+
try {
|
|
2045
|
+
process.kill(pid, "SIGTERM");
|
|
2046
|
+
}
|
|
2047
|
+
catch { /* already dead */ }
|
|
2048
|
+
}
|
|
2049
|
+
return reply.send({ ok: true });
|
|
2050
|
+
}
|
|
2051
|
+
catch (error) {
|
|
2052
|
+
return reply.code(500).send({
|
|
2053
|
+
error: error instanceof Error ? error.message : "Failed to stop MITM server",
|
|
2054
|
+
});
|
|
2055
|
+
}
|
|
1997
2056
|
});
|
|
1998
|
-
app.post("/api/cli-tools/mitm-dns", async (
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2057
|
+
app.post("/api/cli-tools/mitm-dns", async (request, reply) => {
|
|
2058
|
+
const body = request.body;
|
|
2059
|
+
const toolId = typeof body?.toolId === "string" ? body.toolId : "";
|
|
2060
|
+
const enable = body?.enable !== false;
|
|
2061
|
+
const sudoPassword = typeof body?.sudoPassword === "string" ? body.sudoPassword : "";
|
|
2062
|
+
try {
|
|
2063
|
+
const { addDNSEntry, removeDNSEntry } = await import("./mitm/dns.js");
|
|
2064
|
+
if (enable) {
|
|
2065
|
+
await addDNSEntry(toolId, sudoPassword);
|
|
2066
|
+
}
|
|
2067
|
+
else {
|
|
2068
|
+
await removeDNSEntry(toolId, sudoPassword);
|
|
2069
|
+
}
|
|
2070
|
+
return reply.send({ ok: true });
|
|
2071
|
+
}
|
|
2072
|
+
catch (error) {
|
|
2073
|
+
return reply.code(500).send({
|
|
2074
|
+
error: error instanceof Error ? error.message : "Failed to update DNS",
|
|
2075
|
+
});
|
|
2076
|
+
}
|
|
2077
|
+
});
|
|
2078
|
+
app.post("/api/cli-tools/mitm-trust-cert", async (request, reply) => {
|
|
2079
|
+
const body = request.body;
|
|
2080
|
+
try {
|
|
2081
|
+
const { generateRootCA, trustRootCA } = await import("./mitm/cert.js");
|
|
2082
|
+
generateRootCA();
|
|
2083
|
+
await trustRootCA(body?.sudoPassword);
|
|
2084
|
+
return reply.send({ ok: true });
|
|
2085
|
+
}
|
|
2086
|
+
catch (error) {
|
|
2087
|
+
return reply.code(500).send({
|
|
2088
|
+
error: error instanceof Error ? error.message : "Failed to trust certificate",
|
|
2089
|
+
});
|
|
2090
|
+
}
|
|
2002
2091
|
});
|
|
2003
2092
|
app.get("/api/customer/codex/setup.sh", async (request, reply) => {
|
|
2004
2093
|
const routingApiKey = readBearerToken(request.headers.authorization);
|