responses-proxy 0.1.2 → 0.2.0
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/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 +145 -22
- package/package.json +1 -1
package/cli.js
CHANGED
|
@@ -1,76 +1,307 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* responses-proxy CLI —
|
|
3
|
+
* responses-proxy CLI — full command interface for managing the proxy.
|
|
4
4
|
*
|
|
5
5
|
* Usage:
|
|
6
|
-
*
|
|
7
|
-
* responses-proxy --
|
|
8
|
-
* responses-proxy
|
|
6
|
+
* responses-proxy # Start foreground, open browser
|
|
7
|
+
* responses-proxy --background # Start as background daemon
|
|
8
|
+
* responses-proxy stop # Stop daemon
|
|
9
|
+
* responses-proxy status # Check running state
|
|
10
|
+
* responses-proxy restart # Restart daemon
|
|
11
|
+
* responses-proxy logs # Tail server logs
|
|
12
|
+
* responses-proxy config # Show current config
|
|
13
|
+
* responses-proxy config set KEY VAL # Set env config
|
|
14
|
+
* responses-proxy setup # Interactive first-time setup
|
|
15
|
+
* responses-proxy password <pass> # Set dashboard password
|
|
16
|
+
* responses-proxy info # Show connection details (endpoint + key)
|
|
9
17
|
*/
|
|
10
18
|
|
|
11
|
-
const { spawn, exec } = require("child_process");
|
|
19
|
+
const { spawn, exec, execSync } = require("child_process");
|
|
12
20
|
const path = require("path");
|
|
13
21
|
const fs = require("fs");
|
|
14
22
|
const os = require("os");
|
|
23
|
+
const readline = require("readline");
|
|
15
24
|
|
|
16
25
|
const pkg = require("./package.json");
|
|
17
26
|
const args = process.argv.slice(2);
|
|
18
27
|
|
|
19
|
-
//
|
|
28
|
+
// ─── Paths ───────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
const dataDir = path.join(os.homedir(), ".responses-proxy");
|
|
31
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
32
|
+
const PID_FILE = path.join(dataDir, "server.pid");
|
|
33
|
+
const LOG_FILE = path.join(dataDir, "server.log");
|
|
34
|
+
const ENV_FILE = path.join(dataDir, "config.env");
|
|
35
|
+
const serverPath = path.join(__dirname, "dist", "server.js");
|
|
36
|
+
const nodeModulesPath = path.join(__dirname, "dist", "node_modules");
|
|
37
|
+
|
|
38
|
+
// ─── Config ──────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
function loadEnvConfig() {
|
|
41
|
+
if (!fs.existsSync(ENV_FILE)) return {};
|
|
42
|
+
const lines = fs.readFileSync(ENV_FILE, "utf8").split("\n");
|
|
43
|
+
const env = {};
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
const match = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
|
46
|
+
if (match) env[match[1]] = match[2];
|
|
47
|
+
}
|
|
48
|
+
return env;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function saveEnvConfig(env) {
|
|
52
|
+
const lines = Object.entries(env).map(([k, v]) => `${k}=${v}`);
|
|
53
|
+
fs.writeFileSync(ENV_FILE, lines.join("\n") + "\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getConfig(key) {
|
|
57
|
+
const env = loadEnvConfig();
|
|
58
|
+
return env[key] || process.env[key] || "";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function setConfig(key, value) {
|
|
62
|
+
const env = loadEnvConfig();
|
|
63
|
+
env[key] = value;
|
|
64
|
+
saveEnvConfig(env);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Parse args ──────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
20
69
|
const DEFAULT_PORT = 8318;
|
|
21
|
-
let port = DEFAULT_PORT;
|
|
22
|
-
let host = "0.0.0.0";
|
|
70
|
+
let port = parseInt(getConfig("PORT")) || DEFAULT_PORT;
|
|
71
|
+
let host = getConfig("HOST") || "0.0.0.0";
|
|
23
72
|
let noBrowser = false;
|
|
73
|
+
let background = false;
|
|
74
|
+
let subcommand = null;
|
|
75
|
+
let subArgs = [];
|
|
24
76
|
|
|
25
|
-
// Parse args
|
|
26
77
|
for (let i = 0; i < args.length; i++) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
78
|
+
const arg = args[i];
|
|
79
|
+
if (arg === "--port" || arg === "-p") { port = parseInt(args[++i], 10) || DEFAULT_PORT; }
|
|
80
|
+
else if (arg === "--host" || arg === "-H") { host = args[++i] || "0.0.0.0"; }
|
|
81
|
+
else if (arg === "--no-browser" || arg === "-n") { noBrowser = true; }
|
|
82
|
+
else if (arg === "--background" || arg === "--bg" || arg === "-b" || arg === "--daemon") { background = true; }
|
|
83
|
+
else if (arg === "--help" || arg === "-h") { printHelp(); process.exit(0); }
|
|
84
|
+
else if (arg === "--version" || arg === "-v") { console.log(pkg.version); process.exit(0); }
|
|
85
|
+
else if (!arg.startsWith("-")) {
|
|
86
|
+
subcommand = arg;
|
|
87
|
+
subArgs = args.slice(i + 1);
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function printHelp() {
|
|
93
|
+
console.log(`
|
|
94
|
+
responses-proxy v${pkg.version} — AI Routing Proxy
|
|
95
|
+
|
|
96
|
+
Usage: responses-proxy [command] [options]
|
|
97
|
+
|
|
98
|
+
Commands:
|
|
99
|
+
(none) Start the server (foreground)
|
|
100
|
+
stop Stop the background daemon
|
|
101
|
+
status Check if running
|
|
102
|
+
restart Restart background daemon
|
|
103
|
+
logs Show recent server logs
|
|
104
|
+
info Show endpoint URL + API key
|
|
105
|
+
config Show all config
|
|
106
|
+
config set KEY VALUE Set a config value
|
|
107
|
+
config get KEY Get a config value
|
|
108
|
+
setup Interactive first-time setup wizard
|
|
109
|
+
password <pass> Set dashboard admin password
|
|
110
|
+
open Open dashboard in browser
|
|
40
111
|
|
|
41
112
|
Options:
|
|
42
|
-
-p, --port <port>
|
|
43
|
-
-H, --host <host>
|
|
44
|
-
-n, --no-browser
|
|
45
|
-
-
|
|
46
|
-
-
|
|
113
|
+
-p, --port <port> Port (default: ${DEFAULT_PORT})
|
|
114
|
+
-H, --host <host> Host (default: 0.0.0.0)
|
|
115
|
+
-n, --no-browser Don't auto-open browser
|
|
116
|
+
-b, --background Run as background daemon
|
|
117
|
+
-h, --help Show help
|
|
118
|
+
-v, --version Show version
|
|
119
|
+
|
|
120
|
+
Data directory: ${dataDir}
|
|
121
|
+
Config file: ${ENV_FILE}
|
|
47
122
|
`);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ─── Subcommand dispatch ─────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
if (subcommand === "stop") { stopDaemon(); process.exit(0); }
|
|
128
|
+
if (subcommand === "status") { checkStatus(); process.exit(0); }
|
|
129
|
+
if (subcommand === "restart") { stopDaemon(); background = true; }
|
|
130
|
+
if (subcommand === "logs") { showLogs(); process.exit(0); }
|
|
131
|
+
if (subcommand === "info") { showInfo(); process.exit(0); }
|
|
132
|
+
if (subcommand === "open") { openDashboard(); process.exit(0); }
|
|
133
|
+
if (subcommand === "config") { handleConfig(); process.exit(0); }
|
|
134
|
+
if (subcommand === "password") { handlePassword(); process.exit(0); }
|
|
135
|
+
if (subcommand === "setup") { runSetup().then(() => process.exit(0)); }
|
|
136
|
+
else if (subcommand && !["restart"].includes(subcommand)) {
|
|
137
|
+
console.error(`Unknown command: ${subcommand}\nRun 'responses-proxy --help' for usage.`);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── Subcommand implementations ──────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
function getDaemonPid() {
|
|
144
|
+
try {
|
|
145
|
+
if (!fs.existsSync(PID_FILE)) return null;
|
|
146
|
+
const pid = parseInt(fs.readFileSync(PID_FILE, "utf8").trim(), 10);
|
|
147
|
+
if (!pid) return null;
|
|
148
|
+
process.kill(pid, 0);
|
|
149
|
+
return pid;
|
|
150
|
+
} catch {
|
|
151
|
+
try { fs.unlinkSync(PID_FILE); } catch {}
|
|
152
|
+
return null;
|
|
52
153
|
}
|
|
53
154
|
}
|
|
54
155
|
|
|
55
|
-
|
|
56
|
-
const
|
|
156
|
+
function stopDaemon() {
|
|
157
|
+
const pid = getDaemonPid();
|
|
158
|
+
if (!pid) { console.log("⏹ Not running."); return; }
|
|
159
|
+
try {
|
|
160
|
+
process.kill(pid, "SIGTERM");
|
|
161
|
+
console.log(`⏹ Stopped (PID: ${pid})`);
|
|
162
|
+
try { fs.unlinkSync(PID_FILE); } catch {}
|
|
163
|
+
} catch (e) { console.error(`Failed: ${e.message}`); }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function checkStatus() {
|
|
167
|
+
const pid = getDaemonPid();
|
|
168
|
+
const displayHost = host === "0.0.0.0" ? "localhost" : host;
|
|
169
|
+
if (pid) {
|
|
170
|
+
console.log(`✅ Running (PID: ${pid})`);
|
|
171
|
+
console.log(` Endpoint: http://${displayHost}:${port}/v1`);
|
|
172
|
+
console.log(` Dashboard: http://${displayHost}:${port}/`);
|
|
173
|
+
console.log(` Data: ${dataDir}`);
|
|
174
|
+
} else {
|
|
175
|
+
console.log("⏹ Not running.");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function showLogs() {
|
|
180
|
+
if (!fs.existsSync(LOG_FILE)) { console.log("No logs yet."); return; }
|
|
181
|
+
const lines = fs.readFileSync(LOG_FILE, "utf8").split("\n");
|
|
182
|
+
console.log(lines.slice(-80).join("\n"));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function showInfo() {
|
|
186
|
+
const displayHost = host === "0.0.0.0" ? "localhost" : host;
|
|
187
|
+
const password = getConfig("DASHBOARD_PASSWORD") || "admin";
|
|
188
|
+
console.log(`
|
|
189
|
+
┌─────────────────────────────────────────┐
|
|
190
|
+
│ responses-proxy v${pkg.version.padEnd(25)}│
|
|
191
|
+
├─────────────────────────────────────────┤
|
|
192
|
+
│ Endpoint: http://${displayHost}:${port}/v1${" ".repeat(Math.max(0, 18 - String(port).length))}│
|
|
193
|
+
│ Dashboard: http://${displayHost}:${port}/${" ".repeat(Math.max(0, 20 - String(port).length))}│
|
|
194
|
+
│ Password: ${password.padEnd(27)}│
|
|
195
|
+
├─────────────────────────────────────────┤
|
|
196
|
+
│ For Claude Code: │
|
|
197
|
+
│ export ANTHROPIC_BASE_URL=http://${displayHost}:${port}/v1
|
|
198
|
+
│ export ANTHROPIC_API_KEY=sk_anything │
|
|
199
|
+
├─────────────────────────────────────────┤
|
|
200
|
+
│ For Codex / OpenAI SDK: │
|
|
201
|
+
│ base_url = http://${displayHost}:${port}/v1${" ".repeat(Math.max(0, 18 - String(port).length))}│
|
|
202
|
+
│ api_key = sk_anything │
|
|
203
|
+
└─────────────────────────────────────────┘
|
|
204
|
+
`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function openDashboard() {
|
|
208
|
+
const displayHost = host === "0.0.0.0" ? "localhost" : host;
|
|
209
|
+
const url = `http://${displayHost}:${port}`;
|
|
210
|
+
const cmd = process.platform === "darwin" ? `open "${url}"` : process.platform === "win32" ? `start "" "${url}"` : `xdg-open "${url}"`;
|
|
211
|
+
exec(cmd, { windowsHide: true }, () => {});
|
|
212
|
+
console.log(`Opening ${url}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function handleConfig() {
|
|
216
|
+
if (subArgs[0] === "set" && subArgs[1]) {
|
|
217
|
+
setConfig(subArgs[1], subArgs.slice(2).join(" "));
|
|
218
|
+
console.log(`✅ ${subArgs[1]} = ${subArgs.slice(2).join(" ")}`);
|
|
219
|
+
} else if (subArgs[0] === "get" && subArgs[1]) {
|
|
220
|
+
console.log(getConfig(subArgs[1]) || "(not set)");
|
|
221
|
+
} else {
|
|
222
|
+
const env = loadEnvConfig();
|
|
223
|
+
console.log(`Config file: ${ENV_FILE}\n`);
|
|
224
|
+
if (Object.keys(env).length === 0) {
|
|
225
|
+
console.log(" (empty — using defaults)");
|
|
226
|
+
console.log(`\n Default port: ${DEFAULT_PORT}`);
|
|
227
|
+
console.log(` Default password: admin`);
|
|
228
|
+
console.log(`\n Set values with: responses-proxy config set KEY VALUE`);
|
|
229
|
+
} else {
|
|
230
|
+
for (const [k, v] of Object.entries(env)) {
|
|
231
|
+
console.log(` ${k} = ${v}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
console.log(`\nAvailable keys:`);
|
|
235
|
+
console.log(` PORT Server port (default: 8318)`);
|
|
236
|
+
console.log(` HOST Bind host (default: 0.0.0.0)`);
|
|
237
|
+
console.log(` DASHBOARD_PASSWORD Admin password (default: admin)`);
|
|
238
|
+
console.log(` UPSTREAM_BASE_URL Default upstream provider URL`);
|
|
239
|
+
console.log(` UPSTREAM_API_KEY Default upstream API key`);
|
|
240
|
+
console.log(` KIRO_ENABLED Enable Kiro provider (true/false)`);
|
|
241
|
+
console.log(` KIRO_DB_PATH Path to 9router kiro.sqlite`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function handlePassword() {
|
|
246
|
+
const pass = subArgs[0];
|
|
247
|
+
if (!pass) {
|
|
248
|
+
console.log(`Current password: ${getConfig("DASHBOARD_PASSWORD") || "admin"}`);
|
|
249
|
+
console.log(`\nUsage: responses-proxy password <new-password>`);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
setConfig("DASHBOARD_PASSWORD", pass);
|
|
253
|
+
console.log(`✅ Dashboard password set to: ${pass}`);
|
|
254
|
+
console.log(` Restart the server for changes to take effect.`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function runSetup() {
|
|
258
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
259
|
+
const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
|
|
260
|
+
|
|
261
|
+
console.log(`\n🚀 responses-proxy Setup Wizard\n`);
|
|
262
|
+
|
|
263
|
+
const portAnswer = await ask(`Port [${port}]: `);
|
|
264
|
+
if (portAnswer.trim()) setConfig("PORT", portAnswer.trim());
|
|
265
|
+
|
|
266
|
+
const passAnswer = await ask(`Dashboard password [admin]: `);
|
|
267
|
+
if (passAnswer.trim()) setConfig("DASHBOARD_PASSWORD", passAnswer.trim());
|
|
268
|
+
|
|
269
|
+
const kiroAnswer = await ask(`Enable Kiro provider? (y/n) [y]: `);
|
|
270
|
+
if (kiroAnswer.trim().toLowerCase() === "n") setConfig("KIRO_ENABLED", "false");
|
|
271
|
+
else setConfig("KIRO_ENABLED", "true");
|
|
272
|
+
|
|
273
|
+
const upstreamAnswer = await ask(`Default upstream URL (leave empty to skip): `);
|
|
274
|
+
if (upstreamAnswer.trim()) setConfig("UPSTREAM_BASE_URL", upstreamAnswer.trim());
|
|
275
|
+
|
|
276
|
+
const keyAnswer = await ask(`Default upstream API key (leave empty to skip): `);
|
|
277
|
+
if (keyAnswer.trim()) setConfig("UPSTREAM_API_KEY", keyAnswer.trim());
|
|
278
|
+
|
|
279
|
+
rl.close();
|
|
280
|
+
|
|
281
|
+
console.log(`\n✅ Config saved to ${ENV_FILE}`);
|
|
282
|
+
console.log(`\nStart the server with:`);
|
|
283
|
+
console.log(` responses-proxy`);
|
|
284
|
+
console.log(` responses-proxy --background`);
|
|
285
|
+
console.log(`\nView connection info:`);
|
|
286
|
+
console.log(` responses-proxy info\n`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ─── If subcommand was handled above (setup is async), bail ──────────────────
|
|
290
|
+
if (subcommand === "setup") { /* runSetup handles exit */ return; }
|
|
291
|
+
if (subcommand && subcommand !== "restart") process.exit(0);
|
|
292
|
+
|
|
293
|
+
// ─── Ensure deps ─────────────────────────────────────────────────────────────
|
|
57
294
|
|
|
58
295
|
if (!fs.existsSync(serverPath)) {
|
|
59
|
-
console.error("Error: Built server not found
|
|
60
|
-
console.error("Run 'npm run build' first, or reinstall the package.");
|
|
296
|
+
console.error("Error: Built server not found. Reinstall the package.");
|
|
61
297
|
process.exit(1);
|
|
62
298
|
}
|
|
63
299
|
|
|
64
|
-
// Check if deps are installed
|
|
65
|
-
const nodeModulesPath = path.join(__dirname, "dist", "node_modules");
|
|
66
300
|
if (!fs.existsSync(path.join(nodeModulesPath, "fastify"))) {
|
|
67
|
-
console.log("📦
|
|
68
|
-
const { execSync } = require("child_process");
|
|
301
|
+
console.log("📦 Installing runtime dependencies...");
|
|
69
302
|
try {
|
|
70
303
|
execSync("npm install --omit=dev --no-audit --no-fund --loglevel=error", {
|
|
71
|
-
cwd: path.join(__dirname, "dist"),
|
|
72
|
-
stdio: "inherit",
|
|
73
|
-
timeout: 120000,
|
|
304
|
+
cwd: path.join(__dirname, "dist"), stdio: "inherit", timeout: 120000,
|
|
74
305
|
});
|
|
75
306
|
} catch {
|
|
76
307
|
console.error("Failed to install deps. Run: cd " + path.join(__dirname, "dist") + " && npm install --omit=dev");
|
|
@@ -78,69 +309,112 @@ if (!fs.existsSync(path.join(nodeModulesPath, "fastify"))) {
|
|
|
78
309
|
}
|
|
79
310
|
}
|
|
80
311
|
|
|
81
|
-
//
|
|
82
|
-
|
|
312
|
+
// ─── Kill existing ───────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
const existingPid = getDaemonPid();
|
|
315
|
+
if (existingPid) {
|
|
316
|
+
try { process.kill(existingPid, "SIGTERM"); } catch {}
|
|
317
|
+
try { fs.unlinkSync(PID_FILE); } catch {}
|
|
318
|
+
try { execSync("sleep 1", { stdio: "ignore" }); } catch {}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ─── Build env ───────────────────────────────────────────────────────────────
|
|
322
|
+
|
|
83
323
|
fs.mkdirSync(path.join(dataDir, "sessions"), { recursive: true });
|
|
324
|
+
const userConfig = loadEnvConfig();
|
|
325
|
+
|
|
326
|
+
const serverEnv = {
|
|
327
|
+
...process.env,
|
|
328
|
+
...userConfig,
|
|
329
|
+
NODE_PATH: nodeModulesPath,
|
|
330
|
+
PORT: String(port),
|
|
331
|
+
HOST: host,
|
|
332
|
+
UPSTREAM_BASE_URL: userConfig.UPSTREAM_BASE_URL || process.env.UPSTREAM_BASE_URL || "https://placeholder.invalid",
|
|
333
|
+
UPSTREAM_API_KEY: userConfig.UPSTREAM_API_KEY || process.env.UPSTREAM_API_KEY || "",
|
|
334
|
+
APP_DB_PATH: path.join(dataDir, "app.sqlite"),
|
|
335
|
+
SESSION_LOG_DIR: path.join(dataDir, "sessions"),
|
|
336
|
+
CUSTOMER_KEY_DB_PATH: path.join(dataDir, "telegram-bot.sqlite"),
|
|
337
|
+
KIRO_DB_PATH: userConfig.KIRO_DB_PATH || path.join(dataDir, "kiro.sqlite"),
|
|
338
|
+
KIRO_ENABLED: userConfig.KIRO_ENABLED || process.env.KIRO_ENABLED || "true",
|
|
339
|
+
QUICK_APPLY_HERMES_CONFIG_PATH: path.join(os.homedir(), ".hermes", "config.yaml"),
|
|
340
|
+
QUICK_APPLY_CODEX_CONFIG_PATH: path.join(os.homedir(), ".codex", "config.toml"),
|
|
341
|
+
QUICK_APPLY_CODEX_AUTH_PATH: path.join(os.homedir(), ".codex", "auth.json"),
|
|
342
|
+
TELEGRAM_BOT_TOKEN: userConfig.TELEGRAM_BOT_TOKEN || "",
|
|
343
|
+
TELEGRAM_OWNER_USER_IDS: userConfig.TELEGRAM_OWNER_USER_IDS || "",
|
|
344
|
+
TELEGRAM_ADMIN_USER_IDS: userConfig.TELEGRAM_ADMIN_USER_IDS || "",
|
|
345
|
+
DASHBOARD_PASSWORD: userConfig.DASHBOARD_PASSWORD || process.env.DASHBOARD_PASSWORD || "admin",
|
|
346
|
+
};
|
|
84
347
|
|
|
85
348
|
const displayHost = host === "0.0.0.0" ? "localhost" : host;
|
|
86
349
|
const url = `http://${displayHost}:${port}`;
|
|
87
350
|
|
|
88
|
-
|
|
351
|
+
// ─── Background mode ─────────────────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
if (background) {
|
|
354
|
+
const logFd = fs.openSync(LOG_FILE, "a");
|
|
355
|
+
const child = spawn(process.execPath, [serverPath], {
|
|
356
|
+
cwd: __dirname, env: serverEnv,
|
|
357
|
+
stdio: ["ignore", logFd, logFd],
|
|
358
|
+
detached: true,
|
|
359
|
+
});
|
|
360
|
+
child.unref();
|
|
361
|
+
fs.writeFileSync(PID_FILE, String(child.pid));
|
|
362
|
+
fs.closeSync(logFd);
|
|
363
|
+
|
|
364
|
+
console.log(`
|
|
89
365
|
┌─────────────────────────────────────────┐
|
|
90
366
|
│ responses-proxy v${pkg.version.padEnd(25)}│
|
|
367
|
+
│ Running in background (PID: ${String(child.pid).padEnd(10)}│
|
|
91
368
|
│ ${url.padEnd(39)}│
|
|
92
369
|
│ Dashboard: ${(url + "/").padEnd(27)}│
|
|
93
370
|
│ API: ${(url + "/v1").padEnd(33)}│
|
|
371
|
+
├─────────────────────────────────────────┤
|
|
372
|
+
│ Stop: responses-proxy stop │
|
|
373
|
+
│ Status: responses-proxy status │
|
|
374
|
+
│ Logs: responses-proxy logs │
|
|
375
|
+
│ Config: responses-proxy config │
|
|
376
|
+
│ Info: responses-proxy info │
|
|
94
377
|
└─────────────────────────────────────────┘
|
|
95
378
|
`);
|
|
96
379
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
...process.env,
|
|
103
|
-
NODE_PATH: nodeModulesPath,
|
|
104
|
-
PORT: String(port),
|
|
105
|
-
HOST: host,
|
|
106
|
-
UPSTREAM_BASE_URL: process.env.UPSTREAM_BASE_URL || "https://placeholder.invalid",
|
|
107
|
-
UPSTREAM_API_KEY: process.env.UPSTREAM_API_KEY || "",
|
|
108
|
-
APP_DB_PATH: path.join(dataDir, "app.sqlite"),
|
|
109
|
-
SESSION_LOG_DIR: path.join(dataDir, "sessions"),
|
|
110
|
-
CUSTOMER_KEY_DB_PATH: path.join(dataDir, "telegram-bot.sqlite"),
|
|
111
|
-
KIRO_DB_PATH: path.join(dataDir, "kiro.sqlite"),
|
|
112
|
-
KIRO_ENABLED: process.env.KIRO_ENABLED || "false",
|
|
113
|
-
QUICK_APPLY_HERMES_CONFIG_PATH: path.join(os.homedir(), ".hermes", "config.yaml"),
|
|
114
|
-
QUICK_APPLY_CODEX_CONFIG_PATH: path.join(os.homedir(), ".codex", "config.toml"),
|
|
115
|
-
QUICK_APPLY_CODEX_AUTH_PATH: path.join(os.homedir(), ".codex", "auth.json"),
|
|
116
|
-
TELEGRAM_BOT_TOKEN: "",
|
|
117
|
-
TELEGRAM_OWNER_USER_IDS: "",
|
|
118
|
-
TELEGRAM_ADMIN_USER_IDS: "",
|
|
119
|
-
DASHBOARD_PASSWORD: process.env.DASHBOARD_PASSWORD || "admin",
|
|
120
|
-
},
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
// Open browser after short delay
|
|
124
|
-
if (!noBrowser) {
|
|
125
|
-
setTimeout(() => {
|
|
126
|
-
const openCmd =
|
|
127
|
-
process.platform === "darwin" ? `open "${url}"` :
|
|
128
|
-
process.platform === "win32" ? `start "" "${url}"` :
|
|
129
|
-
`xdg-open "${url}"`;
|
|
130
|
-
exec(openCmd, { windowsHide: true }, () => {});
|
|
131
|
-
}, 2000);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Handle exit
|
|
135
|
-
function cleanup() {
|
|
136
|
-
if (server.pid) {
|
|
137
|
-
try { process.kill(server.pid, "SIGTERM"); } catch {}
|
|
380
|
+
if (!noBrowser) {
|
|
381
|
+
setTimeout(() => {
|
|
382
|
+
const cmd = process.platform === "darwin" ? `open "${url}"` : process.platform === "win32" ? `start "" "${url}"` : `xdg-open "${url}"`;
|
|
383
|
+
exec(cmd, { windowsHide: true }, () => {});
|
|
384
|
+
}, 2000);
|
|
138
385
|
}
|
|
139
|
-
|
|
386
|
+
setTimeout(() => process.exit(0), 2500);
|
|
387
|
+
|
|
388
|
+
} else {
|
|
389
|
+
// ─── Foreground mode ───────────────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
console.log(`
|
|
392
|
+
┌─────────────────────────────────────────┐
|
|
393
|
+
│ responses-proxy v${pkg.version.padEnd(25)}│
|
|
394
|
+
│ ${url.padEnd(39)}│
|
|
395
|
+
│ Dashboard: ${(url + "/").padEnd(27)}│
|
|
396
|
+
│ API: ${(url + "/v1").padEnd(33)}│
|
|
397
|
+
│ Press Ctrl+C to stop │
|
|
398
|
+
└─────────────────────────────────────────┘
|
|
399
|
+
`);
|
|
400
|
+
|
|
401
|
+
const server = spawn(process.execPath, [serverPath], {
|
|
402
|
+
cwd: __dirname, stdio: "inherit", env: serverEnv,
|
|
403
|
+
});
|
|
404
|
+
fs.writeFileSync(PID_FILE, String(server.pid));
|
|
140
405
|
|
|
141
|
-
|
|
142
|
-
|
|
406
|
+
if (!noBrowser) {
|
|
407
|
+
setTimeout(() => {
|
|
408
|
+
const cmd = process.platform === "darwin" ? `open "${url}"` : process.platform === "win32" ? `start "" "${url}"` : `xdg-open "${url}"`;
|
|
409
|
+
exec(cmd, { windowsHide: true }, () => {});
|
|
410
|
+
}, 2000);
|
|
411
|
+
}
|
|
143
412
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
413
|
+
function cleanup() {
|
|
414
|
+
try { fs.unlinkSync(PID_FILE); } catch {}
|
|
415
|
+
if (server.pid) { try { process.kill(server.pid, "SIGTERM"); } catch {} }
|
|
416
|
+
}
|
|
417
|
+
process.on("SIGINT", () => { cleanup(); process.exit(0); });
|
|
418
|
+
process.on("SIGTERM", () => { cleanup(); process.exit(0); });
|
|
419
|
+
server.on("close", (code) => { try { fs.unlinkSync(PID_FILE); } catch {} process.exit(code || 0); });
|
|
420
|
+
}
|
|
@@ -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
|
@@ -95,6 +95,20 @@ const deviceLoginService = new DeviceLoginService({
|
|
|
95
95
|
kiroDbPath: config.KIRO_DB_PATH,
|
|
96
96
|
});
|
|
97
97
|
setInterval(() => deviceLoginService.pruneExpiredSessions(), 60_000).unref();
|
|
98
|
+
// Auto-disable Kiro provider if it has no accounts
|
|
99
|
+
if (kiroTokenStore) {
|
|
100
|
+
const kiroAccounts = kiroTokenStore.listAccounts();
|
|
101
|
+
if (kiroAccounts.length === 0) {
|
|
102
|
+
try {
|
|
103
|
+
const kiroProvider = providerRepository.getProvider("account-kiro");
|
|
104
|
+
if (kiroProvider && kiroProvider.enabled !== false) {
|
|
105
|
+
providerRepository.updateProvider("account-kiro", { ...kiroProvider, enabled: false });
|
|
106
|
+
console.log("[kiro] No accounts found — provider auto-disabled. Add accounts via dashboard to enable.");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch { /* provider might not exist yet */ }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
98
112
|
// Initialize routing services after all stores are available
|
|
99
113
|
const routingComboRepository = new RoutingComboRepository(providerRepository.getDatabase());
|
|
100
114
|
const modelComboRepository = new ModelComboRepository(providerRepository.getDatabase());
|
|
@@ -1551,6 +1565,16 @@ app.post("/api/kiro/import", async (request, reply) => {
|
|
|
1551
1565
|
destDbPath: destPath,
|
|
1552
1566
|
provider: 'kiro',
|
|
1553
1567
|
});
|
|
1568
|
+
// Auto-enable the Kiro provider after importing accounts
|
|
1569
|
+
if (result.imported > 0) {
|
|
1570
|
+
try {
|
|
1571
|
+
const kiroProvider = providerRepository.getProvider("account-kiro");
|
|
1572
|
+
if (kiroProvider && !kiroProvider.enabled) {
|
|
1573
|
+
providerRepository.updateProvider("account-kiro", { ...kiroProvider, enabled: true });
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
catch { /* ignore */ }
|
|
1577
|
+
}
|
|
1554
1578
|
return reply.send({
|
|
1555
1579
|
ok: true,
|
|
1556
1580
|
imported: result.imported,
|
|
@@ -1636,6 +1660,16 @@ app.post("/api/kiro/device/poll", async (request, reply) => {
|
|
|
1636
1660
|
}
|
|
1637
1661
|
try {
|
|
1638
1662
|
const result = await deviceLoginService.pollDeviceLogin(sessionId);
|
|
1663
|
+
// Auto-enable Kiro provider when a new account is added
|
|
1664
|
+
if (result.status === "completed") {
|
|
1665
|
+
try {
|
|
1666
|
+
const kiroProvider = providerRepository.getProvider("account-kiro");
|
|
1667
|
+
if (kiroProvider && !kiroProvider.enabled) {
|
|
1668
|
+
providerRepository.updateProvider("account-kiro", { ...kiroProvider, enabled: true });
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
catch { /* ignore */ }
|
|
1672
|
+
}
|
|
1639
1673
|
return reply.send({ ok: true, ...result });
|
|
1640
1674
|
}
|
|
1641
1675
|
catch (error) {
|
|
@@ -1938,33 +1972,122 @@ app.delete("/api/cli-tools/codex-settings", async (_request, reply) => {
|
|
|
1938
1972
|
return reply.code(500).send({ error: error instanceof Error ? error.message : "Failed to reset settings" });
|
|
1939
1973
|
}
|
|
1940
1974
|
});
|
|
1941
|
-
// ─── MITM Server Status
|
|
1975
|
+
// ─── MITM Server Status ─────────────────────────────────────────────────────
|
|
1942
1976
|
app.get("/api/cli-tools/mitm-status", async (_request, reply) => {
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
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
|
+
}
|
|
1953
2001
|
});
|
|
1954
|
-
app.post("/api/cli-tools/mitm-start", async (
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
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
|
+
}
|
|
1958
2038
|
});
|
|
1959
2039
|
app.post("/api/cli-tools/mitm-stop", async (_request, reply) => {
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
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
|
+
}
|
|
1963
2056
|
});
|
|
1964
|
-
app.post("/api/cli-tools/mitm-dns", async (
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
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
|
+
}
|
|
1968
2091
|
});
|
|
1969
2092
|
app.get("/api/customer/codex/setup.sh", async (request, reply) => {
|
|
1970
2093
|
const routingApiKey = readBearerToken(request.headers.authorization);
|