troxy-cli 1.29.2 → 1.29.4
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/README.md +6 -0
- package/bin/troxy.js +13 -0
- package/package.json +3 -2
- package/src/auth.js +81 -33
- package/src/daemon.js +116 -3
- package/src/init.js +278 -26
- package/src/interceptor.js +402 -0
- package/src/providers.js +84 -0
- package/src/proxy.js +184 -0
- package/src/tests/auth.test.js +12 -1
- 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 +559 -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,402 @@
|
|
|
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
|
+
/** Headers for the leg going to Troxy: the client's own Authorization (an
|
|
77
|
+
* Anthropic API key or OAuth token) is never sent to Troxy as-is - it
|
|
78
|
+
* rides the side channel app.py already knows how to consume
|
|
79
|
+
* (X-Troxy-Upstream-Authorization), while Authorization itself becomes the
|
|
80
|
+
* Troxy key, matching how app.py authenticates every other proxied call. */
|
|
81
|
+
export function buildTroxyForwardHeaders(incomingHeaders, troxyKey) {
|
|
82
|
+
const headers = {};
|
|
83
|
+
for (const [k, v] of Object.entries(incomingHeaders)) {
|
|
84
|
+
if (_HOP_BY_HOP_HEADERS.has(k) || k === 'authorization' || k === 'accept-encoding') continue;
|
|
85
|
+
headers[k] = v;
|
|
86
|
+
}
|
|
87
|
+
if (incomingHeaders.authorization) {
|
|
88
|
+
headers['x-troxy-upstream-authorization'] = incomingHeaders.authorization;
|
|
89
|
+
}
|
|
90
|
+
headers.authorization = `Bearer ${troxyKey}`;
|
|
91
|
+
return headers;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Headers for the leg going straight to the real host (no Troxy route
|
|
95
|
+
* matched, or Troxy was unreachable): the client's own credential passes
|
|
96
|
+
* through completely unmodified - this is not a Troxy-authenticated call
|
|
97
|
+
* at all, just a transparent relay. */
|
|
98
|
+
export function buildRelayHeaders(incomingHeaders) {
|
|
99
|
+
const headers = {};
|
|
100
|
+
for (const [k, v] of Object.entries(incomingHeaders)) {
|
|
101
|
+
if (_HOP_BY_HOP_HEADERS.has(k)) continue;
|
|
102
|
+
headers[k] = v;
|
|
103
|
+
}
|
|
104
|
+
return headers;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function _drainBody(req) {
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
const chunks = [];
|
|
110
|
+
req.on('data', (c) => chunks.push(c));
|
|
111
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
112
|
+
req.on('error', reject);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function _relayToRealHost(host, port, req, res, body, log, lookup, ca) {
|
|
117
|
+
const upstreamReq = https.request({
|
|
118
|
+
method: req.method,
|
|
119
|
+
hostname: host,
|
|
120
|
+
port,
|
|
121
|
+
path: req.url,
|
|
122
|
+
headers: buildRelayHeaders(req.headers),
|
|
123
|
+
...(lookup ? { lookup } : {}),
|
|
124
|
+
...(ca ? { ca } : {}), // test-only override; production always uses Node's real trust store
|
|
125
|
+
}, (upstreamRes) => {
|
|
126
|
+
res.writeHead(upstreamRes.statusCode, upstreamRes.headers);
|
|
127
|
+
upstreamRes.pipe(res);
|
|
128
|
+
});
|
|
129
|
+
upstreamReq.on('error', (err) => {
|
|
130
|
+
log('relay to real host %s failed: %s', host, err.message);
|
|
131
|
+
if (!res.headersSent) res.writeHead(502, { 'content-type': 'application/json' });
|
|
132
|
+
res.end(JSON.stringify({ error: 'troxy interceptor: could not reach ' + host }));
|
|
133
|
+
});
|
|
134
|
+
upstreamReq.end(body);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// http.request/https.request chosen per the configured upstream's own
|
|
138
|
+
// protocol - a test stub commonly uses plain http on loopback, production
|
|
139
|
+
// always uses https://proxy.troxy.io. Re-dispatch here so both work
|
|
140
|
+
// without the caller needing to know which.
|
|
141
|
+
function _clientForProtocol(url) {
|
|
142
|
+
return url.startsWith('https:') ? https : http;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Attempts the Troxy-routed leg; resolves true if it fully served the
|
|
146
|
+
* response, false if it failed/timed out and the caller should fail open
|
|
147
|
+
* to _relayToRealHost instead. Never writes to `res` on a false
|
|
148
|
+
* resolution, so the caller can safely hand off afterward.
|
|
149
|
+
*
|
|
150
|
+
* Two separate deadlines, not one: `connectTimeoutMs` bounds how long we
|
|
151
|
+
* wait to establish the TCP/TLS connection to Troxy at all (a real
|
|
152
|
+
* unreachability signal - fail open fast). `responseTimeoutMs` is a much
|
|
153
|
+
* longer backstop that only starts once that connection is up, covering
|
|
154
|
+
* the wait for the model's own response (which can legitimately take
|
|
155
|
+
* several real seconds and has nothing to do with whether Troxy is
|
|
156
|
+
* healthy). */
|
|
157
|
+
function _relayToTroxy(route, req, res, body, troxyKey, connectTimeoutMs, responseTimeoutMs, log) {
|
|
158
|
+
return new Promise((resolve) => {
|
|
159
|
+
const url = new URL(route.upstream);
|
|
160
|
+
const qsIdx = req.url.indexOf('?');
|
|
161
|
+
const search = qsIdx === -1 ? '' : req.url.slice(qsIdx);
|
|
162
|
+
let settled = false;
|
|
163
|
+
let connected = false;
|
|
164
|
+
let connectTimer = null;
|
|
165
|
+
// The socket to Troxy is very likely reused across many requests
|
|
166
|
+
// (Node's default agent keeps connections alive) - `socket.setTimeout`
|
|
167
|
+
// adds a NEW 'timeout' listener each call without removing the last
|
|
168
|
+
// one, so leaving this armed after settling leaks a listener onto a
|
|
169
|
+
// long-lived socket every single request (confirmed live:
|
|
170
|
+
// MaxListenersExceededWarning after ~11 real requests). Track exactly
|
|
171
|
+
// what we armed so it can always be torn down on settle, regardless of
|
|
172
|
+
// which branch settled it.
|
|
173
|
+
let timedSocket = null;
|
|
174
|
+
let responseTimeoutHandler = null;
|
|
175
|
+
|
|
176
|
+
const disarmResponseTimeout = () => {
|
|
177
|
+
if (timedSocket && responseTimeoutHandler) {
|
|
178
|
+
timedSocket.removeListener('timeout', responseTimeoutHandler);
|
|
179
|
+
timedSocket.setTimeout(0); // clear the idle timer so a reused socket starts clean for its next request
|
|
180
|
+
}
|
|
181
|
+
timedSocket = null;
|
|
182
|
+
responseTimeoutHandler = null;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const settle = (result) => {
|
|
186
|
+
if (settled) return;
|
|
187
|
+
settled = true;
|
|
188
|
+
clearTimeout(connectTimer);
|
|
189
|
+
disarmResponseTimeout();
|
|
190
|
+
resolve(result);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const upstreamReq = _clientForProtocol(route.upstream).request({
|
|
194
|
+
method: req.method,
|
|
195
|
+
hostname: url.hostname,
|
|
196
|
+
port: url.port || (url.protocol === 'http:' ? 80 : 443),
|
|
197
|
+
path: url.pathname + search,
|
|
198
|
+
headers: buildTroxyForwardHeaders(req.headers, troxyKey),
|
|
199
|
+
}, (upstreamRes) => {
|
|
200
|
+
if (settled) return; // already failed open on a timeout; drop this late arrival
|
|
201
|
+
settled = true;
|
|
202
|
+
clearTimeout(connectTimer);
|
|
203
|
+
disarmResponseTimeout();
|
|
204
|
+
// The only positive confirmation a real request was actually policy-
|
|
205
|
+
// evaluated by Troxy rather than relayed/fallen-open - previously
|
|
206
|
+
// this path logged nothing at all on success, so a live test had no
|
|
207
|
+
// way to tell "silently worked" apart from "silently never ran".
|
|
208
|
+
log('routed %s to troxy (%s) -> %d', req.url, route.upstream, upstreamRes.statusCode);
|
|
209
|
+
res.writeHead(upstreamRes.statusCode, upstreamRes.headers);
|
|
210
|
+
upstreamRes.pipe(res);
|
|
211
|
+
upstreamRes.on('end', () => resolve(true));
|
|
212
|
+
upstreamRes.on('error', () => resolve(true)); // response already started; not a fail-open case anymore
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
connectTimer = setTimeout(() => {
|
|
216
|
+
if (connected) return; // connection-phase deadline no longer applies once connected
|
|
217
|
+
upstreamReq.destroy();
|
|
218
|
+
settle(false);
|
|
219
|
+
}, connectTimeoutMs);
|
|
220
|
+
connectTimer.unref?.();
|
|
221
|
+
|
|
222
|
+
upstreamReq.on('socket', (socket) => {
|
|
223
|
+
const onConnected = () => {
|
|
224
|
+
connected = true;
|
|
225
|
+
clearTimeout(connectTimer);
|
|
226
|
+
if (settled) return; // response (or an error) already arrived before the connect event did
|
|
227
|
+
// Post-connect backstop: Troxy is reachable, so only a genuine
|
|
228
|
+
// hang (not normal model latency) should still fail open here.
|
|
229
|
+
responseTimeoutHandler = () => {
|
|
230
|
+
upstreamReq.destroy();
|
|
231
|
+
settle(false);
|
|
232
|
+
};
|
|
233
|
+
timedSocket = socket;
|
|
234
|
+
socket.setTimeout(responseTimeoutMs, responseTimeoutHandler);
|
|
235
|
+
};
|
|
236
|
+
if (!socket.connecting) onConnected(); // already connected (e.g. a reused keep-alive socket)
|
|
237
|
+
else socket.once(url.protocol === 'https:' ? 'secureConnect' : 'connect', onConnected);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
upstreamReq.on('error', () => settle(false));
|
|
241
|
+
upstreamReq.end(body);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function _handleInterceptedRequest(req, res, { routeResolver, getTroxyKey, troxyConnectTimeoutMs, troxyResponseTimeoutMs, log, lookup, ca }) {
|
|
246
|
+
const targetHost = req.socket._troxyTargetHost;
|
|
247
|
+
const targetPort = req.socket._troxyTargetPort || 443;
|
|
248
|
+
let body;
|
|
249
|
+
try {
|
|
250
|
+
body = await _drainBody(req);
|
|
251
|
+
} catch (err) {
|
|
252
|
+
log('failed to read intercepted request body: %s', err.message);
|
|
253
|
+
res.writeHead(400);
|
|
254
|
+
res.end();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const route = routeResolver(targetHost, req.method, req.url);
|
|
259
|
+
if (route) {
|
|
260
|
+
const troxyKey = getTroxyKey();
|
|
261
|
+
if (troxyKey) {
|
|
262
|
+
const served = await _relayToTroxy(route, req, res, body, troxyKey, troxyConnectTimeoutMs, troxyResponseTimeoutMs, log);
|
|
263
|
+
if (served) return;
|
|
264
|
+
log('troxy upstream unreachable, failing open: relaying %s directly to %s', req.url, targetHost);
|
|
265
|
+
} else {
|
|
266
|
+
log('no troxy key available, failing open: relaying %s directly to %s', req.url, targetHost);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
_relayToRealHost(targetHost, targetPort, req, res, body, log, lookup, ca);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function _blindTunnel(host, port, clientSocket, head, log, lookup) {
|
|
274
|
+
const upstream = net.connect({ host, port, ...(lookup ? { lookup } : {}) });
|
|
275
|
+
upstream.on('connect', () => {
|
|
276
|
+
if (head && head.length) upstream.write(head);
|
|
277
|
+
clientSocket.pipe(upstream);
|
|
278
|
+
upstream.pipe(clientSocket);
|
|
279
|
+
});
|
|
280
|
+
upstream.on('error', (err) => { log('blind tunnel to %s failed: %s', host, err.message); clientSocket.destroy(); });
|
|
281
|
+
clientSocket.on('error', () => upstream.destroy());
|
|
282
|
+
clientSocket.on('close', () => upstream.destroy());
|
|
283
|
+
upstream.on('close', () => clientSocket.destroy());
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Creates the interceptor as an http.Server (not yet listening - call
|
|
288
|
+
* .listen() on it, same as any Node server). Options:
|
|
289
|
+
* - interceptHosts: string[] - hosts to terminate TLS for; everything
|
|
290
|
+
* else is a blind tunnel.
|
|
291
|
+
* - routeResolver: (host, method, path) => {upstream} | undefined -
|
|
292
|
+
* normally providers.js's troxyRouteFor bound to the enabled providers.
|
|
293
|
+
* - getTroxyKey: () => string | null - called fresh per request, not
|
|
294
|
+
* cached, so a key rotation takes effect without a daemon restart.
|
|
295
|
+
* - getCerts: () => {leafCertPem, leafKeyPem} | null - null means
|
|
296
|
+
* unhealthy; the interceptor tunnels the allowlisted host too rather
|
|
297
|
+
* than fail.
|
|
298
|
+
* - troxyConnectTimeoutMs: fail-open budget for reaching Troxy at all
|
|
299
|
+
* (TCP+TLS connect) - real unreachability. troxyResponseTimeoutMs: a
|
|
300
|
+
* much longer backstop that only starts once connected, covering the
|
|
301
|
+
* model's own response time (not an outage signal - see _relayToTroxy).
|
|
302
|
+
* - log: (fmt, ...args) => void
|
|
303
|
+
* - lookup: optional custom DNS resolver, Node's standard (hostname,
|
|
304
|
+
* options, callback) => void shape - passed straight through to the
|
|
305
|
+
* outbound net.connect/https.request calls that reach a real target
|
|
306
|
+
* host. Never needed in production (real DNS is exactly right there);
|
|
307
|
+
* exists so tests can redirect a real hostname like api.anthropic.com
|
|
308
|
+
* to a local stub server without touching production code paths.
|
|
309
|
+
* - ca: optional extra trusted CA(s) for _relayToRealHost's own outbound
|
|
310
|
+
* TLS verification of the real target host. Never needed in production
|
|
311
|
+
* (Node's real trust store already trusts api.anthropic.com's real
|
|
312
|
+
* cert) - exists only so a test's local stub server, presenting a
|
|
313
|
+
* self-signed test cert, can be trusted without weakening what
|
|
314
|
+
* production actually verifies against.
|
|
315
|
+
*/
|
|
316
|
+
export function createInterceptor({
|
|
317
|
+
interceptHosts,
|
|
318
|
+
routeResolver,
|
|
319
|
+
getTroxyKey,
|
|
320
|
+
getCerts,
|
|
321
|
+
troxyConnectTimeoutMs = DEFAULT_TROXY_CONNECT_TIMEOUT_MS,
|
|
322
|
+
troxyResponseTimeoutMs = DEFAULT_TROXY_RESPONSE_TIMEOUT_MS,
|
|
323
|
+
log = () => {},
|
|
324
|
+
lookup,
|
|
325
|
+
ca,
|
|
326
|
+
}) {
|
|
327
|
+
const interceptedServer = http.createServer((req, res) => {
|
|
328
|
+
_handleInterceptedRequest(req, res, { routeResolver, getTroxyKey, troxyConnectTimeoutMs, troxyResponseTimeoutMs, log, lookup, ca })
|
|
329
|
+
.catch((err) => {
|
|
330
|
+
log('unexpected error handling intercepted request: %s', err.stack || err.message);
|
|
331
|
+
try { if (!res.headersSent) res.writeHead(502); res.end(); } catch {}
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
interceptedServer.on('clientError', (err, socket) => { try { socket.destroy(); } catch {} });
|
|
335
|
+
// Errors on individual TLS-terminated connections must never escape as
|
|
336
|
+
// uncaught 'error' events on the server itself (a single bad handshake
|
|
337
|
+
// must not take the whole interceptor down).
|
|
338
|
+
interceptedServer.on('error', (err) => log('intercepted-server error: %s', err.message));
|
|
339
|
+
|
|
340
|
+
const proxyServer = http.createServer((req, res) => {
|
|
341
|
+
res.writeHead(400, { 'content-type': 'text/plain' });
|
|
342
|
+
res.end('troxy interceptor: only CONNECT is supported\n');
|
|
343
|
+
});
|
|
344
|
+
proxyServer.on('clientError', (err, socket) => { try { socket.end('HTTP/1.1 400 Bad Request\r\n\r\n'); } catch {} });
|
|
345
|
+
|
|
346
|
+
proxyServer.on('connect', (req, clientSocket, head) => {
|
|
347
|
+
clientSocket.on('error', () => {}); // a reset/closed client connection is routine, not a crash
|
|
348
|
+
const { host, port } = parseConnectTarget(req.url);
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
352
|
+
} catch {
|
|
353
|
+
return; // client already gone
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (!interceptHosts.includes(host)) {
|
|
357
|
+
_blindTunnel(host, port, clientSocket, head, log, lookup);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const certs = getCerts();
|
|
362
|
+
if (!certs) {
|
|
363
|
+
log('certificates unhealthy - tunneling %s instead of intercepting', host);
|
|
364
|
+
_blindTunnel(host, port, clientSocket, head, log, lookup);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
let tlsSocket;
|
|
369
|
+
try {
|
|
370
|
+
tlsSocket = new tls.TLSSocket(clientSocket, {
|
|
371
|
+
isServer: true,
|
|
372
|
+
cert: certs.leafCertPem,
|
|
373
|
+
key: certs.leafKeyPem,
|
|
374
|
+
// The real api.anthropic.com speaks HTTP/2, and a client that
|
|
375
|
+
// offers ALPN (Chromium's network stack - what the desktop app's
|
|
376
|
+
// Code tab actually uses, not Node's own https client - does this
|
|
377
|
+
// strictly) can abort the handshake outright if the server doesn't
|
|
378
|
+
// declare a protocol it's willing to accept, surfacing here as a
|
|
379
|
+
// bare ECONNRESET during the handshake rather than a clean TLS
|
|
380
|
+
// alert. This server only ever speaks HTTP/1.1 (interceptedServer
|
|
381
|
+
// is a plain http.Server fed via emit('connection', ...)), so
|
|
382
|
+
// declare exactly that - found live, a real client aborted every
|
|
383
|
+
// single connection attempt without this.
|
|
384
|
+
ALPNProtocols: ['http/1.1'],
|
|
385
|
+
});
|
|
386
|
+
} catch (err) {
|
|
387
|
+
log('failed to start TLS termination for %s: %s', host, err.message);
|
|
388
|
+
clientSocket.destroy();
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
tlsSocket.on('error', (err) => {
|
|
392
|
+
log('tls handshake failed for %s: %s', host, err.message);
|
|
393
|
+
try { clientSocket.destroy(); } catch {}
|
|
394
|
+
});
|
|
395
|
+
tlsSocket._troxyTargetHost = host;
|
|
396
|
+
tlsSocket._troxyTargetPort = port;
|
|
397
|
+
if (head && head.length) tlsSocket.unshift(head);
|
|
398
|
+
interceptedServer.emit('connection', tlsSocket);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
return proxyServer;
|
|
402
|
+
}
|
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
|
+
}
|
package/src/proxy.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `troxy proxy status/enable/disable` - the explicit, hands-on control
|
|
3
|
+
* surface for Layer 3 of the Live Model Policy Enforcement plan (local
|
|
4
|
+
* interceptor set up in interceptor.js/tls-ca.js/daemon.js). Deliberately
|
|
5
|
+
* NOT wired into `troxy init`'s own interactive flow yet: per the plan's
|
|
6
|
+
* rollout order, this ships first as an explicit, undocumented opt-in
|
|
7
|
+
* (`--experimental` on enable) so it can be validated by hand against a
|
|
8
|
+
* real desktop app before it becomes something an ordinary `troxy init`
|
|
9
|
+
* run could ever turn on for every user.
|
|
10
|
+
*/
|
|
11
|
+
import net from 'node:net';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import {
|
|
16
|
+
hasClaudeCode, hasClaudeDesktop, claudeCodeSettingsPath,
|
|
17
|
+
patchClaudeCodeInterception, unpatchClaudeCodeInterception, interceptionIsConfigured,
|
|
18
|
+
troxyInterceptorProxyUrl, troxyLocalCaCertPath,
|
|
19
|
+
} from './init.js';
|
|
20
|
+
import { ensureInterceptionCerts, certIsValid, certExpiresWithin } from './tls-ca.js';
|
|
21
|
+
import { enabledProviders, interceptHostsFor } from './providers.js';
|
|
22
|
+
import { INTERCEPTOR_PORT } from './daemon.js';
|
|
23
|
+
|
|
24
|
+
const TROXY_DIR = path.join(os.homedir(), '.troxy');
|
|
25
|
+
|
|
26
|
+
// Quick, bounded TCP probe - "is anything listening on 127.0.0.1:<port>",
|
|
27
|
+
// not a real request. Exists to tell the fail-open layer-6 mismatch
|
|
28
|
+
// (settings.json points HTTPS_PROXY at the interceptor, but nothing is
|
|
29
|
+
// actually there - exactly the outage this whole plan's fail-open design
|
|
30
|
+
// was written around) apart from a healthy setup, so status/enable can say
|
|
31
|
+
// so plainly instead of a user discovering it as a mysterious hang.
|
|
32
|
+
export function probePort(port, timeoutMs = 800) {
|
|
33
|
+
return new Promise(resolve => {
|
|
34
|
+
const socket = net.connect({ host: '127.0.0.1', port, timeout: timeoutMs });
|
|
35
|
+
socket.once('connect', () => { socket.destroy(); resolve(true); });
|
|
36
|
+
socket.once('timeout', () => { socket.destroy(); resolve(false); });
|
|
37
|
+
socket.once('error', () => resolve(false));
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function _readCert(certPath) {
|
|
42
|
+
try { return fs.readFileSync(certPath, 'utf8'); } catch { return null; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function runProxyStatus() {
|
|
46
|
+
console.log('\n Troxy: Live Model Policy Enforcement (interception)\n');
|
|
47
|
+
|
|
48
|
+
const hasClaude = hasClaudeCode();
|
|
49
|
+
const hasDesktop = hasClaudeDesktop();
|
|
50
|
+
console.log(` Terminal Claude Code: ${hasClaude ? 'detected' : 'not detected'}`);
|
|
51
|
+
console.log(` Claude Desktop app: ${hasDesktop ? 'detected' : 'not detected'}`);
|
|
52
|
+
|
|
53
|
+
const settingsPath = claudeCodeSettingsPath();
|
|
54
|
+
const proxyUrl = troxyInterceptorProxyUrl();
|
|
55
|
+
const caCertPath = troxyLocalCaCertPath();
|
|
56
|
+
const configured = interceptionIsConfigured(settingsPath, { proxyUrl, caCertPath });
|
|
57
|
+
console.log(`\n Configured in settings.json: ${configured ? 'yes' : 'no'}`);
|
|
58
|
+
if (!configured) {
|
|
59
|
+
console.log(' Run `troxy proxy enable --experimental` to turn this on.\n');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const bound = await probePort(INTERCEPTOR_PORT);
|
|
64
|
+
if (!bound) {
|
|
65
|
+
console.log(` ⚠ settings.json points at 127.0.0.1:${INTERCEPTOR_PORT}, but nothing is`);
|
|
66
|
+
console.log(' listening there right now - Claude Code and the desktop app will');
|
|
67
|
+
console.log(' fail to connect while this is the case.');
|
|
68
|
+
console.log(' Fix: `troxy restart` (restarts the background daemon), or');
|
|
69
|
+
console.log(' `troxy proxy disable` to remove the setting until this is resolved.\n');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
console.log(` Interceptor listening: yes (127.0.0.1:${INTERCEPTOR_PORT})`);
|
|
73
|
+
|
|
74
|
+
const caPem = _readCert(caCertPath);
|
|
75
|
+
if (!caPem) {
|
|
76
|
+
console.log(` CA certificate: ✗ not found at ${caCertPath}\n`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
console.log(` CA certificate: ${certIsValid(caPem) ? 'valid' : '✗ invalid/expired'}`);
|
|
80
|
+
|
|
81
|
+
const leafPath = path.join(TROXY_DIR, 'tls', 'leaf-api.anthropic.com.crt');
|
|
82
|
+
const leafPem = _readCert(leafPath);
|
|
83
|
+
if (leafPem) {
|
|
84
|
+
const leafOk = certIsValid(leafPem);
|
|
85
|
+
const renewingSoon = leafOk && certExpiresWithin(leafPem, 30);
|
|
86
|
+
console.log(` Leaf certificate: ${leafOk ? 'valid' : '✗ invalid/expired'}${renewingSoon ? ' (renews within 30 days)' : ''}`);
|
|
87
|
+
}
|
|
88
|
+
console.log(` CA cert path: ${caCertPath}`);
|
|
89
|
+
console.log(` Providers intercepted: ${interceptHostsFor(enabledProviders()).join(', ') || '(none)'}\n`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function runProxyEnable(flags = {}) {
|
|
93
|
+
if (flags.help || flags.h) {
|
|
94
|
+
console.log(`
|
|
95
|
+
troxy proxy enable --experimental
|
|
96
|
+
|
|
97
|
+
Routes the Claude Desktop app's Code tab (and terminal Claude Code)
|
|
98
|
+
through Troxy's local interceptor, so model policies (e.g. a BLOCK rule
|
|
99
|
+
on a specific model) are enforced in real time even inside the desktop
|
|
100
|
+
app - not just terminal \`claude\`.
|
|
101
|
+
|
|
102
|
+
How: writes HTTPS_PROXY + NODE_EXTRA_CA_CERTS into
|
|
103
|
+
~/.claude/settings.json, pointing at a certificate authority generated
|
|
104
|
+
ON THIS MACHINE (~/.troxy/tls/). The private key never leaves this
|
|
105
|
+
machine and is never sent to Troxy. It is locked to api.anthropic.com
|
|
106
|
+
only, via a nameConstraints extension - it cannot be used to intercept
|
|
107
|
+
any other site - and it is never installed in your system keychain, only
|
|
108
|
+
referenced by Claude Code itself.
|
|
109
|
+
|
|
110
|
+
\`troxy init\` already offers this for terminal Claude Code automatically.
|
|
111
|
+
This command is for a desktop-only machine (no terminal \`claude\`), or to
|
|
112
|
+
reconfigure by hand. --experimental is required as an explicit
|
|
113
|
+
acknowledgment.
|
|
114
|
+
|
|
115
|
+
Remove any time with: troxy proxy disable
|
|
116
|
+
`);
|
|
117
|
+
process.exit(0);
|
|
118
|
+
}
|
|
119
|
+
if (!flags.experimental) {
|
|
120
|
+
console.error('\n This is experimental. Run: troxy proxy enable --experimental');
|
|
121
|
+
console.error(' Run `troxy proxy enable --help` first to see what this does.\n');
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
console.log("\n Route Claude's model calls through Troxy so policies are enforced in");
|
|
126
|
+
console.log(" real time, including the Claude desktop app's Code tab, which ignores");
|
|
127
|
+
console.log(' the normal ANTHROPIC_BASE_URL setting.\n');
|
|
128
|
+
console.log(' To do that on the desktop app, Troxy generates a certificate authority');
|
|
129
|
+
console.log(' ON THIS MACHINE. The private key never leaves this computer and is');
|
|
130
|
+
console.log(' never sent to Troxy. It is locked to api.anthropic.com only - it cannot');
|
|
131
|
+
console.log(' be used for any other site - and it is NOT installed in your system');
|
|
132
|
+
console.log(' keychain, only referenced by Claude Code. Everything except');
|
|
133
|
+
console.log(' api.anthropic.com passes through untouched and unreadable.\n');
|
|
134
|
+
|
|
135
|
+
process.stdout.write(' Generating certificate authority... ');
|
|
136
|
+
try {
|
|
137
|
+
ensureInterceptionCerts(TROXY_DIR, {
|
|
138
|
+
hostname: os.hostname(),
|
|
139
|
+
leafDnsNames: interceptHostsFor(enabledProviders()),
|
|
140
|
+
});
|
|
141
|
+
console.log('✓');
|
|
142
|
+
} catch (err) {
|
|
143
|
+
console.log('✗');
|
|
144
|
+
console.error(`\n Could not generate certificates: ${err.message}\n`);
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const settingsPath = claudeCodeSettingsPath();
|
|
149
|
+
process.stdout.write(' Writing ~/.claude/settings.json... ');
|
|
150
|
+
try {
|
|
151
|
+
patchClaudeCodeInterception(settingsPath);
|
|
152
|
+
console.log('✓');
|
|
153
|
+
} catch (err) {
|
|
154
|
+
console.log('✗');
|
|
155
|
+
console.error(`\n Could not write settings.json: ${err.message}\n`);
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const bound = await probePort(INTERCEPTOR_PORT);
|
|
160
|
+
if (!bound) {
|
|
161
|
+
console.log(`\n ⚠ Nothing is listening on 127.0.0.1:${INTERCEPTOR_PORT} yet - the running`);
|
|
162
|
+
console.log(' background daemon does not have this feature (it predates this');
|
|
163
|
+
console.log(' build). Restart it so the interceptor actually binds:');
|
|
164
|
+
console.log(' troxy restart');
|
|
165
|
+
console.log(' Until then, Claude Code will fail to connect while this is enabled.\n');
|
|
166
|
+
} else {
|
|
167
|
+
console.log(`\n Interceptor is live on 127.0.0.1:${INTERCEPTOR_PORT}. Restart Claude Code`);
|
|
168
|
+
console.log(' (and the desktop app, if open) to pick up the new settings.\n');
|
|
169
|
+
}
|
|
170
|
+
console.log(' Remove any time with: troxy proxy disable\n');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function runProxyDisable() {
|
|
174
|
+
const settingsPath = claudeCodeSettingsPath();
|
|
175
|
+
process.stdout.write('\n Removing HTTPS_PROXY / NODE_EXTRA_CA_CERTS from settings.json... ');
|
|
176
|
+
try {
|
|
177
|
+
unpatchClaudeCodeInterception(settingsPath);
|
|
178
|
+
console.log('✓\n');
|
|
179
|
+
} catch (err) {
|
|
180
|
+
console.log('✗');
|
|
181
|
+
console.error(`\n ${err.message}\n`);
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
}
|