troxy-cli 1.29.3 → 1.29.5
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/bin/troxy.js +13 -0
- package/package.json +3 -2
- package/src/daemon.js +116 -3
- package/src/init.js +278 -26
- package/src/interceptor.js +419 -0
- package/src/providers.js +84 -0
- package/src/proxy.js +184 -0
- package/src/tests/claude-code-hook.test.js +96 -1
- package/src/tests/claude-code-proxy.test.js +120 -1
- package/src/tests/daemon.test.js +85 -0
- package/src/tests/init-detection.test.js +95 -0
- package/src/tests/install-service.test.js +69 -0
- package/src/tests/interception-config.test.js +203 -0
- package/src/tests/interceptor.test.js +596 -0
- package/src/tests/providers.test.js +78 -0
- package/src/tests/proxy-wiring.test.js +118 -0
- package/src/tests/tls-ca.test.js +222 -0
- package/src/tls-ca.js +208 -0
- package/src/uninstall.js +32 -4
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local interceptor - Layer 2 of the Live Model Policy Enforcement
|
|
3
|
+
* plan. Runs inside `troxy daemon` (see daemon.js), listening on loopback
|
|
4
|
+
* only. Handles HTTP CONNECT the way any forward proxy does:
|
|
5
|
+
*
|
|
6
|
+
* - A host NOT in `interceptHosts` is a pure blind byte tunnel. This code
|
|
7
|
+
* never sees its plaintext, never parses it, makes no network call of
|
|
8
|
+
* its own - just net.connect + pipe() in both directions. This is the
|
|
9
|
+
* hot path for everything Claude Code (and anything it spawns) talks to
|
|
10
|
+
* that isn't the one host this feature cares about.
|
|
11
|
+
* - An allowlisted host gets its TLS terminated with a leaf signed by the
|
|
12
|
+
* local, name-constrained CA (tls-ca.js), fed into a real Node
|
|
13
|
+
* http.Server instance via `.emit('connection', tlsSocket)` so Node's
|
|
14
|
+
* own battle-tested HTTP/1.1 parsing handles the request, not a
|
|
15
|
+
* hand-rolled one. Only the ONE configured route (providers.js) gets
|
|
16
|
+
* re-addressed to Troxy; every other path on that same host relays to
|
|
17
|
+
* the real destination untouched, same as a non-allowlisted host would.
|
|
18
|
+
*
|
|
19
|
+
* Fail-open, required by the plan (found the hard way tonight - a dead
|
|
20
|
+
* local proxy broke Claude Code outright until manually reverted):
|
|
21
|
+
* - getCerts() returning null (missing/corrupt/expired) -> tunnel the
|
|
22
|
+
* allowlisted host too, exactly like a non-allowlisted one. Coverage is
|
|
23
|
+
* lost; the user's Claude Code is not.
|
|
24
|
+
* - Troxy upstream unreachable or slow past troxyConnectTimeoutMs -> the
|
|
25
|
+
* matched request falls back to relaying directly to the real host,
|
|
26
|
+
* with the client's own original credentials, instead of erroring.
|
|
27
|
+
* - No Troxy key available -> same fallback, without even attempting Troxy.
|
|
28
|
+
*/
|
|
29
|
+
import net from 'node:net';
|
|
30
|
+
import tls from 'node:tls';
|
|
31
|
+
import http from 'node:http';
|
|
32
|
+
import https from 'node:https';
|
|
33
|
+
|
|
34
|
+
// Found live 2026-09-08, first real terminal validation of this whole
|
|
35
|
+
// mechanism: a real `claude` process opens a burst of ~15 concurrent
|
|
36
|
+
// connections through the interceptor at startup (telemetry, mcp-registry
|
|
37
|
+
// pagination, oauth/account/settings, bootstrap, the real /v1/messages
|
|
38
|
+
// call, etc.), all landing on this single daemon process's event loop at
|
|
39
|
+
// once. A cold Node https.request to proxy.troxy.io in isolation completes
|
|
40
|
+
// in ~150-350ms (measured directly), comfortably inside 1.5s - but under
|
|
41
|
+
// that real startup burst, the specific connection carrying the actual
|
|
42
|
+
// message reliably missed the 1.5s budget and fell open, so every message
|
|
43
|
+
// in a fresh terminal session silently bypassed Troxy. Verified live: the
|
|
44
|
+
// SAME session's second and later messages, once the startup burst has
|
|
45
|
+
// settled, connect and route correctly well within 1.5s - this is a
|
|
46
|
+
// startup-burst problem, not a genuinely slow or unreachable Troxy.
|
|
47
|
+
// Widened for headroom under that real burst; still bounded, so a
|
|
48
|
+
// genuinely dead Troxy backend still fails open within a few seconds, not
|
|
49
|
+
// hangs indefinitely.
|
|
50
|
+
const DEFAULT_TROXY_CONNECT_TIMEOUT_MS = 5000;
|
|
51
|
+
// Once the TCP/TLS connection to Troxy is actually established, Troxy
|
|
52
|
+
// itself is reachable - any further wait is the model genuinely thinking,
|
|
53
|
+
// not an outage. Found live: a real Claude Code/Desktop request (full
|
|
54
|
+
// system prompt, tool definitions, history) commonly takes several
|
|
55
|
+
// seconds of time-to-first-token, which blew straight past the old single
|
|
56
|
+
// 1.5s budget on every real message, so the interceptor fell back to a
|
|
57
|
+
// direct call every single time and never actually reached policy
|
|
58
|
+
// enforcement in practice. Falling back at that point buys the user
|
|
59
|
+
// nothing anyway - Anthropic direct would show the same latency - so this
|
|
60
|
+
// is a generous absolute backstop against Troxy hanging post-connect, not
|
|
61
|
+
// a value meant to trigger in the normal case.
|
|
62
|
+
const DEFAULT_TROXY_RESPONSE_TIMEOUT_MS = 55_000;
|
|
63
|
+
|
|
64
|
+
export function parseConnectTarget(url) {
|
|
65
|
+
const idx = url.lastIndexOf(':');
|
|
66
|
+
if (idx === -1) return { host: url, port: 443 };
|
|
67
|
+
const host = url.slice(0, idx);
|
|
68
|
+
const port = parseInt(url.slice(idx + 1), 10);
|
|
69
|
+
return { host, port: Number.isFinite(port) ? port : 443 };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Headers that only made sense on the leg that already terminated here -
|
|
73
|
+
// meaningless (or wrong) to forward on either outbound leg.
|
|
74
|
+
const _HOP_BY_HOP_HEADERS = new Set(['host', 'connection', 'content-length', 'transfer-encoding']);
|
|
75
|
+
|
|
76
|
+
/** 'desktop' | 'terminal' | null. Verified live 2026-09-11 against two real
|
|
77
|
+
* captured user-agents from this same machine: terminal sends
|
|
78
|
+
* "claude-cli/2.1.236 (external, cli)", desktop sends "claude-cli/2.1.260
|
|
79
|
+
* (external, claude-desktop, agent-sdk/0.3.260)" - the literal substring
|
|
80
|
+
* "claude-desktop" is the one stable, explicit signal found; everything
|
|
81
|
+
* else in the string (version numbers) will drift across releases. null
|
|
82
|
+
* (never a guess) when the client sends no recognizable claude-cli
|
|
83
|
+
* user-agent at all - some other tool on this port, or a future format
|
|
84
|
+
* change this hasn't been updated for. */
|
|
85
|
+
export function detectSurface(incomingHeaders) {
|
|
86
|
+
const ua = incomingHeaders['user-agent'] || '';
|
|
87
|
+
if (!/^claude-cli\//.test(ua)) return null;
|
|
88
|
+
return ua.includes('claude-desktop') ? 'desktop' : 'terminal';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Headers for the leg going to Troxy: the client's own Authorization (an
|
|
92
|
+
* Anthropic API key or OAuth token) is never sent to Troxy as-is - it
|
|
93
|
+
* rides the side channel app.py already knows how to consume
|
|
94
|
+
* (X-Troxy-Upstream-Authorization), while Authorization itself becomes the
|
|
95
|
+
* Troxy key, matching how app.py authenticates every other proxied call. */
|
|
96
|
+
export function buildTroxyForwardHeaders(incomingHeaders, troxyKey) {
|
|
97
|
+
const headers = {};
|
|
98
|
+
for (const [k, v] of Object.entries(incomingHeaders)) {
|
|
99
|
+
if (_HOP_BY_HOP_HEADERS.has(k) || k === 'authorization' || k === 'accept-encoding') continue;
|
|
100
|
+
headers[k] = v;
|
|
101
|
+
}
|
|
102
|
+
if (incomingHeaders.authorization) {
|
|
103
|
+
headers['x-troxy-upstream-authorization'] = incomingHeaders.authorization;
|
|
104
|
+
}
|
|
105
|
+
const surface = detectSurface(incomingHeaders);
|
|
106
|
+
if (surface) headers['x-troxy-surface'] = surface;
|
|
107
|
+
headers.authorization = `Bearer ${troxyKey}`;
|
|
108
|
+
return headers;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Headers for the leg going straight to the real host (no Troxy route
|
|
112
|
+
* matched, or Troxy was unreachable): the client's own credential passes
|
|
113
|
+
* through completely unmodified - this is not a Troxy-authenticated call
|
|
114
|
+
* at all, just a transparent relay. */
|
|
115
|
+
export function buildRelayHeaders(incomingHeaders) {
|
|
116
|
+
const headers = {};
|
|
117
|
+
for (const [k, v] of Object.entries(incomingHeaders)) {
|
|
118
|
+
if (_HOP_BY_HOP_HEADERS.has(k)) continue;
|
|
119
|
+
headers[k] = v;
|
|
120
|
+
}
|
|
121
|
+
return headers;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function _drainBody(req) {
|
|
125
|
+
return new Promise((resolve, reject) => {
|
|
126
|
+
const chunks = [];
|
|
127
|
+
req.on('data', (c) => chunks.push(c));
|
|
128
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
129
|
+
req.on('error', reject);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function _relayToRealHost(host, port, req, res, body, log, lookup, ca) {
|
|
134
|
+
const upstreamReq = https.request({
|
|
135
|
+
method: req.method,
|
|
136
|
+
hostname: host,
|
|
137
|
+
port,
|
|
138
|
+
path: req.url,
|
|
139
|
+
headers: buildRelayHeaders(req.headers),
|
|
140
|
+
...(lookup ? { lookup } : {}),
|
|
141
|
+
...(ca ? { ca } : {}), // test-only override; production always uses Node's real trust store
|
|
142
|
+
}, (upstreamRes) => {
|
|
143
|
+
res.writeHead(upstreamRes.statusCode, upstreamRes.headers);
|
|
144
|
+
upstreamRes.pipe(res);
|
|
145
|
+
});
|
|
146
|
+
upstreamReq.on('error', (err) => {
|
|
147
|
+
log('relay to real host %s failed: %s', host, err.message);
|
|
148
|
+
if (!res.headersSent) res.writeHead(502, { 'content-type': 'application/json' });
|
|
149
|
+
res.end(JSON.stringify({ error: 'troxy interceptor: could not reach ' + host }));
|
|
150
|
+
});
|
|
151
|
+
upstreamReq.end(body);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// http.request/https.request chosen per the configured upstream's own
|
|
155
|
+
// protocol - a test stub commonly uses plain http on loopback, production
|
|
156
|
+
// always uses https://proxy.troxy.io. Re-dispatch here so both work
|
|
157
|
+
// without the caller needing to know which.
|
|
158
|
+
function _clientForProtocol(url) {
|
|
159
|
+
return url.startsWith('https:') ? https : http;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Attempts the Troxy-routed leg; resolves true if it fully served the
|
|
163
|
+
* response, false if it failed/timed out and the caller should fail open
|
|
164
|
+
* to _relayToRealHost instead. Never writes to `res` on a false
|
|
165
|
+
* resolution, so the caller can safely hand off afterward.
|
|
166
|
+
*
|
|
167
|
+
* Two separate deadlines, not one: `connectTimeoutMs` bounds how long we
|
|
168
|
+
* wait to establish the TCP/TLS connection to Troxy at all (a real
|
|
169
|
+
* unreachability signal - fail open fast). `responseTimeoutMs` is a much
|
|
170
|
+
* longer backstop that only starts once that connection is up, covering
|
|
171
|
+
* the wait for the model's own response (which can legitimately take
|
|
172
|
+
* several real seconds and has nothing to do with whether Troxy is
|
|
173
|
+
* healthy). */
|
|
174
|
+
function _relayToTroxy(route, req, res, body, troxyKey, connectTimeoutMs, responseTimeoutMs, log) {
|
|
175
|
+
return new Promise((resolve) => {
|
|
176
|
+
const url = new URL(route.upstream);
|
|
177
|
+
const qsIdx = req.url.indexOf('?');
|
|
178
|
+
const search = qsIdx === -1 ? '' : req.url.slice(qsIdx);
|
|
179
|
+
let settled = false;
|
|
180
|
+
let connected = false;
|
|
181
|
+
let connectTimer = null;
|
|
182
|
+
// The socket to Troxy is very likely reused across many requests
|
|
183
|
+
// (Node's default agent keeps connections alive) - `socket.setTimeout`
|
|
184
|
+
// adds a NEW 'timeout' listener each call without removing the last
|
|
185
|
+
// one, so leaving this armed after settling leaks a listener onto a
|
|
186
|
+
// long-lived socket every single request (confirmed live:
|
|
187
|
+
// MaxListenersExceededWarning after ~11 real requests). Track exactly
|
|
188
|
+
// what we armed so it can always be torn down on settle, regardless of
|
|
189
|
+
// which branch settled it.
|
|
190
|
+
let timedSocket = null;
|
|
191
|
+
let responseTimeoutHandler = null;
|
|
192
|
+
|
|
193
|
+
const disarmResponseTimeout = () => {
|
|
194
|
+
if (timedSocket && responseTimeoutHandler) {
|
|
195
|
+
timedSocket.removeListener('timeout', responseTimeoutHandler);
|
|
196
|
+
timedSocket.setTimeout(0); // clear the idle timer so a reused socket starts clean for its next request
|
|
197
|
+
}
|
|
198
|
+
timedSocket = null;
|
|
199
|
+
responseTimeoutHandler = null;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const settle = (result) => {
|
|
203
|
+
if (settled) return;
|
|
204
|
+
settled = true;
|
|
205
|
+
clearTimeout(connectTimer);
|
|
206
|
+
disarmResponseTimeout();
|
|
207
|
+
resolve(result);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const upstreamReq = _clientForProtocol(route.upstream).request({
|
|
211
|
+
method: req.method,
|
|
212
|
+
hostname: url.hostname,
|
|
213
|
+
port: url.port || (url.protocol === 'http:' ? 80 : 443),
|
|
214
|
+
path: url.pathname + search,
|
|
215
|
+
headers: buildTroxyForwardHeaders(req.headers, troxyKey),
|
|
216
|
+
}, (upstreamRes) => {
|
|
217
|
+
if (settled) return; // already failed open on a timeout; drop this late arrival
|
|
218
|
+
settled = true;
|
|
219
|
+
clearTimeout(connectTimer);
|
|
220
|
+
disarmResponseTimeout();
|
|
221
|
+
// The only positive confirmation a real request was actually policy-
|
|
222
|
+
// evaluated by Troxy rather than relayed/fallen-open - previously
|
|
223
|
+
// this path logged nothing at all on success, so a live test had no
|
|
224
|
+
// way to tell "silently worked" apart from "silently never ran".
|
|
225
|
+
log('routed %s to troxy (%s) -> %d', req.url, route.upstream, upstreamRes.statusCode);
|
|
226
|
+
res.writeHead(upstreamRes.statusCode, upstreamRes.headers);
|
|
227
|
+
upstreamRes.pipe(res);
|
|
228
|
+
upstreamRes.on('end', () => resolve(true));
|
|
229
|
+
upstreamRes.on('error', () => resolve(true)); // response already started; not a fail-open case anymore
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
connectTimer = setTimeout(() => {
|
|
233
|
+
if (connected) return; // connection-phase deadline no longer applies once connected
|
|
234
|
+
upstreamReq.destroy();
|
|
235
|
+
settle(false);
|
|
236
|
+
}, connectTimeoutMs);
|
|
237
|
+
connectTimer.unref?.();
|
|
238
|
+
|
|
239
|
+
upstreamReq.on('socket', (socket) => {
|
|
240
|
+
const onConnected = () => {
|
|
241
|
+
connected = true;
|
|
242
|
+
clearTimeout(connectTimer);
|
|
243
|
+
if (settled) return; // response (or an error) already arrived before the connect event did
|
|
244
|
+
// Post-connect backstop: Troxy is reachable, so only a genuine
|
|
245
|
+
// hang (not normal model latency) should still fail open here.
|
|
246
|
+
responseTimeoutHandler = () => {
|
|
247
|
+
upstreamReq.destroy();
|
|
248
|
+
settle(false);
|
|
249
|
+
};
|
|
250
|
+
timedSocket = socket;
|
|
251
|
+
socket.setTimeout(responseTimeoutMs, responseTimeoutHandler);
|
|
252
|
+
};
|
|
253
|
+
if (!socket.connecting) onConnected(); // already connected (e.g. a reused keep-alive socket)
|
|
254
|
+
else socket.once(url.protocol === 'https:' ? 'secureConnect' : 'connect', onConnected);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
upstreamReq.on('error', () => settle(false));
|
|
258
|
+
upstreamReq.end(body);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function _handleInterceptedRequest(req, res, { routeResolver, getTroxyKey, troxyConnectTimeoutMs, troxyResponseTimeoutMs, log, lookup, ca }) {
|
|
263
|
+
const targetHost = req.socket._troxyTargetHost;
|
|
264
|
+
const targetPort = req.socket._troxyTargetPort || 443;
|
|
265
|
+
let body;
|
|
266
|
+
try {
|
|
267
|
+
body = await _drainBody(req);
|
|
268
|
+
} catch (err) {
|
|
269
|
+
log('failed to read intercepted request body: %s', err.message);
|
|
270
|
+
res.writeHead(400);
|
|
271
|
+
res.end();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const route = routeResolver(targetHost, req.method, req.url);
|
|
276
|
+
if (route) {
|
|
277
|
+
const troxyKey = getTroxyKey();
|
|
278
|
+
if (troxyKey) {
|
|
279
|
+
const served = await _relayToTroxy(route, req, res, body, troxyKey, troxyConnectTimeoutMs, troxyResponseTimeoutMs, log);
|
|
280
|
+
if (served) return;
|
|
281
|
+
log('troxy upstream unreachable, failing open: relaying %s directly to %s', req.url, targetHost);
|
|
282
|
+
} else {
|
|
283
|
+
log('no troxy key available, failing open: relaying %s directly to %s', req.url, targetHost);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
_relayToRealHost(targetHost, targetPort, req, res, body, log, lookup, ca);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function _blindTunnel(host, port, clientSocket, head, log, lookup) {
|
|
291
|
+
const upstream = net.connect({ host, port, ...(lookup ? { lookup } : {}) });
|
|
292
|
+
upstream.on('connect', () => {
|
|
293
|
+
if (head && head.length) upstream.write(head);
|
|
294
|
+
clientSocket.pipe(upstream);
|
|
295
|
+
upstream.pipe(clientSocket);
|
|
296
|
+
});
|
|
297
|
+
upstream.on('error', (err) => { log('blind tunnel to %s failed: %s', host, err.message); clientSocket.destroy(); });
|
|
298
|
+
clientSocket.on('error', () => upstream.destroy());
|
|
299
|
+
clientSocket.on('close', () => upstream.destroy());
|
|
300
|
+
upstream.on('close', () => clientSocket.destroy());
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Creates the interceptor as an http.Server (not yet listening - call
|
|
305
|
+
* .listen() on it, same as any Node server). Options:
|
|
306
|
+
* - interceptHosts: string[] - hosts to terminate TLS for; everything
|
|
307
|
+
* else is a blind tunnel.
|
|
308
|
+
* - routeResolver: (host, method, path) => {upstream} | undefined -
|
|
309
|
+
* normally providers.js's troxyRouteFor bound to the enabled providers.
|
|
310
|
+
* - getTroxyKey: () => string | null - called fresh per request, not
|
|
311
|
+
* cached, so a key rotation takes effect without a daemon restart.
|
|
312
|
+
* - getCerts: () => {leafCertPem, leafKeyPem} | null - null means
|
|
313
|
+
* unhealthy; the interceptor tunnels the allowlisted host too rather
|
|
314
|
+
* than fail.
|
|
315
|
+
* - troxyConnectTimeoutMs: fail-open budget for reaching Troxy at all
|
|
316
|
+
* (TCP+TLS connect) - real unreachability. troxyResponseTimeoutMs: a
|
|
317
|
+
* much longer backstop that only starts once connected, covering the
|
|
318
|
+
* model's own response time (not an outage signal - see _relayToTroxy).
|
|
319
|
+
* - log: (fmt, ...args) => void
|
|
320
|
+
* - lookup: optional custom DNS resolver, Node's standard (hostname,
|
|
321
|
+
* options, callback) => void shape - passed straight through to the
|
|
322
|
+
* outbound net.connect/https.request calls that reach a real target
|
|
323
|
+
* host. Never needed in production (real DNS is exactly right there);
|
|
324
|
+
* exists so tests can redirect a real hostname like api.anthropic.com
|
|
325
|
+
* to a local stub server without touching production code paths.
|
|
326
|
+
* - ca: optional extra trusted CA(s) for _relayToRealHost's own outbound
|
|
327
|
+
* TLS verification of the real target host. Never needed in production
|
|
328
|
+
* (Node's real trust store already trusts api.anthropic.com's real
|
|
329
|
+
* cert) - exists only so a test's local stub server, presenting a
|
|
330
|
+
* self-signed test cert, can be trusted without weakening what
|
|
331
|
+
* production actually verifies against.
|
|
332
|
+
*/
|
|
333
|
+
export function createInterceptor({
|
|
334
|
+
interceptHosts,
|
|
335
|
+
routeResolver,
|
|
336
|
+
getTroxyKey,
|
|
337
|
+
getCerts,
|
|
338
|
+
troxyConnectTimeoutMs = DEFAULT_TROXY_CONNECT_TIMEOUT_MS,
|
|
339
|
+
troxyResponseTimeoutMs = DEFAULT_TROXY_RESPONSE_TIMEOUT_MS,
|
|
340
|
+
log = () => {},
|
|
341
|
+
lookup,
|
|
342
|
+
ca,
|
|
343
|
+
}) {
|
|
344
|
+
const interceptedServer = http.createServer((req, res) => {
|
|
345
|
+
_handleInterceptedRequest(req, res, { routeResolver, getTroxyKey, troxyConnectTimeoutMs, troxyResponseTimeoutMs, log, lookup, ca })
|
|
346
|
+
.catch((err) => {
|
|
347
|
+
log('unexpected error handling intercepted request: %s', err.stack || err.message);
|
|
348
|
+
try { if (!res.headersSent) res.writeHead(502); res.end(); } catch {}
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
interceptedServer.on('clientError', (err, socket) => { try { socket.destroy(); } catch {} });
|
|
352
|
+
// Errors on individual TLS-terminated connections must never escape as
|
|
353
|
+
// uncaught 'error' events on the server itself (a single bad handshake
|
|
354
|
+
// must not take the whole interceptor down).
|
|
355
|
+
interceptedServer.on('error', (err) => log('intercepted-server error: %s', err.message));
|
|
356
|
+
|
|
357
|
+
const proxyServer = http.createServer((req, res) => {
|
|
358
|
+
res.writeHead(400, { 'content-type': 'text/plain' });
|
|
359
|
+
res.end('troxy interceptor: only CONNECT is supported\n');
|
|
360
|
+
});
|
|
361
|
+
proxyServer.on('clientError', (err, socket) => { try { socket.end('HTTP/1.1 400 Bad Request\r\n\r\n'); } catch {} });
|
|
362
|
+
|
|
363
|
+
proxyServer.on('connect', (req, clientSocket, head) => {
|
|
364
|
+
clientSocket.on('error', () => {}); // a reset/closed client connection is routine, not a crash
|
|
365
|
+
const { host, port } = parseConnectTarget(req.url);
|
|
366
|
+
|
|
367
|
+
try {
|
|
368
|
+
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
369
|
+
} catch {
|
|
370
|
+
return; // client already gone
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (!interceptHosts.includes(host)) {
|
|
374
|
+
_blindTunnel(host, port, clientSocket, head, log, lookup);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const certs = getCerts();
|
|
379
|
+
if (!certs) {
|
|
380
|
+
log('certificates unhealthy - tunneling %s instead of intercepting', host);
|
|
381
|
+
_blindTunnel(host, port, clientSocket, head, log, lookup);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
let tlsSocket;
|
|
386
|
+
try {
|
|
387
|
+
tlsSocket = new tls.TLSSocket(clientSocket, {
|
|
388
|
+
isServer: true,
|
|
389
|
+
cert: certs.leafCertPem,
|
|
390
|
+
key: certs.leafKeyPem,
|
|
391
|
+
// The real api.anthropic.com speaks HTTP/2, and a client that
|
|
392
|
+
// offers ALPN (Chromium's network stack - what the desktop app's
|
|
393
|
+
// Code tab actually uses, not Node's own https client - does this
|
|
394
|
+
// strictly) can abort the handshake outright if the server doesn't
|
|
395
|
+
// declare a protocol it's willing to accept, surfacing here as a
|
|
396
|
+
// bare ECONNRESET during the handshake rather than a clean TLS
|
|
397
|
+
// alert. This server only ever speaks HTTP/1.1 (interceptedServer
|
|
398
|
+
// is a plain http.Server fed via emit('connection', ...)), so
|
|
399
|
+
// declare exactly that - found live, a real client aborted every
|
|
400
|
+
// single connection attempt without this.
|
|
401
|
+
ALPNProtocols: ['http/1.1'],
|
|
402
|
+
});
|
|
403
|
+
} catch (err) {
|
|
404
|
+
log('failed to start TLS termination for %s: %s', host, err.message);
|
|
405
|
+
clientSocket.destroy();
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
tlsSocket.on('error', (err) => {
|
|
409
|
+
log('tls handshake failed for %s: %s', host, err.message);
|
|
410
|
+
try { clientSocket.destroy(); } catch {}
|
|
411
|
+
});
|
|
412
|
+
tlsSocket._troxyTargetHost = host;
|
|
413
|
+
tlsSocket._troxyTargetPort = port;
|
|
414
|
+
if (head && head.length) tlsSocket.unshift(head);
|
|
415
|
+
interceptedServer.emit('connection', tlsSocket);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
return proxyServer;
|
|
419
|
+
}
|
package/src/providers.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single source of truth for which hosts the local interceptor
|
|
3
|
+
* (interceptor.js) ever terminates TLS for, and where an intercepted
|
|
4
|
+
* request gets re-addressed to. Everything else - every other host the
|
|
5
|
+
* client talks to, and every path on an intercepted host that has no
|
|
6
|
+
* route here - passes through as a blind byte tunnel, never parsed, never
|
|
7
|
+
* visible to this codebase at all.
|
|
8
|
+
*
|
|
9
|
+
* Phase 1 (see the plan doc) is Anthropic only. openai_codex/cursor are
|
|
10
|
+
* declared here, disabled, purely so Phase 2/3 is a data change to this
|
|
11
|
+
* file later, not a rewrite of the interceptor or the CA logic - the
|
|
12
|
+
* provider's real divergence (Codex signs with CODEX_CA_CERTIFICATE, not
|
|
13
|
+
* NODE_EXTRA_CA_CERTS, since it's Rust-based) is already captured as a
|
|
14
|
+
* field, not hardcoded control flow, even though it isn't wired to
|
|
15
|
+
* anything yet. Do not flip `enabled` on either without first doing that
|
|
16
|
+
* phase's own hands-on verification - see the plan's Phase 2/3 section for
|
|
17
|
+
* exactly what's still unverified about each.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const PROVIDERS = [
|
|
21
|
+
{
|
|
22
|
+
id: 'anthropic',
|
|
23
|
+
name: 'Claude (Code + Desktop)',
|
|
24
|
+
enabled: true,
|
|
25
|
+
interceptHosts: ['api.anthropic.com'],
|
|
26
|
+
troxyRoutes: [
|
|
27
|
+
{ method: 'POST', path: '/v1/messages', upstream: 'https://proxy.troxy.io/v1/messages' },
|
|
28
|
+
],
|
|
29
|
+
envKeys: { proxy: 'HTTPS_PROXY', ca: 'NODE_EXTRA_CA_CERTS' },
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: 'openai_codex',
|
|
33
|
+
name: 'Codex CLI',
|
|
34
|
+
enabled: false, // Phase 2 - not wired up. See plan doc: no official network-config
|
|
35
|
+
// doc exists for Codex, and whether its TLS stack enforces
|
|
36
|
+
// nameConstraints the way OpenSSL does is unverified.
|
|
37
|
+
interceptHosts: ['api.openai.com'],
|
|
38
|
+
troxyRoutes: [],
|
|
39
|
+
envKeys: { proxy: 'HTTPS_PROXY', ca: 'CODEX_CA_CERTIFICATE' },
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'cursor',
|
|
43
|
+
name: 'Cursor',
|
|
44
|
+
enabled: false, // Phase 3 - not wired up, shape genuinely unknown. See plan doc:
|
|
45
|
+
// Cursor routes ALL traffic through its own backend even with
|
|
46
|
+
// BYOK, so this would need to intercept api2.cursor.sh (Cursor
|
|
47
|
+
// itself), not a model provider - a materially different,
|
|
48
|
+
// unverified scope. Needs its own hands-on test before design.
|
|
49
|
+
interceptHosts: [],
|
|
50
|
+
troxyRoutes: [],
|
|
51
|
+
envKeys: {},
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
export function enabledProviders() {
|
|
56
|
+
return PROVIDERS.filter(p => p.enabled);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Every host any of the given providers' TLS should be terminated for -
|
|
60
|
+
* the interceptor's live allowlist. Anything not in this list is a blind
|
|
61
|
+
* tunnel, unconditionally. */
|
|
62
|
+
export function interceptHostsFor(providers) {
|
|
63
|
+
return providers.flatMap(p => p.interceptHosts || []);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The Troxy route to re-address an intercepted request to, or undefined
|
|
67
|
+
* if this host+method+path has no configured route - in which case the
|
|
68
|
+
* interceptor relays it to the real host verbatim instead of proxying it
|
|
69
|
+
* (e.g. api.anthropic.com's own /api/oauth/account/settings, which has
|
|
70
|
+
* nothing to do with policy enforcement and must reach Anthropic
|
|
71
|
+
* unmodified). Case-insensitive on method; ignores any query string on
|
|
72
|
+
* the incoming path since a route is keyed on the path alone. */
|
|
73
|
+
export function troxyRouteFor(providers, host, method, requestPath) {
|
|
74
|
+
const pathOnly = requestPath.split('?')[0];
|
|
75
|
+
for (const provider of providers) {
|
|
76
|
+
if (!(provider.interceptHosts || []).includes(host)) continue;
|
|
77
|
+
for (const route of provider.troxyRoutes || []) {
|
|
78
|
+
if (route.method.toUpperCase() === method.toUpperCase() && route.path === pathOnly) {
|
|
79
|
+
return route;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|