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,559 @@
|
|
|
1
|
+
// Layer 2's actual TCP/TLS listener. Design principle from the plan doc:
|
|
2
|
+
// "interception is used only where nothing gentler works" - a host NOT on
|
|
3
|
+
// the intercept allowlist is a pure blind byte tunnel (this file's plaintext
|
|
4
|
+
// never sees it at all), and even an allowlisted host's traffic only gets
|
|
5
|
+
// re-addressed to Troxy for the one specific route configured in
|
|
6
|
+
// providers.js - everything else on that same host relays to the real
|
|
7
|
+
// destination untouched. The pure header/parsing helpers are unit-tested in
|
|
8
|
+
// isolation; the actual CONNECT+TLS+relay behavior is proven with real
|
|
9
|
+
// sockets and a real TLS handshake, not mocked - verified directly via a
|
|
10
|
+
// standalone smoke test before writing this file that Node's
|
|
11
|
+
// `createConnection` socket-injection pattern actually works, since this
|
|
12
|
+
// entire design leans on it.
|
|
13
|
+
|
|
14
|
+
import { describe, it, after } from 'node:test';
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import net from 'node:net';
|
|
17
|
+
import tls from 'node:tls';
|
|
18
|
+
import http from 'node:http';
|
|
19
|
+
import https from 'node:https';
|
|
20
|
+
import { format } from 'node:util';
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
parseConnectTarget,
|
|
24
|
+
buildTroxyForwardHeaders,
|
|
25
|
+
buildRelayHeaders,
|
|
26
|
+
createInterceptor,
|
|
27
|
+
} from '../interceptor.js';
|
|
28
|
+
import { generateCA, generateLeaf } from '../tls-ca.js';
|
|
29
|
+
|
|
30
|
+
describe('parseConnectTarget', () => {
|
|
31
|
+
it('splits host:port', () => {
|
|
32
|
+
assert.deepEqual(parseConnectTarget('api.anthropic.com:443'), { host: 'api.anthropic.com', port: 443 });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('defaults to port 443 when none is given', () => {
|
|
36
|
+
assert.deepEqual(parseConnectTarget('api.anthropic.com'), { host: 'api.anthropic.com', port: 443 });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('falls back to 443 on an unparseable port instead of NaN', () => {
|
|
40
|
+
assert.deepEqual(parseConnectTarget('api.anthropic.com:notaport'), { host: 'api.anthropic.com', port: 443 });
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('buildTroxyForwardHeaders', () => {
|
|
45
|
+
it('translates the client\'s own Authorization into X-Troxy-Upstream-Authorization, ' +
|
|
46
|
+
'and sets Authorization to the Troxy key instead', () => {
|
|
47
|
+
const headers = buildTroxyForwardHeaders(
|
|
48
|
+
{ authorization: 'Bearer sk-ant-oat01-realtoken', 'content-type': 'application/json' },
|
|
49
|
+
'txy-the-troxy-key',
|
|
50
|
+
);
|
|
51
|
+
assert.equal(headers.authorization, 'Bearer txy-the-troxy-key');
|
|
52
|
+
assert.equal(headers['x-troxy-upstream-authorization'], 'Bearer sk-ant-oat01-realtoken');
|
|
53
|
+
assert.equal(headers['content-type'], 'application/json');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('omits x-troxy-upstream-authorization entirely when the client sent no credential', () => {
|
|
57
|
+
const headers = buildTroxyForwardHeaders({ 'content-type': 'application/json' }, 'txy-key');
|
|
58
|
+
assert.equal(headers['x-troxy-upstream-authorization'], undefined);
|
|
59
|
+
assert.equal(headers.authorization, 'Bearer txy-key');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('drops hop-by-hop / connection-level headers that only made sense on the inbound leg', () => {
|
|
63
|
+
const headers = buildTroxyForwardHeaders(
|
|
64
|
+
{ host: 'api.anthropic.com', connection: 'keep-alive', 'content-length': '123', authorization: 'Bearer x' },
|
|
65
|
+
'txy-key',
|
|
66
|
+
);
|
|
67
|
+
assert.equal(headers.host, undefined);
|
|
68
|
+
assert.equal(headers.connection, undefined);
|
|
69
|
+
assert.equal(headers['content-length'], undefined);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('buildRelayHeaders', () => {
|
|
74
|
+
it('preserves the client\'s own Authorization unmodified - this path talks to the ' +
|
|
75
|
+
'real host directly, not Troxy, so the client\'s real credential must go through as-is', () => {
|
|
76
|
+
const headers = buildRelayHeaders({ authorization: 'Bearer sk-ant-oat01-realtoken', accept: 'application/json' });
|
|
77
|
+
assert.equal(headers.authorization, 'Bearer sk-ant-oat01-realtoken');
|
|
78
|
+
assert.equal(headers.accept, 'application/json');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('still drops host/connection - those are meaningless on a fresh outbound connection', () => {
|
|
82
|
+
const headers = buildRelayHeaders({ host: 'api.anthropic.com', connection: 'keep-alive' });
|
|
83
|
+
assert.equal(headers.host, undefined);
|
|
84
|
+
assert.equal(headers.connection, undefined);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// --- Integration: real sockets, real TLS, no mocks ---------------------
|
|
89
|
+
|
|
90
|
+
function connectThroughProxy(proxyPort, targetHost, targetPort) {
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const socket = net.connect(proxyPort, '127.0.0.1', () => {
|
|
93
|
+
socket.write(`CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n\r\n`);
|
|
94
|
+
});
|
|
95
|
+
let buf = '';
|
|
96
|
+
function onData(chunk) {
|
|
97
|
+
buf += chunk.toString('latin1');
|
|
98
|
+
const idx = buf.indexOf('\r\n\r\n');
|
|
99
|
+
if (idx === -1) return;
|
|
100
|
+
socket.removeListener('data', onData);
|
|
101
|
+
if (buf.startsWith('HTTP/1.1 200')) resolve(socket);
|
|
102
|
+
else reject(new Error('CONNECT failed: ' + buf.slice(0, idx)));
|
|
103
|
+
}
|
|
104
|
+
socket.on('data', onData);
|
|
105
|
+
socket.on('error', reject);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function httpsOverConnectedSocket(rawSocket, { servername, ca, method = 'GET', path = '/', headers = {}, body = '' }) {
|
|
110
|
+
return new Promise((resolve, reject) => {
|
|
111
|
+
const tlsSocket = tls.connect({ socket: rawSocket, servername, ca, rejectUnauthorized: !!ca });
|
|
112
|
+
tlsSocket.on('error', reject);
|
|
113
|
+
tlsSocket.on('secureConnect', () => {
|
|
114
|
+
const req = https.request({ method, path, headers, createConnection: () => tlsSocket }, (res) => {
|
|
115
|
+
const chunks = [];
|
|
116
|
+
res.on('data', c => chunks.push(c));
|
|
117
|
+
res.on('end', () => resolve({ statusCode: res.statusCode, headers: res.headers, body: Buffer.concat(chunks).toString() }));
|
|
118
|
+
});
|
|
119
|
+
req.on('error', reject);
|
|
120
|
+
req.end(body);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A plain TLS server standing in for a real destination (Anthropic, or a
|
|
126
|
+
* non-allowlisted host) - presents ITS OWN certificate, never Troxy's,
|
|
127
|
+
* so a blind-tunnel test can prove the client's TLS really terminates
|
|
128
|
+
* there and not at the interceptor. */
|
|
129
|
+
function startStubTlsServer(certPem, keyPem, handler) {
|
|
130
|
+
return new Promise((resolve) => {
|
|
131
|
+
const server = tls.createServer({ cert: certPem, key: keyPem }, (socket) => {
|
|
132
|
+
http.createServer(handler).emit('connection', socket);
|
|
133
|
+
});
|
|
134
|
+
server.listen(0, '127.0.0.1', () => resolve(server));
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Every stub server in these tests actually listens on 127.0.0.1, but
|
|
139
|
+
// CONNECT targets use real-looking hostnames (api.anthropic.com,
|
|
140
|
+
// example.com) to exercise the interceptor's real allowlist/SNI/cert logic
|
|
141
|
+
// faithfully. In production, _relayToRealHost/_blindTunnel's real DNS
|
|
142
|
+
// resolution is exactly correct - this override only exists so a test can
|
|
143
|
+
// point that same hostname at a local stub instead of the real internet.
|
|
144
|
+
const LOOPBACK_LOOKUP = (hostname, options, callback) => {
|
|
145
|
+
if (typeof options === 'function') { callback = options; options = {}; }
|
|
146
|
+
// Node's autoSelectFamily (Happy Eyeballs, on by default) calls lookup
|
|
147
|
+
// with {all: true} and expects an array of {address, family} back, not
|
|
148
|
+
// the single (err, address, family) shape - confirmed directly against
|
|
149
|
+
// a real https.request before relying on it. Handle both.
|
|
150
|
+
if (options.all) {
|
|
151
|
+
callback(null, [{ address: '127.0.0.1', family: 4 }]);
|
|
152
|
+
} else {
|
|
153
|
+
callback(null, '127.0.0.1', 4);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
function startStubHttpServer(handler) {
|
|
158
|
+
return new Promise((resolve) => {
|
|
159
|
+
const server = http.createServer(handler);
|
|
160
|
+
server.listen(0, '127.0.0.1', () => resolve(server));
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
describe('createInterceptor - integration, real sockets', () => {
|
|
165
|
+
const openServers = [];
|
|
166
|
+
after(() => { for (const s of openServers) { try { s.close(); } catch {} } });
|
|
167
|
+
|
|
168
|
+
it('negotiates ALPN down to http/1.1 when the client offers h2 first - found live: ' +
|
|
169
|
+
'the real desktop app aborted every handshake with ECONNRESET when the server ' +
|
|
170
|
+
'declared no ALPN protocols at all, since the real api.anthropic.com speaks HTTP/2 ' +
|
|
171
|
+
'and a strict client can refuse to proceed without an explicit protocol match', async () => {
|
|
172
|
+
const ca = generateCA('test-host');
|
|
173
|
+
const leaf = generateLeaf(ca.certPem, ca.keyPem, ['api.anthropic.com']);
|
|
174
|
+
|
|
175
|
+
const interceptor = createInterceptor({
|
|
176
|
+
interceptHosts: ['api.anthropic.com'],
|
|
177
|
+
routeResolver: () => undefined,
|
|
178
|
+
getTroxyKey: () => 'txy-test-key',
|
|
179
|
+
getCerts: () => ({ leafCertPem: leaf.certPem, leafKeyPem: leaf.keyPem }),
|
|
180
|
+
});
|
|
181
|
+
await new Promise(r => interceptor.listen(0, '127.0.0.1', r));
|
|
182
|
+
openServers.push(interceptor);
|
|
183
|
+
|
|
184
|
+
const rawSocket = await connectThroughProxy(interceptor.address().port, 'api.anthropic.com', 443);
|
|
185
|
+
const alpnProtocol = await new Promise((resolve, reject) => {
|
|
186
|
+
const tlsSocket = tls.connect({
|
|
187
|
+
socket: rawSocket, servername: 'api.anthropic.com', ca: [ca.certPem],
|
|
188
|
+
ALPNProtocols: ['h2', 'http/1.1'], // same offer order a real HTTP/2-capable client makes
|
|
189
|
+
});
|
|
190
|
+
tlsSocket.once('secureConnect', () => { resolve(tlsSocket.alpnProtocol); tlsSocket.destroy(); });
|
|
191
|
+
tlsSocket.once('error', reject);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
assert.equal(alpnProtocol, 'http/1.1',
|
|
195
|
+
'the interceptor only ever speaks HTTP/1.1 internally - it must negotiate that explicitly, not leave ALPN unresolved');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('an allowlisted host + matching route gets re-addressed to Troxy, ' +
|
|
199
|
+
'with the credential header translated', async () => {
|
|
200
|
+
const ca = generateCA('test-host');
|
|
201
|
+
const leaf = generateLeaf(ca.certPem, ca.keyPem, ['api.anthropic.com']);
|
|
202
|
+
|
|
203
|
+
const troxyRequests = [];
|
|
204
|
+
const troxyServer = await startStubHttpServer((req, res) => {
|
|
205
|
+
const chunks = [];
|
|
206
|
+
req.on('data', c => chunks.push(c));
|
|
207
|
+
req.on('end', () => {
|
|
208
|
+
troxyRequests.push({ method: req.method, url: req.url, headers: req.headers, body: Buffer.concat(chunks).toString() });
|
|
209
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
210
|
+
res.end('{"ok":true,"from":"troxy"}');
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
openServers.push(troxyServer);
|
|
214
|
+
const troxyPort = troxyServer.address().port;
|
|
215
|
+
|
|
216
|
+
const logLines = [];
|
|
217
|
+
const interceptor = createInterceptor({
|
|
218
|
+
interceptHosts: ['api.anthropic.com'],
|
|
219
|
+
routeResolver: (host, method, path) => {
|
|
220
|
+
if (host === 'api.anthropic.com' && method === 'POST' && path.split('?')[0] === '/v1/messages') {
|
|
221
|
+
return { upstream: `http://127.0.0.1:${troxyPort}/v1/messages` };
|
|
222
|
+
}
|
|
223
|
+
return undefined;
|
|
224
|
+
},
|
|
225
|
+
getTroxyKey: () => 'txy-test-key',
|
|
226
|
+
getCerts: () => ({ leafCertPem: leaf.certPem, leafKeyPem: leaf.keyPem }),
|
|
227
|
+
troxyConnectTimeoutMs: 1000,
|
|
228
|
+
log: (fmt, ...args) => logLines.push(format(fmt, ...args)),
|
|
229
|
+
});
|
|
230
|
+
await new Promise(r => interceptor.listen(0, '127.0.0.1', r));
|
|
231
|
+
openServers.push(interceptor);
|
|
232
|
+
|
|
233
|
+
const rawSocket = await connectThroughProxy(interceptor.address().port, 'api.anthropic.com', 443);
|
|
234
|
+
const result = await httpsOverConnectedSocket(rawSocket, {
|
|
235
|
+
servername: 'api.anthropic.com', ca: [ca.certPem],
|
|
236
|
+
method: 'POST', path: '/v1/messages?beta=true',
|
|
237
|
+
headers: { authorization: 'Bearer sk-ant-oat01-realtoken', 'content-type': 'application/json' },
|
|
238
|
+
body: '{"model":"claude-sonnet-5"}',
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
assert.equal(result.statusCode, 200);
|
|
242
|
+
assert.equal(result.body, '{"ok":true,"from":"troxy"}');
|
|
243
|
+
assert.equal(troxyRequests.length, 1);
|
|
244
|
+
assert.equal(troxyRequests[0].headers.authorization, 'Bearer txy-test-key');
|
|
245
|
+
assert.ok(
|
|
246
|
+
logLines.some(l => l.includes('routed') && l.includes('/v1/messages?beta=true') && l.includes('200')),
|
|
247
|
+
// Previously this success path logged nothing at all - a live test
|
|
248
|
+
// had no way to tell "silently worked" apart from "silently never
|
|
249
|
+
// ran" without checking the dashboard for a decision_source row.
|
|
250
|
+
`expected a "routed ... to troxy" confirmation log line, got: ${JSON.stringify(logLines)}`,
|
|
251
|
+
);
|
|
252
|
+
assert.equal(troxyRequests[0].headers['x-troxy-upstream-authorization'], 'Bearer sk-ant-oat01-realtoken');
|
|
253
|
+
assert.equal(troxyRequests[0].body, '{"model":"claude-sonnet-5"}');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('an allowlisted host but a path with NO configured route relays straight to the ' +
|
|
257
|
+
'real host, with the client\'s own credential untouched - not everything on an ' +
|
|
258
|
+
'intercepted host goes through Troxy', async () => {
|
|
259
|
+
const ca = generateCA('test-host');
|
|
260
|
+
const leaf = generateLeaf(ca.certPem, ca.keyPem, ['api.anthropic.com']);
|
|
261
|
+
|
|
262
|
+
const realRequests = [];
|
|
263
|
+
const realServer = await startStubTlsServer(leaf.certPem, leaf.keyPem, (req, res) => {
|
|
264
|
+
realRequests.push({ url: req.url, headers: req.headers });
|
|
265
|
+
res.writeHead(200, {});
|
|
266
|
+
res.end('real-anthropic-response');
|
|
267
|
+
});
|
|
268
|
+
openServers.push(realServer);
|
|
269
|
+
|
|
270
|
+
const interceptor = createInterceptor({
|
|
271
|
+
interceptHosts: ['api.anthropic.com'],
|
|
272
|
+
routeResolver: () => undefined, // nothing configured - simulates e.g. GET /api/oauth/account/settings
|
|
273
|
+
getTroxyKey: () => 'txy-test-key',
|
|
274
|
+
getCerts: () => ({ leafCertPem: leaf.certPem, leafKeyPem: leaf.keyPem }),
|
|
275
|
+
lookup: LOOPBACK_LOOKUP,
|
|
276
|
+
ca: [ca.certPem], // the interceptor's OWN outbound trust for reaching the stub "real host"
|
|
277
|
+
});
|
|
278
|
+
await new Promise(r => interceptor.listen(0, '127.0.0.1', r));
|
|
279
|
+
openServers.push(interceptor);
|
|
280
|
+
|
|
281
|
+
const rawSocket = await connectThroughProxy(interceptor.address().port, 'api.anthropic.com', realServer.address().port);
|
|
282
|
+
const result = await httpsOverConnectedSocket(rawSocket, {
|
|
283
|
+
servername: 'api.anthropic.com', ca: [ca.certPem],
|
|
284
|
+
method: 'GET', path: '/api/oauth/account/settings',
|
|
285
|
+
headers: { authorization: 'Bearer sk-ant-oat01-realtoken' },
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
assert.equal(result.statusCode, 200);
|
|
289
|
+
assert.equal(result.body, 'real-anthropic-response');
|
|
290
|
+
assert.equal(realRequests[0].headers.authorization, 'Bearer sk-ant-oat01-realtoken',
|
|
291
|
+
'the client\'s own credential must reach the real host unmodified');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('a host NOT on the intercept allowlist is a blind tunnel - the client\'s TLS ' +
|
|
295
|
+
'terminates at the REAL destination\'s own certificate, never at Troxy\'s', async () => {
|
|
296
|
+
// A completely unrelated CA/cert pair, standing in for a real site the
|
|
297
|
+
// interceptor was never told to intercept - if this succeeds, the
|
|
298
|
+
// client's TLS handshake happened end-to-end with THIS cert, proving
|
|
299
|
+
// the interceptor never touched the encrypted bytes at all. Scoped to
|
|
300
|
+
// example.com, not the default api.anthropic.com - generateCA's
|
|
301
|
+
// nameConstraints default stands in for TROXY's own CA specifically;
|
|
302
|
+
// a genuinely unrelated real-world site's CA has no such restriction
|
|
303
|
+
// at all, so forcing this one to actually permit example.com is what
|
|
304
|
+
// makes it a faithful stand-in, not an accidental second exercise of
|
|
305
|
+
// the same restriction this test isn't about.
|
|
306
|
+
const otherCa = generateCA('unrelated', ['example.com']);
|
|
307
|
+
const otherLeaf = generateLeaf(otherCa.certPem, otherCa.keyPem, ['example.com']);
|
|
308
|
+
const targetServer = await startStubTlsServer(otherLeaf.certPem, otherLeaf.keyPem, (req, res) => {
|
|
309
|
+
res.writeHead(200, {});
|
|
310
|
+
res.end('untouched-by-troxy');
|
|
311
|
+
});
|
|
312
|
+
openServers.push(targetServer);
|
|
313
|
+
|
|
314
|
+
const troxyCa = generateCA('test-host'); // the interceptor's own CA - irrelevant here
|
|
315
|
+
const troxyLeaf = generateLeaf(troxyCa.certPem, troxyCa.keyPem, ['api.anthropic.com']);
|
|
316
|
+
const interceptor = createInterceptor({
|
|
317
|
+
interceptHosts: ['api.anthropic.com'], // example.com is deliberately NOT on this list
|
|
318
|
+
routeResolver: () => undefined,
|
|
319
|
+
getTroxyKey: () => 'txy-test-key',
|
|
320
|
+
getCerts: () => ({ leafCertPem: troxyLeaf.certPem, leafKeyPem: troxyLeaf.keyPem }),
|
|
321
|
+
lookup: LOOPBACK_LOOKUP,
|
|
322
|
+
});
|
|
323
|
+
await new Promise(r => interceptor.listen(0, '127.0.0.1', r));
|
|
324
|
+
openServers.push(interceptor);
|
|
325
|
+
|
|
326
|
+
const rawSocket = await connectThroughProxy(interceptor.address().port, 'example.com', targetServer.address().port);
|
|
327
|
+
// Trusting only the TARGET's own CA (not Troxy's) - if the interceptor
|
|
328
|
+
// tried to terminate this with its own leaf instead of tunneling, this
|
|
329
|
+
// handshake would fail outright.
|
|
330
|
+
const result = await httpsOverConnectedSocket(rawSocket, {
|
|
331
|
+
servername: 'example.com', ca: [otherCa.certPem], path: '/',
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
assert.equal(result.statusCode, 200);
|
|
335
|
+
assert.equal(result.body, 'untouched-by-troxy');
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('fail-open: Troxy upstream unreachable falls back to relaying the intercepted ' +
|
|
339
|
+
'request directly to the real host instead of erroring the client\'s request', async () => {
|
|
340
|
+
const ca = generateCA('test-host');
|
|
341
|
+
const leaf = generateLeaf(ca.certPem, ca.keyPem, ['api.anthropic.com']);
|
|
342
|
+
|
|
343
|
+
const realServer = await startStubTlsServer(leaf.certPem, leaf.keyPem, (req, res) => {
|
|
344
|
+
res.writeHead(200, {});
|
|
345
|
+
res.end('real-anthropic-fallback-response');
|
|
346
|
+
});
|
|
347
|
+
openServers.push(realServer);
|
|
348
|
+
|
|
349
|
+
const interceptor = createInterceptor({
|
|
350
|
+
interceptHosts: ['api.anthropic.com'],
|
|
351
|
+
routeResolver: (host, method, path) =>
|
|
352
|
+
(path.split('?')[0] === '/v1/messages') ? { upstream: 'http://127.0.0.1:1/v1/messages' } : undefined, // port 1: nothing listens, connection refused fast
|
|
353
|
+
getTroxyKey: () => 'txy-test-key',
|
|
354
|
+
getCerts: () => ({ leafCertPem: leaf.certPem, leafKeyPem: leaf.keyPem }),
|
|
355
|
+
troxyConnectTimeoutMs: 500,
|
|
356
|
+
lookup: LOOPBACK_LOOKUP,
|
|
357
|
+
ca: [ca.certPem],
|
|
358
|
+
});
|
|
359
|
+
await new Promise(r => interceptor.listen(0, '127.0.0.1', r));
|
|
360
|
+
openServers.push(interceptor);
|
|
361
|
+
|
|
362
|
+
const rawSocket = await connectThroughProxy(interceptor.address().port, 'api.anthropic.com', realServer.address().port);
|
|
363
|
+
const result = await httpsOverConnectedSocket(rawSocket, {
|
|
364
|
+
servername: 'api.anthropic.com', ca: [ca.certPem],
|
|
365
|
+
method: 'POST', path: '/v1/messages',
|
|
366
|
+
headers: { authorization: 'Bearer sk-ant-oat01-realtoken' }, body: '{}',
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
assert.equal(result.statusCode, 200);
|
|
370
|
+
assert.equal(result.body, 'real-anthropic-fallback-response',
|
|
371
|
+
'a dead Troxy upstream must not break the user\'s actual Claude Code usage');
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('a slow-but-connected Troxy response is NOT treated as fail-open, even well past the ' +
|
|
375
|
+
'connect-timeout budget - found live: a real model response commonly takes several ' +
|
|
376
|
+
'real seconds of time-to-first-token, which is not an outage signal', async () => {
|
|
377
|
+
const ca = generateCA('test-host');
|
|
378
|
+
const leaf = generateLeaf(ca.certPem, ca.keyPem, ['api.anthropic.com']);
|
|
379
|
+
|
|
380
|
+
const troxyServer = await startStubHttpServer((req, res) => {
|
|
381
|
+
// Deliberately much longer than the tiny connect-timeout below, but
|
|
382
|
+
// well under the response-timeout - simulates a real model actually
|
|
383
|
+
// thinking, not Troxy being down.
|
|
384
|
+
setTimeout(() => {
|
|
385
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
386
|
+
res.end('{"ok":true,"from":"troxy","slow":true}');
|
|
387
|
+
}, 300);
|
|
388
|
+
});
|
|
389
|
+
openServers.push(troxyServer);
|
|
390
|
+
const troxyPort = troxyServer.address().port;
|
|
391
|
+
|
|
392
|
+
const interceptor = createInterceptor({
|
|
393
|
+
interceptHosts: ['api.anthropic.com'],
|
|
394
|
+
routeResolver: () => ({ upstream: `http://127.0.0.1:${troxyPort}/v1/messages` }),
|
|
395
|
+
getTroxyKey: () => 'txy-test-key',
|
|
396
|
+
getCerts: () => ({ leafCertPem: leaf.certPem, leafKeyPem: leaf.keyPem }),
|
|
397
|
+
troxyConnectTimeoutMs: 50, // connecting to a local stub is near-instant either way
|
|
398
|
+
troxyResponseTimeoutMs: 5000, // generous enough to outlast the stub's 300ms "thinking"
|
|
399
|
+
});
|
|
400
|
+
await new Promise(r => interceptor.listen(0, '127.0.0.1', r));
|
|
401
|
+
openServers.push(interceptor);
|
|
402
|
+
|
|
403
|
+
const rawSocket = await connectThroughProxy(interceptor.address().port, 'api.anthropic.com', 443);
|
|
404
|
+
const result = await httpsOverConnectedSocket(rawSocket, {
|
|
405
|
+
servername: 'api.anthropic.com', ca: [ca.certPem],
|
|
406
|
+
method: 'POST', path: '/v1/messages',
|
|
407
|
+
headers: { authorization: 'Bearer sk-ant-oat01-realtoken' }, body: '{}',
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
assert.equal(result.statusCode, 200);
|
|
411
|
+
assert.equal(result.body, '{"ok":true,"from":"troxy","slow":true}',
|
|
412
|
+
'a slow-but-reachable Troxy response must still be served, not abandoned for a direct fallback');
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('reusing a keep-alive connection to Troxy across many requests does not leak ' +
|
|
416
|
+
'"timeout" listeners on the socket - found live: MaxListenersExceededWarning after ' +
|
|
417
|
+
'about 11 real requests, since socket.setTimeout(ms, cb) adds a NEW listener every ' +
|
|
418
|
+
'call without removing the last one', async () => {
|
|
419
|
+
const ca = generateCA('test-host');
|
|
420
|
+
const leaf = generateLeaf(ca.certPem, ca.keyPem, ['api.anthropic.com']);
|
|
421
|
+
|
|
422
|
+
const troxyServer = await startStubHttpServer((req, res) => {
|
|
423
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
424
|
+
res.end('{"ok":true}');
|
|
425
|
+
});
|
|
426
|
+
openServers.push(troxyServer);
|
|
427
|
+
const troxyPort = troxyServer.address().port;
|
|
428
|
+
|
|
429
|
+
const interceptor = createInterceptor({
|
|
430
|
+
interceptHosts: ['api.anthropic.com'],
|
|
431
|
+
routeResolver: () => ({ upstream: `http://127.0.0.1:${troxyPort}/v1/messages` }),
|
|
432
|
+
getTroxyKey: () => 'txy-test-key',
|
|
433
|
+
getCerts: () => ({ leafCertPem: leaf.certPem, leafKeyPem: leaf.keyPem }),
|
|
434
|
+
troxyConnectTimeoutMs: 1000,
|
|
435
|
+
troxyResponseTimeoutMs: 2000,
|
|
436
|
+
});
|
|
437
|
+
await new Promise(r => interceptor.listen(0, '127.0.0.1', r));
|
|
438
|
+
openServers.push(interceptor);
|
|
439
|
+
|
|
440
|
+
// Forces real connection reuse on the interceptor's own OUTBOUND leg to
|
|
441
|
+
// Troxy deterministically, rather than depending on whatever this Node
|
|
442
|
+
// version's ambient global-agent keepAlive default happens to be (it
|
|
443
|
+
// varies by version; production hits this exact path either way once a
|
|
444
|
+
// real client sends enough sequential requests). Each iteration below
|
|
445
|
+
// is its own separate CLIENT connection (same proven pattern as every
|
|
446
|
+
// other test in this file) - what matters for this leak is only
|
|
447
|
+
// whether the INTERCEPTOR's own request to Troxy reuses a socket
|
|
448
|
+
// across those, which depends solely on the shared global agent.
|
|
449
|
+
const originalKeepAlive = http.globalAgent.keepAlive;
|
|
450
|
+
http.globalAgent.keepAlive = true;
|
|
451
|
+
const warnings = [];
|
|
452
|
+
const onWarning = (w) => warnings.push(w);
|
|
453
|
+
process.on('warning', onWarning);
|
|
454
|
+
|
|
455
|
+
try {
|
|
456
|
+
// One more than Node's default MaxListeners (10) - exactly the shape
|
|
457
|
+
// that surfaced this live.
|
|
458
|
+
for (let i = 0; i < 11; i++) {
|
|
459
|
+
const rawSocket = await connectThroughProxy(interceptor.address().port, 'api.anthropic.com', 443);
|
|
460
|
+
const result = await httpsOverConnectedSocket(rawSocket, {
|
|
461
|
+
servername: 'api.anthropic.com', ca: [ca.certPem],
|
|
462
|
+
method: 'POST', path: '/v1/messages',
|
|
463
|
+
headers: { authorization: 'Bearer x' }, body: '{}',
|
|
464
|
+
});
|
|
465
|
+
assert.equal(result.statusCode, 200);
|
|
466
|
+
}
|
|
467
|
+
await new Promise((r) => setImmediate(r)); // let any deferred 'warning' emission surface
|
|
468
|
+
} finally {
|
|
469
|
+
process.removeListener('warning', onWarning);
|
|
470
|
+
http.globalAgent.keepAlive = originalKeepAlive;
|
|
471
|
+
// Forcing keepAlive above made the interceptor's outbound leg to the
|
|
472
|
+
// stub Troxy server pool real, reused sockets on the GLOBAL agent -
|
|
473
|
+
// restoring keepAlive alone does not close those already-pooled
|
|
474
|
+
// sockets. Left open, they're handles Node won't exit on, which is
|
|
475
|
+
// invisible locally (the process exits anyway once the whole suite's
|
|
476
|
+
// other activity winds down) but hangs `node --test` indefinitely in
|
|
477
|
+
// CI once this is the last real activity in the run - found live
|
|
478
|
+
// 2026-09-08, Node 18 in GitHub Actions timed out at 5 minutes with
|
|
479
|
+
// no further output after this suite's last test finished, while
|
|
480
|
+
// Node 20/22 happened not to hang on the exact same code (a timing/
|
|
481
|
+
// GC difference between versions, not a reason to trust either).
|
|
482
|
+
http.globalAgent.destroy();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const leakWarnings = warnings.filter(w => w.name === 'MaxListenersExceededWarning' && /timeout/i.test(w.message));
|
|
486
|
+
assert.equal(leakWarnings.length, 0,
|
|
487
|
+
`expected no listener-leak warning, got: ${leakWarnings.map(w => w.message).join('; ')}`);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it('a Troxy upstream that connects but then genuinely hangs past troxyResponseTimeoutMs ' +
|
|
491
|
+
'still fails open eventually - the post-connect backstop is not "never time out"', async () => {
|
|
492
|
+
const ca = generateCA('test-host');
|
|
493
|
+
const leaf = generateLeaf(ca.certPem, ca.keyPem, ['api.anthropic.com']);
|
|
494
|
+
|
|
495
|
+
// Accepts the connection but never responds at all - a genuine hang,
|
|
496
|
+
// not normal model latency.
|
|
497
|
+
const troxyServer = await startStubHttpServer(() => {});
|
|
498
|
+
openServers.push(troxyServer);
|
|
499
|
+
const troxyPort = troxyServer.address().port;
|
|
500
|
+
|
|
501
|
+
const realServer = await startStubTlsServer(leaf.certPem, leaf.keyPem, (req, res) => {
|
|
502
|
+
res.writeHead(200, {});
|
|
503
|
+
res.end('real-anthropic-fallback-after-hang');
|
|
504
|
+
});
|
|
505
|
+
openServers.push(realServer);
|
|
506
|
+
|
|
507
|
+
const interceptor = createInterceptor({
|
|
508
|
+
interceptHosts: ['api.anthropic.com'],
|
|
509
|
+
routeResolver: () => ({ upstream: `http://127.0.0.1:${troxyPort}/v1/messages` }),
|
|
510
|
+
getTroxyKey: () => 'txy-test-key',
|
|
511
|
+
getCerts: () => ({ leafCertPem: leaf.certPem, leafKeyPem: leaf.keyPem }),
|
|
512
|
+
troxyConnectTimeoutMs: 500,
|
|
513
|
+
troxyResponseTimeoutMs: 300, // short only so the test doesn't wait 55s for real
|
|
514
|
+
lookup: LOOPBACK_LOOKUP,
|
|
515
|
+
ca: [ca.certPem],
|
|
516
|
+
});
|
|
517
|
+
await new Promise(r => interceptor.listen(0, '127.0.0.1', r));
|
|
518
|
+
openServers.push(interceptor);
|
|
519
|
+
|
|
520
|
+
const rawSocket = await connectThroughProxy(interceptor.address().port, 'api.anthropic.com', realServer.address().port);
|
|
521
|
+
const result = await httpsOverConnectedSocket(rawSocket, {
|
|
522
|
+
servername: 'api.anthropic.com', ca: [ca.certPem],
|
|
523
|
+
method: 'POST', path: '/v1/messages',
|
|
524
|
+
headers: { authorization: 'Bearer sk-ant-oat01-realtoken' }, body: '{}',
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
assert.equal(result.statusCode, 200);
|
|
528
|
+
assert.equal(result.body, 'real-anthropic-fallback-after-hang');
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it('fail-open: unhealthy/missing certs tunnel the allowlisted host too, instead of throwing', async () => {
|
|
532
|
+
const ca = generateCA('test-host'); // used only to sign the stub target's cert
|
|
533
|
+
const leaf = generateLeaf(ca.certPem, ca.keyPem, ['api.anthropic.com']);
|
|
534
|
+
const realServer = await startStubTlsServer(leaf.certPem, leaf.keyPem, (req, res) => {
|
|
535
|
+
res.writeHead(200, {});
|
|
536
|
+
res.end('tunneled-because-cert-unhealthy');
|
|
537
|
+
});
|
|
538
|
+
openServers.push(realServer);
|
|
539
|
+
|
|
540
|
+
const interceptor = createInterceptor({
|
|
541
|
+
interceptHosts: ['api.anthropic.com'],
|
|
542
|
+
routeResolver: () => ({ upstream: 'http://127.0.0.1:1/v1/messages' }),
|
|
543
|
+
getTroxyKey: () => 'txy-test-key',
|
|
544
|
+
getCerts: () => null, // simulates a missing/corrupt/expired cert with no healthy fallback
|
|
545
|
+
lookup: LOOPBACK_LOOKUP,
|
|
546
|
+
});
|
|
547
|
+
await new Promise(r => interceptor.listen(0, '127.0.0.1', r));
|
|
548
|
+
openServers.push(interceptor);
|
|
549
|
+
|
|
550
|
+
const rawSocket = await connectThroughProxy(interceptor.address().port, 'api.anthropic.com', realServer.address().port);
|
|
551
|
+
// Trusting the TARGET's cert directly, same as the blind-tunnel test -
|
|
552
|
+
// if the interceptor tried to terminate anyway despite getCerts()
|
|
553
|
+
// returning null, this would fail since it has nothing valid to present.
|
|
554
|
+
const result = await httpsOverConnectedSocket(rawSocket, { servername: 'api.anthropic.com', ca: [ca.certPem], path: '/' });
|
|
555
|
+
|
|
556
|
+
assert.equal(result.statusCode, 200);
|
|
557
|
+
assert.equal(result.body, 'tunneled-because-cert-unhealthy');
|
|
558
|
+
});
|
|
559
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The single source of truth for which hosts the local interceptor ever
|
|
2
|
+
// terminates TLS for, and where an intercepted request gets re-addressed
|
|
3
|
+
// to. Only 'anthropic' is enabled in Phase 1 - openai_codex/cursor are
|
|
4
|
+
// declared but disabled, so Phase 2/3 becomes a data change later, not a
|
|
5
|
+
// rewrite (see the plan doc). The critical property this file's tests
|
|
6
|
+
// guard: a disabled provider must NEVER contribute to the live intercept
|
|
7
|
+
// allowlist - that's the guardrail against a Phase-2 stub silently
|
|
8
|
+
// widening what gets MITM'd before it's actually ready.
|
|
9
|
+
|
|
10
|
+
import { describe, it } from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
|
|
13
|
+
import { PROVIDERS, enabledProviders, interceptHostsFor, troxyRouteFor } from '../providers.js';
|
|
14
|
+
|
|
15
|
+
describe('PROVIDERS', () => {
|
|
16
|
+
it('anthropic is enabled in Phase 1', () => {
|
|
17
|
+
const anthropic = PROVIDERS.find(p => p.id === 'anthropic');
|
|
18
|
+
assert.equal(anthropic.enabled, true);
|
|
19
|
+
assert.deepEqual(anthropic.interceptHosts, ['api.anthropic.com']);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('codex and cursor are declared but NOT enabled - Phase 1 is Anthropic only', () => {
|
|
23
|
+
const codex = PROVIDERS.find(p => p.id === 'openai_codex');
|
|
24
|
+
const cursor = PROVIDERS.find(p => p.id === 'cursor');
|
|
25
|
+
assert.equal(codex.enabled, false);
|
|
26
|
+
assert.equal(cursor.enabled, false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('enabledProviders', () => {
|
|
31
|
+
it('returns only providers with enabled: true', () => {
|
|
32
|
+
const providers = enabledProviders();
|
|
33
|
+
assert.ok(providers.every(p => p.enabled === true));
|
|
34
|
+
assert.ok(providers.some(p => p.id === 'anthropic'));
|
|
35
|
+
assert.ok(!providers.some(p => p.id === 'openai_codex'));
|
|
36
|
+
assert.ok(!providers.some(p => p.id === 'cursor'));
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('interceptHostsFor', () => {
|
|
41
|
+
it('an enabled-only provider list yields exactly api.anthropic.com in Phase 1', () => {
|
|
42
|
+
assert.deepEqual(interceptHostsFor(enabledProviders()), ['api.anthropic.com']);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('a disabled provider passed in explicitly contributes nothing - ' +
|
|
46
|
+
'enabling it later must be a deliberate PROVIDERS edit, not automatic', () => {
|
|
47
|
+
const cursor = PROVIDERS.find(p => p.id === 'cursor');
|
|
48
|
+
assert.deepEqual(interceptHostsFor([cursor]), []);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('empty input yields an empty allowlist, not an error', () => {
|
|
52
|
+
assert.deepEqual(interceptHostsFor([]), []);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('troxyRouteFor', () => {
|
|
57
|
+
it('matches the configured /v1/messages route for api.anthropic.com', () => {
|
|
58
|
+
const route = troxyRouteFor(enabledProviders(), 'api.anthropic.com', 'POST', '/v1/messages');
|
|
59
|
+
assert.ok(route, 'expected a route match');
|
|
60
|
+
assert.equal(route.upstream, 'https://proxy.troxy.io/v1/messages');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns undefined for a path on an intercepted host that has no configured route ' +
|
|
64
|
+
'(e.g. /api/oauth/account/settings) - the interceptor must relay these verbatim, not proxy them', () => {
|
|
65
|
+
const route = troxyRouteFor(enabledProviders(), 'api.anthropic.com', 'GET', '/api/oauth/account/settings');
|
|
66
|
+
assert.equal(route, undefined);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('returns undefined for a host that is not on the intercept allowlist at all', () => {
|
|
70
|
+
const route = troxyRouteFor(enabledProviders(), 'evil.example.com', 'POST', '/v1/messages');
|
|
71
|
+
assert.equal(route, undefined);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('is case-insensitive and query-string-agnostic on the path match', () => {
|
|
75
|
+
const route = troxyRouteFor(enabledProviders(), 'api.anthropic.com', 'post', '/v1/messages?beta=true');
|
|
76
|
+
assert.ok(route, 'expected a route match despite lowercase method and a query string');
|
|
77
|
+
});
|
|
78
|
+
});
|