llm-switcher 1.1.11 → 1.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/CHANGELOG.md +51 -0
- package/README.md +202 -257
- package/README.vi.md +200 -256
- package/blindfold/blindfold.mjs +200 -53
- package/blindfold/make-certs.sh +26 -7
- package/catalog.mjs +246 -0
- package/config.example.json +12 -34
- package/docs/TOKEN-OPTIMIZER-INTEROP.md +110 -110
- package/docs/codex-blindfold.md +28 -17
- package/docs/cross-platform.md +16 -7
- package/docs/diagrams/ir-healer-pipeline.mmd +16 -0
- package/docs/diagrams/ir-healer-pipeline.png +0 -0
- package/docs/diagrams/ir-healer-pipeline.svg +90 -0
- package/docs/diagrams/ir-translation-pipeline.html +14925 -0
- package/docs/diagrams/ir-translation-pipeline.sequence.json +31 -0
- package/docs/diagrams/ir-translation-pipeline.svg +5128 -0
- package/docs/diagrams/system-architecture.architecture.json +76 -0
- package/docs/diagrams/system-architecture.html +14978 -0
- package/docs/diagrams/system-architecture.svg +5147 -0
- package/docs/diagrams/system-topology.mmd +30 -0
- package/docs/diagrams/system-topology.png +0 -0
- package/docs/diagrams/system-topology.svg +125 -0
- package/docs/response-matrix.json +1130 -1130
- package/ensure-ca-bundle.mjs +28 -0
- package/formats.mjs +13 -155
- package/mcp.mjs +39 -11
- package/package.json +1 -1
- package/proxy.mjs +92 -27
- package/shim.mjs +200 -57
- package/skills/llm-switcher/SKILL.md +93 -88
- package/state.mjs +1100 -191
- package/switch +0 -0
- package/switch.cmd +2 -2
- package/switch.mjs +228 -53
- package/tests/blindfold-e2e.test.mjs +380 -0
- package/tests/blindfold-task5.test.mjs +429 -0
- package/tests/blindfold.task3.test.mjs +700 -0
- package/tests/blindfold.test.mjs +10 -5
- package/tests/catalog.test.mjs +147 -0
- package/tests/contract-lab.test.mjs +22 -7
- package/tests/formats.test.mjs +33 -46
- package/tests/gateway.e2e.test.mjs +136 -36
- package/tests/helpers.mjs +24 -24
- package/tests/lifecycle.test.mjs +16 -10
- package/tests/live-optimizer-interop.mjs +205 -205
- package/tests/mcp.test.mjs +78 -2
- package/tests/real-user-sim.test.mjs +464 -0
- package/tests/shim.test.mjs +159 -66
- package/tests/state.test.mjs +975 -193
- package/tests/switch.test.mjs +446 -2
- package/ui.html +61 -154
package/blindfold/blindfold.mjs
CHANGED
|
@@ -19,9 +19,10 @@
|
|
|
19
19
|
// this process restores normal behaviour with nothing left behind.
|
|
20
20
|
//
|
|
21
21
|
// SCOPE OF THE INTERCEPT
|
|
22
|
-
//
|
|
23
|
-
// the gateway
|
|
24
|
-
//
|
|
22
|
+
// The host table names every host this process terminates TLS for, which path prefixes of
|
|
23
|
+
// each belong to the gateway, and which tool must be active for them to be taken. Everything
|
|
24
|
+
// else — every other path on those hosts, and every other host — is passed through untouched,
|
|
25
|
+
// so sign-in, token refresh and usage pages keep working exactly as they do without it.
|
|
25
26
|
// ============================================================
|
|
26
27
|
|
|
27
28
|
import fs from 'node:fs';
|
|
@@ -33,7 +34,10 @@ import tls from 'node:tls';
|
|
|
33
34
|
import zlib from 'node:zlib';
|
|
34
35
|
import crypto from 'node:crypto';
|
|
35
36
|
import dns from 'node:dns/promises';
|
|
36
|
-
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
37
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
38
|
+
// Pure: reads no file, migrates nothing. The interceptor imports it to re-derive the active tool
|
|
39
|
+
// set from config.json on every control request instead of trusting what it was spawned with.
|
|
40
|
+
import { deriveActiveTools } from '../state.mjs';
|
|
37
41
|
import { createFrameReader, negotiatesDeflate } from './wsframe.mjs';
|
|
38
42
|
|
|
39
43
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -55,17 +59,28 @@ function port(value, fallback) {
|
|
|
55
59
|
const LISTEN_PORT = port(arg('port', process.env.LLM_SWITCHER_BLINDFOLD_PORT), 3457);
|
|
56
60
|
const GATEWAY_HOST = arg('gateway-host', '127.0.0.1');
|
|
57
61
|
const GATEWAY_PORT = port(arg('gateway-port', process.env.LLM_SWITCHER_PORT), 3456);
|
|
58
|
-
|
|
59
|
-
//
|
|
60
|
-
// the
|
|
61
|
-
export const API_PREFIX = arg('prefix', '/backend-api/codex');
|
|
62
|
+
// R3: `--host` and `--prefix` are gone. Which hosts are intercepted, under which prefix, and for
|
|
63
|
+
// which tool is decided by the host table below — a value handed in on the command line can be
|
|
64
|
+
// stale while this process runs, and the config is the one copy that cannot (F6).
|
|
62
65
|
export const GATEWAY_PREFIX = arg('gateway-prefix', '/v1');
|
|
63
66
|
const CERT_DIR = arg('certs', path.join(HERE, 'certs'));
|
|
67
|
+
// Where the control endpoint re-reads the tool set from. Passed explicitly by the spawner so the
|
|
68
|
+
// process never guesses at a path (Finding 2 / F6).
|
|
69
|
+
const CONFIG_FILE = arg('config', path.join(HERE, '..', 'config.json'));
|
|
64
70
|
const VERBOSE = process.argv.includes('--verbose');
|
|
65
71
|
const CAPTURE_DIR = arg('capture', null);
|
|
66
|
-
// The switcher's admin.token
|
|
72
|
+
// The switcher's admin.token: it signs the identity probe, and the control endpoint compares it
|
|
73
|
+
// in the x-llm-switcher-token header.
|
|
67
74
|
const TOKEN_FILE = arg('token-file', path.join(HERE, '..', 'admin.token'));
|
|
68
75
|
|
|
76
|
+
// Finding 6: one fixed, sorted list. Sorting means the spawner's argument and this process's own
|
|
77
|
+
// derivation produce the same string, so `matches` and the identity proof are stable.
|
|
78
|
+
const parseToolList = (value) => String(value || '').split(',').map(s => s.trim()).filter(Boolean).sort();
|
|
79
|
+
// Spawned with the tool set of the config at that moment; the control endpoint replaces it from
|
|
80
|
+
// the file itself, in memory, without touching the port or any open stream.
|
|
81
|
+
let ACTIVE_TOOLS = parseToolList(arg('active-tools', ''));
|
|
82
|
+
const activeToolSet = () => new Set(ACTIVE_TOOLS);
|
|
83
|
+
|
|
69
84
|
const log = (...args) => { if (VERBOSE) console.log('[blindfold]', ...args); };
|
|
70
85
|
|
|
71
86
|
// A capture file records what a genuine client sends on the wire. It must never
|
|
@@ -150,10 +165,12 @@ export function writeCaptureFile(dir, fileName, record, { uid = process.getuid?.
|
|
|
150
165
|
}
|
|
151
166
|
|
|
152
167
|
// A capture is a diagnostic. Failing to write one must never fail the request.
|
|
153
|
-
|
|
154
|
-
|
|
168
|
+
// `dir` defaults to the --capture of this process; recordExchange passes its own so the
|
|
169
|
+
// pass-through rule (R3c) can be exercised against a directory of its own.
|
|
170
|
+
function writeCapture(record, { fileName, dir = CAPTURE_DIR } = {}) {
|
|
171
|
+
if (!dir) return;
|
|
155
172
|
try {
|
|
156
|
-
writeCaptureFile(
|
|
173
|
+
writeCaptureFile(dir, fileName || captureName(record.method, record.url), record);
|
|
157
174
|
} catch (err) {
|
|
158
175
|
log('capture failed:', err.message);
|
|
159
176
|
}
|
|
@@ -178,7 +195,87 @@ function normalizedTarget(url) {
|
|
|
178
195
|
}
|
|
179
196
|
}
|
|
180
197
|
|
|
198
|
+
// The chatgpt.com rule, named on its own because it is the only one that rewrites a prefix:
|
|
199
|
+
// Codex with ChatGPT auth calls /backend-api/codex/<endpoint>, the gateway serves it under /v1.
|
|
200
|
+
export const API_PREFIX = '/backend-api/codex';
|
|
201
|
+
|
|
202
|
+
// ---------------- the host table of R3 ----------------
|
|
203
|
+
// One host, the whole-path prefixes of that host that belong to the gateway, the path the gateway
|
|
204
|
+
// serves them under, and the tool that has to be active for them to be taken at all. A prefix only
|
|
205
|
+
// ever matches on a segment boundary, so `/v1/responses_compact` is not `/v1/responses`; and the
|
|
206
|
+
// decision is made on the NORMALIZED pathname (normalizedTarget), so a `%2e%2e` segment cannot
|
|
207
|
+
// carry a request into another gateway path. A host outside this table is tunneled, never
|
|
208
|
+
// terminated — this process then copies bytes it cannot read.
|
|
209
|
+
const HOST_ROUTES = {
|
|
210
|
+
'api.anthropic.com': { tool: 'claude', prefixes: ['/v1/messages'], rewrite: null },
|
|
211
|
+
'api.openai.com': { tool: 'codex', prefixes: ['/v1/responses', '/v1/models'], rewrite: null },
|
|
212
|
+
'chatgpt.com': { tool: 'codex', prefixes: [API_PREFIX], rewrite: { from: API_PREFIX, to: '/v1' } }
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// Only reachable if a socket reaches the TLS endpoint without a CONNECT to name its host — a test
|
|
216
|
+
// emitting a connection directly. A real request always carries the tunnel's host.
|
|
217
|
+
const API_HOST_FALLBACK = 'chatgpt.com';
|
|
218
|
+
|
|
219
|
+
// A Host header carries an optional port; the CONNECT target does not, because its port was split
|
|
220
|
+
// off when the tunnel was opened. Case never matters to DNS and a client may write either.
|
|
221
|
+
export function normalizeHost(value) {
|
|
222
|
+
return String(value || '').trim().toLowerCase().replace(/:443$/, '');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function isInterceptedHost(host) {
|
|
226
|
+
return Boolean(HOST_ROUTES[normalizeHost(host)]);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// One CONNECT tunnel carries one host. TLS already bound the certificate this endpoint presents
|
|
230
|
+
// to the host the tunnel was opened for; a Host header naming a different host would ask for a
|
|
231
|
+
// second origin over that same binding. Refuse instead of guessing which one was meant (A4b).
|
|
232
|
+
/**
|
|
233
|
+
* Which host this TLS session belongs to.
|
|
234
|
+
*
|
|
235
|
+
* The interceptor learns a host when it accepts the CONNECT; the request handler runs on the TLS
|
|
236
|
+
* socket wrapped around that raw socket, so the property may not be visible from here. Try the
|
|
237
|
+
* raw socket first (the CONNECT host, which is what R3b binds the check to), then the socket that
|
|
238
|
+
* wrapped it, then SNI — but SNI only counts when it names a host of the table: a client must
|
|
239
|
+
* never be able to point this process at an address of its own choosing.
|
|
240
|
+
*/
|
|
241
|
+
export function tunnelHostOf(req) {
|
|
242
|
+
const socket = req?.socket;
|
|
243
|
+
const fromTunnel = normalizeHost(socket?._connectHost ?? socket?._parent?._connectHost);
|
|
244
|
+
if (fromTunnel) return fromTunnel;
|
|
245
|
+
const sni = normalizeHost(socket?.servername);
|
|
246
|
+
return Object.hasOwn(HOST_ROUTES, sni) ? sni : '';
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function misdirected(req) {
|
|
250
|
+
const want = tunnelHostOf(req);
|
|
251
|
+
if (!want) return false; // not a host we terminated: there is nothing to compare against
|
|
252
|
+
return normalizeHost(req.headers?.host) !== want;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The one routing decision this process makes. null means pass-through. The gateway path is
|
|
257
|
+
* unchanged for the two API hosts and has the ChatGPT prefix swapped for /v1, as it always was.
|
|
258
|
+
* `activeTools` gates the whole host: when the tool behind a host is off, that host keeps its own
|
|
259
|
+
* endpoint and this switcher is no longer in its path at all (F2).
|
|
260
|
+
*/
|
|
261
|
+
export function hostRoute(connectHost, url, activeTools) {
|
|
262
|
+
const route = HOST_ROUTES[normalizeHost(connectHost)];
|
|
263
|
+
if (!route) return null;
|
|
264
|
+
if (activeTools && !activeTools.has(route.tool)) return null;
|
|
265
|
+
const parsed = normalizedTarget(url);
|
|
266
|
+
if (!parsed) return null;
|
|
267
|
+
const p = parsed.pathname;
|
|
268
|
+
const prefix = route.prefixes.find(x => p === x || p.startsWith(`${x}/`));
|
|
269
|
+
if (!prefix) return null;
|
|
270
|
+
const gatewayPath = route.rewrite && prefix === route.rewrite.from
|
|
271
|
+
? route.rewrite.to + p.slice(route.rewrite.from.length)
|
|
272
|
+
: p;
|
|
273
|
+
return { host: normalizeHost(connectHost), tool: route.tool, gatewayPath: gatewayPath + parsed.search };
|
|
274
|
+
}
|
|
275
|
+
|
|
181
276
|
export function isGatewayPath(url) {
|
|
277
|
+
// The rule for chatgpt.com, kept as its own predicate because it is the one rule with a
|
|
278
|
+
// rewrite. hostRoute() above is the decision the interceptor actually makes, per CONNECT host.
|
|
182
279
|
const parsed = normalizedTarget(url);
|
|
183
280
|
if (!parsed) return false;
|
|
184
281
|
const p = parsed.pathname;
|
|
@@ -193,8 +290,11 @@ export function toGatewayPath(url) {
|
|
|
193
290
|
}
|
|
194
291
|
|
|
195
292
|
// 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
|
-
|
|
293
|
+
// HTTP that another local account can hold once it is free, so they stay on this side — and
|
|
294
|
+
// `authorization` and `x-api-key` join them: the gateway has its own provider keys and must
|
|
295
|
+
// never see the ones the client brought (R3, spec F-host-table).
|
|
296
|
+
const GATEWAY_STRIPPED = ['authorization', 'proxy-authorization', 'cookie', 'chatgpt-account-id',
|
|
297
|
+
'openai-organization', 'x-api-key'];
|
|
198
298
|
|
|
199
299
|
// The gateway answers only to a loopback Host, so rewrite it. Origin carries the
|
|
200
300
|
// intercepted hostname and would fail the same check.
|
|
@@ -230,34 +330,36 @@ function armTimeout(socket, onTimeout) {
|
|
|
230
330
|
socket.setTimeout(IDLE_TIMEOUT_MS, onTimeout);
|
|
231
331
|
}
|
|
232
332
|
|
|
233
|
-
// ---------- the TLS endpoint that
|
|
333
|
+
// ---------- the TLS endpoint that terminates for a host of the table ----------
|
|
234
334
|
|
|
235
335
|
const mitm = https.createServer();
|
|
236
336
|
|
|
237
337
|
// 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.
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
338
|
+
// write the file first would hold back a streamed response.
|
|
339
|
+
//
|
|
340
|
+
// `includeBody: false` is the pass-through route (R3c): those bytes belong to the provider and
|
|
341
|
+
// to an OAuth exchange, and a capture exists to be read and shared. Only the shape of the
|
|
342
|
+
// exchange is kept — method, path, status and the redacted headers.
|
|
343
|
+
export function recordExchange(req, { includeBody = true, captureDir = CAPTURE_DIR } = {}) {
|
|
344
|
+
if (!captureDir) return null;
|
|
243
345
|
const reqChunks = [];
|
|
244
|
-
req.on('data', (c) => reqChunks.push(c));
|
|
346
|
+
if (includeBody) req.on('data', (c) => reqChunks.push(c));
|
|
245
347
|
return (upRes) => {
|
|
246
348
|
const resChunks = [];
|
|
247
|
-
upRes.on('data', (c) => resChunks.push(c));
|
|
349
|
+
if (includeBody) upRes.on('data', (c) => resChunks.push(c));
|
|
248
350
|
upRes.on('end', () => writeCapture({
|
|
249
351
|
method: req.method,
|
|
250
352
|
url: req.url,
|
|
251
353
|
requestHeaders: redactHeaders(req.headers),
|
|
252
|
-
requestBody: clip(decodeBody(Buffer.concat(reqChunks), req.headers['content-encoding'])),
|
|
354
|
+
...(includeBody ? { requestBody: clip(decodeBody(Buffer.concat(reqChunks), req.headers['content-encoding'])) } : {}),
|
|
253
355
|
status: upRes.statusCode,
|
|
254
356
|
responseHeaders: redactHeaders(upRes.headers),
|
|
255
|
-
responseBody: clip(decodeBody(Buffer.concat(resChunks), upRes.headers['content-encoding']))
|
|
256
|
-
}));
|
|
357
|
+
...(includeBody ? { responseBody: clip(decodeBody(Buffer.concat(resChunks), upRes.headers['content-encoding'])) } : {})
|
|
358
|
+
}, { dir: captureDir }));
|
|
257
359
|
};
|
|
258
360
|
}
|
|
259
361
|
|
|
260
|
-
export function relayToGateway(req, res, { host = GATEWAY_HOST, port = GATEWAY_PORT } = {}) {
|
|
362
|
+
export function relayToGateway(req, res, { host = GATEWAY_HOST, port = GATEWAY_PORT, gatewayPath = null } = {}) {
|
|
261
363
|
log('gateway', req.method, req.url);
|
|
262
364
|
const record = recordExchange(req);
|
|
263
365
|
|
|
@@ -265,7 +367,7 @@ export function relayToGateway(req, res, { host = GATEWAY_HOST, port = GATEWAY_P
|
|
|
265
367
|
host,
|
|
266
368
|
port,
|
|
267
369
|
method: req.method,
|
|
268
|
-
path: toGatewayPath(req.url),
|
|
370
|
+
path: gatewayPath || toGatewayPath(req.url),
|
|
269
371
|
headers: gatewayHeaders(req.headers, { host, port })
|
|
270
372
|
}, (upRes) => {
|
|
271
373
|
res.writeHead(upRes.statusCode, upRes.headers);
|
|
@@ -280,22 +382,28 @@ export function relayToGateway(req, res, { host = GATEWAY_HOST, port = GATEWAY_P
|
|
|
280
382
|
}
|
|
281
383
|
|
|
282
384
|
mitm.on('request', (req, res) => {
|
|
283
|
-
|
|
284
|
-
|
|
385
|
+
// The Host header must name the host this tunnel was opened for; a mismatch is refused before
|
|
386
|
+
// any upstream connection exists (A4b).
|
|
387
|
+
if (misdirected(req)) return failExchange(res, 421, 'text/plain', 'Misdirected Request');
|
|
388
|
+
const route = hostRoute(tunnelHostOf(req), req.url, activeToolSet());
|
|
389
|
+
if (!route) return passThroughRequest(req, res);
|
|
390
|
+
relayToGateway(req, res, { gatewayPath: route.gatewayPath });
|
|
285
391
|
});
|
|
286
392
|
|
|
287
|
-
// Everything that is not
|
|
288
|
-
// usage pages behave exactly as they do without this process
|
|
393
|
+
// Everything that is not an intercepted API call goes to the host the tunnel was opened for, so
|
|
394
|
+
// sign-in and usage pages behave exactly as they do without this process — including on a host
|
|
395
|
+
// that is in the table but whose tool is switched off (F2).
|
|
289
396
|
function passThroughRequest(req, res) {
|
|
290
|
-
|
|
291
|
-
|
|
397
|
+
const connectHost = tunnelHostOf(req) || API_HOST_FALLBACK;
|
|
398
|
+
log('passthrough', req.method, req.url, '->', connectHost);
|
|
399
|
+
const record = recordExchange(req, { includeBody: false });
|
|
292
400
|
const upstream = https.request({
|
|
293
|
-
host:
|
|
294
|
-
servername:
|
|
401
|
+
host: connectHost,
|
|
402
|
+
servername: connectHost,
|
|
295
403
|
port: 443,
|
|
296
404
|
method: req.method,
|
|
297
405
|
path: req.url,
|
|
298
|
-
headers: { ...req.headers, host:
|
|
406
|
+
headers: { ...req.headers, host: connectHost }
|
|
299
407
|
}, (upRes) => {
|
|
300
408
|
res.writeHead(upRes.statusCode, upRes.headers);
|
|
301
409
|
if (record) record(upRes);
|
|
@@ -423,15 +531,24 @@ function relayUpgrade(req, clientSocket, head, target, readyEvent, headers, requ
|
|
|
423
531
|
clientSocket.on('error', () => target.destroy());
|
|
424
532
|
}
|
|
425
533
|
|
|
426
|
-
export function relayUpgradeToGateway(req, clientSocket, head, { host = GATEWAY_HOST, port = GATEWAY_PORT } = {}) {
|
|
534
|
+
export function relayUpgradeToGateway(req, clientSocket, head, { host = GATEWAY_HOST, port = GATEWAY_PORT, gatewayPath = null } = {}) {
|
|
427
535
|
relayUpgrade(req, clientSocket, head, net.connect(port, host), 'connect',
|
|
428
|
-
gatewayHeaders(req.headers, { host, port }), toGatewayPath(req.url), 'gateway');
|
|
536
|
+
gatewayHeaders(req.headers, { host, port }), gatewayPath || toGatewayPath(req.url), 'gateway');
|
|
429
537
|
}
|
|
430
538
|
|
|
431
539
|
mitm.on('upgrade', (req, clientSocket, head) => {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
540
|
+
// An upgrade answers on the raw socket: there is no response object to write 421 to.
|
|
541
|
+
if (misdirected(req)) {
|
|
542
|
+
if (clientSocket.writable) clientSocket.end('HTTP/1.1 421 Misdirected Request\r\nConnection: close\r\n\r\n');
|
|
543
|
+
return clientSocket.destroy();
|
|
544
|
+
}
|
|
545
|
+
const route = hostRoute(tunnelHostOf(req), req.url, activeToolSet());
|
|
546
|
+
if (route) return relayUpgradeToGateway(req, clientSocket, head, { gatewayPath: route.gatewayPath });
|
|
547
|
+
// Pass-through follows the host the tunnel was opened for, with that same name as the SNI —
|
|
548
|
+
// not a name this process happened to be configured with (A9).
|
|
549
|
+
const connectHost = tunnelHostOf(req) || API_HOST_FALLBACK;
|
|
550
|
+
relayUpgrade(req, clientSocket, head, tls.connect({ host: connectHost, port: 443, servername: connectHost }),
|
|
551
|
+
'secureConnect', { ...req.headers, host: connectHost }, req.url, 'passthrough');
|
|
435
552
|
});
|
|
436
553
|
|
|
437
554
|
mitm.on('tlsClientError', (err) => log('tls client error:', err.message));
|
|
@@ -447,18 +564,51 @@ mitm.on('clientError', (err, socket) => {
|
|
|
447
564
|
export function identityAnswer(url) {
|
|
448
565
|
const challenge = new URL(url || '/', 'http://blindfold.invalid').searchParams.get('challenge');
|
|
449
566
|
if (!challenge) return null;
|
|
450
|
-
const fields = { role: 'blindfold', port: LISTEN_PORT, pid: process.pid, gatewayPort: GATEWAY_PORT,
|
|
567
|
+
const fields = { role: 'blindfold', port: LISTEN_PORT, pid: process.pid, gatewayPort: GATEWAY_PORT, activeTools: ACTIVE_TOOLS.join(',') };
|
|
451
568
|
let proof = '';
|
|
452
569
|
try {
|
|
453
570
|
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.
|
|
571
|
+
// Same fields and order as identityProof in state.mjs. `host` and `prefix` left with R3.
|
|
572
|
+
const msg = [fields.role, fields.port, fields.pid, fields.gatewayPort, fields.activeTools, challenge].join('|');
|
|
456
573
|
if (token) proof = crypto.createHmac('sha256', token).update(msg).digest('hex');
|
|
457
574
|
} catch {}
|
|
458
575
|
return { proxy: 'llm-switcher-blindfold', proof, ...fields };
|
|
459
576
|
}
|
|
460
577
|
|
|
578
|
+
// ---------------- the one control channel ----------------
|
|
579
|
+
// Finding 2 / Item 7: the tool set is the only thing that changes while this process runs. One
|
|
580
|
+
// authenticated loopback POST replaces it in memory — no signal, no restart, no socket closed and
|
|
581
|
+
// no in-flight stream severed. The request body is read and then ignored, on purpose: the config
|
|
582
|
+
// file is the one copy both ends already agree on, and a delta is one more thing to get wrong
|
|
583
|
+
// (Finding 4, option (a)).
|
|
584
|
+
const readToken = () => { try { return fs.readFileSync(TOKEN_FILE, 'utf8').trim(); } catch { return ''; } };
|
|
585
|
+
|
|
586
|
+
function handleControl(req, res) {
|
|
587
|
+
req.resume();
|
|
588
|
+
const answer = (status, body) => {
|
|
589
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
590
|
+
res.end(JSON.stringify(body));
|
|
591
|
+
};
|
|
592
|
+
const token = readToken();
|
|
593
|
+
const given = String(req.headers['x-llm-switcher-token'] || '');
|
|
594
|
+
// Loopback is not an identity: every process on this machine can reach this port.
|
|
595
|
+
if (!token || !given || given !== token) return answer(403, { ok: false, error: 'forbidden' });
|
|
596
|
+
let next;
|
|
597
|
+
try {
|
|
598
|
+
next = deriveActiveTools(JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'))).sort();
|
|
599
|
+
} catch (err) {
|
|
600
|
+
// A config we cannot read must not empty the table: keep serving the set we already have.
|
|
601
|
+
return answer(500, { ok: false, error: err.message });
|
|
602
|
+
}
|
|
603
|
+
ACTIVE_TOOLS = next;
|
|
604
|
+
log('active tools ->', next.join(',') || '(none)');
|
|
605
|
+
return answer(200, { ok: true, activeTools: next });
|
|
606
|
+
}
|
|
607
|
+
|
|
461
608
|
const proxy = http.createServer((req, res) => {
|
|
609
|
+
let pathname = '';
|
|
610
|
+
try { pathname = new URL(req.url || '/', 'http://blindfold.invalid').pathname; } catch {}
|
|
611
|
+
if (req.method === 'POST' && pathname === '/_control/active-tools') return handleControl(req, res);
|
|
462
612
|
const identity = identityAnswer(req.url);
|
|
463
613
|
if (identity) {
|
|
464
614
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -484,12 +634,6 @@ for (const [prefix, bits] of [['::', 128], ['::1', 128], ['fc00::', 7], ['fe80::
|
|
|
484
634
|
LOCAL_RANGES.addSubnet(prefix, bits, 'ipv6');
|
|
485
635
|
}
|
|
486
636
|
|
|
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
637
|
export function isLocalAddress(address) {
|
|
494
638
|
const family = net.isIP(address);
|
|
495
639
|
if (family === 0) return true;
|
|
@@ -552,6 +696,9 @@ proxy.on('connect', (req, clientSocket, head) => {
|
|
|
552
696
|
|
|
553
697
|
if (isInterceptedHost(host)) {
|
|
554
698
|
log('intercept CONNECT', target);
|
|
699
|
+
// The host this tunnel was opened for. Every later decision — the 421 check, the host table,
|
|
700
|
+
// and which origin pass-through re-originates to — reads this instead of a configured name.
|
|
701
|
+
clientSocket._connectHost = normalizeHost(host);
|
|
555
702
|
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
556
703
|
if (head?.length) clientSocket.unshift(head);
|
|
557
704
|
// Hand the raw socket to the TLS endpoint: it completes the handshake with our leaf.
|
|
@@ -623,9 +770,9 @@ export function start() {
|
|
|
623
770
|
proxy.listen(LISTEN_PORT, '127.0.0.1', () => {
|
|
624
771
|
listening = true;
|
|
625
772
|
console.log(`[blindfold] proxy on http://127.0.0.1:${LISTEN_PORT}`);
|
|
626
|
-
console.log(`[blindfold] ${
|
|
627
|
-
console.log(`[blindfold]
|
|
628
|
-
console.log('[blindfold] every other
|
|
773
|
+
console.log(`[blindfold] intercepts ${Object.keys(HOST_ROUTES).join(', ')}`);
|
|
774
|
+
console.log(`[blindfold] api paths -> http://${GATEWAY_HOST}:${GATEWAY_PORT}${GATEWAY_PREFIX} for ${ACTIVE_TOOLS.join(',') || 'no tool'}`);
|
|
775
|
+
console.log('[blindfold] every other path is re-originated to its own host; local destinations are refused');
|
|
629
776
|
});
|
|
630
777
|
}
|
|
631
778
|
|
package/blindfold/make-certs.sh
CHANGED
|
@@ -14,7 +14,18 @@
|
|
|
14
14
|
# CODEX_CA_CERTIFICATE environment variable, which the switcher exports for you.
|
|
15
15
|
set -euo pipefail
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
# R3: the interceptor answers on a fixed host table, so one leaf has to carry all three hosts and
|
|
18
|
+
# this CA may only ever sign for them. A certificate an older version built covers a single host;
|
|
19
|
+
# state.mjs `blindfoldPreflight` detects that and names this script instead of starting an
|
|
20
|
+
# interceptor whose handshake would fail on two of the three hosts.
|
|
21
|
+
INTERCEPT_HOSTS=(api.anthropic.com api.openai.com chatgpt.com)
|
|
22
|
+
SAN_LIST=""
|
|
23
|
+
NC_LIST=""
|
|
24
|
+
for h in "${INTERCEPT_HOSTS[@]}"; do
|
|
25
|
+
SAN_LIST="${SAN_LIST:+$SAN_LIST,}DNS:$h"
|
|
26
|
+
NC_LIST="${NC_LIST:+$NC_LIST,}permitted;DNS:$h"
|
|
27
|
+
done
|
|
28
|
+
|
|
18
29
|
# The default must match the folder the gateway reads (state.mjs paths.blindfoldCA): an npm
|
|
19
30
|
# install keeps its data in ~/.llm-switcher, a git checkout next to the code.
|
|
20
31
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
@@ -22,7 +33,14 @@ if [ -n "${LLM_SWITCHER_BLINDFOLD_CERTS:-}" ]; then DEFAULT_OUT="$LLM_SWITCHER_B
|
|
|
22
33
|
elif [ -n "${LLM_SWITCHER_HOME:-}" ]; then DEFAULT_OUT="$LLM_SWITCHER_HOME/blindfold/certs"
|
|
23
34
|
elif [ -d "$ROOT/.git" ]; then DEFAULT_OUT="$ROOT/blindfold/certs"
|
|
24
35
|
else DEFAULT_OUT="$HOME/.llm-switcher/blindfold/certs"; fi
|
|
25
|
-
|
|
36
|
+
# Older commands read `make-certs.sh <host> <out-dir>`. The host list is fixed now, so the host is
|
|
37
|
+
# ignored; two arguments keep those commands working. A lone path-like argument is the directory.
|
|
38
|
+
OUT_ARG=""
|
|
39
|
+
if [ "$#" -ge 2 ]; then OUT_ARG="$2"
|
|
40
|
+
elif [ "$#" -eq 1 ]; then
|
|
41
|
+
case "$1" in */*|.*) OUT_ARG="$1" ;; *) : ;; esac
|
|
42
|
+
fi
|
|
43
|
+
OUT_DIR="${OUT_ARG:-$DEFAULT_OUT}"
|
|
26
44
|
CA_DAYS=3650
|
|
27
45
|
LEAF_DAYS=825
|
|
28
46
|
|
|
@@ -42,7 +60,7 @@ chmod 700 "$OUT_DIR"
|
|
|
42
60
|
WORK="$(mktemp -d "$OUT_DIR/.build.XXXXXX")"
|
|
43
61
|
trap 'rm -rf "$WORK"' EXIT
|
|
44
62
|
|
|
45
|
-
echo "[blindfold]
|
|
63
|
+
echo "[blindfold] hosts : ${INTERCEPT_HOSTS[*]}"
|
|
46
64
|
echo "[blindfold] output : $OUT_DIR"
|
|
47
65
|
|
|
48
66
|
cat > "$WORK/ca.cnf" <<EOF
|
|
@@ -58,8 +76,9 @@ CN = LLM Switcher Local CA
|
|
|
58
76
|
basicConstraints = critical,CA:TRUE,pathlen:0
|
|
59
77
|
keyUsage = critical,keyCertSign,cRLSign
|
|
60
78
|
subjectKeyIdentifier = hash
|
|
61
|
-
# Codex trusts this CA for every host. The constraint
|
|
62
|
-
|
|
79
|
+
# Codex trusts this CA for every host. The constraint keeps a leaked ca.key to the host table:
|
|
80
|
+
# a client that obeys name constraints refuses every other certificate this CA could sign.
|
|
81
|
+
nameConstraints = critical,$NC_LIST
|
|
63
82
|
EOF
|
|
64
83
|
|
|
65
84
|
cat > "$WORK/leaf.cnf" <<EOF
|
|
@@ -68,14 +87,14 @@ prompt = no
|
|
|
68
87
|
distinguished_name = dn
|
|
69
88
|
|
|
70
89
|
[dn]
|
|
71
|
-
CN =
|
|
90
|
+
CN = llm-switcher
|
|
72
91
|
EOF
|
|
73
92
|
|
|
74
93
|
cat > "$WORK/leaf.ext" <<EOF
|
|
75
94
|
basicConstraints = critical,CA:FALSE
|
|
76
95
|
keyUsage = critical,digitalSignature,keyEncipherment
|
|
77
96
|
extendedKeyUsage = serverAuth
|
|
78
|
-
subjectAltName =
|
|
97
|
+
subjectAltName = $SAN_LIST
|
|
79
98
|
EOF
|
|
80
99
|
|
|
81
100
|
openssl ecparam -name prime256v1 -genkey -noout -out "$WORK/ca.key"
|