@karpeleslab/teamclaude 1.0.7 → 1.0.9
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 +85 -4
- package/package.json +9 -1
- package/src/account-manager.js +182 -27
- package/src/account-uuid-rewrite.js +115 -0
- package/src/h2/frames.js +83 -0
- package/src/h2/hpack.js +314 -0
- package/src/h2/relay.js +417 -0
- package/src/index.js +177 -22
- package/src/json-format-stream.js +65 -0
- package/src/mitm.js +387 -0
- package/src/oauth.js +76 -1
- package/src/prober.js +82 -0
- package/src/request-log.js +194 -0
- package/src/server.js +148 -90
- package/src/sx.js +218 -0
- package/src/tui.js +275 -7
- package/src/upstream-fetch.js +85 -0
- package/src/x509.js +166 -0
package/src/mitm.js
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
// MITM forward-proxy support: local cert lifecycle + transparent CONNECT relay.
|
|
2
|
+
//
|
|
3
|
+
// When a claude instance is launched with HTTPS_PROXY pointed at teamclaude it
|
|
4
|
+
// sends `CONNECT api.anthropic.com:443`. We act as a transparent MITM: dial the
|
|
5
|
+
// real upstream first (mirroring SNI + adopting its negotiated ALPN), present
|
|
6
|
+
// claude our locally-minted leaf advertising that same protocol, then relay the
|
|
7
|
+
// decrypted stream — rewriting ONLY the auth header (h2 via our HPACK codec, h1
|
|
8
|
+
// via a plaintext head edit) and reading quota from responses. Everything else
|
|
9
|
+
// is copied as-is. A host routing table decides per-CONNECT behavior:
|
|
10
|
+
// api.anthropic.com → rewrite, www.example.org → local test server,
|
|
11
|
+
// anything else → blind tunnel.
|
|
12
|
+
|
|
13
|
+
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
|
14
|
+
import { X509Certificate } from 'node:crypto';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import net from 'node:net';
|
|
17
|
+
import tls from 'node:tls';
|
|
18
|
+
import { getConfigPath } from './config.js';
|
|
19
|
+
import { generateCertChain } from './x509.js';
|
|
20
|
+
import { h2Relay, h1Relay, rewriteH1Auth } from './h2/relay.js';
|
|
21
|
+
import { AccountUuidPatcher } from './account-uuid-rewrite.js';
|
|
22
|
+
import { makeMitmTap, makeActivityTap, combineTaps } from './request-log.js';
|
|
23
|
+
import { tunnelTls } from './sx.js';
|
|
24
|
+
|
|
25
|
+
const CA_CERT = 'teamclaude-ca.pem';
|
|
26
|
+
const LEAF_CERT = 'teamclaude-leaf.pem';
|
|
27
|
+
const LEAF_KEY = 'teamclaude-leaf.key';
|
|
28
|
+
|
|
29
|
+
// A built-in host the MITM proxy always intercepts and answers itself (never
|
|
30
|
+
// forwarded upstream). Lets you verify the proxy + CA end-to-end with no
|
|
31
|
+
// credentials, e.g.:
|
|
32
|
+
// curl --proxy http://localhost:3456 --cacert <ca.pem> https://www.example.org/
|
|
33
|
+
export const TEST_HOST = 'www.example.org';
|
|
34
|
+
|
|
35
|
+
const certDir = () => dirname(getConfigPath());
|
|
36
|
+
const fpath = (n) => join(certDir(), n);
|
|
37
|
+
|
|
38
|
+
/** Path to the CA cert clients should trust via NODE_EXTRA_CA_CERTS. */
|
|
39
|
+
export function caCertPath() {
|
|
40
|
+
return fpath(CA_CERT);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function readIf(p) {
|
|
44
|
+
try { return await readFile(p, 'utf8'); } catch { return null; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function atomicWrite(path, data, mode) {
|
|
48
|
+
const tmp = `${path}.tmp${process.pid}`;
|
|
49
|
+
await writeFile(tmp, data, { mode });
|
|
50
|
+
await rename(tmp, path);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Is the stored leaf signed by the stored CA and valid for every host in `hosts`?
|
|
54
|
+
function leafCovers(caCertPem, leafCertPem, hosts) {
|
|
55
|
+
try {
|
|
56
|
+
const ca = new X509Certificate(caCertPem);
|
|
57
|
+
const leaf = new X509Certificate(leafCertPem);
|
|
58
|
+
if (!leaf.verify(ca.publicKey)) return false;
|
|
59
|
+
const names = (leaf.subjectAltName || '').split(',').map((s) => s.trim());
|
|
60
|
+
return hosts.every((h) => names.includes(`DNS:${h}`));
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Ensure a CA cert + a leaf for `host` exist in the config dir, generating them
|
|
68
|
+
* if missing/mismatched. The CA *private* key is never persisted — we regenerate
|
|
69
|
+
* the whole chain when needed, so the only on-disk secret is the leaf key (0600),
|
|
70
|
+
* which only authenticates as `host` to a process that already trusts our CA.
|
|
71
|
+
* Returns { caPath, caCertPem, leafCertPem, leafKeyPem }.
|
|
72
|
+
*/
|
|
73
|
+
export async function ensureCerts(host) {
|
|
74
|
+
const hosts = host === TEST_HOST ? [TEST_HOST] : [host, TEST_HOST];
|
|
75
|
+
const [caCertPem, leafCertPem, leafKeyPem] = await Promise.all([
|
|
76
|
+
readIf(fpath(CA_CERT)), readIf(fpath(LEAF_CERT)), readIf(fpath(LEAF_KEY)),
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
if (caCertPem && leafCertPem && leafKeyPem && leafCovers(caCertPem, leafCertPem, hosts)) {
|
|
80
|
+
return { caPath: fpath(CA_CERT), caCertPem, leafCertPem, leafKeyPem };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const chain = generateCertChain(hosts); // caKeyPem intentionally discarded
|
|
84
|
+
await mkdir(certDir(), { recursive: true });
|
|
85
|
+
await atomicWrite(fpath(CA_CERT), chain.caCertPem, 0o644);
|
|
86
|
+
await atomicWrite(fpath(LEAF_CERT), chain.leafCertPem, 0o644);
|
|
87
|
+
await atomicWrite(fpath(LEAF_KEY), chain.leafKeyPem, 0o600);
|
|
88
|
+
return {
|
|
89
|
+
caPath: fpath(CA_CERT),
|
|
90
|
+
caCertPem: chain.caCertPem,
|
|
91
|
+
leafCertPem: chain.leafCertPem,
|
|
92
|
+
leafKeyPem: chain.leafKeyPem,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function upstreamHostOf(config) {
|
|
97
|
+
try { return new URL(config?.upstream || 'https://api.anthropic.com').hostname; }
|
|
98
|
+
catch { return 'api.anthropic.com'; }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Per-CONNECT behavior: 'rewrite' (intercept + token inject), 'test', or 'tunnel'. */
|
|
102
|
+
export function hostMode(host, config) {
|
|
103
|
+
if (host === TEST_HOST) return 'test';
|
|
104
|
+
if (host === upstreamHostOf(config)) return 'rewrite';
|
|
105
|
+
return 'tunnel';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Build a `connect` event handler implementing the transparent MITM described
|
|
110
|
+
* at the top of this file.
|
|
111
|
+
* @param ensureLeaf async () => { key, cert } // current leaf PEMs
|
|
112
|
+
*/
|
|
113
|
+
export function createConnectHandler({ config, accountManager, ensureLeaf, upstreamTlsOptions = {}, logDir = null, hooks = {}, log = () => {}, sx = null }) {
|
|
114
|
+
return (req, clientSocket, head) => {
|
|
115
|
+
clientSocket.on('error', () => {});
|
|
116
|
+
const [host, portStr] = (req.url || '').split(':');
|
|
117
|
+
const port = parseInt(portStr, 10) || 443;
|
|
118
|
+
const mode = hostMode(host, config);
|
|
119
|
+
|
|
120
|
+
if (mode === 'tunnel') {
|
|
121
|
+
const up = net.connect(port, host, () => {
|
|
122
|
+
reply200Raw(clientSocket);
|
|
123
|
+
if (head && head.length) up.write(head);
|
|
124
|
+
up.pipe(clientSocket); clientSocket.pipe(up);
|
|
125
|
+
});
|
|
126
|
+
up.on('error', () => clientSocket.destroy());
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
intercept({ host, port, mode, clientSocket, head, accountManager, ensureLeaf, upstreamTlsOptions, logDir, hooks, log, sx })
|
|
131
|
+
.catch((err) => { log(`[TeamClaude] MITM ${host}: ${err.message}`); clientSocket.destroy(); });
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function intercept({ host, port, mode, clientSocket, head, accountManager, ensureLeaf, upstreamTlsOptions, logDir, hooks = {}, log, sx }) {
|
|
136
|
+
const { key, cert } = await ensureLeaf();
|
|
137
|
+
|
|
138
|
+
if (mode === 'test') {
|
|
139
|
+
reply200Raw(clientSocket);
|
|
140
|
+
const tlsSock = termClaude(clientSocket, head, key, cert, ['http/1.1']);
|
|
141
|
+
serveTest(tlsSock);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// rewrite: be a faithful ALPN mirror. We don't terminate TLS at the byte
|
|
146
|
+
// level (that's node:tls), so to avoid imposing our own protocol preference we
|
|
147
|
+
// peek the client's ClientHello, read the exact ALPN list it offers, present
|
|
148
|
+
// THAT list to the upstream, let the upstream pick, then terminate the client
|
|
149
|
+
// with whatever the upstream selected. The upstream decides — constrained to
|
|
150
|
+
// what the client can speak — so an http/1.1-only client (undici/claude) and
|
|
151
|
+
// an h2 client both negotiate end-to-end exactly as they would directly.
|
|
152
|
+
const account = accountManager.getActiveAccount();
|
|
153
|
+
if (!account) { clientSocket.destroy(); return; }
|
|
154
|
+
await accountManager.ensureTokenFresh(account.index);
|
|
155
|
+
|
|
156
|
+
reply200Raw(clientSocket);
|
|
157
|
+
const offered = await peekClientAlpn(clientSocket, head); // client's ALPN list, or null
|
|
158
|
+
|
|
159
|
+
// When sx.org is enabled, dial the upstream THROUGH its proxy (different egress
|
|
160
|
+
// IP — the IP-based-429 workaround); TLS still terminates end-to-end at the
|
|
161
|
+
// upstream, so the proxy sees only ciphertext. Otherwise connect directly.
|
|
162
|
+
// autoSelectFamily (happy-eyeballs) is the default on Node 20+ but not 18; set
|
|
163
|
+
// it explicitly so a dual-stack upstream whose IPv6 path is unreachable falls
|
|
164
|
+
// back to IPv4 instead of hanging the connect (option ignored pre-18.13).
|
|
165
|
+
let upstreamSock;
|
|
166
|
+
if (sx?.useForConnect()) {
|
|
167
|
+
upstreamSock = await tunnelTls({
|
|
168
|
+
proxy: sx.getProxy(), targetHost: host, targetPort: port,
|
|
169
|
+
tlsOptions: { ...(offered ? { ALPNProtocols: offered } : {}), ...upstreamTlsOptions },
|
|
170
|
+
});
|
|
171
|
+
} else {
|
|
172
|
+
upstreamSock = tls.connect({
|
|
173
|
+
host, port, servername: host, autoSelectFamily: true,
|
|
174
|
+
...(offered ? { ALPNProtocols: offered } : {}),
|
|
175
|
+
...upstreamTlsOptions,
|
|
176
|
+
});
|
|
177
|
+
await new Promise((resolve, reject) => {
|
|
178
|
+
upstreamSock.once('secureConnect', resolve);
|
|
179
|
+
upstreamSock.once('error', reject);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
const upstreamAlpn = upstreamSock.alpnProtocol; // false if none negotiated
|
|
183
|
+
const alpn = upstreamAlpn || 'http/1.1'; // relay protocol selector
|
|
184
|
+
|
|
185
|
+
// Terminate the client advertising exactly what the upstream chose (a subset
|
|
186
|
+
// of what the client offered, so it always overlaps).
|
|
187
|
+
const claudeTls = new tls.TLSSocket(clientSocket, {
|
|
188
|
+
isServer: true, key, cert,
|
|
189
|
+
...(upstreamAlpn ? { ALPNProtocols: [upstreamAlpn] } : {}),
|
|
190
|
+
});
|
|
191
|
+
claudeTls.on('error', () => { claudeTls.destroy(); upstreamSock.destroy(); });
|
|
192
|
+
upstreamSock.on('error', () => claudeTls.destroy());
|
|
193
|
+
await new Promise((resolve, reject) => {
|
|
194
|
+
claudeTls.once('secure', resolve);
|
|
195
|
+
claudeTls.once('error', reject);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Per-stream streaming body patcher: align metadata.user_id.account_uuid with
|
|
199
|
+
// the injected account (same length; no-op if the account has no UUID).
|
|
200
|
+
const makeBodyPatcher = account.accountUuid
|
|
201
|
+
? () => new AccountUuidPatcher(account.accountUuid)
|
|
202
|
+
: null;
|
|
203
|
+
// Fan request lifecycle out to the on-disk log (when configured) AND the live
|
|
204
|
+
// TUI activity feed, so MITM traffic is visible in the TUI like reverse-proxy
|
|
205
|
+
// traffic — not just on disk.
|
|
206
|
+
const tap = combineTaps(makeMitmTap(logDir, account.name), makeActivityTap(hooks, account.name));
|
|
207
|
+
|
|
208
|
+
if (alpn === 'h2') {
|
|
209
|
+
h2Relay(claudeTls, upstreamSock, {
|
|
210
|
+
rewriteRequest: makeRewriteRequest(account),
|
|
211
|
+
makeBodyPatcher,
|
|
212
|
+
onResponseHeaders: makeQuotaObserver(accountManager, account, sx),
|
|
213
|
+
tap,
|
|
214
|
+
log,
|
|
215
|
+
});
|
|
216
|
+
} else {
|
|
217
|
+
const auth = account.type === 'oauth'
|
|
218
|
+
? { authorization: `Bearer ${account.credential}` }
|
|
219
|
+
: { apiKey: account.credential };
|
|
220
|
+
h1Relay(claudeTls, upstreamSock, {
|
|
221
|
+
rewriteHead: (h) => rewriteH1Auth(h, auth),
|
|
222
|
+
makeBodyPatcher,
|
|
223
|
+
onResponseHeaders: makeQuotaObserver(accountManager, account, sx),
|
|
224
|
+
tap,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function reply200Raw(sock) { sock.write('HTTP/1.1 200 Connection Established\r\n\r\n'); }
|
|
230
|
+
|
|
231
|
+
function termClaude(clientSocket, head, key, cert, alpn) {
|
|
232
|
+
if (head && head.length) clientSocket.unshift(head);
|
|
233
|
+
const t = new tls.TLSSocket(clientSocket, { isServer: true, key, cert, ALPNProtocols: alpn });
|
|
234
|
+
t.on('error', () => t.destroy());
|
|
235
|
+
return t;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Read the client's first TLS record (the ClientHello) to learn the ALPN
|
|
239
|
+
// protocol list it offers, WITHOUT consuming it: bytes are unshifted back so the
|
|
240
|
+
// real TLS handshake still sees the full ClientHello. Returns the protocol
|
|
241
|
+
// string array, or null if there is no ALPN extension / it can't be parsed (in
|
|
242
|
+
// which case we offer the upstream no ALPN and mirror its no-ALPN result).
|
|
243
|
+
function peekClientAlpn(sock, head) {
|
|
244
|
+
return new Promise((resolve) => {
|
|
245
|
+
let buf = head && head.length ? Buffer.from(head) : Buffer.alloc(0);
|
|
246
|
+
let done = false;
|
|
247
|
+
const finish = (result) => {
|
|
248
|
+
if (done) return; done = true;
|
|
249
|
+
sock.removeListener('readable', onReadable);
|
|
250
|
+
sock.removeListener('end', onEnd);
|
|
251
|
+
sock.removeListener('error', onEnd);
|
|
252
|
+
if (buf.length) sock.unshift(buf); // put the ClientHello back for node:tls
|
|
253
|
+
resolve(result);
|
|
254
|
+
};
|
|
255
|
+
const tryParse = () => {
|
|
256
|
+
const r = parseClientHelloAlpn(buf);
|
|
257
|
+
if (r === undefined && buf.length < 16384) return false; // need more bytes
|
|
258
|
+
finish(Array.isArray(r) ? r : null);
|
|
259
|
+
return true;
|
|
260
|
+
};
|
|
261
|
+
// Read in PAUSED mode (readable/read) — never switch the socket to flowing,
|
|
262
|
+
// or the TLSSocket we hand it to afterwards won't receive the unshifted bytes.
|
|
263
|
+
const onReadable = () => {
|
|
264
|
+
let chunk;
|
|
265
|
+
while ((chunk = sock.read()) !== null) {
|
|
266
|
+
buf = Buffer.concat([buf, chunk]);
|
|
267
|
+
if (tryParse()) return;
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
const onEnd = () => finish(null);
|
|
271
|
+
sock.on('readable', onReadable);
|
|
272
|
+
sock.once('end', onEnd);
|
|
273
|
+
sock.once('error', onEnd);
|
|
274
|
+
if (tryParse()) return; // head may already contain the whole ClientHello
|
|
275
|
+
onReadable(); // drain anything already buffered
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Parse the ALPN protocol list out of a TLS ClientHello.
|
|
280
|
+
// returns string[] — ALPN extension present
|
|
281
|
+
// returns null — parsed far enough to know there is no usable ALPN
|
|
282
|
+
// returns undefined — need more bytes to decide
|
|
283
|
+
export function parseClientHelloAlpn(buf) {
|
|
284
|
+
if (buf.length < 5) return undefined;
|
|
285
|
+
if (buf[0] !== 0x16) return null; // not a handshake record
|
|
286
|
+
const recEnd = 5 + buf.readUInt16BE(3);
|
|
287
|
+
if (buf.length < recEnd) return undefined; // wait for the full record
|
|
288
|
+
let p = 5;
|
|
289
|
+
if (buf[p] !== 0x01) return null; // not a ClientHello
|
|
290
|
+
const hsEnd = p + 4 + ((buf[p + 1] << 16) | (buf[p + 2] << 8) | buf[p + 3]);
|
|
291
|
+
const end = Math.min(hsEnd, recEnd);
|
|
292
|
+
p += 4;
|
|
293
|
+
p += 2 + 32; // client_version + random
|
|
294
|
+
if (p >= end) return null;
|
|
295
|
+
p += 1 + buf[p]; // session_id
|
|
296
|
+
if (p + 2 > end) return null;
|
|
297
|
+
p += 2 + buf.readUInt16BE(p); // cipher_suites
|
|
298
|
+
if (p + 1 > end) return null;
|
|
299
|
+
p += 1 + buf[p]; // compression_methods
|
|
300
|
+
if (p + 2 > end) return null;
|
|
301
|
+
let extEnd = p + 2 + buf.readUInt16BE(p);
|
|
302
|
+
p += 2;
|
|
303
|
+
extEnd = Math.min(extEnd, end);
|
|
304
|
+
while (p + 4 <= extEnd) {
|
|
305
|
+
const type = buf.readUInt16BE(p);
|
|
306
|
+
const len = buf.readUInt16BE(p + 2);
|
|
307
|
+
p += 4;
|
|
308
|
+
if (type === 0x0010) { // application_layer_protocol_negotiation
|
|
309
|
+
let q = p + 2; // skip ALPN list length
|
|
310
|
+
const listEnd = Math.min(p + len, extEnd);
|
|
311
|
+
const protos = [];
|
|
312
|
+
while (q < listEnd) {
|
|
313
|
+
const l = buf[q]; q += 1;
|
|
314
|
+
if (q + l > listEnd) break;
|
|
315
|
+
protos.push(buf.toString('latin1', q, q + l));
|
|
316
|
+
q += l;
|
|
317
|
+
}
|
|
318
|
+
return protos.length ? protos : null;
|
|
319
|
+
}
|
|
320
|
+
p += len;
|
|
321
|
+
}
|
|
322
|
+
return null; // no ALPN extension
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Rewrite only the auth field on an h2 request header list (account token in,
|
|
326
|
+
// client's x-api-key out). Preserves order; marks auth never-indexed.
|
|
327
|
+
function makeRewriteRequest(account) {
|
|
328
|
+
const isOAuth = account.type === 'oauth';
|
|
329
|
+
return (fields) => {
|
|
330
|
+
let replaced = false;
|
|
331
|
+
const out = [];
|
|
332
|
+
for (const f of fields) {
|
|
333
|
+
const n = f.name.toString().toLowerCase();
|
|
334
|
+
if (n === 'x-api-key') continue;
|
|
335
|
+
if (n === 'authorization') {
|
|
336
|
+
if (isOAuth) { out.push({ name: f.name, value: Buffer.from(`Bearer ${account.credential}`), sensitive: true }); replaced = true; }
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
out.push(f);
|
|
340
|
+
}
|
|
341
|
+
if (!replaced) {
|
|
342
|
+
out.push(isOAuth
|
|
343
|
+
? { name: Buffer.from('authorization'), value: Buffer.from(`Bearer ${account.credential}`), sensitive: true }
|
|
344
|
+
: { name: Buffer.from('x-api-key'), value: Buffer.from(account.credential), sensitive: true });
|
|
345
|
+
}
|
|
346
|
+
return out;
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function makeQuotaObserver(accountManager, account, sx = null) {
|
|
351
|
+
return (fields) => {
|
|
352
|
+
const m = {};
|
|
353
|
+
for (const f of fields) m[f.name.toString().toLowerCase()] = f.value.toString();
|
|
354
|
+
if (!m[':status']) return;
|
|
355
|
+
const rl = {};
|
|
356
|
+
for (const k in m) if (k.startsWith('anthropic-ratelimit-')) rl[k] = m[k];
|
|
357
|
+
if (Object.keys(rl).length) accountManager.updateQuota(account.index, rl);
|
|
358
|
+
if (m[':status'] === '429') {
|
|
359
|
+
let ra = parseInt(m['retry-after'], 10);
|
|
360
|
+
if (Number.isNaN(ra)) ra = 60;
|
|
361
|
+
ra = Math.min(Math.max(ra, 1), 300);
|
|
362
|
+
accountManager.markRateLimited(account.index, ra);
|
|
363
|
+
// Arm sx.org sticky routing so the next MITM tunnel uses the proxy (in
|
|
364
|
+
// '429' mode); no-op in 'off'/'always'.
|
|
365
|
+
sx?.noteRateLimited(ra);
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Answer the built-in test host locally over h1 with a canned JSON response.
|
|
371
|
+
function serveTest(tlsSock) {
|
|
372
|
+
let buf = Buffer.alloc(0);
|
|
373
|
+
const onData = (chunk) => {
|
|
374
|
+
buf = Buffer.concat([buf, chunk]);
|
|
375
|
+
const idx = buf.indexOf('\r\n\r\n');
|
|
376
|
+
if (idx < 0) { if (buf.length > 65536) tlsSock.destroy(); return; }
|
|
377
|
+
tlsSock.removeListener('data', onData);
|
|
378
|
+
const reqLine = buf.subarray(0, buf.indexOf('\r\n')).toString('latin1');
|
|
379
|
+
const path = reqLine.split(' ')[1] || '/';
|
|
380
|
+
const body = JSON.stringify({ teamclaude: 'mitm-proxy-ok', host: TEST_HOST, path });
|
|
381
|
+
tlsSock.end(
|
|
382
|
+
`HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: ${Buffer.byteLength(body)}\r\nconnection: close\r\n\r\n${body}`,
|
|
383
|
+
);
|
|
384
|
+
};
|
|
385
|
+
tlsSock.on('data', onData);
|
|
386
|
+
tlsSock.on('error', () => tlsSock.destroy());
|
|
387
|
+
}
|
package/src/oauth.js
CHANGED
|
@@ -24,6 +24,8 @@ export async function importCredentials(filePath) {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile';
|
|
27
|
+
const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
|
|
28
|
+
const OAUTH_USAGE_BETA = 'oauth-2025-04-20';
|
|
27
29
|
const DEFAULT_TOKEN_ENDPOINT = 'https://platform.claude.com/v1/oauth/token';
|
|
28
30
|
const DEFAULT_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
|
29
31
|
|
|
@@ -62,7 +64,13 @@ export async function refreshAccessToken(refreshToken, endpoint = DEFAULT_TOKEN_
|
|
|
62
64
|
continue;
|
|
63
65
|
}
|
|
64
66
|
const text = await res.text();
|
|
65
|
-
|
|
67
|
+
const err = new Error(`Token refresh failed (${res.status}): ${text}`);
|
|
68
|
+
// Surface the HTTP status so callers can distinguish a genuine auth
|
|
69
|
+
// rejection (the refresh token is dead — re-login needed) from a
|
|
70
|
+
// transient server error. 5xx is retried above; reaching here with a 5xx
|
|
71
|
+
// means retries were exhausted, which is still transient, not auth.
|
|
72
|
+
err.status = res.status;
|
|
73
|
+
throw err;
|
|
66
74
|
}
|
|
67
75
|
|
|
68
76
|
const data = await res.json();
|
|
@@ -140,6 +148,73 @@ export async function fetchProfile(accessToken) {
|
|
|
140
148
|
}
|
|
141
149
|
}
|
|
142
150
|
|
|
151
|
+
// Normalize one usage bucket from the /api/oauth/usage payload into
|
|
152
|
+
// { utilization: 0-1, resetAt: ms-epoch }. The endpoint reports utilization
|
|
153
|
+
// as a percentage in the 0-100 range, so 1 means 1%, not 100%.
|
|
154
|
+
export function normalizeUsageBucket(bucket) {
|
|
155
|
+
if (!bucket || typeof bucket !== 'object') return null;
|
|
156
|
+
|
|
157
|
+
const rawPct = bucket.used_percentage ?? bucket.utilization ?? bucket.usedPercentage;
|
|
158
|
+
const parsedPct = typeof rawPct === 'number' ? rawPct : parseFloat(rawPct);
|
|
159
|
+
const utilization = Number.isFinite(parsedPct)
|
|
160
|
+
? parsedPct / 100
|
|
161
|
+
: null;
|
|
162
|
+
|
|
163
|
+
const rawReset = bucket.resets_at ?? bucket.resetsAt ?? bucket.reset_at ?? bucket.resetAt;
|
|
164
|
+
let resetAt = null;
|
|
165
|
+
if (typeof rawReset === 'number') {
|
|
166
|
+
resetAt = rawReset < 1e12 ? rawReset * 1000 : rawReset;
|
|
167
|
+
} else if (typeof rawReset === 'string') {
|
|
168
|
+
const asNum = Number(rawReset);
|
|
169
|
+
if (Number.isFinite(asNum) && rawReset.trim() !== '') {
|
|
170
|
+
resetAt = asNum < 1e12 ? asNum * 1000 : asNum;
|
|
171
|
+
} else {
|
|
172
|
+
const parsed = Date.parse(rawReset);
|
|
173
|
+
if (Number.isFinite(parsed)) resetAt = parsed;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { utilization, resetAt };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Fetch OAuth subscription usage from the usage endpoint. This reports quota
|
|
182
|
+
* utilization WITHOUT spending message quota, which is what makes it safe to
|
|
183
|
+
* poll. Returns normalized { fiveHour, sevenDay, sevenDaySonnet } buckets, or
|
|
184
|
+
* { error, status } on failure.
|
|
185
|
+
*/
|
|
186
|
+
export async function fetchUsage(accessToken) {
|
|
187
|
+
try {
|
|
188
|
+
const res = await fetch(USAGE_URL, {
|
|
189
|
+
headers: {
|
|
190
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
191
|
+
'anthropic-beta': OAUTH_USAGE_BETA,
|
|
192
|
+
'Accept': 'application/json',
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
if (!res.ok) {
|
|
197
|
+
let detail = '';
|
|
198
|
+
try {
|
|
199
|
+
const body = await res.json();
|
|
200
|
+
detail = body?.error?.message || JSON.stringify(body).slice(0, 200);
|
|
201
|
+
} catch {
|
|
202
|
+
detail = await res.text().catch(() => '');
|
|
203
|
+
}
|
|
204
|
+
return { error: `HTTP ${res.status}${detail ? ': ' + detail : ''}`, status: res.status };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const data = await res.json();
|
|
208
|
+
return {
|
|
209
|
+
fiveHour: normalizeUsageBucket(data?.five_hour),
|
|
210
|
+
sevenDay: normalizeUsageBucket(data?.seven_day),
|
|
211
|
+
sevenDaySonnet: normalizeUsageBucket(data?.seven_day_sonnet),
|
|
212
|
+
};
|
|
213
|
+
} catch (err) {
|
|
214
|
+
return { error: err.message || String(err), status: null };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
143
218
|
// OAuth config (extracted from Claude Code)
|
|
144
219
|
const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
|
145
220
|
const OAUTH_AUTHORIZE = 'https://claude.ai/oauth/authorize';
|
package/src/prober.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Opt-in background quota probe.
|
|
2
|
+
//
|
|
3
|
+
// DISABLED BY DEFAULT. When enabled (config.quotaProbeSeconds > 0), periodically
|
|
4
|
+
// reads each OAuth account's quota from the zero-spend /api/oauth/usage endpoint
|
|
5
|
+
// so idle accounts' utilization/reset stay fresh without waiting to be rotated
|
|
6
|
+
// to — and without consuming any message quota. This is the one sanctioned
|
|
7
|
+
// active-upstream feature; the proxy is otherwise passive.
|
|
8
|
+
|
|
9
|
+
import { fetchUsage } from './oauth.js';
|
|
10
|
+
|
|
11
|
+
export class Prober {
|
|
12
|
+
constructor(accountManager, { intervalMs = 0, probeFn = fetchUsage, timeoutMs = 10_000, log = console.log } = {}) {
|
|
13
|
+
this.am = accountManager;
|
|
14
|
+
this.intervalMs = intervalMs;
|
|
15
|
+
this.probeFn = probeFn;
|
|
16
|
+
this.timeoutMs = timeoutMs;
|
|
17
|
+
this.log = log;
|
|
18
|
+
this.timer = null;
|
|
19
|
+
this._running = false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
start() {
|
|
23
|
+
if (this.intervalMs > 0) this.reschedule(this.intervalMs);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Change the interval at runtime (0 = off). Probes once immediately when on. */
|
|
27
|
+
reschedule(intervalMs) {
|
|
28
|
+
const wasOn = this.intervalMs > 0 && this.timer;
|
|
29
|
+
this.intervalMs = intervalMs;
|
|
30
|
+
if (this.timer) { clearInterval(this.timer); this.timer = null; }
|
|
31
|
+
|
|
32
|
+
if (intervalMs > 0) {
|
|
33
|
+
// Probe right away so quota populates without waiting a full cycle.
|
|
34
|
+
this.probeAll().catch(() => {});
|
|
35
|
+
this.timer = setInterval(() => this.probeAll().catch(() => {}), intervalMs);
|
|
36
|
+
this.timer.unref?.();
|
|
37
|
+
this.log(`[TeamClaude] Quota probe enabled (every ${Math.round(intervalMs / 1000)}s)`);
|
|
38
|
+
} else if (wasOn) {
|
|
39
|
+
this.log('[TeamClaude] Quota probe disabled');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
stop() {
|
|
44
|
+
if (this.timer) { clearInterval(this.timer); this.timer = null; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Probe every OAuth account once. Overlapping cycles are skipped. */
|
|
48
|
+
async probeAll() {
|
|
49
|
+
if (this._running) return;
|
|
50
|
+
this._running = true;
|
|
51
|
+
try {
|
|
52
|
+
const accounts = this.am.accounts.filter(a => a.type === 'oauth' && a.credential);
|
|
53
|
+
await Promise.all(accounts.map(a => this.probeOne(a)));
|
|
54
|
+
} finally {
|
|
55
|
+
this._running = false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async probeOne(account) {
|
|
60
|
+
try {
|
|
61
|
+
await this.am.ensureTokenFresh(account.index);
|
|
62
|
+
let usage = await this._withTimeout(this.probeFn(account.credential));
|
|
63
|
+
if (usage?.status === 401) {
|
|
64
|
+
// Token rejected — force a refresh and retry once.
|
|
65
|
+
await this.am.ensureTokenFresh(account.index, true);
|
|
66
|
+
usage = await this._withTimeout(this.probeFn(account.credential));
|
|
67
|
+
}
|
|
68
|
+
if (!usage || usage.error) return; // transient — try again next cycle
|
|
69
|
+
this.am.applyUsageData(account.index, usage);
|
|
70
|
+
} catch { /* best-effort; never let a probe throw */ }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_withTimeout(promise) {
|
|
74
|
+
return Promise.race([
|
|
75
|
+
promise,
|
|
76
|
+
new Promise(resolve => {
|
|
77
|
+
const t = setTimeout(() => resolve(null), this.timeoutMs);
|
|
78
|
+
t.unref?.();
|
|
79
|
+
}),
|
|
80
|
+
]);
|
|
81
|
+
}
|
|
82
|
+
}
|