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.
- package/dist/ConfigManager.d.ts.map +1 -1
- package/dist/ConfigManager.js +3 -0
- package/dist/ConfigManager.js.map +1 -1
- package/dist/EdgeWorker.d.ts +28 -0
- package/dist/EdgeWorker.d.ts.map +1 -1
- package/dist/EdgeWorker.js +189 -5
- package/dist/EdgeWorker.js.map +1 -1
- package/dist/EgressProxy.d.ts +158 -0
- package/dist/EgressProxy.d.ts.map +1 -0
- package/dist/EgressProxy.js +699 -0
- package/dist/EgressProxy.js.map +1 -0
- package/dist/GitService.d.ts +4 -6
- package/dist/GitService.d.ts.map +1 -1
- package/dist/GitService.js +16 -12
- package/dist/GitService.js.map +1 -1
- package/dist/McpConfigService.d.ts.map +1 -1
- package/dist/McpConfigService.js +8 -1
- package/dist/McpConfigService.js.map +1 -1
- package/dist/RunnerConfigBuilder.d.ts +12 -1
- package/dist/RunnerConfigBuilder.d.ts.map +1 -1
- package/dist/RunnerConfigBuilder.js +49 -0
- package/dist/RunnerConfigBuilder.js.map +1 -1
- package/dist/SharedApplicationServer.d.ts.map +1 -1
- package/dist/SharedApplicationServer.js +1 -0
- package/dist/SharedApplicationServer.js.map +1 -1
- package/dist/cyrus-skills-plugin/skills/verify-and-ship/SKILL.md +14 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/prompts/builder.md +4 -4
- package/dist/prompts/debugger.md +4 -4
- package/dist/prompts/scoper.md +5 -5
- package/dist/prompts/todolist-system-prompt-extension.md +6 -6
- package/package.json +18 -16
- package/prompt-template.md +5 -5
- package/prompts/builder.md +4 -4
- package/prompts/debugger.md +4 -4
- package/prompts/scoper.md +5 -5
- 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
|