@openape/proxy 0.4.3 → 0.4.5

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/index.js CHANGED
@@ -1,14 +1,107 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
+ import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
4
5
  import { createServer } from "http";
6
+ import { homedir } from "os";
7
+ import { join } from "path";
5
8
  import { parseArgs } from "util";
6
9
 
10
+ // src/ca-store.ts
11
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
12
+ import { dirname } from "path";
13
+ import forge from "node-forge";
14
+ function loadOrCreateCa(opts) {
15
+ if (existsSync(opts.certPath) && existsSync(opts.keyPath)) {
16
+ return {
17
+ certPem: readFileSync(opts.certPath, "utf-8"),
18
+ keyPem: readFileSync(opts.keyPath, "utf-8"),
19
+ created: false
20
+ };
21
+ }
22
+ const keys = forge.pki.rsa.generateKeyPair({ bits: 2048 });
23
+ const cert = forge.pki.createCertificate();
24
+ cert.publicKey = keys.publicKey;
25
+ cert.serialNumber = String(Date.now());
26
+ cert.validity.notBefore = /* @__PURE__ */ new Date();
27
+ cert.validity.notAfter = new Date(Date.now() + 10 * 365 * 864e5);
28
+ const attrs = [
29
+ // @types/node-forge types valueTagClass as asn1.Class, but node-forge's
30
+ // x509.js reads it as an asn1.Type at runtime (see comment above). Cast to
31
+ // satisfy the too-narrow typing without changing runtime behaviour.
32
+ { name: "commonName", value: opts.subjectCN, valueTagClass: forge.asn1.Type.UTF8 }
33
+ ];
34
+ cert.setSubject(attrs);
35
+ cert.setIssuer(attrs);
36
+ cert.setExtensions([
37
+ { name: "basicConstraints", cA: true },
38
+ { name: "keyUsage", keyCertSign: true, cRLSign: true },
39
+ { name: "subjectKeyIdentifier" }
40
+ ]);
41
+ cert.sign(keys.privateKey, forge.md.sha256.create());
42
+ const certPem = forge.pki.certificateToPem(cert);
43
+ const keyPem = forge.pki.privateKeyToPem(keys.privateKey);
44
+ mkdirSync(dirname(opts.certPath), { recursive: true, mode: 448 });
45
+ writeFileSync(opts.certPath, certPem, { mode: 384 });
46
+ writeFileSync(opts.keyPath, keyPem, { mode: 384 });
47
+ return { certPem, keyPem, created: true };
48
+ }
49
+ function mintLeafCert(ca, hostname) {
50
+ const caCert = forge.pki.certificateFromPem(ca.certPem);
51
+ const caKey = forge.pki.privateKeyFromPem(ca.keyPem);
52
+ const keys = forge.pki.rsa.generateKeyPair({ bits: 2048 });
53
+ const cert = forge.pki.createCertificate();
54
+ cert.publicKey = keys.publicKey;
55
+ cert.serialNumber = `${Date.now()}${Math.floor(Math.random() * 1e6)}`;
56
+ cert.validity.notBefore = /* @__PURE__ */ new Date();
57
+ const expiresAt = Date.now() + 24 * 36e5;
58
+ cert.validity.notAfter = new Date(expiresAt);
59
+ cert.setSubject([
60
+ // See note in createCaCert: valueTagClass is an asn1.Type at runtime;
61
+ // cast around the too-narrow @types/node-forge annotation.
62
+ { name: "commonName", value: hostname, valueTagClass: forge.asn1.Type.UTF8 }
63
+ ]);
64
+ cert.setIssuer(caCert.subject.attributes);
65
+ cert.setExtensions([
66
+ { name: "basicConstraints", cA: false },
67
+ { name: "keyUsage", digitalSignature: true, keyEncipherment: true },
68
+ { name: "extKeyUsage", serverAuth: true },
69
+ { name: "subjectAltName", altNames: [{ type: 2, value: hostname }] }
70
+ ]);
71
+ cert.sign(caKey, forge.md.sha256.create());
72
+ return {
73
+ certPem: forge.pki.certificateToPem(cert),
74
+ keyPem: forge.pki.privateKeyToPem(keys.privateKey),
75
+ expiresAt
76
+ };
77
+ }
78
+ function createLeafCertCache(ca, opts) {
79
+ const cache = /* @__PURE__ */ new Map();
80
+ return {
81
+ get(hostname) {
82
+ const existing = cache.get(hostname);
83
+ if (existing && existing.expiresAt > Date.now()) {
84
+ cache.delete(hostname);
85
+ cache.set(hostname, existing);
86
+ return existing;
87
+ }
88
+ const fresh = mintLeafCert(ca, hostname);
89
+ cache.set(hostname, fresh);
90
+ while (cache.size > opts.capacity) {
91
+ const oldestKey = cache.keys().next().value;
92
+ if (oldestKey === void 0) break;
93
+ cache.delete(oldestKey);
94
+ }
95
+ return fresh;
96
+ }
97
+ };
98
+ }
99
+
7
100
  // src/config.ts
8
- import { readFileSync } from "fs";
101
+ import { readFileSync as readFileSync2 } from "fs";
9
102
  import { parse as parseTOML } from "smol-toml";
10
103
  function loadMultiAgentConfig(path, overrides) {
11
- const raw = readFileSync(path, "utf-8");
104
+ const raw = readFileSync2(path, "utf-8");
12
105
  let parsed;
13
106
  if (path.endsWith(".json")) {
14
107
  parsed = JSON.parse(raw);
@@ -67,26 +160,26 @@ function matchesRule(rule, domain, method, path) {
67
160
  }
68
161
  return true;
69
162
  }
70
- function evaluateRules(config2, domain, method, path) {
71
- for (const rule of config2.deny) {
163
+ function evaluateRules(config, domain, method, path) {
164
+ for (const rule of config.deny) {
72
165
  if (matchesRule(rule, domain, method, path)) {
73
166
  return { type: "deny", note: rule.note };
74
167
  }
75
168
  }
76
- for (const rule of config2.allow) {
169
+ for (const rule of config.allow) {
77
170
  if (matchesRule(rule, domain, method, path)) {
78
171
  return { type: "allow" };
79
172
  }
80
173
  }
81
- for (const rule of config2.grant_required) {
174
+ for (const rule of config.grant_required) {
82
175
  if (matchesRule(rule, domain, method, path)) {
83
176
  return { type: "grant_required", rule };
84
177
  }
85
178
  }
86
- if (config2.proxy.default_action === "block") {
179
+ if (config.proxy.default_action === "block") {
87
180
  return { type: "deny", note: "No matching rule (default: block)" };
88
181
  }
89
- if (config2.proxy.default_action === "allow") {
182
+ if (config.proxy.default_action === "allow") {
90
183
  return { type: "allow" };
91
184
  }
92
185
  return {
@@ -276,14 +369,14 @@ function isPrivateIP(ip) {
276
369
  if (isIP(ip) === 6) return isPrivateIPv6(ip);
277
370
  return false;
278
371
  }
279
- async function checkEgress(hostname2) {
280
- if (isIP(hostname2)) {
281
- return isPrivateIP(hostname2) ? { kind: "private" } : { kind: "ok" };
372
+ async function checkEgress(hostname) {
373
+ if (isIP(hostname)) {
374
+ return isPrivateIP(hostname) ? { kind: "private" } : { kind: "ok" };
282
375
  }
283
- if (hostname2 === "localhost") return { kind: "private" };
376
+ if (hostname === "localhost") return { kind: "private" };
284
377
  let settled;
285
378
  try {
286
- settled = await Promise.allSettled([resolve4(hostname2), resolve6(hostname2)]);
379
+ settled = await Promise.allSettled([resolve4(hostname), resolve6(hostname)]);
287
380
  } catch {
288
381
  return { kind: "unresolvable", reason: "dns-error" };
289
382
  }
@@ -299,17 +392,100 @@ async function checkEgress(hostname2) {
299
392
 
300
393
  // src/connect.ts
301
394
  import { connect } from "net";
302
- async function handleConnect(config2, grantsClients, req, clientSocket, _head) {
395
+
396
+ // src/mitm-connect.ts
397
+ import { TLSSocket, createSecureContext, connect as tlsConnect } from "tls";
398
+ function handleMitmConnect(opts) {
399
+ const leaf = opts.leafCache.get(opts.host);
400
+ const ctx = createSecureContext({ cert: leaf.certPem, key: leaf.keyPem });
401
+ const tls = new TLSSocket(opts.clientSocket, { isServer: true, secureContext: ctx });
402
+ opts.clientSocket.resume();
403
+ let buffer = Buffer.alloc(0);
404
+ let dispatched = false;
405
+ const onData = (chunk) => {
406
+ if (dispatched) return;
407
+ buffer = Buffer.concat([buffer, chunk]);
408
+ const headerEnd = buffer.indexOf("\r\n\r\n");
409
+ if (headerEnd < 0) return;
410
+ const headerBlock = buffer.subarray(0, headerEnd).toString("utf-8");
411
+ const lines = headerBlock.split("\r\n");
412
+ const requestLine = lines[0] ?? "";
413
+ const [method, path] = requestLine.split(" ");
414
+ const headers = /* @__PURE__ */ new Map();
415
+ for (const line of lines.slice(1)) {
416
+ const i = line.indexOf(":");
417
+ if (i > 0) headers.set(line.slice(0, i).trim().toLowerCase(), line.slice(i + 1).trim());
418
+ }
419
+ const decision = opts.onRequest({
420
+ method: method ?? "",
421
+ host: opts.host,
422
+ path: path ?? "/",
423
+ headers,
424
+ // inject decisions use only target+headers; raw body bytes still flow through to upstream below
425
+ body: null
426
+ });
427
+ if (decision.type === "short-circuit") {
428
+ dispatched = true;
429
+ const body = Buffer.from(decision.body, "utf-8");
430
+ tls.write(`HTTP/1.1 ${decision.status} OK\r
431
+ Content-Length: ${body.length}\r
432
+ \r
433
+ `);
434
+ tls.write(body);
435
+ tls.end();
436
+ return;
437
+ }
438
+ dispatched = true;
439
+ tls.pause();
440
+ const upstream = tlsConnect({
441
+ host: opts.host,
442
+ port: opts.port,
443
+ servername: opts.host,
444
+ rejectUnauthorized: opts.upstreamRejectUnauthorized ?? true
445
+ });
446
+ upstream.once("secureConnect", () => {
447
+ const mutated = decision.mutatedHeaders;
448
+ const newHeaderLines = [];
449
+ for (const line of lines.slice(1)) {
450
+ const i = line.indexOf(":");
451
+ if (i <= 0) continue;
452
+ const lower = line.slice(0, i).trim().toLowerCase();
453
+ if (mutated.has(lower)) continue;
454
+ newHeaderLines.push(line);
455
+ }
456
+ for (const [name, value] of mutated.entries()) {
457
+ newHeaderLines.push(`${capitalizeHeader(name)}: ${value}`);
458
+ }
459
+ const fullRequest = [requestLine, ...newHeaderLines, "", ""].join("\r\n");
460
+ upstream.write(fullRequest);
461
+ const remainder = buffer.subarray(headerEnd + 4);
462
+ if (remainder.length > 0) upstream.write(remainder);
463
+ upstream.pipe(tls);
464
+ tls.removeListener("data", onData);
465
+ tls.pipe(upstream);
466
+ tls.resume();
467
+ });
468
+ upstream.on("error", () => tls.end());
469
+ };
470
+ tls.on("data", onData);
471
+ tls.on("error", () => opts.clientSocket.destroy());
472
+ }
473
+ function capitalizeHeader(name) {
474
+ return name.split("-").map((p) => (p[0]?.toUpperCase() ?? "") + p.slice(1)).join("-");
475
+ }
476
+
477
+ // src/connect.ts
478
+ async function handleConnect(config, grantsClients, req, clientSocket, head, deps) {
303
479
  const target = req.url ?? "";
304
480
  const [host, portStr] = target.split(":");
305
- const port2 = Number.parseInt(portStr || "443");
306
- if (!host || !port2) {
481
+ const port = Number.parseInt(portStr || "443");
482
+ if (!host || !port) {
307
483
  clientSocket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
308
484
  clientSocket.destroy();
309
485
  return;
310
486
  }
311
487
  const idpHosts = new Set(
312
- config2.agents.map((a) => {
488
+ config.agents.map((a) => {
313
489
  try {
314
490
  return new URL(a.idp_url).hostname.toLowerCase();
315
491
  } catch {
@@ -327,46 +503,51 @@ async function handleConnect(config2, grantsClients, req, clientSocket, _head) {
327
503
  path: target,
328
504
  rule: "idp-system-bypass"
329
505
  });
330
- tunnel(host, port2, clientSocket);
506
+ tunnel(host, port, clientSocket);
331
507
  return;
332
508
  }
333
- const mandatoryAuth = config2.proxy.mandatory_auth ?? false;
509
+ const mandatoryAuth = config.proxy.mandatory_auth ?? false;
510
+ const daemonMode = deps?.daemonMode ?? false;
334
511
  let agentEmail;
335
- try {
336
- const authHeader = req.headers["proxy-authorization"];
337
- let identity = null;
338
- for (const agentConf2 of config2.agents) {
339
- identity = await verifyAgentAuth(
340
- authHeader ?? null,
341
- agentConf2.idp_url,
342
- mandatoryAuth && config2.agents.length === 1
343
- );
344
- if (identity) break;
345
- }
346
- if (mandatoryAuth && !identity) {
347
- throw new AuthError("JWT required");
348
- }
349
- agentEmail = identity?.email;
350
- if (agentEmail) {
351
- const known = config2.agents.find((a) => a.email === agentEmail);
352
- if (!known) {
353
- clientSocket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
512
+ if (daemonMode) {
513
+ agentEmail = config.agents[0]?.email;
514
+ } else {
515
+ try {
516
+ const authHeader = req.headers["proxy-authorization"];
517
+ let identity = null;
518
+ for (const agentConf2 of config.agents) {
519
+ identity = await verifyAgentAuth(
520
+ authHeader ?? null,
521
+ agentConf2.idp_url,
522
+ mandatoryAuth && config.agents.length === 1
523
+ );
524
+ if (identity) break;
525
+ }
526
+ if (mandatoryAuth && !identity) {
527
+ throw new AuthError("JWT required");
528
+ }
529
+ agentEmail = identity?.email;
530
+ if (agentEmail) {
531
+ const known = config.agents.find((a) => a.email === agentEmail);
532
+ if (!known) {
533
+ clientSocket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
534
+ clientSocket.destroy();
535
+ return;
536
+ }
537
+ } else if (config.agents.length > 1) {
538
+ throw new AuthError("JWT required for multi-agent proxy");
539
+ }
540
+ } catch (err) {
541
+ if (err instanceof AuthError) {
542
+ clientSocket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
354
543
  clientSocket.destroy();
355
544
  return;
356
545
  }
357
- } else if (config2.agents.length > 1) {
358
- throw new AuthError("JWT required for multi-agent proxy");
359
- }
360
- } catch (err) {
361
- if (err instanceof AuthError) {
362
- clientSocket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
363
- clientSocket.destroy();
364
- return;
546
+ throw err;
365
547
  }
366
- throw err;
367
548
  }
368
549
  const egress = await checkEgress(host);
369
- const auditAgent = agentEmail ?? config2.agents[0]?.email ?? "unknown";
550
+ const auditAgent = agentEmail ?? config.agents[0]?.email ?? "unknown";
370
551
  if (egress.kind === "private") {
371
552
  writeAudit({
372
553
  ts: (/* @__PURE__ */ new Date()).toISOString(),
@@ -401,7 +582,7 @@ DNS lookup failed for ${host} (${egress.reason}).\r
401
582
  clientSocket.destroy();
402
583
  return;
403
584
  }
404
- const agentConf = agentEmail ? config2.agents.find((a) => a.email === agentEmail) : config2.agents[0];
585
+ const agentConf = agentEmail ? config.agents.find((a) => a.email === agentEmail) : config.agents[0];
405
586
  if (!agentConf) {
406
587
  clientSocket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
407
588
  clientSocket.destroy();
@@ -410,10 +591,10 @@ DNS lookup failed for ${host} (${egress.reason}).\r
410
591
  const effectiveEmail = agentEmail ?? agentConf.email;
411
592
  const rulesConfig = {
412
593
  proxy: {
413
- listen: config2.proxy.listen,
594
+ listen: config.proxy.listen,
414
595
  idp_url: agentConf.idp_url,
415
596
  agent_email: agentConf.email,
416
- default_action: config2.proxy.default_action
597
+ default_action: config.proxy.default_action
417
598
  },
418
599
  allow: agentConf.allow ?? [],
419
600
  deny: agentConf.deny ?? [],
@@ -454,7 +635,7 @@ Blocked:${note}\r
454
635
  audience: "ape-proxy",
455
636
  grantType: action.rule.grant_type,
456
637
  permissions: action.rule.permissions,
457
- reason: `CONNECT ${host}:${port2}`,
638
+ reason: `CONNECT ${host}:${port}`,
458
639
  duration: action.rule.duration
459
640
  });
460
641
  const decided = await grantsClient.waitForApproval(grant.id);
@@ -483,10 +664,40 @@ Grant request failed: ${msg}\r
483
664
  } else {
484
665
  writeAudit({ ...baseAudit, action: "allow", rule: "allow-list" });
485
666
  }
486
- tunnel(host, port2, clientSocket);
667
+ if (deps?.secretsStore && deps?.leafCache) {
668
+ clientSocket.pause();
669
+ clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
670
+ if (head.length > 0) {
671
+ clientSocket.unshift(head);
672
+ }
673
+ const store = deps.secretsStore;
674
+ handleMitmConnect({
675
+ clientSocket,
676
+ host,
677
+ port,
678
+ leafCache: deps.leafCache,
679
+ upstreamRejectUnauthorized: deps.upstreamRejectUnauthorized,
680
+ onRequest: (mreq) => {
681
+ const portSuffix = port === 443 ? "" : `:${port}`;
682
+ const targetUrl = new URL(`https://${mreq.host}${portSuffix}${mreq.path}`);
683
+ const match = store.findFor(targetUrl);
684
+ if (!match) return { type: "forward", mutatedHeaders: /* @__PURE__ */ new Map() };
685
+ const rendered = match.template.replace(/\$\{value\}/g, match.value);
686
+ console.error(
687
+ `[openape-proxy] injected secret '${match.name}' into ${match.header} for ${mreq.method} ${targetUrl.href}`
688
+ );
689
+ return {
690
+ type: "forward",
691
+ mutatedHeaders: /* @__PURE__ */ new Map([[match.header.toLowerCase(), rendered]])
692
+ };
693
+ }
694
+ });
695
+ return;
696
+ }
697
+ tunnel(host, port, clientSocket);
487
698
  }
488
- function tunnel(host, port2, clientSocket) {
489
- const targetSocket = connect(port2, host, () => {
699
+ function tunnel(host, port, clientSocket) {
700
+ const targetSocket = connect(port, host, () => {
490
701
  clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
491
702
  targetSocket.pipe(clientSocket);
492
703
  clientSocket.pipe(targetSocket);
@@ -512,30 +723,33 @@ async function computeRequestHash(method, targetUrl, body) {
512
723
  }
513
724
  return hash.digest("hex");
514
725
  }
515
- function buildGrantsClients(config2) {
726
+ function buildGrantsClients(config) {
516
727
  const grantsClients = /* @__PURE__ */ new Map();
517
- for (const agent of config2.agents) {
728
+ for (const agent of config.agents) {
518
729
  grantsClients.set(agent.email, new GrantsClient(agent.idp_url));
519
730
  }
520
731
  return grantsClients;
521
732
  }
522
- function createMultiAgentProxy(config2, grantsClients = buildGrantsClients(config2)) {
523
- for (const agent of config2.agents) {
733
+ function createMultiAgentProxy(config, grantsClients = buildGrantsClients(config), deps = {}) {
734
+ for (const agent of config.agents) {
524
735
  if (!grantsClients.has(agent.email)) {
525
736
  grantsClients.set(agent.email, new GrantsClient(agent.idp_url));
526
737
  }
527
738
  }
528
- const mandatoryAuth = config2.proxy.mandatory_auth ?? false;
739
+ const mandatoryAuth = config.proxy.mandatory_auth ?? false;
740
+ const secretsStore = deps.secretsStore;
741
+ const daemonMode = deps.daemonMode ?? false;
742
+ const fetchImpl = deps.fetchImpl ?? ((input, init) => globalThis.fetch(input, init));
529
743
  return {
530
- port: Number.parseInt(config2.proxy.listen.split(":")[1] || "9090"),
531
- hostname: config2.proxy.listen.split(":")[0] || "127.0.0.1",
744
+ port: Number.parseInt(config.proxy.listen.split(":")[1] || "9090"),
745
+ hostname: config.proxy.listen.split(":")[0] || "127.0.0.1",
532
746
  async fetch(req) {
533
747
  const url = new URL(req.url);
534
748
  const startTime = Date.now();
535
749
  if (url.pathname === "/healthz") {
536
750
  return Response.json({
537
751
  status: "ok",
538
- agents: config2.agents.map((a) => a.email)
752
+ agents: config.agents.map((a) => a.email)
539
753
  });
540
754
  }
541
755
  const targetUrl = url.pathname.slice(1) + url.search;
@@ -563,33 +777,37 @@ function createMultiAgentProxy(config2, grantsClients = buildGrantsClients(confi
563
777
  }
564
778
  const bodyBuffer = req.body ? await req.arrayBuffer() : null;
565
779
  let agentIdentity = null;
566
- try {
567
- for (const agentConf2 of config2.agents) {
568
- agentIdentity = await verifyAgentAuth(
569
- req.headers.get("proxy-authorization"),
570
- agentConf2.idp_url,
571
- mandatoryAuth && config2.agents.length === 1
572
- );
573
- if (agentIdentity) break;
574
- }
575
- if (mandatoryAuth && !agentIdentity) {
576
- throw new AuthError("JWT required");
577
- }
578
- } catch (err) {
579
- if (err instanceof AuthError) {
580
- return new Response(`Unauthorized: ${err.message}`, { status: 401 });
780
+ if (daemonMode) {
781
+ agentIdentity = { email: config.agents[0].email, act: "agent" };
782
+ } else {
783
+ try {
784
+ for (const agentConf2 of config.agents) {
785
+ agentIdentity = await verifyAgentAuth(
786
+ req.headers.get("proxy-authorization"),
787
+ agentConf2.idp_url,
788
+ mandatoryAuth && config.agents.length === 1
789
+ );
790
+ if (agentIdentity) break;
791
+ }
792
+ if (mandatoryAuth && !agentIdentity) {
793
+ throw new AuthError("JWT required");
794
+ }
795
+ } catch (err) {
796
+ if (err instanceof AuthError) {
797
+ return new Response(`Unauthorized: ${err.message}`, { status: 401 });
798
+ }
799
+ throw err;
581
800
  }
582
- throw err;
583
801
  }
584
802
  const agentEmail = agentIdentity?.email;
585
803
  let agentConf;
586
804
  if (agentEmail) {
587
- agentConf = config2.agents.find((a) => a.email === agentEmail);
805
+ agentConf = config.agents.find((a) => a.email === agentEmail);
588
806
  if (!agentConf) {
589
807
  return new Response(`Forbidden: unknown agent ${agentEmail}`, { status: 403 });
590
808
  }
591
- } else if (config2.agents.length === 1) {
592
- agentConf = config2.agents[0];
809
+ } else if (config.agents.length === 1) {
810
+ agentConf = config.agents[0];
593
811
  } else {
594
812
  return new Response("Unauthorized: JWT required for multi-agent proxy", { status: 401 });
595
813
  }
@@ -597,10 +815,10 @@ function createMultiAgentProxy(config2, grantsClients = buildGrantsClients(confi
597
815
  const grantsClient = grantsClients.get(agentConf.email);
598
816
  const rulesConfig = {
599
817
  proxy: {
600
- listen: config2.proxy.listen,
818
+ listen: config.proxy.listen,
601
819
  idp_url: agentConf.idp_url,
602
820
  agent_email: agentConf.email,
603
- default_action: config2.proxy.default_action
821
+ default_action: config.proxy.default_action
604
822
  },
605
823
  allow: agentConf.allow ?? [],
606
824
  deny: agentConf.deny ?? [],
@@ -620,7 +838,7 @@ function createMultiAgentProxy(config2, grantsClients = buildGrantsClients(confi
620
838
  }
621
839
  if (action.type === "allow") {
622
840
  writeAudit({ ...baseAudit, action: "allow", rule: "allow-list", grant_id: null });
623
- return forwardRequest(req, targetUrl, bodyBuffer);
841
+ return forwardRequest(req, targetUrl, targetParsed, bodyBuffer, secretsStore, fetchImpl);
624
842
  }
625
843
  const rule = action.rule;
626
844
  const permissions = rule.permissions ?? [`${method.toLowerCase()}:${domain}`];
@@ -639,13 +857,13 @@ function createMultiAgentProxy(config2, grantsClients = buildGrantsClients(confi
639
857
  grant_id: existing.id,
640
858
  request_hash: requestHash
641
859
  });
642
- return forwardRequest(req, targetUrl, bodyBuffer);
860
+ return forwardRequest(req, targetUrl, targetParsed, bodyBuffer, secretsStore, fetchImpl);
643
861
  }
644
- if (config2.proxy.default_action === "block") {
862
+ if (config.proxy.default_action === "block") {
645
863
  writeAudit({ ...baseAudit, action: "deny", rule: "no-grant (block mode)", grant_id: null });
646
864
  return new Response("No grant \u2014 blocked", { status: 403 });
647
865
  }
648
- if (config2.proxy.default_action === "request-async") {
866
+ if (config.proxy.default_action === "request-async") {
649
867
  const grant = await grantsClient.requestGrant({
650
868
  requester: effectiveEmail,
651
869
  targetHost: domain,
@@ -694,7 +912,7 @@ function createMultiAgentProxy(config2, grantsClients = buildGrantsClients(confi
694
912
  request_hash: requestHash,
695
913
  waited_ms: waitedMs
696
914
  });
697
- return forwardRequest(req, targetUrl, bodyBuffer);
915
+ return forwardRequest(req, targetUrl, targetParsed, bodyBuffer, secretsStore, fetchImpl);
698
916
  }
699
917
  writeAudit({
700
918
  ...baseAudit,
@@ -717,9 +935,12 @@ function createMultiAgentProxy(config2, grantsClients = buildGrantsClients(confi
717
935
  }
718
936
  };
719
937
  }
720
- function createNodeHandler(config2) {
721
- const grantsClients = buildGrantsClients(config2);
722
- const proxy = createMultiAgentProxy(config2, grantsClients);
938
+ function createNodeHandler(config, deps = {}) {
939
+ const grantsClients = buildGrantsClients(config);
940
+ const proxy = createMultiAgentProxy(config, grantsClients, {
941
+ secretsStore: deps.secretsStore,
942
+ daemonMode: deps.daemonMode
943
+ });
723
944
  return {
724
945
  handleRequest(req, res) {
725
946
  const reqUrl = req.url || "/";
@@ -767,24 +988,39 @@ function createNodeHandler(config2) {
767
988
  });
768
989
  },
769
990
  handleConnect(req, socket, head) {
770
- handleConnect(config2, grantsClients, req, socket, head);
991
+ handleConnect(config, grantsClients, req, socket, head, {
992
+ secretsStore: deps.secretsStore,
993
+ leafCache: deps.leafCache,
994
+ daemonMode: deps.daemonMode
995
+ });
771
996
  }
772
997
  };
773
998
  }
774
- async function forwardRequest(originalReq, targetUrl, cachedBody) {
999
+ async function forwardRequest(originalReq, targetUrl, targetParsed, cachedBody, secretsStore, fetchImpl) {
775
1000
  const headers = new Headers(originalReq.headers);
776
1001
  headers.delete("proxy-authorization");
777
1002
  headers.delete("proxy-connection");
778
1003
  headers.delete("host");
1004
+ if (secretsStore) {
1005
+ const match = secretsStore.findFor(targetParsed);
1006
+ if (match) {
1007
+ const rendered = match.template.replace(/\$\{value\}/g, match.value);
1008
+ headers.set(match.header, rendered);
1009
+ console.error(
1010
+ `[openape-proxy] injected secret '${match.name}' into ${match.header} for ${originalReq.method} ${targetUrl}`
1011
+ );
1012
+ }
1013
+ }
779
1014
  const body = cachedBody && cachedBody.byteLength > 0 ? cachedBody : null;
780
1015
  try {
781
- const res = await fetch(targetUrl, {
1016
+ const upstreamReq = new Request(targetUrl, {
782
1017
  method: originalReq.method,
783
1018
  headers,
784
1019
  body,
785
1020
  duplex: "half",
786
1021
  redirect: "manual"
787
1022
  });
1023
+ const res = await fetchImpl(upstreamReq);
788
1024
  const responseHeaders = new Headers(res.headers);
789
1025
  responseHeaders.delete("transfer-encoding");
790
1026
  responseHeaders.delete("connection");
@@ -799,56 +1035,220 @@ async function forwardRequest(originalReq, targetUrl, cachedBody) {
799
1035
  }
800
1036
  }
801
1037
 
1038
+ // src/secrets-store.ts
1039
+ import { parse as parseTOML2 } from "smol-toml";
1040
+
1041
+ // src/secrets-match.ts
1042
+ function compileGlob(glob) {
1043
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
1044
+ return new RegExp(`^${escaped}$`);
1045
+ }
1046
+ function literalPrefixLen(glob) {
1047
+ const i = glob.indexOf("*");
1048
+ return i < 0 ? glob.length : i;
1049
+ }
1050
+ function targetString(url) {
1051
+ const port = url.port ? `:${url.port}` : "";
1052
+ const path = url.pathname === "/" ? "" : url.pathname;
1053
+ return `${url.hostname}${port}${path}`;
1054
+ }
1055
+ function matchSecret(target, entries) {
1056
+ const targetStr = targetString(target);
1057
+ let best = null;
1058
+ for (let i = 0; i < entries.length; i++) {
1059
+ const entry = entries[i];
1060
+ const re = compileGlob(entry.target);
1061
+ if (!re.test(targetStr) && !re.test(`${targetStr}/`)) continue;
1062
+ const prefix = literalPrefixLen(entry.target);
1063
+ if (!best || prefix > best.prefix) {
1064
+ best = { entry, prefix, idx: i };
1065
+ }
1066
+ }
1067
+ return best?.entry ?? null;
1068
+ }
1069
+
1070
+ // src/secrets-store.ts
1071
+ var MAX_BLOB_BYTES = 4 * 1024;
1072
+ var SUPPORTED_VERSION = "1";
1073
+ function parseSecretsBlob(toml) {
1074
+ if (Buffer.byteLength(toml, "utf-8") > MAX_BLOB_BYTES) {
1075
+ throw new Error(`Secrets blob too large (max ${MAX_BLOB_BYTES} bytes / 4 KiB)`);
1076
+ }
1077
+ const parsed = parseTOML2(toml);
1078
+ const version = parsed.version;
1079
+ if (version !== SUPPORTED_VERSION) {
1080
+ throw new Error(`Unsupported or missing version (expected "${SUPPORTED_VERSION}", got ${JSON.stringify(version)})`);
1081
+ }
1082
+ const secretsBlock = parsed.secrets;
1083
+ const entries = [];
1084
+ if (secretsBlock) {
1085
+ for (const [name, raw] of Object.entries(secretsBlock)) {
1086
+ const required = ["target", "header", "template", "value"];
1087
+ for (const field of required) {
1088
+ if (!raw[field] || typeof raw[field] !== "string") {
1089
+ throw new Error(`Secret '${name}' is missing required field '${field}'`);
1090
+ }
1091
+ }
1092
+ entries.push({
1093
+ name,
1094
+ target: raw.target,
1095
+ header: raw.header,
1096
+ template: raw.template,
1097
+ value: raw.value
1098
+ });
1099
+ }
1100
+ }
1101
+ const literalPrefix = (glob) => {
1102
+ const i = glob.indexOf("*");
1103
+ return i < 0 ? glob : glob.slice(0, i);
1104
+ };
1105
+ const seen = /* @__PURE__ */ new Map();
1106
+ for (const e of entries) {
1107
+ const key = `${literalPrefix(e.target)}|${e.target}`;
1108
+ if (seen.has(key)) {
1109
+ throw new Error(`Duplicate target with identical specificity: '${e.target}' (entries '${seen.get(key)}' and '${e.name}')`);
1110
+ }
1111
+ seen.set(key, e.name);
1112
+ }
1113
+ return {
1114
+ entries,
1115
+ findFor: (target) => matchSecret(target, entries)
1116
+ };
1117
+ }
1118
+
802
1119
  // src/index.ts
1120
+ function loadIdentity() {
1121
+ const path = join(homedir(), ".config", "apes", "auth.json");
1122
+ if (!existsSync2(path)) {
1123
+ console.error(`[openape-proxy] missing ${path} \u2014 run \`apes login\` first`);
1124
+ process.exit(2);
1125
+ }
1126
+ const raw = JSON.parse(readFileSync3(path, "utf-8"));
1127
+ const bearer = raw.access_token ?? raw.bearer;
1128
+ if (!raw.email || !raw.idp || !bearer) {
1129
+ console.error(`[openape-proxy] malformed ${path} \u2014 re-run \`apes login\``);
1130
+ process.exit(2);
1131
+ }
1132
+ return { email: raw.email, idpUrl: raw.idp, bearer };
1133
+ }
803
1134
  var { values } = parseArgs({
804
1135
  options: {
805
1136
  config: { type: "string", short: "c", default: "config.toml" },
806
1137
  "dry-run": { type: "boolean", default: false },
807
- "mandatory-auth": { type: "boolean", default: false }
1138
+ "mandatory-auth": { type: "boolean", default: false },
1139
+ global: { type: "boolean", default: false },
1140
+ port: { type: "string" }
808
1141
  }
809
1142
  });
810
- var configPath = values.config;
811
- console.log(`[openape-proxy] Loading config from ${configPath}`);
812
- var config = loadMultiAgentConfig(configPath, {
813
- mandatoryAuth: values["mandatory-auth"] || void 0
814
- });
815
- if (values["dry-run"]) {
816
- console.log("[openape-proxy] DRY RUN mode \u2014 logging only, not blocking");
817
- console.log("[openape-proxy] Config loaded:");
818
- console.log(` Listen: ${config.proxy.listen}`);
819
- console.log(` Default action: ${config.proxy.default_action}`);
820
- console.log(` Mandatory auth: ${config.proxy.mandatory_auth ?? false}`);
821
- console.log(` Agents: ${config.agents.length}`);
822
- for (const agent of config.agents) {
823
- const allowCount = agent.allow?.length ?? 0;
824
- const denyCount = agent.deny?.length ?? 0;
825
- const grantCount = agent.grant_required?.length ?? 0;
826
- console.log(` ${agent.email} (${agent.idp_url}) \u2014 ${allowCount} allow, ${denyCount} deny, ${grantCount} grant`);
1143
+ if (values.global) {
1144
+ if (process.versions.bun) {
1145
+ console.error(
1146
+ "[openape-proxy] --global mode requires Node (Bun is not supported).\n Bun's node:tls compat layer does not handle TLSSocket-on-existing-socket\n for the CONNECT-MITM pipeline. Re-run with `node` directly, or use the\n built `openape-proxy` binary (its shebang invokes Node)."
1147
+ );
1148
+ process.exit(2);
827
1149
  }
828
- process.exit(0);
1150
+ const stdinBuf = [];
1151
+ let total = 0;
1152
+ let aborted = false;
1153
+ process.stdin.on("data", (chunk) => {
1154
+ if (aborted) return;
1155
+ total += chunk.length;
1156
+ if (total > 8 * 1024) {
1157
+ console.error("[openape-proxy] stdin > 8 KiB, refusing");
1158
+ aborted = true;
1159
+ process.exit(2);
1160
+ }
1161
+ stdinBuf.push(chunk);
1162
+ });
1163
+ process.stdin.on("end", () => {
1164
+ if (aborted) return;
1165
+ const blob = Buffer.concat(stdinBuf).toString("utf-8");
1166
+ if (!blob.trim()) {
1167
+ console.error("[openape-proxy] --global requires secrets TOML on stdin");
1168
+ process.exit(2);
1169
+ }
1170
+ let store;
1171
+ try {
1172
+ store = parseSecretsBlob(blob);
1173
+ } catch (err) {
1174
+ console.error(`[openape-proxy] secrets parse error: ${err.message}`);
1175
+ process.exit(2);
1176
+ }
1177
+ const identity = loadIdentity();
1178
+ const port = Number.parseInt(values.port ?? "18789");
1179
+ const ca = loadOrCreateCa({
1180
+ certPath: join(homedir(), ".openape", "proxy", "ca.crt"),
1181
+ keyPath: join(homedir(), ".openape", "proxy", "ca.key"),
1182
+ subjectCN: `OpenApe Proxy CA (${identity.email})`
1183
+ });
1184
+ if (ca.created) {
1185
+ console.log(`[openape-proxy] generated new CA at ~/.openape/proxy/ca.crt`);
1186
+ }
1187
+ const leafCache = createLeafCertCache(ca, { capacity: 256 });
1188
+ const config = {
1189
+ proxy: { listen: `127.0.0.1:${port}`, default_action: "allow" },
1190
+ agents: [{ email: identity.email, idp_url: identity.idpUrl }]
1191
+ };
1192
+ const handler = createNodeHandler(config, { secretsStore: store, leafCache, daemonMode: true });
1193
+ const server = createServer(handler.handleRequest);
1194
+ server.on("connect", handler.handleConnect);
1195
+ server.listen(port, "127.0.0.1", () => {
1196
+ const addr = server.address();
1197
+ const actualPort = typeof addr === "object" && addr ? addr.port : port;
1198
+ console.log(`[openape-proxy] identity: ${identity.email} (${identity.idpUrl})`);
1199
+ const names = store.entries.map((e) => e.name).join(", ");
1200
+ console.log(`[openape-proxy] secrets: ${names}`);
1201
+ console.log(`[openape-proxy] export OPENAPE_PROXY=127.0.0.1:${actualPort}`);
1202
+ console.log(`[openape-proxy] listening on 127.0.0.1:${actualPort}`);
1203
+ });
1204
+ process.on("SIGINT", () => server.close(() => process.exit(0)));
1205
+ process.on("SIGTERM", () => server.close(() => process.exit(0)));
1206
+ });
1207
+ process.stdin.resume();
1208
+ } else {
1209
+ const configPath = values.config;
1210
+ console.log(`[openape-proxy] Loading config from ${configPath}`);
1211
+ const config = loadMultiAgentConfig(configPath, {
1212
+ mandatoryAuth: values["mandatory-auth"] || void 0
1213
+ });
1214
+ if (values["dry-run"]) {
1215
+ console.log("[openape-proxy] DRY RUN mode \u2014 logging only, not blocking");
1216
+ console.log("[openape-proxy] Config loaded:");
1217
+ console.log(` Listen: ${config.proxy.listen}`);
1218
+ console.log(` Default action: ${config.proxy.default_action}`);
1219
+ console.log(` Mandatory auth: ${config.proxy.mandatory_auth ?? false}`);
1220
+ console.log(` Agents: ${config.agents.length}`);
1221
+ for (const agent of config.agents) {
1222
+ const allowCount = agent.allow?.length ?? 0;
1223
+ const denyCount = agent.deny?.length ?? 0;
1224
+ const grantCount = agent.grant_required?.length ?? 0;
1225
+ console.log(` ${agent.email} (${agent.idp_url}) \u2014 ${allowCount} allow, ${denyCount} deny, ${grantCount} grant`);
1226
+ }
1227
+ process.exit(0);
1228
+ }
1229
+ const handler = createNodeHandler(config);
1230
+ const port = Number.parseInt(config.proxy.listen.split(":")[1] || "9090");
1231
+ const hostname = config.proxy.listen.split(":")[0] || "127.0.0.1";
1232
+ const server = createServer(handler.handleRequest);
1233
+ server.on("connect", handler.handleConnect);
1234
+ server.listen(port, hostname, () => {
1235
+ const addr = server.address();
1236
+ const actualPort = typeof addr === "object" && addr ? addr.port : port;
1237
+ console.log(`[openape-proxy] Listening on http://${hostname}:${actualPort}`);
1238
+ console.log(`[openape-proxy] CONNECT tunneling enabled`);
1239
+ console.log(`[openape-proxy] Mandatory auth: ${config.proxy.mandatory_auth ?? false}`);
1240
+ console.log(`[openape-proxy] Agents: ${config.agents.map((a) => a.email).join(", ")}`);
1241
+ console.log(`[openape-proxy] Default action: ${config.proxy.default_action}`);
1242
+ });
1243
+ process.on("SIGINT", () => {
1244
+ console.log("\n[openape-proxy] Shutting down...");
1245
+ server.close();
1246
+ process.exit(0);
1247
+ });
1248
+ process.on("SIGTERM", () => {
1249
+ console.log("[openape-proxy] Shutting down...");
1250
+ server.close();
1251
+ process.exit(0);
1252
+ });
829
1253
  }
830
- var handler = createNodeHandler(config);
831
- var port = Number.parseInt(config.proxy.listen.split(":")[1] || "9090");
832
- var hostname = config.proxy.listen.split(":")[0] || "127.0.0.1";
833
- var server = createServer(handler.handleRequest);
834
- server.on("connect", handler.handleConnect);
835
- server.listen(port, hostname, () => {
836
- const addr = server.address();
837
- const actualPort = typeof addr === "object" && addr ? addr.port : port;
838
- console.log(`[openape-proxy] Listening on http://${hostname}:${actualPort}`);
839
- console.log(`[openape-proxy] CONNECT tunneling enabled`);
840
- console.log(`[openape-proxy] Mandatory auth: ${config.proxy.mandatory_auth ?? false}`);
841
- console.log(`[openape-proxy] Agents: ${config.agents.map((a) => a.email).join(", ")}`);
842
- console.log(`[openape-proxy] Default action: ${config.proxy.default_action}`);
843
- });
844
- process.on("SIGINT", () => {
845
- console.log("\n[openape-proxy] Shutting down...");
846
- server.close();
847
- process.exit(0);
848
- });
849
- process.on("SIGTERM", () => {
850
- console.log("[openape-proxy] Shutting down...");
851
- server.close();
852
- process.exit(0);
853
- });
854
1254
  //# sourceMappingURL=index.js.map