@volter/world-runtime 2.0.0

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 (164) hide show
  1. package/LICENSE +202 -0
  2. package/dist/known-external-services.json +1108 -0
  3. package/dist/src/ancestry.d.ts +2 -0
  4. package/dist/src/ancestry.js +42 -0
  5. package/dist/src/app-url.d.ts +47 -0
  6. package/dist/src/app-url.js +239 -0
  7. package/dist/src/attach.d.ts +48 -0
  8. package/dist/src/attach.js +87 -0
  9. package/dist/src/branch.d.ts +20 -0
  10. package/dist/src/branch.js +65 -0
  11. package/dist/src/browser-proxy-cli.d.ts +2 -0
  12. package/dist/src/browser-proxy-cli.js +41 -0
  13. package/dist/src/ca-trust.d.ts +5 -0
  14. package/dist/src/ca-trust.js +64 -0
  15. package/dist/src/catalog.d.ts +31 -0
  16. package/dist/src/catalog.js +148 -0
  17. package/dist/src/changeset.d.ts +142 -0
  18. package/dist/src/changeset.js +570 -0
  19. package/dist/src/cli.d.ts +2 -0
  20. package/dist/src/cli.js +1262 -0
  21. package/dist/src/command-lifetime.d.ts +15 -0
  22. package/dist/src/command-lifetime.js +98 -0
  23. package/dist/src/configs.d.ts +18 -0
  24. package/dist/src/configs.js +119 -0
  25. package/dist/src/console-apart.d.ts +38 -0
  26. package/dist/src/console-apart.js +107 -0
  27. package/dist/src/consumers.d.ts +46 -0
  28. package/dist/src/consumers.js +200 -0
  29. package/dist/src/covers.d.ts +183 -0
  30. package/dist/src/covers.js +800 -0
  31. package/dist/src/fixture-env.d.ts +42 -0
  32. package/dist/src/fixture-env.js +221 -0
  33. package/dist/src/host-cli.d.ts +2 -0
  34. package/dist/src/host-cli.js +92 -0
  35. package/dist/src/host-fault-fixture.d.ts +32 -0
  36. package/dist/src/host-fault-fixture.js +100 -0
  37. package/dist/src/host-worker.d.ts +1 -0
  38. package/dist/src/host-worker.js +23 -0
  39. package/dist/src/host.d.ts +38 -0
  40. package/dist/src/host.js +135 -0
  41. package/dist/src/index.d.ts +48 -0
  42. package/dist/src/index.js +35 -0
  43. package/dist/src/infra-cli.d.ts +2 -0
  44. package/dist/src/infra-cli.js +136 -0
  45. package/dist/src/init.d.ts +227 -0
  46. package/dist/src/init.js +1117 -0
  47. package/dist/src/inject-map.d.ts +34 -0
  48. package/dist/src/inject-map.js +56 -0
  49. package/dist/src/lifecycle-record.d.ts +47 -0
  50. package/dist/src/lifecycle-record.js +196 -0
  51. package/dist/src/origin.d.ts +31 -0
  52. package/dist/src/origin.js +139 -0
  53. package/dist/src/pack-facts.d.ts +75 -0
  54. package/dist/src/pack-facts.js +98 -0
  55. package/dist/src/pglite-backing.d.ts +21 -0
  56. package/dist/src/pglite-backing.js +158 -0
  57. package/dist/src/pglite-host.mjs +147 -0
  58. package/dist/src/placeholder.d.ts +20 -0
  59. package/dist/src/placeholder.js +100 -0
  60. package/dist/src/prerequisites.d.ts +21 -0
  61. package/dist/src/prerequisites.js +49 -0
  62. package/dist/src/process-groups.d.ts +4 -0
  63. package/dist/src/process-groups.js +49 -0
  64. package/dist/src/project-inspect.d.ts +109 -0
  65. package/dist/src/project-inspect.js +827 -0
  66. package/dist/src/proxy-daemon.d.ts +2 -0
  67. package/dist/src/proxy-daemon.js +18 -0
  68. package/dist/src/redirect-proxy.d.ts +105 -0
  69. package/dist/src/redirect-proxy.js +665 -0
  70. package/dist/src/reflect.d.ts +74 -0
  71. package/dist/src/reflect.js +392 -0
  72. package/dist/src/resources.d.ts +26 -0
  73. package/dist/src/resources.js +22 -0
  74. package/dist/src/root.d.ts +114 -0
  75. package/dist/src/root.js +312 -0
  76. package/dist/src/run-task-worker.d.ts +1 -0
  77. package/dist/src/run-task-worker.js +38 -0
  78. package/dist/src/run-task.d.ts +18 -0
  79. package/dist/src/run-task.js +48 -0
  80. package/dist/src/runtime-test-support.d.ts +59 -0
  81. package/dist/src/runtime-test-support.js +205 -0
  82. package/dist/src/runtime.d.ts +256 -0
  83. package/dist/src/runtime.js +3502 -0
  84. package/dist/src/schema.d.ts +449 -0
  85. package/dist/src/schema.js +605 -0
  86. package/dist/src/serve.d.ts +30 -0
  87. package/dist/src/serve.js +82 -0
  88. package/dist/src/served-world.d.ts +194 -0
  89. package/dist/src/served-world.js +986 -0
  90. package/dist/src/service-exit.d.ts +46 -0
  91. package/dist/src/service-exit.js +195 -0
  92. package/dist/src/service-recorder.d.ts +1 -0
  93. package/dist/src/service-recorder.js +121 -0
  94. package/dist/src/sibling.d.ts +1 -0
  95. package/dist/src/sibling.js +9 -0
  96. package/dist/src/signals.d.ts +1 -0
  97. package/dist/src/signals.js +11 -0
  98. package/dist/src/storage-capacity.d.ts +8 -0
  99. package/dist/src/storage-capacity.js +61 -0
  100. package/dist/src/tail.d.ts +30 -0
  101. package/dist/src/tail.js +160 -0
  102. package/dist/src/tcp-port.d.ts +2 -0
  103. package/dist/src/tcp-port.js +36 -0
  104. package/dist/src/up-task-worker.d.ts +1 -0
  105. package/dist/src/up-task-worker.js +61 -0
  106. package/dist/src/up-task.d.ts +17 -0
  107. package/dist/src/up-task.js +49 -0
  108. package/dist/src/websocket-relay.d.ts +3 -0
  109. package/dist/src/websocket-relay.js +40 -0
  110. package/known-external-services.json +1108 -0
  111. package/package.json +83 -0
  112. package/src/ancestry.ts +36 -0
  113. package/src/app-url.ts +253 -0
  114. package/src/attach.ts +117 -0
  115. package/src/branch.ts +63 -0
  116. package/src/browser-proxy-cli.ts +44 -0
  117. package/src/ca-trust.ts +57 -0
  118. package/src/catalog.ts +156 -0
  119. package/src/changeset.ts +627 -0
  120. package/src/cli.ts +1111 -0
  121. package/src/command-lifetime.ts +79 -0
  122. package/src/configs.ts +110 -0
  123. package/src/console-apart.ts +90 -0
  124. package/src/consumers.ts +185 -0
  125. package/src/covers.ts +934 -0
  126. package/src/fixture-env.ts +230 -0
  127. package/src/host-cli.ts +90 -0
  128. package/src/host-worker.ts +23 -0
  129. package/src/host.ts +169 -0
  130. package/src/index.ts +171 -0
  131. package/src/infra-cli.ts +133 -0
  132. package/src/init.ts +1316 -0
  133. package/src/inject-map.ts +72 -0
  134. package/src/lifecycle-record.ts +168 -0
  135. package/src/origin.ts +134 -0
  136. package/src/pack-facts.ts +128 -0
  137. package/src/pglite-backing.ts +141 -0
  138. package/src/pglite-host.mjs +147 -0
  139. package/src/placeholder.ts +89 -0
  140. package/src/prerequisites.ts +66 -0
  141. package/src/process-groups.ts +33 -0
  142. package/src/project-inspect.ts +770 -0
  143. package/src/proxy-daemon.ts +21 -0
  144. package/src/redirect-proxy.ts +684 -0
  145. package/src/reflect.ts +440 -0
  146. package/src/resources.ts +22 -0
  147. package/src/root.ts +290 -0
  148. package/src/run-task-worker.ts +27 -0
  149. package/src/run-task.ts +44 -0
  150. package/src/runtime-test-support.ts +208 -0
  151. package/src/runtime.ts +3357 -0
  152. package/src/schema.ts +922 -0
  153. package/src/serve.ts +102 -0
  154. package/src/served-world.ts +812 -0
  155. package/src/service-exit.ts +175 -0
  156. package/src/service-recorder.ts +89 -0
  157. package/src/sibling.ts +10 -0
  158. package/src/signals.ts +10 -0
  159. package/src/storage-capacity.ts +60 -0
  160. package/src/tail.ts +205 -0
  161. package/src/tcp-port.ts +35 -0
  162. package/src/up-task-worker.ts +40 -0
  163. package/src/up-task.ts +45 -0
  164. package/src/websocket-relay.ts +32 -0
@@ -0,0 +1,684 @@
1
+ import { serveHttp, type HttpServer } from '@volter/world-core';
2
+ import { worldNetworkPolicyFromEnv, allowsWorldNetworkEgress } from '@volter/world-core/network-policy';
3
+ import { websocketRelay } from './websocket-relay.ts';
4
+ // The ambient TLS redirect proxy — the substance of "any CLI, zero config" (WORLD_ACTIVATE.md §"How
5
+ // redirection works", tier 1). A local TLS-terminating forward proxy that maps vendor API hosts
6
+ // (api.stripe.com, api.github.com, …) → the world's twin services, so an UNMODIFIED `gh`/`stripe`/
7
+ // `curl`/app — anything that honors HTTPS_PROXY + a CA bundle env — transparently lands in the twins
8
+ // with no per-tool config.
9
+ //
10
+ // Mechanism:
11
+ // - The host→twin table is reused from `@volter/world-core/inject` (`VENDOR_HOSTS` + `readMap` +
12
+ // `resolveTwin`) so this proxy and the Node injector never drift. No vendor names live here.
13
+ // - A throwaway, session-scoped CA (per instance dir) signs per-host leaf certs on the fly. The CA
14
+ // is trusted ONLY via the per-shell env (`NODE_EXTRA_CA_CERTS`, `CURL_CA_BUNDLE`, …) that
15
+ // `activate`/`shell` export — never installed system-wide. `down` drops it.
16
+ // - On CONNECT to a matched vendor host, the proxy MITM-terminates TLS (leaf cert for that SNI) and
17
+ // forwards the decrypted request to the twin over plain HTTP. CONNECT to a host the World refuses is
18
+ // terminated the same way and answered 502 with the reason (outsideWorld). CONNECT to any OTHER host is
19
+ // blind-tunneled to the real origin untouched, so non-vendor traffic in the shell still works normally.
20
+ //
21
+ // Cert tooling: we shell out to `openssl` (already a dev prerequisite; node:crypto can generate keys
22
+ // but cannot mint X.509 certs without a third-party lib). This keeps world-runtime dependency-light.
23
+ import { spawnSync } from 'node:child_process';
24
+ import { createHash } from 'node:crypto';
25
+ import { lookup as dnsLookup } from 'node:dns/promises';
26
+ import { BlockList, Socket, isIP, connect as netConnect, createServer as createNetServer } from 'node:net';
27
+ import { networkInterfaces } from 'node:os';
28
+ import { connect as tlsConnect } from 'node:tls';
29
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
30
+ import { join } from 'node:path';
31
+ // The host→twin table is loaded through the shared inject-map module (one world-runtime home
32
+ // for the injector's data), so this proxy, `covers`, and `up`'s inert-injectEnv warning can
33
+ // never re-encode the vendor host predicates — they stay one source of truth with the Node
34
+ // injector. External callers go through `proxyTargetFor`/`activeVendorMap`.
35
+ import { loadInject } from './inject-map.ts';
36
+ import { sessionTrustEnv, type CaTrustInputs } from './ca-trust.ts';
37
+
38
+ /**
39
+ * The body served when a host was MITM'd but no twin serves the PATH.
40
+ *
41
+ * Two deliberate choices, both §9 round 2 findings:
42
+ * • The request path is JSON-escaped and the response is sent as `text/plain`. `req.url` may
43
+ * legally carry `<` and `>`, and an untyped body is MIME-sniffable — so on a machine that has
44
+ * trusted the session CA, a crafted URL could otherwise execute in the vendor's origin. The
45
+ * sibling raw-socket error path already set `text/plain`; this matches it.
46
+ * • It names the SITUATION, not just the miss (the Cal.com finding): this handler only runs after
47
+ * the host was MITM'd, i.e. the host IS twinned in this world — so the bare "no twin for host"
48
+ * sent readers hunting for a `*_TWIN_URL` that was already set. On a shared host the path may
49
+ * belong to a vendor whose twin merely is not configured (youtube vs googleauth), OR to no pack
50
+ * at all (Google Calendar on www.googleapis.com has no twin yet) — the message states both,
51
+ * because this refusal is exactly what replaced the silent mis-route that answered Calendar
52
+ * calls with plausible Google-shaped 404s from the gemini pack.
53
+ */
54
+ export function noTwinMessage(host: string, path: string): string {
55
+ return `host ${host} is twinned in this world, but path ${JSON.stringify(path)} belongs to no twin configured here.\n`
56
+ + 'This host is shared between vendors, and no pack in this world serves this path. Either the\n'
57
+ + 'vendor that owns this path has a twin that is not configured — set its *_TWIN_URL (for\n'
58
+ + 'www.googleapis.com: YOUTUBE_TWIN_URL serves /youtube/v3/*, GOOGLEAUTH_TWIN_URL serves the\n'
59
+ + 'OAuth2 token paths) — or this API has no twin pack yet (e.g. Google Calendar /calendar/v3/*),\n'
60
+ + 'in which case this loud refusal is the honest outcome: the request is neither answered by the\n'
61
+ + 'wrong twin nor leaked to the real vendor.\n';
62
+ }
63
+
64
+ /** Resolve which twin origin (if any) a vendor host maps to, given the world env (which carries the
65
+ * `*_TWIN_URL` vars). Returns null for hosts the world doesn't twin — those are tunneled untouched.
66
+ *
67
+ * `pathname` is optional and matters only for hosts TWO vendors share (see `VENDOR_HOSTS` in
68
+ * inject.cjs — `www.googleapis.com` is served by both `googleauth` and `youtube`, split by path).
69
+ * At CONNECT time there is no path yet, so it is omitted and the answer is host-level candidacy:
70
+ * "MITM this host". Once TLS is terminated and the real request line is readable, the caller
71
+ * re-resolves WITH the path so each request reaches the twin that actually serves it. */
72
+ export function proxyTargetFor(
73
+ hostname: string,
74
+ env: Record<string, string | undefined>,
75
+ pathname?: string,
76
+ ): { vendor: string; origin: string } | null {
77
+ const inject = loadInject();
78
+ return inject.resolveTwin(hostname, inject.readMap(env), pathname);
79
+ }
80
+
81
+ /** The vendor→twin map this world actively redirects (for diagnostics / activate output). */
82
+ export function activeVendorMap(env: Record<string, string | undefined>): Record<string, string> {
83
+ return loadInject().readMap(env);
84
+ }
85
+
86
+ function strictEgressEnabled(env: Record<string, string | undefined>): boolean {
87
+ return env.VOLTER_TWIN_STRICT_EGRESS === '1' || env.VOLTER_TWIN_STRICT_EGRESS === 'true';
88
+ }
89
+
90
+ function isLocalOrPrivateHost(hostname: string): boolean {
91
+ const h = String(hostname || '').toLowerCase();
92
+ if (!h) return true;
93
+ if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.test')) return true;
94
+ if (h === '0.0.0.0' || h === '127.0.0.1' || h === '::1' || h === '[::1]') return true;
95
+ if (isIP(h) !== 4) return false;
96
+ if (/^127\./.test(h)) return true;
97
+ if (/^10\./.test(h)) return true;
98
+ if (/^192\.168\./.test(h)) return true;
99
+ const match = h.match(/^172\.(\d+)\./);
100
+ if (match) {
101
+ const n = Number(match[1]);
102
+ if (n >= 16 && n <= 31) return true;
103
+ }
104
+ return false;
105
+ }
106
+
107
+ /** A connection from anything but the proxy host's own loopback is a guest's (a World Machine: its forwarder connects
108
+ * from 127.0.0.2 on this host, or from its VM's address): architecture, "The scoped proxy's local pass-through". */
109
+ export function isGuestSource(address: string | undefined): boolean {
110
+ const a = String(address ?? '').replace(/^::ffff:/i, '');
111
+ return a !== '127.0.0.1' && a !== '::1';
112
+ }
113
+
114
+ // What a guest may not be connected to: the host's own networks. IPv4: this network, private, CGNAT, loopback, link-local,
115
+ // IETF protocol, benchmarking, multicast and reserved (broadcast included). IPv6: unspecified, loopback, IPv4-compatible,
116
+ // unique-local, link-local, multicast, NAT64 and 6to4 (both carry an IPv4 address a translator reaches).
117
+ const GUEST_V4_BLOCKED = new BlockList();
118
+ for (const [net, bits] of [['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['224.0.0.0', 4], ['240.0.0.0', 4]] as const) GUEST_V4_BLOCKED.addSubnet(net, bits, 'ipv4');
119
+ const GUEST_V6_BLOCKED = new BlockList();
120
+ for (const [net, bits] of [['::', 128], ['::1', 128], ['::', 96], ['fc00::', 7], ['fe80::', 10], ['ff00::', 8], ['64:ff9b::', 96], ['64:ff9b:1::', 48], ['2002::', 16]] as const) GUEST_V6_BLOCKED.addSubnet(net, bits, 'ipv6');
121
+
122
+ /** An IPv6 address's 16 bytes (any spelling: `::`, a dotted IPv4 tail), or null. */
123
+ function ipv6Bytes(a: string): number[] | null {
124
+ let text = a.replace(/%.*$/, '');
125
+ const dotted = /(\d+\.\d+\.\d+\.\d+)$/.exec(text);
126
+ if (dotted) {
127
+ const q = dotted[1]!.split('.').map(Number);
128
+ text = `${text.slice(0, -dotted[1]!.length)}${((q[0]! << 8) | q[1]!).toString(16)}:${((q[2]! << 8) | q[3]!).toString(16)}`;
129
+ }
130
+ const halves = text.split('::');
131
+ if (halves.length > 2) return null;
132
+ const words = (h: string) => (h ? h.split(':') : []);
133
+ const head = words(halves[0]!); const tail = halves.length === 2 ? words(halves[1]!) : [];
134
+ const fill = 8 - head.length - tail.length;
135
+ if (fill < 0 || (halves.length === 1 && fill !== 0)) return null;
136
+ const all = [...head, ...Array(fill).fill('0'), ...tail].map((w) => parseInt(w, 16));
137
+ if (all.some((w) => !Number.isInteger(w) || w < 0 || w > 0xffff)) return null;
138
+ return all.flatMap((w) => [w >> 8, w & 0xff]);
139
+ }
140
+
141
+ /** An address a guest may not be connected to: one of the host's networks (above), or an address of the host's own
142
+ * interfaces; an IPv4 address mapped into IPv6 (in any spelling) is checked as IPv4. */
143
+ export function isGuestForbiddenAddress(ip: string): boolean {
144
+ let a = ip.toLowerCase().replace(/^\[|\]$/g, '');
145
+ if (isIP(a) === 6) {
146
+ const b = ipv6Bytes(a);
147
+ if (!b) return true;
148
+ if (b.slice(0, 10).every((x) => x === 0) && b[10] === 0xff && b[11] === 0xff) a = b.slice(12).join('.');
149
+ }
150
+ const own = Object.values(networkInterfaces()).flat().some((i) => i && i.address.toLowerCase() === a);
151
+ if (own) return true;
152
+ if (isIP(a) === 4) return GUEST_V4_BLOCKED.check(a, 'ipv4');
153
+ if (isIP(a) === 6) return GUEST_V6_BLOCKED.check(a, 'ipv6');
154
+ return true;
155
+ }
156
+
157
+ /** Where a guest may be connected for `host`: the address checked (a literal, or what the name resolves to, every
158
+ * answer checked), or the refusal. The caller connects to that address, so a second resolution cannot move it. */
159
+ export async function guestDestination(host: string): Promise<{ address: string } | { refused: string }> {
160
+ const h = host.replace(/^\[|\]$/g, '');
161
+ let addresses: string[];
162
+ try { addresses = isIP(h) ? [h] : (await dnsLookup(h, { all: true })).map((r) => r.address); } catch (error) {
163
+ return { refused: `${host} does not resolve (${(error as Error).message})` };
164
+ }
165
+ const bad = addresses.find(isGuestForbiddenAddress);
166
+ if (bad || !addresses.length) return { refused: `${host} is on the machine's host network (${bad ?? 'no address'}), which a World Machine cannot reach` };
167
+ return { address: addresses[0]! };
168
+ }
169
+
170
+ function shouldBlockUntwinned(value: URL | string, env: Record<string, string | undefined>): boolean {
171
+ let url: URL;
172
+ try { url = value instanceof URL ? value : new URL(value); } catch { return true; }
173
+ const policy = worldNetworkPolicyFromEnv(env);
174
+ if (isLocalOrPrivateHost(url.hostname) || proxyTargetFor(url.hostname, env)) return false;
175
+ return strictEgressEnabled(env) || (policy !== undefined && !allowsWorldNetworkEgress(policy, url));
176
+ }
177
+
178
+ // --- session-scoped CA + on-the-fly leaf certs (via openssl) ---------------------------------
179
+
180
+ // Not exported: no importer outside this file references the type by name (`ensureCa`/`leafCertFor`
181
+ // callers rely on inference).
182
+ type CaPaths = { dir: string; caKey: string; caCert: string };
183
+
184
+ function caPaths(tlsDir: string): CaPaths {
185
+ return { dir: tlsDir, caKey: join(tlsDir, 'ca-key.pem'), caCert: join(tlsDir, 'ca-cert.pem') };
186
+ }
187
+
188
+ export function opensslAvailable(): boolean {
189
+ // TEST-ONLY seam (TWIN-64): forces "openssl unavailable" deterministically. A PATH-shim (fake
190
+ // `openssl` on PATH) doesn't reliably work here because `spawnSync('openssl', …)` below is called
191
+ // with no explicit `env`, and some runtimes resolve the executable against a PATH snapshotted at
192
+ // process start rather than a same-process `process.env.PATH` mutation made mid-test. Never set
193
+ // this env var in real usage.
194
+ if (process.env.VOLTER_TEST_NO_OPENSSL === '1') return false;
195
+ const r = spawnSync('openssl', ['version'], { stdio: 'ignore' });
196
+ return r.status === 0;
197
+ }
198
+
199
+ function runOpenssl(args: string[]): string {
200
+ const r = spawnSync('openssl', args, { encoding: 'utf8' });
201
+ if (r.status !== 0) throw new Error(`openssl ${args[0]} failed: ${(r.stderr || r.stdout || '').trim()}`);
202
+ return r.stdout;
203
+ }
204
+
205
+ /** Generate (once) a throwaway CA in `tlsDir`. Idempotent: reuses an existing CA so a re-activate in
206
+ * the same world trusts the same cert. Session/project-scoped — it lives under the instance dir and
207
+ * is removed by `tearDownCa` on `down`. */
208
+ export function ensureCa(tlsDir: string): CaPaths {
209
+ if (!opensslAvailable()) {
210
+ throw new Error('openssl not found — the TLS redirect proxy needs openssl to mint a session CA. Install openssl, or use per-CLI endpoint env (cliRedirect) instead.');
211
+ }
212
+ const paths = caPaths(tlsDir);
213
+ if (existsSync(paths.caKey) && existsSync(paths.caCert)) return paths;
214
+ mkdirSync(tlsDir, { recursive: true });
215
+ runOpenssl(['genrsa', '-out', paths.caKey, '2048']);
216
+ runOpenssl([
217
+ 'req', '-x509', '-new', '-nodes', '-key', paths.caKey,
218
+ '-sha256', '-days', '3650', '-out', paths.caCert,
219
+ '-subj', '/CN=volter-world session CA/O=volter-world',
220
+ // A CA cert without keyUsage is refused by OpenSSL 3 (Python, curl on Debian 13: "CA cert does not
221
+ // include key usage extension"), so the CA states what a CA is for.
222
+ '-addext', 'basicConstraints=critical,CA:TRUE',
223
+ '-addext', 'keyUsage=critical,keyCertSign,cRLSign',
224
+ '-addext', 'subjectKeyIdentifier=hash',
225
+ ]);
226
+ return paths;
227
+ }
228
+
229
+ /** Remove the session CA + any minted leaf certs. Called on `down` so the trusted CA never outlives
230
+ * the world (it was only ever trusted via the per-shell env, never system-wide). */
231
+ export function tearDownCa(tlsDir: string): void {
232
+ leafCache.clear();
233
+ rmSync(tlsDir, { recursive: true, force: true });
234
+ }
235
+
236
+ const leafCache = new Map<string, { key: string; cert: string }>();
237
+
238
+ /** Mint (and cache) a leaf cert for `host`, signed by the session CA. SAN = the host so the client
239
+ * accepts it for that SNI. */
240
+ export function leafCertFor(host: string, ca: CaPaths): { key: string; cert: string } {
241
+ // A pathname can hold a replacement CA; its certificate identifies the signing generation.
242
+ const cacheKey = `${createHash('sha256').update(readFileSync(ca.caCert)).digest('hex')}:${host}`;
243
+ const cached = leafCache.get(cacheKey);
244
+ if (cached) return cached;
245
+ const safe = host.replace(/[^A-Za-z0-9._-]/g, '_');
246
+ const keyPath = join(ca.dir, `leaf-${safe}-key.pem`);
247
+ const csrPath = join(ca.dir, `leaf-${safe}.csr`);
248
+ const certPath = join(ca.dir, `leaf-${safe}.pem`);
249
+ const extPath = join(ca.dir, `leaf-${safe}.ext`);
250
+ runOpenssl(['genrsa', '-out', keyPath, '2048']);
251
+ runOpenssl(['req', '-new', '-key', keyPath, '-out', csrPath, '-subj', `/CN=${host}`]);
252
+ writeFileSync(extPath, `subjectAltName=DNS:${host}\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n`);
253
+ runOpenssl([
254
+ // 397 days, NOT the CA's 3650: macOS's system verifier (used by Go — `gh`, and any Go/Swift
255
+ // client) rejects a LEAF cert valid >398 days as "certificate is not standards compliant"
256
+ // (CA/Browser-Forum max). The session CA itself is exempt and stays long-lived.
257
+ 'x509', '-req', '-in', csrPath, '-CA', ca.caCert, '-CAkey', ca.caKey,
258
+ '-CAcreateserial', '-out', certPath, '-days', '397', '-sha256', '-extfile', extPath,
259
+ ]);
260
+ const minted = { key: readFileSync(keyPath, 'utf8'), cert: readFileSync(certPath, 'utf8') };
261
+ leafCache.set(cacheKey, minted);
262
+ return minted;
263
+ }
264
+
265
+ // --- the proxy server ------------------------------------------------------------------------
266
+
267
+ // Neither type below is exported: `startRedirectProxy` is the only consumer of these names outside
268
+ // this file, and its callers (runtime.ts) rely on inference (`Awaited<ReturnType<typeof
269
+ // startRedirectProxy>>`) rather than importing the type names.
270
+ type RedirectProxyOptions = {
271
+ /** The world env — supplies the `*_TWIN_URL` host→twin map. */
272
+ env?: Record<string, string | undefined>;
273
+ /** Keep caller trust separate from persisted World trust so neither overwrites the other. */
274
+ trustEnv?: CaTrustInputs;
275
+ /** Optional dynamic env source. Used by the detached world daemon while services are still adding
276
+ * `*_TWIN_URL` vars during `up`; each proxied request resolves against the latest file-backed env. */
277
+ envLoader?: () => Record<string, string | undefined>;
278
+ /** Where the session CA + leaf certs live (instance dir/tls). */
279
+ tlsDir: string;
280
+ /** Where each refusal and each failed twin request is written (the detached daemon's log), so an app that swallows
281
+ * a 502 still leaves the World a record of it. */
282
+ note?: (line: string) => void;
283
+ /** Listen host (default 127.0.0.1) and port (default 0 = ephemeral). */
284
+ host?: string;
285
+ port?: number;
286
+ };
287
+
288
+ type RedirectProxyHandle = {
289
+ port: number;
290
+ host: string;
291
+ caCertPath: string;
292
+ url: string;
293
+ /** Its guests' listener (a World Machine's forwarder targets it): every connection there gets the guest rule. */
294
+ guestUrl: string;
295
+ /** The cert-trust + proxy env an activate/shell session exports to route through this proxy. */
296
+ proxyEnv(): Record<string, string>;
297
+ close(): Promise<void>;
298
+ };
299
+
300
+ /** A failed twin request's answer: the twin, its origin, the World and the cause, so the app's error says which service
301
+ * of which World to look at (`doctor`). Shared by every front that relays to twins. */
302
+ export function twinUnreachableMessage(vendor: string, origin: string, err: Error, world?: string): string {
303
+ const cause = (err as { cause?: { code?: string; message?: string } }).cause;
304
+ return `twin "${vendor}" at ${origin} is unreachable${world ? ` in World "${world}"` : ''}: ${err.message}${cause?.code ? ` (${cause.code})` : cause?.message ? ` (${cause.message})` : ''}; check \`volter-world doctor${world ? ` ${world}` : ''}\`\n`;
305
+ }
306
+
307
+ /** Start the ambient redirect proxy. Returns a handle with the listen port + the env to export.
308
+ * - Plain `http://` proxied requests to a vendor host → forwarded to the twin.
309
+ * - `CONNECT host:443` to a vendor host → MITM-terminated with a session-signed leaf, decrypted, and
310
+ * forwarded to the twin. CONNECT to any other host → blind-tunneled to the real origin untouched. */
311
+ export async function startRedirectProxy(options: RedirectProxyOptions): Promise<RedirectProxyHandle> {
312
+ const host = options.host ?? '127.0.0.1';
313
+ const currentEnv = (): Record<string, string | undefined> => {
314
+ const env = options.envLoader?.() ?? options.env ?? {};
315
+ worldNetworkPolicyFromEnv(env);
316
+ return env;
317
+ };
318
+ currentEnv(); // Invalid authority must fail before the proxy reports readiness.
319
+ const note = (line: string): string => { try { options.note?.(`${new Date().toISOString()} ${line.trimEnd()}`); } catch { /* the answer still goes out */ } return line; };
320
+ const twinUnreachable = (vendor: string, origin: string, err: Error): string => {
321
+ let world: string | undefined;
322
+ try { world = currentEnv().VOLTER_WORLD; } catch { /* an unreadable env must not crash the proxy on its error path */ }
323
+ return note(twinUnreachableMessage(vendor, origin, err, world));
324
+ };
325
+ const ca = ensureCa(options.tlsDir);
326
+ const trust = sessionTrustEnv(ca.caCert, options.trustEnv ?? options.envLoader?.() ?? options.env ?? process.env);
327
+ // Track raw client/upstream sockets so close() can forcibly drop tunnels (a blind tunnel to a slow
328
+ // or real upstream would otherwise keep the server from closing).
329
+ const openSockets = new Set<Socket>();
330
+
331
+ // The MITM TLS endpoint(s): a REAL listening TLS server PER vendor host (loopback, ephemeral port), each pinned
332
+ // to that host's leaf cert. On CONNECT to a vendor host we tunnel the client's raw bytes to the matching per-host
333
+ // server, which terminates TLS and forwards the decrypted request to the resolved twin. (One server per host
334
+ // rather than SNI: hosts are few and known from the world's vendor map.)
335
+ // The portable host uses native upgrades on each runtime, so a websocket on a twinned host —
336
+ // discord.py's gateway at wss://gateway.discord.gg/ — must reach the twin. HTTP is forwarded with fetch; a
337
+ // websocket is relayed message by message to the twin's own websocket at the same path.
338
+ const tlsServers = new Map<string, HttpServer>();
339
+ const tlsPorts = new Map<string, number>();
340
+ const ensureHostServer = async (vendorHost: string): Promise<number> => {
341
+ const existing = tlsPorts.get(vendorHost);
342
+ if (existing) return existing;
343
+ const leaf = leafCertFor(vendorHost, ca);
344
+ const hostServer = await serveHttp({
345
+ hostname: '127.0.0.1',
346
+ port: 0,
347
+ tls: { key: leaf.key, cert: leaf.cert },
348
+ // A twin can think before its first byte (a git pack built from a cold cache took 19-28 s); Bun's 10 s idle
349
+ // default cut those connections and the client read an empty reply. Bun's maximum, as reflect.ts serves.
350
+ idleTimeout: 255,
351
+ async fetch(req) {
352
+ const u = new URL(req.url);
353
+ const hostHeader = (req.headers.get('host') ?? vendorHost).split(':')[0] ?? vendorHost;
354
+ const env = currentEnv();
355
+ // The request line is readable now, so resolve WITH the path: this is the point where a host two vendors
356
+ // share (www.googleapis.com → googleauth vs youtube) gets split correctly; on a shared host there IS a twin
357
+ // for the host and merely not for this path, and the bare message sends the next reader hunting.
358
+ const twin = proxyTargetFor(hostHeader, env, u.pathname) ?? proxyTargetFor(vendorHost, env, u.pathname);
359
+ if (!twin) {
360
+ // a host the World refuses (sealed, or outside its network policy): answered here, over the World's TLS, so
361
+ // a person's browser shows where it was sent and why nothing is there, not an empty tunnel error; nothing
362
+ // leaves the World
363
+ if (shouldBlockUntwinned(`https://${hostHeader}`, env)) return outsideWorld(hostHeader, `${u.pathname}${u.search}`, req.headers.get('accept'));
364
+ const unreachable = unreachableHosts.get(hostHeader.toLowerCase());
365
+ if (unreachable) return outsideWorld(hostHeader, `${u.pathname}${u.search}`, req.headers.get('accept'), unreachable);
366
+ return new Response(note(noTwinMessage(hostHeader, u.pathname)), { status: 502, headers: { 'content-type': 'text/plain; charset=utf-8' } });
367
+ }
368
+ const origin = new URL(twin.origin);
369
+
370
+ const headers = new Headers(req.headers);
371
+ for (const h of ['proxy-connection', 'accept-encoding', 'connection']) headers.delete(h);
372
+ // the application is not a twin: it gets no World key, keeps its Host and hears it was reached over TLS
373
+ if (twin.vendor === 'app') {
374
+ headers.delete('x-twins-key');
375
+ for (const [k, v] of Object.entries(loadInject().appForwardHeaders(new URL(`https://${req.headers.get('host') ?? vendorHost}`)))) headers.set(k, v);
376
+ } else if (env.VOLTER_TWINS_KEY) headers.set('x-twins-key', env.VOLTER_TWINS_KEY);
377
+ // Hosted namespaces need their transport Host; direct twins can use
378
+ // the original Host for vendor semantics (for example S3 buckets).
379
+ if (origin.pathname.replace(/\/$/, '') && twin.vendor !== 'app') {
380
+ headers.set('x-volter-twin-original-host', hostHeader);
381
+ headers.set('host', origin.host);
382
+ }
383
+ const target = new URL(origin);
384
+ target.pathname = `${origin.pathname.replace(/\/$/, '')}${u.pathname}`;
385
+ target.search = u.search;
386
+ try {
387
+ const upRes = await fetch(target, { method: req.method, headers, body: req.method === 'GET' || req.method === 'HEAD' ? undefined : req.body, ...(req.body ? { duplex: 'half' as const } : {}), redirect: 'manual' });
388
+ const out = new Headers(upRes.headers);
389
+ for (const h of ['content-encoding', 'content-length', 'transfer-encoding', 'connection', 'keep-alive']) out.delete(h);
390
+ return new Response(upRes.body, { status: upRes.status, headers: out });
391
+ } catch (err) {
392
+ return new Response(twinUnreachable(twin.vendor, origin.origin, err as Error), { status: 502 });
393
+ }
394
+ },
395
+ upgrade: websocketRelay(req => {
396
+ const url = new URL(req.url);
397
+ const host = (req.headers.get('host') ?? vendorHost).split(':')[0]!;
398
+ const twin = proxyTargetFor(host, currentEnv(), url.pathname) ?? proxyTargetFor(vendorHost, currentEnv(), url.pathname);
399
+ if (!twin) return undefined;
400
+ const origin = new URL(twin.origin);
401
+ return `${origin.protocol === 'https:' ? 'wss' : 'ws'}://${origin.host}${origin.pathname.replace(/\/$/, '')}${url.pathname}${url.search}`;
402
+ }),
403
+ });
404
+ const p = hostServer.port ?? 0;
405
+ tlsServers.set(vendorHost, hostServer);
406
+ tlsPorts.set(vendorHost, p);
407
+ return p;
408
+ };
409
+
410
+ // two listeners with one handler: the World's (host clients; a connection from elsewhere is still a guest's) and its
411
+ // guests' (a World Machine's forwarder targets it: every connection a guest's, whatever NAT makes its source look like)
412
+ const accept = (guestListener: boolean) => (clientSocket: Socket) => {
413
+ const guest = guestListener || isGuestSource(clientSocket.remoteAddress);
414
+ openSockets.add(clientSocket);
415
+ clientSocket.on('close', () => openSockets.delete(clientSocket));
416
+ clientSocket.on('error', () => clientSocket.destroy());
417
+
418
+ let buffered = Buffer.alloc(0);
419
+ const onData = (chunk: Buffer) => {
420
+ buffered = Buffer.concat([buffered, chunk]);
421
+ const headerEnd = buffered.indexOf('\r\n\r\n');
422
+ if (headerEnd === -1) return;
423
+ clientSocket.removeListener('data', onData);
424
+
425
+ const headerBytes = buffered.subarray(0, headerEnd + 4);
426
+ const head = buffered.subarray(headerEnd + 4);
427
+ const headerText = headerBytes.toString('latin1');
428
+ const [requestLine = '', ...headerLines] = headerText.split('\r\n');
429
+ const [method = '', urlStr = '', version = 'HTTP/1.1'] = requestLine.split(' ');
430
+ const headers: Record<string, string> = {};
431
+ for (const line of headerLines) {
432
+ if (!line) continue;
433
+ const idx = line.indexOf(':');
434
+ if (idx === -1) continue;
435
+ headers[line.slice(0, idx).toLowerCase()] = line.slice(idx + 1).trim();
436
+ }
437
+
438
+ if (method.toUpperCase() === 'CONNECT') {
439
+ try { handleConnect(urlStr, clientSocket, head, guest); }
440
+ catch { writeResponse(clientSocket, 'HTTP/1.1 502 Bad Gateway', note('World network policy unavailable\n')); }
441
+ return;
442
+ }
443
+
444
+ handlePlainProxyRequest(method, urlStr, version, headers, clientSocket, head, guest);
445
+ };
446
+ clientSocket.on('data', onData);
447
+ };
448
+ const server = createNetServer(accept(false));
449
+ const guestServer = createNetServer(accept(true));
450
+
451
+ /** A refused host's answer: 502, naming the host and why, as a page a person reads in a browser (the address bar
452
+ * keeps where the request was sent) or as text for any other client. */
453
+ const outsideWorld = (host: string, path: string, accept: string | null, unreachable?: string): Response => {
454
+ // the CONNECT that reached this server was noted when it was refused
455
+ const why = unreachable
456
+ ? `no twin serves it, and it did not answer from this World (${unreachable}).`
457
+ : 'no twin serves it, and the World does not reach the internet for it.';
458
+ const text = `${host} is outside this World: ${why}\n`;
459
+ // connection: close, so a reload opens a new CONNECT, which reaches the origin once it answers (a kept-alive tunnel
460
+ // would keep answering this page)
461
+ if (!(accept ?? '').includes('text/html')) return new Response(text, { status: 502, headers: { 'content-type': 'text/plain; charset=utf-8', connection: 'close' } });
462
+ const esc = (v: string) => v.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]!);
463
+ const page = `<!doctype html><html><head><meta charset="utf-8"><title>${esc(host)} is outside this World</title>`
464
+ + '<style>body{font:15px system-ui,sans-serif;max-width:620px;margin:12vh auto;padding:0 16px;color:#222}code{background:#f2f2f2;padding:1px 4px;border-radius:4px}</style></head><body>'
465
+ + `<h1>${esc(host)} is outside this World</h1><p>You were sent to <code>https://${esc(host)}${esc(path)}</code>. ${esc(why.charAt(0).toUpperCase() + why.slice(1))} There is nothing to show here.</p></body></html>`;
466
+ return new Response(page, { status: 502, headers: { 'content-type': 'text/html; charset=utf-8', connection: 'close' } });
467
+ };
468
+
469
+ // the refused hosts answered with a page, each holding a leaf and a listener until the proxy closes: bounded
470
+ const REFUSED_PAGES_MAX = 64;
471
+ const refusedPages = new Set<string>();
472
+ // the names on 443 a tunnel could not reach, with why, answered by their host server's page
473
+ const unreachableHosts = new Map<string, string>();
474
+ const isDnsName = (host: string): boolean => /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$/i.test(host);
475
+
476
+ const writeResponse = (socket: Socket, status: string, body: string): void => {
477
+ socket.write(`${status}\r\ncontent-type: text/plain\r\ncontent-length: ${Buffer.byteLength(body)}\r\nconnection: close\r\n\r\n${body}`);
478
+ socket.end();
479
+ };
480
+
481
+ const forwardRawHttp = (
482
+ origin: URL,
483
+ method: string,
484
+ path: string,
485
+ version: string,
486
+ headers: Record<string, string>,
487
+ socket: Socket,
488
+ head: Buffer,
489
+ vendor?: string,
490
+ address?: string,
491
+ ): void => {
492
+ // where to connect: the address a guest's check passed, else the origin's host (an IPv6 one unbracketed)
493
+ const connectHost = address ?? origin.hostname.replace(/^\[|\]$/g, '');
494
+ const connected = () => {
495
+ const outHeaders = { ...headers };
496
+ if (origin.pathname.replace(/\/$/, '')) {
497
+ outHeaders['x-volter-twin-original-host'] = headers.host ?? origin.host;
498
+ outHeaders.host = origin.host;
499
+ }
500
+ delete outHeaders['proxy-connection'];
501
+ delete outHeaders['accept-encoding'];
502
+ upstream.write(`${method} ${path} ${version}\r\n`);
503
+ for (const [name, value] of Object.entries(outHeaders)) upstream.write(`${name}: ${value}\r\n`);
504
+ upstream.write('\r\n');
505
+ if (head.length) upstream.write(head);
506
+ socket.pipe(upstream);
507
+ upstream.pipe(socket);
508
+ };
509
+ const upstream = origin.protocol === 'https:'
510
+ ? tlsConnect({ host: connectHost, port: Number(origin.port || 443), ...(isIP(origin.hostname.replace(/^\[|\]$/g, '')) ? {} : { servername: origin.hostname }), ca: readFileSync(trust.SSL_CERT_FILE!) }, connected)
511
+ : netConnect(Number(origin.port || 80), connectHost, connected);
512
+ openSockets.add(upstream);
513
+ upstream.on('close', () => openSockets.delete(upstream));
514
+ upstream.on('error', (err: Error) => writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', vendor ? twinUnreachable(vendor, origin.origin, err) : note(`upstream ${origin.host} unreachable: ${err.message}\n`)));
515
+ };
516
+
517
+ const handlePlainProxyRequest = (
518
+ method: string,
519
+ urlStr: string,
520
+ version: string,
521
+ headers: Record<string, string>,
522
+ socket: Socket,
523
+ head: Buffer,
524
+ guest: boolean,
525
+ ): void => {
526
+ try {
527
+ const u = new URL(urlStr);
528
+ const twin = proxyTargetFor(u.hostname, currentEnv(), u.pathname);
529
+ const path = u.pathname + u.search;
530
+ if (twin) {
531
+ const target = new URL(twin.origin);
532
+ const key = twin.vendor === 'app' ? undefined : currentEnv().VOLTER_TWINS_KEY;
533
+ const app = twin.vendor === 'app' ? loadInject().appForwardHeaders(u) : {};
534
+ forwardRawHttp(target, method, `${target.pathname.replace(/\/$/, '')}${path}`, version, { ...headers, ...app, ...(key ? { 'x-twins-key': key } : {}) }, socket, head, twin.vendor);
535
+ return;
536
+ }
537
+ // No twin claims this PATH — but if the HOST is twinned (host-level candidacy), the path is
538
+ // an unclaimed slice of a shared, virtualized host: refuse loudly (same rule as the MITM
539
+ // handler's noTwinMessage), never pass through to the real vendor.
540
+ if (proxyTargetFor(u.hostname, currentEnv())) {
541
+ writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(noTwinMessage(u.hostname, u.pathname)));
542
+ return;
543
+ }
544
+ if (shouldBlockUntwinned(u, currentEnv())) {
545
+ writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(`blocked untwinned external request to ${u.hostname}\n`));
546
+ return;
547
+ }
548
+ // Non-vendor: plain pass-through to the real host; for a World Machine, only off its host's own network, and to
549
+ // the address checked (architecture, "The scoped proxy's local pass-through")
550
+ if (guest) {
551
+ void guestDestination(u.hostname).then((d) => {
552
+ if ('refused' in d) { writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(`${d.refused}\n`)); return; }
553
+ // to the address checked (TLS still names the host: SNI and the certificate)
554
+ forwardRawHttp(new URL(`${u.protocol}//${u.host}`), method, path, version, headers, socket, head, undefined, d.address);
555
+ }).catch(() => writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', 'bad gateway\n')); // (a throw must not end the proxy)
556
+ return;
557
+ }
558
+ forwardRawHttp(new URL(`${u.protocol}//${u.host}`), method, path, version, headers, socket, head);
559
+ } catch {
560
+ writeResponse(socket, 'HTTP/1.1 400 Bad Request', 'bad request\n');
561
+ }
562
+ };
563
+
564
+ const handleConnect = (urlStr: string, clientSocket: Socket, head: Buffer, guest: boolean): void => {
565
+ const [connectHostRaw, connectPortRaw] = urlStr.split(':');
566
+ const connectHost = connectHostRaw?.toLowerCase(); // hostnames are case-insensitive; the host rules are lowercase
567
+ const connectPort = Number(connectPortRaw || 443);
568
+ const twin = connectHost ? proxyTargetFor(connectHost, currentEnv()) : null;
569
+ const tunnel = (destPort: number, destHost: string, unreachable?: (reason: string) => boolean): void => {
570
+ let connected = false;
571
+ const upstream = netConnect(destPort, destHost, () => {
572
+ connected = true;
573
+ clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
574
+ if (head && head.length) upstream.write(head);
575
+ upstream.pipe(clientSocket);
576
+ clientSocket.pipe(upstream);
577
+ });
578
+ openSockets.add(upstream);
579
+ upstream.on('close', () => openSockets.delete(upstream));
580
+ // a destination that cannot be reached at all (a name that does not resolve, a refused port) is answered by the
581
+ // caller's fallback when it has one, before the tunnel was ever established; otherwise the client's socket ends
582
+ upstream.on('error', (error: NodeJS.ErrnoException) => {
583
+ if (!connected && unreachable && unreachable(error.code ?? error.message)) return;
584
+ clientSocket.destroy();
585
+ });
586
+ };
587
+ /** A page for a DNS name on 443 that cannot be reached, as for a refused one (within REFUSED_PAGES_MAX). */
588
+ const unreachablePage = (reason: string): boolean => {
589
+ if (!(connectHost && connectPort === 443 && isDnsName(connectHost) && (refusedPages.has(connectHost) || refusedPages.size < REFUSED_PAGES_MAX))) return false;
590
+ note(`${connectHost}:${connectPort} did not answer (${reason})`);
591
+ refusedPages.add(connectHost);
592
+ unreachableHosts.set(connectHost, reason);
593
+ ensureHostServer(connectHost).then((port) => tunnel(port, '127.0.0.1')).catch(() => clientSocket.destroy());
594
+ return true;
595
+ };
596
+ // MITM vendor hosts → tunnel to the per-host listening https server (terminates TLS, forwards to
597
+ // the twin). Non-vendor hosts → blind-tunnel to the REAL origin, untouched.
598
+ if (twin && connectHost) {
599
+ ensureHostServer(connectHost)
600
+ .then((port) => tunnel(port, '127.0.0.1'))
601
+ .catch(() => clientSocket.destroy());
602
+ } else if (connectHost && shouldBlockUntwinned(`https://${connectHost}:${connectPort}`, currentEnv())) {
603
+ note(`blocked untwinned external request to ${connectHost}:${connectPort}`);
604
+ // a browser's https to a DNS name: terminated with the World's TLS and answered by the host server's page
605
+ // (outsideWorld), so the person sees where they were sent. Anything else (another port or protocol, an address,
606
+ // a name past the first REFUSED_PAGES_MAX, each of which costs a leaf and a listener) is refused on the tunnel
607
+ // line, as before
608
+ if (connectPort === 443 && isDnsName(connectHost) && (refusedPages.has(connectHost) || refusedPages.size < REFUSED_PAGES_MAX)) {
609
+ refusedPages.add(connectHost);
610
+ ensureHostServer(connectHost)
611
+ .then((port) => tunnel(port, '127.0.0.1'))
612
+ .catch(() => clientSocket.destroy());
613
+ } else {
614
+ clientSocket.write('HTTP/1.1 502 Bad Gateway\r\ncontent-type: text/plain\r\n\r\nblocked untwinned external request\r\n');
615
+ clientSocket.destroy();
616
+ }
617
+ } else if (guest) {
618
+ // a World Machine: never its host's own network (architecture, "The scoped proxy's local pass-through")
619
+ void guestDestination(connectHost!).then((d) => {
620
+ if ('refused' in d) {
621
+ note(`refused a machine's tunnel to ${connectHost}:${connectPort}: ${d.refused}`);
622
+ clientSocket.write(`HTTP/1.1 502 Bad Gateway\r\ncontent-type: text/plain\r\n\r\n${d.refused}\r\n`);
623
+ clientSocket.destroy();
624
+ } else tunnel(connectPort, d.address);
625
+ }).catch(() => clientSocket.destroy()); // (outside the handler's own try: a throw here must not end the proxy)
626
+ } else {
627
+ tunnel(connectPort, connectHost!, unreachablePage);
628
+ }
629
+ };
630
+
631
+ await new Promise<void>((resolveListen, reject) => {
632
+ server.once('error', reject);
633
+ server.listen(options.port ?? 0, host, () => resolveListen());
634
+ });
635
+ await new Promise<void>((resolveListen, reject) => {
636
+ guestServer.once('error', reject);
637
+ guestServer.listen(0, host, () => resolveListen());
638
+ });
639
+ const addr = server.address();
640
+ const port = typeof addr === 'object' && addr ? addr.port : (options.port ?? 0);
641
+ const url = `http://${host}:${port}`;
642
+ const guestAddr = guestServer.address();
643
+ const guestUrl = `http://${host}:${typeof guestAddr === 'object' && guestAddr ? guestAddr.port : 0}`;
644
+
645
+ return {
646
+ port,
647
+ host,
648
+ caCertPath: ca.caCert,
649
+ url,
650
+ guestUrl,
651
+ proxyEnv: () => ({ ...proxyAddressEnv(url, ca.caCert, guestUrl), ...trust }),
652
+ close: () =>
653
+ new Promise<void>((resolveClose) => {
654
+ // Forcibly drop any in-flight tunnels/keep-alive sockets so close() can't hang on a blind
655
+ // tunnel to a slow/real upstream.
656
+ for (const s of openSockets) { try { s.destroy(); } catch { /* ignore */ } }
657
+ openSockets.clear();
658
+ for (const hostServer of tlsServers.values()) { try { hostServer.stop(true); } catch { /* ignore */ } }
659
+ guestServer.close();
660
+ server.close(() => resolveClose());
661
+ }),
662
+ };
663
+ }
664
+
665
+ /** The cert-trust + proxy env that points an unmodified tool through the proxy and trusts the session
666
+ * CA — and ONLY this session (these are per-shell exports; nothing is installed system-wide). Mirrors
667
+ * the env list in WORLD_ACTIVATE.md §"The honest cost: a scoped CA". */
668
+ export function proxyEnvFor(proxyUrl: string, caCertPath: string, caller: CaTrustInputs = process.env, guestUrl?: string): Record<string, string> {
669
+ return { ...proxyAddressEnv(proxyUrl, caCertPath, guestUrl), ...sessionTrustEnv(caCertPath, caller) };
670
+ }
671
+
672
+ /** The proxy's address env; `VOLTER_WORLD_GUEST_PROXY` is its guests' listener, which a World Machine's forwarder
673
+ * targets (architecture, "The scoped proxy's local pass-through"). */
674
+ function proxyAddressEnv(proxyUrl: string, caCertPath: string, guestUrl?: string): Record<string, string> {
675
+ return {
676
+ ...(guestUrl ? { VOLTER_WORLD_GUEST_PROXY: guestUrl } : {}),
677
+ HTTPS_PROXY: proxyUrl,
678
+ HTTP_PROXY: proxyUrl,
679
+ https_proxy: proxyUrl,
680
+ http_proxy: proxyUrl,
681
+ VOLTER_WORLD_PROXY: proxyUrl,
682
+ VOLTER_WORLD_CA: caCertPath,
683
+ };
684
+ }