cyrus-edge-worker 0.2.44 → 0.2.45

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.
Files changed (40) hide show
  1. package/dist/ConfigManager.d.ts.map +1 -1
  2. package/dist/ConfigManager.js +3 -0
  3. package/dist/ConfigManager.js.map +1 -1
  4. package/dist/EdgeWorker.d.ts +28 -0
  5. package/dist/EdgeWorker.d.ts.map +1 -1
  6. package/dist/EdgeWorker.js +189 -5
  7. package/dist/EdgeWorker.js.map +1 -1
  8. package/dist/EgressProxy.d.ts +158 -0
  9. package/dist/EgressProxy.d.ts.map +1 -0
  10. package/dist/EgressProxy.js +699 -0
  11. package/dist/EgressProxy.js.map +1 -0
  12. package/dist/GitService.d.ts +4 -6
  13. package/dist/GitService.d.ts.map +1 -1
  14. package/dist/GitService.js +16 -12
  15. package/dist/GitService.js.map +1 -1
  16. package/dist/McpConfigService.d.ts.map +1 -1
  17. package/dist/McpConfigService.js +8 -1
  18. package/dist/McpConfigService.js.map +1 -1
  19. package/dist/RunnerConfigBuilder.d.ts +12 -1
  20. package/dist/RunnerConfigBuilder.d.ts.map +1 -1
  21. package/dist/RunnerConfigBuilder.js +49 -0
  22. package/dist/RunnerConfigBuilder.js.map +1 -1
  23. package/dist/SharedApplicationServer.d.ts.map +1 -1
  24. package/dist/SharedApplicationServer.js +1 -0
  25. package/dist/SharedApplicationServer.js.map +1 -1
  26. package/dist/cyrus-skills-plugin/skills/verify-and-ship/SKILL.md +14 -2
  27. package/dist/index.d.ts +1 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +1 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/prompts/builder.md +4 -4
  32. package/dist/prompts/debugger.md +4 -4
  33. package/dist/prompts/scoper.md +5 -5
  34. package/dist/prompts/todolist-system-prompt-extension.md +6 -6
  35. package/package.json +18 -16
  36. package/prompt-template.md +5 -5
  37. package/prompts/builder.md +4 -4
  38. package/prompts/debugger.md +4 -4
  39. package/prompts/scoper.md +5 -5
  40. package/prompts/todolist-system-prompt-extension.md +6 -6
@@ -0,0 +1,699 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { createServer as createHttpServer, request as httpRequest, } from "node:http";
3
+ import { createServer as createHttpsServer, request as httpsRequest, } from "node:https";
4
+ import { createServer as createNetServer, connect as netConnect, } from "node:net";
5
+ import { join } from "node:path";
6
+ import { createLogger, TRUSTED_DOMAINS } from "cyrus-core";
7
+ import forge from "node-forge";
8
+ /**
9
+ * EgressProxy provides an HTTP/HTTPS forward proxy for Claude Code sandbox
10
+ * network egress control.
11
+ *
12
+ * Scope: The SDK's sandbox.network proxy only intercepts traffic from
13
+ * Bash tool subprocesses (git, gh, npm, curl, etc.). Claude's own inference
14
+ * API calls, MCP server traffic, and built-in file tools (Read/Edit/Write)
15
+ * are NOT routed through this proxy.
16
+ * @see https://docs.anthropic.com/en/docs/claude-code/security#sandbox
17
+ *
18
+ * Capabilities:
19
+ * - Domain-based allow/deny filtering for subprocess traffic
20
+ * - TLS termination (MITM) for domains with header transform rules
21
+ * - Per-domain header injection (credentials brokering)
22
+ * - Request logging
23
+ *
24
+ * Architecture follows the Vercel Sandbox Firewall pattern:
25
+ * @see https://vercel.com/docs/vercel-sandbox/concepts/firewall
26
+ *
27
+ * TLS termination is selective — only domains with transform rules get intercepted.
28
+ * A per-instance CA certificate is generated and must be trusted by the client
29
+ * via NODE_EXTRA_CA_CERTS.
30
+ */
31
+ export class EgressProxy {
32
+ httpServer = null;
33
+ socksServer = null;
34
+ httpProxyPort;
35
+ socksProxyPort;
36
+ networkPolicy;
37
+ logRequests;
38
+ logger;
39
+ /** CA key pair and certificate for on-the-fly cert generation */
40
+ caKey = null;
41
+ caCert = null;
42
+ caKeyPem = "";
43
+ caCertPem = "";
44
+ /** Path where the CA cert PEM is written for NODE_EXTRA_CA_CERTS */
45
+ caCertPath = "";
46
+ /** Directory where cert files are stored */
47
+ certsDir;
48
+ /** Cache of generated server certificates keyed by hostname */
49
+ certCache = new Map();
50
+ /** Set of domains that require TLS termination (have transform rules) */
51
+ tlsTerminationDomains = new Set();
52
+ /** Merged header transforms keyed by domain pattern */
53
+ domainTransforms = new Map();
54
+ /** Set of allowed domain patterns (if policy specifies allow rules) */
55
+ allowedDomains = new Set();
56
+ isRunning = false;
57
+ constructor(config, cyrusHome, logger) {
58
+ this.httpProxyPort = config.httpProxyPort ?? 9080;
59
+ this.socksProxyPort = config.socksProxyPort ?? 9081;
60
+ this.networkPolicy = config.networkPolicy;
61
+ this.logRequests = config.logRequests ?? true;
62
+ this.logger = logger ?? createLogger({ component: "EgressProxy" });
63
+ // Generate CA cert and store path
64
+ this.certsDir = join(cyrusHome, "certs");
65
+ this.caCertPath = join(this.certsDir, "cyrus-egress-ca.pem");
66
+ this.generateCA(this.certsDir);
67
+ // Parse policy into fast-lookup structures
68
+ this.parsePolicy();
69
+ }
70
+ /**
71
+ * Get the path to the CA certificate PEM file.
72
+ * This should be set as NODE_EXTRA_CA_CERTS for child processes.
73
+ */
74
+ getCACertPath() {
75
+ return this.caCertPath;
76
+ }
77
+ /**
78
+ * Build a CA cert bundle that includes the proxy CA and any pre-existing
79
+ * cert file (e.g., corporate proxy CA). NODE_EXTRA_CA_CERTS accepts a
80
+ * single file path, so we concatenate all PEM certs into one bundle.
81
+ *
82
+ * Checks (in order): explicit existingCertPath arg, then the host
83
+ * process's NODE_EXTRA_CA_CERTS env var. If neither is set or the file
84
+ * doesn't exist, returns the proxy CA cert path unchanged.
85
+ */
86
+ buildCACertBundle(existingCertPath) {
87
+ const certPath = existingCertPath ?? process.env.NODE_EXTRA_CA_CERTS;
88
+ if (!certPath || !existsSync(certPath)) {
89
+ return this.caCertPath;
90
+ }
91
+ // If pointing at our own cert or bundle, no merge needed
92
+ if (certPath === this.caCertPath ||
93
+ certPath === join(this.certsDir, "cyrus-ca-bundle.pem")) {
94
+ return this.caCertPath;
95
+ }
96
+ const bundlePath = join(this.certsDir, "cyrus-ca-bundle.pem");
97
+ const existingCerts = readFileSync(certPath, "utf8");
98
+ const bundle = `${existingCerts.trimEnd()}\n${this.caCertPem}`;
99
+ writeFileSync(bundlePath, bundle);
100
+ this.logger.info(`[EgressProxy] Created combined CA bundle: ${bundlePath} (merged with ${certPath})`);
101
+ return bundlePath;
102
+ }
103
+ /**
104
+ * Get configured HTTP proxy port.
105
+ */
106
+ getHttpProxyPort() {
107
+ return this.httpProxyPort;
108
+ }
109
+ /**
110
+ * Get configured SOCKS proxy port.
111
+ */
112
+ getSocksProxyPort() {
113
+ return this.socksProxyPort;
114
+ }
115
+ /**
116
+ * Start the egress proxy servers.
117
+ */
118
+ async start() {
119
+ if (this.isRunning)
120
+ return;
121
+ await this.startHttpProxy();
122
+ await this.startSocksProxy();
123
+ this.isRunning = true;
124
+ this.logger.info(`[EgressProxy] Listening — HTTP :${this.httpProxyPort}, SOCKS :${this.socksProxyPort}`);
125
+ this.logPolicySummary();
126
+ }
127
+ /**
128
+ * Log a human-readable summary of the active network policy.
129
+ */
130
+ logPolicySummary() {
131
+ if (!this.networkPolicy?.allow || this.allowedDomains.size === 0) {
132
+ this.logger.info("[EgressProxy] Policy: allow-all (no domain restrictions)");
133
+ return;
134
+ }
135
+ const domains = [...this.allowedDomains];
136
+ const transformDomains = [...this.tlsTerminationDomains];
137
+ const presetLabel = this.networkPolicy.preset
138
+ ? ` (preset: ${this.networkPolicy.preset})`
139
+ : "";
140
+ this.logger.info(`[EgressProxy] Policy: deny-all with ${domains.length} allowed domain(s)${presetLabel}`);
141
+ for (const domain of domains) {
142
+ const hasTransform = transformDomains.includes(domain);
143
+ this.logger.info(`[EgressProxy] ${hasTransform ? "↔" : "→"} ${domain}${hasTransform ? " (TLS intercept + header transform)" : " (passthrough)"}`);
144
+ }
145
+ }
146
+ /**
147
+ * Stop the egress proxy servers.
148
+ */
149
+ async stop() {
150
+ if (!this.isRunning)
151
+ return;
152
+ const stops = [];
153
+ if (this.httpServer) {
154
+ stops.push(new Promise((resolve) => {
155
+ this.httpServer.close(() => resolve());
156
+ }));
157
+ }
158
+ if (this.socksServer) {
159
+ stops.push(new Promise((resolve) => {
160
+ this.socksServer.close(() => resolve());
161
+ }));
162
+ }
163
+ await Promise.all(stops);
164
+ this.isRunning = false;
165
+ this.logger.info("[EgressProxy] Stopped");
166
+ }
167
+ /**
168
+ * Update the network policy at runtime without restarting.
169
+ */
170
+ updateNetworkPolicy(policy) {
171
+ this.networkPolicy = policy;
172
+ this.tlsTerminationDomains.clear();
173
+ this.domainTransforms.clear();
174
+ this.allowedDomains.clear();
175
+ this.parsePolicy();
176
+ this.logger.info("[EgressProxy] Network policy updated");
177
+ this.logPolicySummary();
178
+ }
179
+ // ---------------------------------------------------------------------------
180
+ // CA Certificate Generation
181
+ // ---------------------------------------------------------------------------
182
+ generateCA(certsDir) {
183
+ // Reuse existing CA if present
184
+ const caKeyPath = join(certsDir, "cyrus-egress-ca-key.pem");
185
+ if (existsSync(this.caCertPath) && existsSync(caKeyPath)) {
186
+ this.caCertPem = readFileSync(this.caCertPath, "utf8");
187
+ this.caKeyPem = readFileSync(caKeyPath, "utf8");
188
+ this.caCert = forge.pki.certificateFromPem(this.caCertPem);
189
+ this.caKey = {
190
+ publicKey: this.caCert.publicKey,
191
+ privateKey: forge.pki.privateKeyFromPem(this.caKeyPem),
192
+ };
193
+ this.logger.debug("[EgressProxy] Loaded existing CA certificate");
194
+ return;
195
+ }
196
+ if (!existsSync(certsDir)) {
197
+ mkdirSync(certsDir, { recursive: true });
198
+ }
199
+ this.logger.info("[EgressProxy] Generating CA certificate for TLS termination...");
200
+ const keys = forge.pki.rsa.generateKeyPair(2048);
201
+ const cert = forge.pki.createCertificate();
202
+ cert.publicKey = keys.publicKey;
203
+ cert.serialNumber = "01";
204
+ cert.validity.notBefore = new Date();
205
+ cert.validity.notAfter = new Date();
206
+ cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 10);
207
+ const attrs = [
208
+ { name: "commonName", value: "Cyrus Egress Proxy CA" },
209
+ { name: "organizationName", value: "Cyrus" },
210
+ ];
211
+ cert.setSubject(attrs);
212
+ cert.setIssuer(attrs);
213
+ cert.setExtensions([
214
+ { name: "basicConstraints", cA: true },
215
+ {
216
+ name: "keyUsage",
217
+ keyCertSign: true,
218
+ digitalSignature: true,
219
+ cRLSign: true,
220
+ },
221
+ ]);
222
+ cert.sign(keys.privateKey, forge.md.sha256.create());
223
+ this.caKey = keys;
224
+ this.caCert = cert;
225
+ this.caCertPem = forge.pki.certificateToPem(cert);
226
+ this.caKeyPem = forge.pki.privateKeyToPem(keys.privateKey);
227
+ writeFileSync(this.caCertPath, this.caCertPem);
228
+ writeFileSync(caKeyPath, this.caKeyPem, { mode: 0o600 });
229
+ this.logger.info(`[EgressProxy] CA certificate written to ${this.caCertPath}`);
230
+ }
231
+ // ---------------------------------------------------------------------------
232
+ // On-the-fly Server Certificate Generation
233
+ // ---------------------------------------------------------------------------
234
+ generateServerCert(hostname) {
235
+ const cached = this.certCache.get(hostname);
236
+ if (cached)
237
+ return cached;
238
+ const keys = forge.pki.rsa.generateKeyPair(2048);
239
+ const cert = forge.pki.createCertificate();
240
+ cert.publicKey = keys.publicKey;
241
+ cert.serialNumber = String(Date.now());
242
+ cert.validity.notBefore = new Date();
243
+ cert.validity.notAfter = new Date();
244
+ cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1);
245
+ cert.setSubject([{ name: "commonName", value: hostname }]);
246
+ cert.setIssuer(this.caCert.subject.attributes);
247
+ cert.setExtensions([
248
+ {
249
+ name: "subjectAltName",
250
+ altNames: [{ type: 2, value: hostname }], // DNS
251
+ },
252
+ ]);
253
+ cert.sign(this.caKey.privateKey, forge.md.sha256.create());
254
+ const result = {
255
+ key: forge.pki.privateKeyToPem(keys.privateKey),
256
+ cert: forge.pki.certificateToPem(cert),
257
+ };
258
+ this.certCache.set(hostname, result);
259
+ return result;
260
+ }
261
+ // ---------------------------------------------------------------------------
262
+ // Policy Parsing
263
+ // ---------------------------------------------------------------------------
264
+ parsePolicy() {
265
+ if (!this.networkPolicy)
266
+ return;
267
+ // Expand "trusted" preset into allow rules
268
+ if (this.networkPolicy.preset === "trusted") {
269
+ const presetAllow = {};
270
+ for (const domain of TRUSTED_DOMAINS) {
271
+ presetAllow[domain] = [{}];
272
+ }
273
+ // Merge: explicit allow rules take precedence over preset
274
+ this.networkPolicy = {
275
+ ...this.networkPolicy,
276
+ allow: { ...presetAllow, ...this.networkPolicy.allow },
277
+ };
278
+ }
279
+ // Warn if subnet rules are configured (not yet enforced)
280
+ if (this.networkPolicy.subnets?.allow?.length ||
281
+ this.networkPolicy.subnets?.deny?.length) {
282
+ this.logger.warn("[EgressProxy] Subnet allow/deny rules are configured but not yet enforced — only domain rules are active");
283
+ }
284
+ if (!this.networkPolicy.allow)
285
+ return;
286
+ const allow = this.networkPolicy.allow;
287
+ for (const domain of Object.keys(allow)) {
288
+ const rules = allow[domain];
289
+ this.allowedDomains.add(domain);
290
+ // Merge all transform headers for this domain
291
+ const mergedHeaders = {};
292
+ let hasTransforms = false;
293
+ for (const rule of rules) {
294
+ if (rule.transform) {
295
+ for (const t of rule.transform) {
296
+ Object.assign(mergedHeaders, t.headers);
297
+ hasTransforms = true;
298
+ }
299
+ }
300
+ }
301
+ if (hasTransforms) {
302
+ this.tlsTerminationDomains.add(domain);
303
+ this.domainTransforms.set(domain, mergedHeaders);
304
+ }
305
+ }
306
+ }
307
+ // ---------------------------------------------------------------------------
308
+ // Domain Matching
309
+ // ---------------------------------------------------------------------------
310
+ /**
311
+ * Check if a hostname is allowed by the network policy.
312
+ *
313
+ * Three modes (matching Vercel Sandbox Firewall):
314
+ * - allow-all: no networkPolicy or no allow rules → all traffic passes
315
+ * - deny-all: networkPolicy with empty allow → all traffic blocked
316
+ * - user-defined: networkPolicy with allow rules → deny-all default,
317
+ * only listed domains pass
318
+ *
319
+ * Only Bash-spawned subprocess traffic reaches this proxy (git, gh,
320
+ * npm, curl, etc.). Claude's inference API and MCP traffic bypass it.
321
+ */
322
+ isDomainAllowed(hostname) {
323
+ // allow-all: no policy or no allow rules defined
324
+ if (!this.networkPolicy?.allow) {
325
+ return true;
326
+ }
327
+ // deny-all: policy has allow map but it's empty (no domains listed)
328
+ if (this.allowedDomains.size === 0) {
329
+ return false;
330
+ }
331
+ // user-defined: deny-all default, check explicit allow list
332
+ return this.matchDomain(hostname) !== null;
333
+ }
334
+ /**
335
+ * Match a hostname against policy domain patterns.
336
+ * Returns the matching pattern or null.
337
+ */
338
+ matchDomain(hostname) {
339
+ // Exact match
340
+ if (this.allowedDomains.has(hostname))
341
+ return hostname;
342
+ // Wildcard matching
343
+ for (const pattern of this.allowedDomains) {
344
+ if (this.matchesPattern(hostname, pattern))
345
+ return pattern;
346
+ }
347
+ return null;
348
+ }
349
+ /**
350
+ * Match hostname against a domain pattern.
351
+ * Supports:
352
+ * - Leading wildcard: *.example.com matches sub.example.com but NOT example.com
353
+ * - Mid-segment wildcard: www.*.com matches www.foo.com
354
+ */
355
+ matchesPattern(hostname, pattern) {
356
+ if (pattern.startsWith("*.")) {
357
+ const suffix = pattern.slice(1); // ".example.com"
358
+ return hostname.endsWith(suffix) && hostname !== pattern.slice(2);
359
+ }
360
+ if (pattern.includes("*")) {
361
+ const regex = new RegExp(`^${pattern.replace(/\./g, "\\.").replace(/\*/g, "[^.]+")}$`);
362
+ return regex.test(hostname);
363
+ }
364
+ return false;
365
+ }
366
+ /**
367
+ * Get the resolved transforms for a domain, if any.
368
+ */
369
+ getTransformsForDomain(hostname) {
370
+ const pattern = this.matchDomain(hostname);
371
+ if (!pattern)
372
+ return null;
373
+ const headers = this.domainTransforms.get(pattern);
374
+ if (!headers)
375
+ return null;
376
+ return { headers };
377
+ }
378
+ /**
379
+ * Check if a domain requires TLS termination (has transform rules).
380
+ */
381
+ requiresTlsTermination(hostname) {
382
+ const pattern = this.matchDomain(hostname);
383
+ if (!pattern)
384
+ return false;
385
+ return this.tlsTerminationDomains.has(pattern);
386
+ }
387
+ // ---------------------------------------------------------------------------
388
+ // HTTP Proxy (handles both HTTP requests and HTTPS CONNECT tunnels)
389
+ // ---------------------------------------------------------------------------
390
+ async startHttpProxy() {
391
+ this.httpServer = createHttpServer((req, res) => {
392
+ this.handleHttpRequest(req, res);
393
+ });
394
+ // Handle CONNECT method for HTTPS tunneling
395
+ this.httpServer.on("connect", (req, clientSocket, head) => {
396
+ this.handleConnect(req, clientSocket, head);
397
+ });
398
+ return new Promise((resolve, reject) => {
399
+ this.httpServer.listen(this.httpProxyPort, "127.0.0.1", () => {
400
+ this.logger.debug(`HTTP proxy listening on 127.0.0.1:${this.httpProxyPort}`);
401
+ resolve();
402
+ });
403
+ this.httpServer.on("error", reject);
404
+ });
405
+ }
406
+ /**
407
+ * Handle plain HTTP proxy requests (non-CONNECT).
408
+ */
409
+ handleHttpRequest(clientReq, clientRes) {
410
+ const url = clientReq.url;
411
+ if (!url) {
412
+ clientRes.writeHead(400);
413
+ clientRes.end("Bad Request");
414
+ return;
415
+ }
416
+ let parsedUrl;
417
+ try {
418
+ parsedUrl = new URL(url);
419
+ }
420
+ catch {
421
+ clientRes.writeHead(400);
422
+ clientRes.end("Invalid URL");
423
+ return;
424
+ }
425
+ const hostname = parsedUrl.hostname;
426
+ if (!this.isDomainAllowed(hostname)) {
427
+ if (this.logRequests) {
428
+ this.logger.warn(`[EgressProxy] ✗ BLOCKED ${clientReq.method} ${hostname}${parsedUrl.pathname} — domain not in allow list`);
429
+ }
430
+ clientRes.writeHead(403);
431
+ clientRes.end("Forbidden by egress policy");
432
+ return;
433
+ }
434
+ if (this.logRequests) {
435
+ this.logger.info(`[EgressProxy] → HTTP ${clientReq.method} ${hostname}${parsedUrl.pathname}`);
436
+ }
437
+ // Apply header transforms
438
+ const transforms = this.getTransformsForDomain(hostname);
439
+ const headers = { ...clientReq.headers };
440
+ delete headers["proxy-connection"];
441
+ if (transforms) {
442
+ Object.assign(headers, transforms.headers);
443
+ }
444
+ const options = {
445
+ hostname: parsedUrl.hostname,
446
+ port: Number(parsedUrl.port) || 80,
447
+ path: parsedUrl.pathname + parsedUrl.search,
448
+ method: clientReq.method,
449
+ headers,
450
+ };
451
+ const proxyReq = httpRequest(options, (proxyRes) => {
452
+ clientRes.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
453
+ proxyRes.pipe(clientRes);
454
+ });
455
+ proxyReq.on("error", (err) => {
456
+ this.logger.error(`[EgressProxy] HTTP error for ${hostname}:`, err);
457
+ clientRes.writeHead(502);
458
+ clientRes.end("Bad Gateway");
459
+ });
460
+ clientReq.pipe(proxyReq);
461
+ }
462
+ /**
463
+ * Handle HTTPS CONNECT tunneling.
464
+ * For domains with transform rules: TLS-terminate, modify headers, re-encrypt.
465
+ * For other allowed domains: TCP passthrough.
466
+ */
467
+ handleConnect(req, clientSocket, head) {
468
+ const parts = (req.url || "").split(":");
469
+ const hostname = parts[0] || "";
470
+ const port = Number(parts[1]) || 443;
471
+ if (!hostname || !this.isDomainAllowed(hostname)) {
472
+ if (this.logRequests) {
473
+ this.logger.warn(`[EgressProxy] ✗ BLOCKED CONNECT ${hostname}:${port} — domain not in allow list`);
474
+ }
475
+ clientSocket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
476
+ clientSocket.destroy();
477
+ return;
478
+ }
479
+ if (this.requiresTlsTermination(hostname)) {
480
+ // TLS termination: MITM to inject headers
481
+ this.handleTlsTermination(hostname, port, clientSocket, head);
482
+ }
483
+ else {
484
+ // Passthrough: direct TCP tunnel
485
+ if (this.logRequests) {
486
+ this.logger.info(`[EgressProxy] → TUNNEL ${hostname}:${port} (passthrough)`);
487
+ }
488
+ this.handleTcpTunnel(hostname, port, clientSocket, head);
489
+ }
490
+ }
491
+ /**
492
+ * Direct TCP tunnel (no TLS termination).
493
+ */
494
+ handleTcpTunnel(hostname, port, clientSocket, head) {
495
+ const serverSocket = netConnect(port, hostname, () => {
496
+ clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
497
+ if (head.length > 0) {
498
+ serverSocket.write(head);
499
+ }
500
+ serverSocket.pipe(clientSocket);
501
+ clientSocket.pipe(serverSocket);
502
+ });
503
+ serverSocket.on("error", (err) => {
504
+ this.logger.error(`[EgressProxy] Tunnel error for ${hostname}:${port}:`, err);
505
+ clientSocket.destroy();
506
+ });
507
+ clientSocket.on("error", () => {
508
+ serverSocket.destroy();
509
+ });
510
+ }
511
+ /**
512
+ * TLS termination for domains with transform rules.
513
+ * Spins up a local HTTPS server on an ephemeral port, bridges
514
+ * the client socket to it, then forwards decrypted HTTP upstream
515
+ * with injected headers.
516
+ */
517
+ handleTlsTermination(hostname, port, clientSocket, head) {
518
+ const serverCert = this.generateServerCert(hostname);
519
+ // Create a real HTTPS server with the generated cert to terminate TLS
520
+ const localServer = createHttpsServer({ key: serverCert.key, cert: serverCert.cert }, (req, res) => {
521
+ const transforms = this.getTransformsForDomain(hostname);
522
+ if (this.logRequests) {
523
+ this.logger.info(`[EgressProxy] ↔ INTERCEPT ${req.method} https://${hostname}${req.url}` +
524
+ (transforms
525
+ ? ` — injecting headers: ${Object.keys(transforms.headers).join(", ")}`
526
+ : ""));
527
+ }
528
+ const headers = { ...req.headers };
529
+ delete headers["proxy-connection"];
530
+ headers.host = hostname + (port !== 443 ? `:${port}` : "");
531
+ if (transforms) {
532
+ Object.assign(headers, transforms.headers);
533
+ }
534
+ const upstreamReq = httpsRequest({
535
+ hostname,
536
+ port,
537
+ path: req.url,
538
+ method: req.method,
539
+ headers,
540
+ rejectUnauthorized: true,
541
+ }, (upstreamRes) => {
542
+ res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers);
543
+ upstreamRes.pipe(res);
544
+ });
545
+ upstreamReq.on("error", (err) => {
546
+ this.logger.error(`[EgressProxy] Upstream error for ${hostname}:`, err);
547
+ if (!res.headersSent) {
548
+ res.writeHead(502);
549
+ res.end("Bad Gateway");
550
+ }
551
+ });
552
+ req.pipe(upstreamReq);
553
+ });
554
+ localServer.on("tlsClientError", (err) => {
555
+ this.logger.error(`[EgressProxy] TLS handshake error for ${hostname}:`, err.message);
556
+ });
557
+ localServer.listen(0, "127.0.0.1", () => {
558
+ const addr = localServer.address();
559
+ if (!addr || typeof addr === "string") {
560
+ clientSocket.destroy();
561
+ localServer.close();
562
+ return;
563
+ }
564
+ // Tell client the tunnel is established
565
+ clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
566
+ // Bridge client socket to our local HTTPS server
567
+ const bridge = netConnect(addr.port, "127.0.0.1", () => {
568
+ if (head.length > 0) {
569
+ bridge.write(head);
570
+ }
571
+ bridge.pipe(clientSocket);
572
+ clientSocket.pipe(bridge);
573
+ });
574
+ bridge.on("error", () => clientSocket.destroy());
575
+ clientSocket.on("error", () => bridge.destroy());
576
+ clientSocket.on("close", () => {
577
+ bridge.destroy();
578
+ localServer.close();
579
+ });
580
+ });
581
+ localServer.on("error", (err) => {
582
+ this.logger.error(`[EgressProxy] TLS server error for ${hostname}:`, err);
583
+ clientSocket.destroy();
584
+ });
585
+ }
586
+ // ---------------------------------------------------------------------------
587
+ // SOCKS5 Proxy (minimal implementation for Claude Code compatibility)
588
+ // ---------------------------------------------------------------------------
589
+ async startSocksProxy() {
590
+ this.socksServer = createNetServer((socket) => {
591
+ this.handleSocksConnection(socket);
592
+ });
593
+ return new Promise((resolve, reject) => {
594
+ this.socksServer.listen(this.socksProxyPort, "127.0.0.1", () => {
595
+ this.logger.debug(`SOCKS5 proxy listening on 127.0.0.1:${this.socksProxyPort}`);
596
+ resolve();
597
+ });
598
+ this.socksServer.on("error", reject);
599
+ });
600
+ }
601
+ /**
602
+ * Handle SOCKS5 connection.
603
+ * Implements the SOCKS5 handshake (RFC 1928) with no-auth only,
604
+ * then tunnels the connection like CONNECT.
605
+ */
606
+ handleSocksConnection(socket) {
607
+ let state = "greeting";
608
+ socket.once("data", (data) => {
609
+ if (state !== "greeting")
610
+ return;
611
+ // SOCKS5 greeting: VER=0x05, NMETHODS, METHODS[]
612
+ if (data[0] !== 0x05) {
613
+ socket.destroy();
614
+ return;
615
+ }
616
+ // Reply: VER=0x05, METHOD=0x00 (no auth)
617
+ socket.write(Buffer.from([0x05, 0x00]));
618
+ state = "request";
619
+ socket.once("data", (reqData) => {
620
+ if (state !== "request")
621
+ return;
622
+ // SOCKS5 request: VER CMD RSV ATYP DST.ADDR DST.PORT
623
+ const ver = reqData[0];
624
+ const cmd = reqData[1];
625
+ const atyp = reqData[3];
626
+ if (ver !== 0x05 || cmd !== 0x01) {
627
+ // Only support CONNECT
628
+ // Reply with command not supported
629
+ const reply = Buffer.from([0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
630
+ socket.write(reply);
631
+ socket.destroy();
632
+ return;
633
+ }
634
+ let hostname;
635
+ let portOffset;
636
+ if (atyp === 0x01) {
637
+ // IPv4
638
+ hostname = `${reqData[4]}.${reqData[5]}.${reqData[6]}.${reqData[7]}`;
639
+ portOffset = 8;
640
+ }
641
+ else if (atyp === 0x03) {
642
+ // Domain name
643
+ const domainLen = reqData[4] ?? 0;
644
+ hostname = reqData.subarray(5, 5 + domainLen).toString("ascii");
645
+ portOffset = 5 + domainLen;
646
+ }
647
+ else if (atyp === 0x04) {
648
+ // IPv6 - not commonly used, basic support
649
+ const parts = [];
650
+ for (let i = 0; i < 16; i += 2) {
651
+ parts.push(reqData.readUInt16BE(4 + i).toString(16));
652
+ }
653
+ hostname = parts.join(":");
654
+ portOffset = 20;
655
+ }
656
+ else {
657
+ socket.destroy();
658
+ return;
659
+ }
660
+ const port = reqData.readUInt16BE(portOffset);
661
+ if (!this.isDomainAllowed(hostname)) {
662
+ if (this.logRequests) {
663
+ this.logger.warn(`[EgressProxy] ✗ BLOCKED SOCKS5 ${hostname}:${port} — domain not in allow list`);
664
+ }
665
+ // Reply with connection not allowed
666
+ const reply = Buffer.from([0x05, 0x02, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
667
+ socket.write(reply);
668
+ socket.destroy();
669
+ return;
670
+ }
671
+ if (this.logRequests) {
672
+ this.logger.info(`[EgressProxy] → SOCKS5 ${hostname}:${port}`);
673
+ }
674
+ // Connect to target
675
+ const target = netConnect(port, hostname, () => {
676
+ // Success reply
677
+ const reply = Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
678
+ socket.write(reply);
679
+ target.pipe(socket);
680
+ socket.pipe(target);
681
+ });
682
+ target.on("error", (err) => {
683
+ this.logger.error(`[EgressProxy] SOCKS5 connection error for ${hostname}:${port}:`, err);
684
+ // Reply with general failure
685
+ const reply = Buffer.from([0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
686
+ socket.write(reply);
687
+ socket.destroy();
688
+ });
689
+ socket.on("error", () => {
690
+ target.destroy();
691
+ });
692
+ });
693
+ });
694
+ socket.on("error", () => {
695
+ socket.destroy();
696
+ });
697
+ }
698
+ }
699
+ //# sourceMappingURL=EgressProxy.js.map