llm-switcher 1.1.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/.gitattributes +16 -0
- package/LICENSE +21 -0
- package/README.md +587 -0
- package/README.vi.md +585 -0
- package/blindfold/blindfold.mjs +633 -0
- package/blindfold/make-certs.sh +88 -0
- package/blindfold/wsframe.mjs +176 -0
- package/codex-catalog-template.json +1 -0
- package/config.example.json +84 -0
- package/contract-exclusions.json +41 -0
- package/contract.mjs +561 -0
- package/docs/LLM-RESPONSE-MATRIX.md +165 -0
- package/docs/TOKEN-OPTIMIZER-INTEROP.md +110 -0
- package/docs/codex-blindfold.md +214 -0
- package/docs/cross-platform.md +136 -0
- package/docs/diagrams/blindfold-request-routing.html +14972 -0
- package/docs/diagrams/blindfold-request-routing.sequence.json +175 -0
- package/docs/diagrams/blindfold-switch-lifecycle.html +14958 -0
- package/docs/diagrams/blindfold-switch-lifecycle.lifecycle.json +159 -0
- package/docs/diagrams/codex-model-name-resolution.html +15005 -0
- package/docs/diagrams/codex-model-name-resolution.workflow.json +71 -0
- package/docs/response-matrix.json +1131 -0
- package/formats.mjs +2308 -0
- package/mcp.mjs +340 -0
- package/package.json +36 -0
- package/proxy.mjs +1743 -0
- package/service.mjs +132 -0
- package/shim.mjs +292 -0
- package/skills/llm-switcher/SKILL.md +88 -0
- package/state.mjs +978 -0
- package/switch +5 -0
- package/switch.cmd +2 -0
- package/switch.mjs +930 -0
- package/tests/blindfold.test.mjs +307 -0
- package/tests/blindfold.wire.test.mjs +170 -0
- package/tests/contract/run.test.mjs +214 -0
- package/tests/contract-check.test.mjs +458 -0
- package/tests/contract-lab.test.mjs +755 -0
- package/tests/datadir.test.mjs +37 -0
- package/tests/formats.test.mjs +794 -0
- package/tests/gateway.e2e.test.mjs +999 -0
- package/tests/helpers.mjs +24 -0
- package/tests/lifecycle.test.mjs +416 -0
- package/tests/live-optimizer-interop.mjs +205 -0
- package/tests/mcp.test.mjs +91 -0
- package/tests/service.test.mjs +69 -0
- package/tests/shim.test.mjs +228 -0
- package/tests/state.test.mjs +675 -0
- package/tests/switch.test.mjs +156 -0
- package/tests/wsframe.test.mjs +154 -0
- package/ui.html +2234 -0
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// blindfold.mjs — make Codex reach the gateway without knowing the gateway exists
|
|
3
|
+
//
|
|
4
|
+
// PROBLEM
|
|
5
|
+
// Routing Codex with `--config openai_base_url=...` works, but the CLI then prints
|
|
6
|
+
// "base URL is overridden to <url>. Selecting models may not be supported or work
|
|
7
|
+
// properly." on its own /model screen. No config key hides that line, because the
|
|
8
|
+
// line exists to report exactly the thing we are doing.
|
|
9
|
+
//
|
|
10
|
+
// FIX
|
|
11
|
+
// Leave the CLI on its official endpoint and intercept one hop lower. Codex honours
|
|
12
|
+
// HTTPS_PROXY, so it sends `CONNECT chatgpt.com:443` here. This process answers that
|
|
13
|
+
// CONNECT itself, terminates TLS with a leaf certificate for that host, and forwards
|
|
14
|
+
// the Codex API calls to the local gateway. Its config.toml stays untouched.
|
|
15
|
+
//
|
|
16
|
+
// WHY NO SYSTEM CHANGE IS NEEDED
|
|
17
|
+
// Codex reads a custom CA from the CODEX_CA_CERTIFICATE environment variable, so the
|
|
18
|
+
// private CA never enters a system trust store, and no hosts file is edited. Stopping
|
|
19
|
+
// this process restores normal behaviour with nothing left behind.
|
|
20
|
+
//
|
|
21
|
+
// SCOPE OF THE INTERCEPT
|
|
22
|
+
// Only requests to the target host whose path starts with the Codex API prefix go to
|
|
23
|
+
// the gateway. Every other path on that host, and every other host, is passed through
|
|
24
|
+
// untouched — sign-in, token refresh and usage pages keep working.
|
|
25
|
+
// ============================================================
|
|
26
|
+
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import net from 'node:net';
|
|
29
|
+
import http from 'node:http';
|
|
30
|
+
import https from 'node:https';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import tls from 'node:tls';
|
|
33
|
+
import zlib from 'node:zlib';
|
|
34
|
+
import crypto from 'node:crypto';
|
|
35
|
+
import dns from 'node:dns/promises';
|
|
36
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
37
|
+
import { createFrameReader, negotiatesDeflate } from './wsframe.mjs';
|
|
38
|
+
|
|
39
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
40
|
+
|
|
41
|
+
// An option value that starts with "--" is the next flag, not this flag's value.
|
|
42
|
+
// Without that test, `--host --verbose` silently sets the target host to "--verbose"
|
|
43
|
+
// and nothing is ever intercepted.
|
|
44
|
+
function arg(name, fallback) {
|
|
45
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
46
|
+
const value = i !== -1 ? process.argv[i + 1] : undefined;
|
|
47
|
+
return value && !value.startsWith('--') ? value : fallback;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function port(value, fallback) {
|
|
51
|
+
const n = parseInt(value, 10);
|
|
52
|
+
return Number.isInteger(n) && n > 0 && n <= 65535 ? n : fallback;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const LISTEN_PORT = port(arg('port', process.env.LLM_SWITCHER_BLINDFOLD_PORT), 3457);
|
|
56
|
+
const GATEWAY_HOST = arg('gateway-host', '127.0.0.1');
|
|
57
|
+
const GATEWAY_PORT = port(arg('gateway-port', process.env.LLM_SWITCHER_PORT), 3456);
|
|
58
|
+
const TARGET_HOST = arg('host', 'chatgpt.com');
|
|
59
|
+
// Codex with ChatGPT auth calls https://chatgpt.com/backend-api/codex/<endpoint>;
|
|
60
|
+
// the gateway serves the same endpoints under /v1.
|
|
61
|
+
export const API_PREFIX = arg('prefix', '/backend-api/codex');
|
|
62
|
+
export const GATEWAY_PREFIX = arg('gateway-prefix', '/v1');
|
|
63
|
+
const CERT_DIR = arg('certs', path.join(HERE, 'certs'));
|
|
64
|
+
const VERBOSE = process.argv.includes('--verbose');
|
|
65
|
+
const CAPTURE_DIR = arg('capture', null);
|
|
66
|
+
// The switcher's admin.token. The identity probe answers HMAC(token, nonce) with it.
|
|
67
|
+
const TOKEN_FILE = arg('token-file', path.join(HERE, '..', 'admin.token'));
|
|
68
|
+
|
|
69
|
+
const log = (...args) => { if (VERBOSE) console.log('[blindfold]', ...args); };
|
|
70
|
+
|
|
71
|
+
// A capture file records what a genuine client sends on the wire. It must never
|
|
72
|
+
// record how that client authenticates, so these header values are replaced while
|
|
73
|
+
// the header names stay, keeping the shape of the request visible.
|
|
74
|
+
// Account identifiers are not credentials, but a capture is meant to be readable
|
|
75
|
+
// and shareable, and these name the person the traffic belongs to.
|
|
76
|
+
const SECRET_HEADERS = new Set([
|
|
77
|
+
'authorization', 'proxy-authorization', 'cookie', 'set-cookie', 'x-api-key', 'api-key',
|
|
78
|
+
'chatgpt-account-id', 'openai-organization', 'x-goog-user-project'
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
export function redactHeaders(headers) {
|
|
82
|
+
const out = {};
|
|
83
|
+
for (const [k, v] of Object.entries(headers || {})) {
|
|
84
|
+
out[k] = SECRET_HEADERS.has(k.toLowerCase()) ? '<redacted>' : v;
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function captureName(method, url, now = Date.now()) {
|
|
90
|
+
const safe = String(url || '/').split('?')[0].replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^_+|_+$/g, '');
|
|
91
|
+
return `${now}-${String(method || 'GET').toUpperCase()}-${safe || 'root'}.json`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const CAPTURE_LIMIT = 200000;
|
|
95
|
+
// The capture decodes a copy of each WS message. A larger message is not recorded; the relay
|
|
96
|
+
// itself still forwards every byte.
|
|
97
|
+
const CAPTURE_MAX_MESSAGE = 64 * 1024 * 1024;
|
|
98
|
+
// Captures that wait for their quiet period. A SIGTERM writes them before the process exits.
|
|
99
|
+
const pendingFlushes = new Set();
|
|
100
|
+
const clip = (s) => (s.length > CAPTURE_LIMIT ? s.slice(0, CAPTURE_LIMIT) + '...[truncated]' : s);
|
|
101
|
+
|
|
102
|
+
// A client asks for gzip, so the bytes on the wire are compressed. Reading them as
|
|
103
|
+
// UTF-8 yields binary noise, which is what a capture recorded before this existed.
|
|
104
|
+
// The proxy forwards the compressed bytes untouched; only the copy is decoded.
|
|
105
|
+
const DECODERS = {
|
|
106
|
+
gzip: zlib.gunzipSync,
|
|
107
|
+
'x-gzip': zlib.gunzipSync,
|
|
108
|
+
br: zlib.brotliDecompressSync,
|
|
109
|
+
deflate: zlib.inflateSync,
|
|
110
|
+
zstd: zlib.zstdDecompressSync
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export function decodeBody(buffer, contentEncoding) {
|
|
114
|
+
const name = String(contentEncoding || '').trim().toLowerCase();
|
|
115
|
+
const decode = DECODERS[name];
|
|
116
|
+
if (!decode) return buffer.toString('utf8');
|
|
117
|
+
try {
|
|
118
|
+
return decode(buffer).toString('utf8');
|
|
119
|
+
} catch (err) {
|
|
120
|
+
// An aborted or truncated stream cannot be decoded. Say so in the file rather
|
|
121
|
+
// than writing noise that reads like a malformed response from the provider.
|
|
122
|
+
return `[capture: cannot decode ${name} body of ${buffer.length} bytes: ${err.message}]`;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// A capture holds full prompts and answers, so the directory is 0700 and each file 0600. A
|
|
127
|
+
// directory that another account owns is refused: it could read the files or plant symlinks.
|
|
128
|
+
const refusedCaptureDirs = new Set();
|
|
129
|
+
|
|
130
|
+
export function writeCaptureFile(dir, fileName, record, { uid = process.getuid?.() } = {}) {
|
|
131
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
132
|
+
// lstat: a symlink planted at the capture path would otherwise pass the owner test for its target.
|
|
133
|
+
const st = fs.lstatSync(dir);
|
|
134
|
+
if (st.isSymbolicLink() || (uid !== undefined && st.uid !== uid)) {
|
|
135
|
+
if (!refusedCaptureDirs.has(dir)) {
|
|
136
|
+
refusedCaptureDirs.add(dir);
|
|
137
|
+
console.error(`[blindfold] capture refused: ${dir} is a symlink or is owned by another account. Nothing is recorded.`);
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
fs.chmodSync(dir, 0o700);
|
|
142
|
+
// tmp + rename: a kill mid-write keeps the previous complete file, and the rename replaces a
|
|
143
|
+
// planted symlink instead of writing through it.
|
|
144
|
+
const file = path.join(dir, fileName);
|
|
145
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
146
|
+
fs.rmSync(tmp, { force: true });
|
|
147
|
+
fs.writeFileSync(tmp, JSON.stringify(record, null, 2), { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
148
|
+
fs.renameSync(tmp, file);
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// A capture is a diagnostic. Failing to write one must never fail the request.
|
|
153
|
+
function writeCapture(record, fileName) {
|
|
154
|
+
if (!CAPTURE_DIR) return;
|
|
155
|
+
try {
|
|
156
|
+
writeCaptureFile(CAPTURE_DIR, fileName || captureName(record.method, record.url), record);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
log('capture failed:', err.message);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Decide on the NORMALIZED path, never on the raw request target.
|
|
163
|
+
//
|
|
164
|
+
// Node hands over the target exactly as the client wrote it, but the gateway resolves
|
|
165
|
+
// it with `new URL(...)`. A raw-target test therefore accepts a string the gateway
|
|
166
|
+
// later reads as a different path: `/backend-api/codex/%2e%2e/api/logs` becomes
|
|
167
|
+
// `/api/logs`, which is the gateway's admin API. The forwarded headers forge a
|
|
168
|
+
// loopback Host, so the admin API's only guard is satisfied by construction.
|
|
169
|
+
//
|
|
170
|
+
// A segment boundary is required as well, otherwise `/backend-api/codex-usage`
|
|
171
|
+
// (a real ChatGPT path) is stolen from the host it belongs to.
|
|
172
|
+
function normalizedTarget(url) {
|
|
173
|
+
if (typeof url !== 'string' || !url.startsWith('/')) return null;
|
|
174
|
+
try {
|
|
175
|
+
return new URL(url, 'http://blindfold.invalid');
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function isGatewayPath(url) {
|
|
182
|
+
const parsed = normalizedTarget(url);
|
|
183
|
+
if (!parsed) return false;
|
|
184
|
+
const p = parsed.pathname;
|
|
185
|
+
return p === API_PREFIX || p.startsWith(`${API_PREFIX}/`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function toGatewayPath(url) {
|
|
189
|
+
const parsed = normalizedTarget(url);
|
|
190
|
+
if (!parsed) return null;
|
|
191
|
+
// The query string must survive: the gateway reads it.
|
|
192
|
+
return GATEWAY_PREFIX + parsed.pathname.slice(API_PREFIX.length) + parsed.search;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Codex's ChatGPT credentials. The gateway never reads them, and the gateway port is plain
|
|
196
|
+
// HTTP that another local account can hold once it is free, so they stay on this side.
|
|
197
|
+
const GATEWAY_STRIPPED = ['authorization', 'proxy-authorization', 'cookie', 'chatgpt-account-id', 'openai-organization'];
|
|
198
|
+
|
|
199
|
+
// The gateway answers only to a loopback Host, so rewrite it. Origin carries the
|
|
200
|
+
// intercepted hostname and would fail the same check.
|
|
201
|
+
export function gatewayHeaders(headers, { host = GATEWAY_HOST, port = GATEWAY_PORT } = {}) {
|
|
202
|
+
const out = { ...headers };
|
|
203
|
+
out.host = `${host}:${port}`;
|
|
204
|
+
delete out.origin;
|
|
205
|
+
for (const name of GATEWAY_STRIPPED) delete out[name];
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// A relay holds two connections. When one side fails or leaves early, end the other:
|
|
210
|
+
// otherwise the client waits forever, or the upstream keeps streaming (and spending).
|
|
211
|
+
function bindExchange(res, upstream) {
|
|
212
|
+
res.on('close', () => { if (!res.writableFinished) upstream.destroy(); });
|
|
213
|
+
upstream.on('response', (upRes) => {
|
|
214
|
+
upRes.on('error', () => res.destroy());
|
|
215
|
+
upRes.on('close', () => { if (!upRes.complete) res.destroy(); });
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function failExchange(res, status, contentType, body) {
|
|
220
|
+
if (res.destroyed) return;
|
|
221
|
+
if (res.headersSent) return res.destroy();
|
|
222
|
+
res.writeHead(status, { 'Content-Type': contentType });
|
|
223
|
+
res.end(body);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// A stalled peer must not hold a socket open for the life of the process.
|
|
227
|
+
const IDLE_TIMEOUT_MS = 120000;
|
|
228
|
+
|
|
229
|
+
function armTimeout(socket, onTimeout) {
|
|
230
|
+
socket.setTimeout(IDLE_TIMEOUT_MS, onTimeout);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------- the TLS endpoint that pretends to be TARGET_HOST ----------
|
|
234
|
+
|
|
235
|
+
const mitm = https.createServer();
|
|
236
|
+
|
|
237
|
+
// Collect a copy for the capture file while the bytes keep flowing. Buffering to
|
|
238
|
+
// write the file first would hold back a streamed response. Both routes record,
|
|
239
|
+
// because on a host with no gateway prefix every exchange is a passthrough and a
|
|
240
|
+
// capture that skipped them would always be empty.
|
|
241
|
+
function recordExchange(req) {
|
|
242
|
+
if (!CAPTURE_DIR) return null;
|
|
243
|
+
const reqChunks = [];
|
|
244
|
+
req.on('data', (c) => reqChunks.push(c));
|
|
245
|
+
return (upRes) => {
|
|
246
|
+
const resChunks = [];
|
|
247
|
+
upRes.on('data', (c) => resChunks.push(c));
|
|
248
|
+
upRes.on('end', () => writeCapture({
|
|
249
|
+
method: req.method,
|
|
250
|
+
url: req.url,
|
|
251
|
+
requestHeaders: redactHeaders(req.headers),
|
|
252
|
+
requestBody: clip(decodeBody(Buffer.concat(reqChunks), req.headers['content-encoding'])),
|
|
253
|
+
status: upRes.statusCode,
|
|
254
|
+
responseHeaders: redactHeaders(upRes.headers),
|
|
255
|
+
responseBody: clip(decodeBody(Buffer.concat(resChunks), upRes.headers['content-encoding']))
|
|
256
|
+
}));
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function relayToGateway(req, res, { host = GATEWAY_HOST, port = GATEWAY_PORT } = {}) {
|
|
261
|
+
log('gateway', req.method, req.url);
|
|
262
|
+
const record = recordExchange(req);
|
|
263
|
+
|
|
264
|
+
const upstream = http.request({
|
|
265
|
+
host,
|
|
266
|
+
port,
|
|
267
|
+
method: req.method,
|
|
268
|
+
path: toGatewayPath(req.url),
|
|
269
|
+
headers: gatewayHeaders(req.headers, { host, port })
|
|
270
|
+
}, (upRes) => {
|
|
271
|
+
res.writeHead(upRes.statusCode, upRes.headers);
|
|
272
|
+
if (record) record(upRes);
|
|
273
|
+
upRes.pipe(res);
|
|
274
|
+
});
|
|
275
|
+
bindExchange(res, upstream);
|
|
276
|
+
upstream.on('error', (err) => {
|
|
277
|
+
failExchange(res, 502, 'application/json', JSON.stringify({ error: { message: `gateway unreachable: ${err.message}` } }));
|
|
278
|
+
});
|
|
279
|
+
req.pipe(upstream);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
mitm.on('request', (req, res) => {
|
|
283
|
+
if (!isGatewayPath(req.url)) return passThroughRequest(req, res);
|
|
284
|
+
relayToGateway(req, res);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// Everything that is not a Codex API call goes to the real host, so sign-in and
|
|
288
|
+
// usage pages behave exactly as they do without this process.
|
|
289
|
+
function passThroughRequest(req, res) {
|
|
290
|
+
log('passthrough', req.method, req.url);
|
|
291
|
+
const record = recordExchange(req);
|
|
292
|
+
const upstream = https.request({
|
|
293
|
+
host: TARGET_HOST,
|
|
294
|
+
servername: TARGET_HOST,
|
|
295
|
+
port: 443,
|
|
296
|
+
method: req.method,
|
|
297
|
+
path: req.url,
|
|
298
|
+
headers: { ...req.headers, host: TARGET_HOST }
|
|
299
|
+
}, (upRes) => {
|
|
300
|
+
res.writeHead(upRes.statusCode, upRes.headers);
|
|
301
|
+
if (record) record(upRes);
|
|
302
|
+
upRes.pipe(res);
|
|
303
|
+
});
|
|
304
|
+
bindExchange(res, upstream);
|
|
305
|
+
upstream.on('error', (err) => {
|
|
306
|
+
failExchange(res, 502, 'text/plain', `upstream unreachable: ${err.message}`);
|
|
307
|
+
});
|
|
308
|
+
req.pipe(upstream);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Relay a WebSocket and record the messages that cross it.
|
|
312
|
+
//
|
|
313
|
+
// A Codex completion is a WebSocket, not an HTTP POST, so without this the capture
|
|
314
|
+
// of the one call that matters holds the handshake and nothing else. Every byte is
|
|
315
|
+
// still forwarded unchanged; the frame reader works on a copy.
|
|
316
|
+
function captureUpgrade(req, clientSocket, target) {
|
|
317
|
+
log('ws capture started for', req.url);
|
|
318
|
+
const messages = [];
|
|
319
|
+
const name = captureName(req.method, req.url);
|
|
320
|
+
let handshake = Buffer.alloc(0);
|
|
321
|
+
let readFromClient = null;
|
|
322
|
+
let readFromServer = null;
|
|
323
|
+
let written = 0;
|
|
324
|
+
|
|
325
|
+
const collect = (from, frames) => {
|
|
326
|
+
for (const f of frames) {
|
|
327
|
+
if (written >= CAPTURE_LIMIT) return;
|
|
328
|
+
// An error item has no payload, and its reader decodes nothing more from this side.
|
|
329
|
+
const text = f.payload ? f.payload.toString('utf8') : '';
|
|
330
|
+
written += text.length;
|
|
331
|
+
messages.push({ from, type: f.type, ...(f.compressed ? { compressed: true } : {}),
|
|
332
|
+
...(f.note ? { note: f.note } : {}), ...(f.reason ? { reason: f.reason } : {}),
|
|
333
|
+
payload: clip(text) });
|
|
334
|
+
}
|
|
335
|
+
if (frames.length) scheduleFlush();
|
|
336
|
+
};
|
|
337
|
+
let scheduleFlush = () => {};
|
|
338
|
+
|
|
339
|
+
// Backpressure: a plain write() loses what pipe() gives for free, and a slow peer
|
|
340
|
+
// would then grow an unbounded buffer inside this process.
|
|
341
|
+
target.on('drain', () => clientSocket.resume());
|
|
342
|
+
clientSocket.on('drain', () => target.resume());
|
|
343
|
+
|
|
344
|
+
target.on('data', (chunk) => {
|
|
345
|
+
if (!clientSocket.write(chunk)) target.pause();
|
|
346
|
+
if (readFromServer) return collect('server', readFromServer(chunk));
|
|
347
|
+
|
|
348
|
+
// The 101 response has to be parsed before any frame: it names the extension,
|
|
349
|
+
// and inflating a payload that was never compressed produces noise.
|
|
350
|
+
handshake = Buffer.concat([handshake, chunk]);
|
|
351
|
+
const end = handshake.indexOf('\r\n\r\n');
|
|
352
|
+
if (end === -1) return;
|
|
353
|
+
const text = handshake.subarray(0, end).toString('latin1');
|
|
354
|
+
const inflate = negotiatesDeflate(/^sec-websocket-extensions:(.*)$/im.exec(text)?.[1]);
|
|
355
|
+
readFromServer = createFrameReader({ inflate, maxMessage: CAPTURE_MAX_MESSAGE });
|
|
356
|
+
readFromClient = createFrameReader({ inflate, maxMessage: CAPTURE_MAX_MESSAGE });
|
|
357
|
+
const rest = handshake.subarray(end + 4);
|
|
358
|
+
if (rest.length) collect('server', readFromServer(rest));
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
clientSocket.on('data', (chunk) => {
|
|
362
|
+
if (!target.write(chunk)) clientSocket.pause();
|
|
363
|
+
if (readFromClient) collect('client', readFromClient(chunk));
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// Do not wait for 'close' to write the file. A WebSocket stays open, and when the
|
|
367
|
+
// client process exits the socket can be collected without ever emitting 'close',
|
|
368
|
+
// so a capture that only wrote on close wrote nothing at all. Flush shortly after
|
|
369
|
+
// the traffic goes quiet instead, and keep flushing as more messages arrive.
|
|
370
|
+
let timer = null;
|
|
371
|
+
const flush = () => {
|
|
372
|
+
if (timer) clearTimeout(timer);
|
|
373
|
+
timer = null;
|
|
374
|
+
pendingFlushes.delete(flush);
|
|
375
|
+
writeCapture({
|
|
376
|
+
method: req.method,
|
|
377
|
+
url: req.url,
|
|
378
|
+
protocol: 'websocket',
|
|
379
|
+
requestHeaders: redactHeaders(req.headers),
|
|
380
|
+
messageCount: messages.length,
|
|
381
|
+
messages
|
|
382
|
+
}, name);
|
|
383
|
+
};
|
|
384
|
+
scheduleFlush = () => {
|
|
385
|
+
if (timer) clearTimeout(timer);
|
|
386
|
+
timer = setTimeout(flush, 800);
|
|
387
|
+
timer.unref?.();
|
|
388
|
+
pendingFlushes.add(flush);
|
|
389
|
+
};
|
|
390
|
+
for (const s of [clientSocket, target]) {
|
|
391
|
+
s.on('close', flush);
|
|
392
|
+
s.on('end', flush);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// WebSocket upgrades: relay the raw TCP stream once the handshake is written.
|
|
397
|
+
function relayUpgrade(req, clientSocket, head, target, readyEvent, headers, requestPath, route) {
|
|
398
|
+
let ready = false;
|
|
399
|
+
target.on(readyEvent, () => {
|
|
400
|
+
ready = true;
|
|
401
|
+
log('upgrade', route, requestPath);
|
|
402
|
+
const lines = [`${req.method} ${requestPath} HTTP/1.1`];
|
|
403
|
+
for (const [k, v] of Object.entries(headers)) lines.push(`${k}: ${v}`);
|
|
404
|
+
target.write(lines.join('\r\n') + '\r\n\r\n');
|
|
405
|
+
if (head?.length) target.write(head);
|
|
406
|
+
if (!CAPTURE_DIR) {
|
|
407
|
+
clientSocket.pipe(target).pipe(clientSocket);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
captureUpgrade(req, clientSocket, target);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
// Either side closing ends the other, in capture mode too, where no pipe carries it. end() lets the
|
|
414
|
+
// last buffered frames (a close frame, for one) reach the peer; destroy follows if it never closes.
|
|
415
|
+
const finish = (s) => { s.end(); setTimeout(() => s.destroy(), 5000).unref(); };
|
|
416
|
+
target.on('close', () => finish(clientSocket));
|
|
417
|
+
clientSocket.on('close', () => finish(target));
|
|
418
|
+
target.on('error', (err) => {
|
|
419
|
+
log('upgrade', route, 'failed:', err.code || err.message);
|
|
420
|
+
if (!ready && clientSocket.writable) clientSocket.end('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n');
|
|
421
|
+
else clientSocket.destroy();
|
|
422
|
+
});
|
|
423
|
+
clientSocket.on('error', () => target.destroy());
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function relayUpgradeToGateway(req, clientSocket, head, { host = GATEWAY_HOST, port = GATEWAY_PORT } = {}) {
|
|
427
|
+
relayUpgrade(req, clientSocket, head, net.connect(port, host), 'connect',
|
|
428
|
+
gatewayHeaders(req.headers, { host, port }), toGatewayPath(req.url), 'gateway');
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
mitm.on('upgrade', (req, clientSocket, head) => {
|
|
432
|
+
if (isGatewayPath(req.url)) return relayUpgradeToGateway(req, clientSocket, head);
|
|
433
|
+
relayUpgrade(req, clientSocket, head, tls.connect({ host: TARGET_HOST, port: 443, servername: TARGET_HOST }),
|
|
434
|
+
'secureConnect', { ...req.headers, host: TARGET_HOST }, req.url, 'passthrough');
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
mitm.on('tlsClientError', (err) => log('tls client error:', err.message));
|
|
438
|
+
mitm.on('clientError', (err, socket) => {
|
|
439
|
+
log('client error:', err.code || err.message);
|
|
440
|
+
if (socket.writable) socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// ---------- the HTTP proxy Codex talks to ----------
|
|
444
|
+
|
|
445
|
+
// A plain (non-CONNECT) request is a probe. With ?challenge=<nonce> it proves identity: only a
|
|
446
|
+
// process that reads admin.token can answer HMAC(token, nonce), and it names the arguments it runs with.
|
|
447
|
+
export function identityAnswer(url) {
|
|
448
|
+
const challenge = new URL(url || '/', 'http://blindfold.invalid').searchParams.get('challenge');
|
|
449
|
+
if (!challenge) return null;
|
|
450
|
+
const fields = { role: 'blindfold', port: LISTEN_PORT, pid: process.pid, gatewayPort: GATEWAY_PORT, host: TARGET_HOST, prefix: API_PREFIX };
|
|
451
|
+
let proof = '';
|
|
452
|
+
try {
|
|
453
|
+
const token = fs.readFileSync(TOKEN_FILE, 'utf8').trim();
|
|
454
|
+
// Same fields and order as identityProof in state.mjs.
|
|
455
|
+
const msg = [fields.role, fields.port, fields.pid, fields.gatewayPort, fields.host, fields.prefix, challenge].join('|');
|
|
456
|
+
if (token) proof = crypto.createHmac('sha256', token).update(msg).digest('hex');
|
|
457
|
+
} catch {}
|
|
458
|
+
return { proxy: 'llm-switcher-blindfold', proof, ...fields };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const proxy = http.createServer((req, res) => {
|
|
462
|
+
const identity = identityAnswer(req.url);
|
|
463
|
+
if (identity) {
|
|
464
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
465
|
+
return res.end(JSON.stringify(identity));
|
|
466
|
+
}
|
|
467
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
468
|
+
res.end('llm-switcher blindfold proxy\n');
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// This listener is a proxy, so anything that reaches it can ask for an arbitrary
|
|
472
|
+
// destination. It binds to loopback, but every local process can still use it. Refuse
|
|
473
|
+
// a destination that is itself local: without that test it is a way to reach services
|
|
474
|
+
// that only listen on the machine, and the cloud metadata address.
|
|
475
|
+
// Addresses this proxy never tunnels to: loopback, unspecified, private, link-local and shared address
|
|
476
|
+
// space (cloud metadata services live in both), benchmark and IETF blocks, and unique-local IPv6.
|
|
477
|
+
// BlockList also matches IPv4-mapped IPv6 forms.
|
|
478
|
+
const LOCAL_RANGES = new net.BlockList();
|
|
479
|
+
for (const [prefix, bits] of [['0.0.0.0', 8], ['127.0.0.0', 8], ['10.0.0.0', 8], ['172.16.0.0', 12], ['192.168.0.0', 16],
|
|
480
|
+
['169.254.0.0', 16], ['100.64.0.0', 10], ['198.18.0.0', 15], ['192.0.0.0', 24]]) {
|
|
481
|
+
LOCAL_RANGES.addSubnet(prefix, bits, 'ipv4');
|
|
482
|
+
}
|
|
483
|
+
for (const [prefix, bits] of [['::', 128], ['::1', 128], ['fc00::', 7], ['fe80::', 10]]) {
|
|
484
|
+
LOCAL_RANGES.addSubnet(prefix, bits, 'ipv6');
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export function isInterceptedHost(host) {
|
|
488
|
+
return host === TARGET_HOST;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Decides on an address, never on a spelling: `0`, `2130706433`, `127.1` and names such as
|
|
492
|
+
// localtest.me all resolve to loopback. Something that is not an address counts as local.
|
|
493
|
+
export function isLocalAddress(address) {
|
|
494
|
+
const family = net.isIP(address);
|
|
495
|
+
if (family === 0) return true;
|
|
496
|
+
if (LOCAL_RANGES.check(address, family === 6 ? 'ipv6' : 'ipv4')) return true;
|
|
497
|
+
// NAT64 (64:ff9b::/96) and 6to4 (2002::/16) carry an IPv4 address; decide on that one. Both also carry
|
|
498
|
+
// public addresses, so the whole prefix cannot be refused.
|
|
499
|
+
if (family === 6) {
|
|
500
|
+
const b = ipv6Bytes(address);
|
|
501
|
+
const nat64 = b[0] === 0 && b[1] === 0x64 && b[2] === 0xff && b[3] === 0x9b && b.slice(4, 12).every(x => x === 0);
|
|
502
|
+
const inner = nat64 ? b.slice(12) : b[0] === 0x20 && b[1] === 0x02 ? b.slice(2, 6) : null;
|
|
503
|
+
if (inner) return isLocalAddress(inner.join('.'));
|
|
504
|
+
}
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function ipv6Bytes(address) {
|
|
509
|
+
let text = address.toLowerCase();
|
|
510
|
+
const dotted = /(\d+\.\d+\.\d+\.\d+)$/.exec(text);
|
|
511
|
+
if (dotted) text = `${text.slice(0, -dotted[1].length)}0:0`;
|
|
512
|
+
const [head, rest] = text.split('::');
|
|
513
|
+
const h = head ? head.split(':') : [];
|
|
514
|
+
const r = rest === undefined ? null : rest ? rest.split(':') : [];
|
|
515
|
+
const groups = r === null ? h : [...h, ...Array(8 - h.length - r.length).fill('0'), ...r];
|
|
516
|
+
const bytes = groups.flatMap(g => { const n = parseInt(g || '0', 16); return [n >> 8, n & 0xff]; });
|
|
517
|
+
if (dotted) bytes.splice(12, 4, ...dotted[1].split('.').map(Number));
|
|
518
|
+
return bytes;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Resolve once and connect to the address that was checked: a second lookup could answer with
|
|
522
|
+
// a local address (DNS rebinding). Every answer must be public, not only the first.
|
|
523
|
+
export async function checkDestination(host, { lookup = dns.lookup } = {}) {
|
|
524
|
+
const bare = String(host || '').replace(/^\[(.*)\]$/, '$1');
|
|
525
|
+
if (!bare) return { refused: 'no host' };
|
|
526
|
+
let answers;
|
|
527
|
+
try {
|
|
528
|
+
answers = await lookup(bare, { all: true, verbatim: true });
|
|
529
|
+
} catch (err) {
|
|
530
|
+
return { refused: `cannot resolve: ${err.code || err.message}`, status: 502 };
|
|
531
|
+
}
|
|
532
|
+
const local = answers.find(a => isLocalAddress(a.address));
|
|
533
|
+
if (!answers.length || local) return { refused: `resolves to local address ${local?.address || '(none)'}` };
|
|
534
|
+
return { address: answers[0].address };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Refusals are printed without --verbose, once per host: a VPN or split DNS can resolve a
|
|
538
|
+
// public name to a private address, and the user must be able to see why it fails.
|
|
539
|
+
const reportedRefusals = new Set();
|
|
540
|
+
function reportRefusal(target, reason) {
|
|
541
|
+
log('refused CONNECT', target, reason);
|
|
542
|
+
if (reportedRefusals.has(target) || reportedRefusals.size > 1000) return;
|
|
543
|
+
reportedRefusals.add(target);
|
|
544
|
+
console.error(`[blindfold] refused CONNECT ${target}: ${reason}`);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
proxy.on('connect', (req, clientSocket, head) => {
|
|
548
|
+
const target = String(req.url || '');
|
|
549
|
+
const sep = target.lastIndexOf(':');
|
|
550
|
+
const host = sep > 0 ? target.slice(0, sep) : target;
|
|
551
|
+
const destPort = port(target.slice(sep + 1), 443);
|
|
552
|
+
|
|
553
|
+
if (isInterceptedHost(host)) {
|
|
554
|
+
log('intercept CONNECT', target);
|
|
555
|
+
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
556
|
+
if (head?.length) clientSocket.unshift(head);
|
|
557
|
+
// Hand the raw socket to the TLS endpoint: it completes the handshake with our leaf.
|
|
558
|
+
return mitm.emit('connection', clientSocket);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// The lookup is asynchronous: the socket can fail before the answer arrives.
|
|
562
|
+
clientSocket.on('error', () => clientSocket.destroy());
|
|
563
|
+
checkDestination(host).then(({ address, refused, status = 403 }) => {
|
|
564
|
+
if (clientSocket.destroyed) return;
|
|
565
|
+
if (refused) {
|
|
566
|
+
reportRefusal(target, refused);
|
|
567
|
+
clientSocket.end(`HTTP/1.1 ${status} ${status === 403 ? 'Forbidden' : 'Bad Gateway'}\r\nConnection: close\r\n\r\n`);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
tunnel(address, destPort, clientSocket, head, target);
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
// Any other host keeps its own end-to-end TLS: this process only copies bytes and
|
|
575
|
+
// never sees the plaintext.
|
|
576
|
+
function tunnel(address, destPort, clientSocket, head, target) {
|
|
577
|
+
log('tunnel CONNECT', target, '->', address);
|
|
578
|
+
const upstream = net.connect(destPort, address, () => {
|
|
579
|
+
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
580
|
+
if (head?.length) upstream.write(head);
|
|
581
|
+
clientSocket.pipe(upstream).pipe(clientSocket);
|
|
582
|
+
});
|
|
583
|
+
const close = () => { upstream.destroy(); clientSocket.destroy(); };
|
|
584
|
+
armTimeout(upstream, close);
|
|
585
|
+
armTimeout(clientSocket, close);
|
|
586
|
+
upstream.on('error', () => {
|
|
587
|
+
try { clientSocket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); } catch {}
|
|
588
|
+
close();
|
|
589
|
+
});
|
|
590
|
+
clientSocket.on('error', close);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Only a bind failure is fatal. A later server error must not be reported as one.
|
|
594
|
+
let listening = false;
|
|
595
|
+
proxy.on('error', (err) => {
|
|
596
|
+
if (listening) return console.error(`[blindfold] proxy error: ${err.message}`);
|
|
597
|
+
console.error(`[blindfold] cannot listen on ${LISTEN_PORT}: ${err.message}`);
|
|
598
|
+
process.exit(1);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
export function start() {
|
|
602
|
+
try {
|
|
603
|
+
const context = {
|
|
604
|
+
key: fs.readFileSync(path.join(CERT_DIR, 'leaf.key')),
|
|
605
|
+
cert: fs.readFileSync(path.join(CERT_DIR, 'leaf.pem'))
|
|
606
|
+
};
|
|
607
|
+
mitm.setSecureContext(context);
|
|
608
|
+
} catch (err) {
|
|
609
|
+
console.error(`[blindfold] cannot read the leaf certificate in ${CERT_DIR}: ${err.message}`);
|
|
610
|
+
console.error('[blindfold] run blindfold/make-certs.sh first');
|
|
611
|
+
process.exit(1);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (CAPTURE_DIR) {
|
|
615
|
+
for (const signal of ['SIGTERM', 'SIGINT']) {
|
|
616
|
+
process.once(signal, () => {
|
|
617
|
+
for (const flush of [...pendingFlushes]) flush();
|
|
618
|
+
process.exit(0);
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
proxy.listen(LISTEN_PORT, '127.0.0.1', () => {
|
|
624
|
+
listening = true;
|
|
625
|
+
console.log(`[blindfold] proxy on http://127.0.0.1:${LISTEN_PORT}`);
|
|
626
|
+
console.log(`[blindfold] ${TARGET_HOST}${API_PREFIX}/* -> http://${GATEWAY_HOST}:${GATEWAY_PORT}${GATEWAY_PREFIX}/*`);
|
|
627
|
+
console.log(`[blindfold] every other path on ${TARGET_HOST} is re-originated to the real host`);
|
|
628
|
+
console.log('[blindfold] every other public host is tunneled; local destinations are refused');
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Importing this module (tests) must not open a socket.
|
|
633
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) start();
|