@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.
- package/LICENSE +202 -0
- package/dist/known-external-services.json +1108 -0
- package/dist/src/ancestry.d.ts +2 -0
- package/dist/src/ancestry.js +42 -0
- package/dist/src/app-url.d.ts +47 -0
- package/dist/src/app-url.js +239 -0
- package/dist/src/attach.d.ts +48 -0
- package/dist/src/attach.js +87 -0
- package/dist/src/branch.d.ts +20 -0
- package/dist/src/branch.js +65 -0
- package/dist/src/browser-proxy-cli.d.ts +2 -0
- package/dist/src/browser-proxy-cli.js +41 -0
- package/dist/src/ca-trust.d.ts +5 -0
- package/dist/src/ca-trust.js +64 -0
- package/dist/src/catalog.d.ts +31 -0
- package/dist/src/catalog.js +148 -0
- package/dist/src/changeset.d.ts +142 -0
- package/dist/src/changeset.js +570 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +1262 -0
- package/dist/src/command-lifetime.d.ts +15 -0
- package/dist/src/command-lifetime.js +98 -0
- package/dist/src/configs.d.ts +18 -0
- package/dist/src/configs.js +119 -0
- package/dist/src/console-apart.d.ts +38 -0
- package/dist/src/console-apart.js +107 -0
- package/dist/src/consumers.d.ts +46 -0
- package/dist/src/consumers.js +200 -0
- package/dist/src/covers.d.ts +183 -0
- package/dist/src/covers.js +800 -0
- package/dist/src/fixture-env.d.ts +42 -0
- package/dist/src/fixture-env.js +221 -0
- package/dist/src/host-cli.d.ts +2 -0
- package/dist/src/host-cli.js +92 -0
- package/dist/src/host-fault-fixture.d.ts +32 -0
- package/dist/src/host-fault-fixture.js +100 -0
- package/dist/src/host-worker.d.ts +1 -0
- package/dist/src/host-worker.js +23 -0
- package/dist/src/host.d.ts +38 -0
- package/dist/src/host.js +135 -0
- package/dist/src/index.d.ts +48 -0
- package/dist/src/index.js +35 -0
- package/dist/src/infra-cli.d.ts +2 -0
- package/dist/src/infra-cli.js +136 -0
- package/dist/src/init.d.ts +227 -0
- package/dist/src/init.js +1117 -0
- package/dist/src/inject-map.d.ts +34 -0
- package/dist/src/inject-map.js +56 -0
- package/dist/src/lifecycle-record.d.ts +47 -0
- package/dist/src/lifecycle-record.js +196 -0
- package/dist/src/origin.d.ts +31 -0
- package/dist/src/origin.js +139 -0
- package/dist/src/pack-facts.d.ts +75 -0
- package/dist/src/pack-facts.js +98 -0
- package/dist/src/pglite-backing.d.ts +21 -0
- package/dist/src/pglite-backing.js +158 -0
- package/dist/src/pglite-host.mjs +147 -0
- package/dist/src/placeholder.d.ts +20 -0
- package/dist/src/placeholder.js +100 -0
- package/dist/src/prerequisites.d.ts +21 -0
- package/dist/src/prerequisites.js +49 -0
- package/dist/src/process-groups.d.ts +4 -0
- package/dist/src/process-groups.js +49 -0
- package/dist/src/project-inspect.d.ts +109 -0
- package/dist/src/project-inspect.js +827 -0
- package/dist/src/proxy-daemon.d.ts +2 -0
- package/dist/src/proxy-daemon.js +18 -0
- package/dist/src/redirect-proxy.d.ts +105 -0
- package/dist/src/redirect-proxy.js +665 -0
- package/dist/src/reflect.d.ts +74 -0
- package/dist/src/reflect.js +392 -0
- package/dist/src/resources.d.ts +26 -0
- package/dist/src/resources.js +22 -0
- package/dist/src/root.d.ts +114 -0
- package/dist/src/root.js +312 -0
- package/dist/src/run-task-worker.d.ts +1 -0
- package/dist/src/run-task-worker.js +38 -0
- package/dist/src/run-task.d.ts +18 -0
- package/dist/src/run-task.js +48 -0
- package/dist/src/runtime-test-support.d.ts +59 -0
- package/dist/src/runtime-test-support.js +205 -0
- package/dist/src/runtime.d.ts +256 -0
- package/dist/src/runtime.js +3502 -0
- package/dist/src/schema.d.ts +449 -0
- package/dist/src/schema.js +605 -0
- package/dist/src/serve.d.ts +30 -0
- package/dist/src/serve.js +82 -0
- package/dist/src/served-world.d.ts +194 -0
- package/dist/src/served-world.js +986 -0
- package/dist/src/service-exit.d.ts +46 -0
- package/dist/src/service-exit.js +195 -0
- package/dist/src/service-recorder.d.ts +1 -0
- package/dist/src/service-recorder.js +121 -0
- package/dist/src/sibling.d.ts +1 -0
- package/dist/src/sibling.js +9 -0
- package/dist/src/signals.d.ts +1 -0
- package/dist/src/signals.js +11 -0
- package/dist/src/storage-capacity.d.ts +8 -0
- package/dist/src/storage-capacity.js +61 -0
- package/dist/src/tail.d.ts +30 -0
- package/dist/src/tail.js +160 -0
- package/dist/src/tcp-port.d.ts +2 -0
- package/dist/src/tcp-port.js +36 -0
- package/dist/src/up-task-worker.d.ts +1 -0
- package/dist/src/up-task-worker.js +61 -0
- package/dist/src/up-task.d.ts +17 -0
- package/dist/src/up-task.js +49 -0
- package/dist/src/websocket-relay.d.ts +3 -0
- package/dist/src/websocket-relay.js +40 -0
- package/known-external-services.json +1108 -0
- package/package.json +83 -0
- package/src/ancestry.ts +36 -0
- package/src/app-url.ts +253 -0
- package/src/attach.ts +117 -0
- package/src/branch.ts +63 -0
- package/src/browser-proxy-cli.ts +44 -0
- package/src/ca-trust.ts +57 -0
- package/src/catalog.ts +156 -0
- package/src/changeset.ts +627 -0
- package/src/cli.ts +1111 -0
- package/src/command-lifetime.ts +79 -0
- package/src/configs.ts +110 -0
- package/src/console-apart.ts +90 -0
- package/src/consumers.ts +185 -0
- package/src/covers.ts +934 -0
- package/src/fixture-env.ts +230 -0
- package/src/host-cli.ts +90 -0
- package/src/host-worker.ts +23 -0
- package/src/host.ts +169 -0
- package/src/index.ts +171 -0
- package/src/infra-cli.ts +133 -0
- package/src/init.ts +1316 -0
- package/src/inject-map.ts +72 -0
- package/src/lifecycle-record.ts +168 -0
- package/src/origin.ts +134 -0
- package/src/pack-facts.ts +128 -0
- package/src/pglite-backing.ts +141 -0
- package/src/pglite-host.mjs +147 -0
- package/src/placeholder.ts +89 -0
- package/src/prerequisites.ts +66 -0
- package/src/process-groups.ts +33 -0
- package/src/project-inspect.ts +770 -0
- package/src/proxy-daemon.ts +21 -0
- package/src/redirect-proxy.ts +684 -0
- package/src/reflect.ts +440 -0
- package/src/resources.ts +22 -0
- package/src/root.ts +290 -0
- package/src/run-task-worker.ts +27 -0
- package/src/run-task.ts +44 -0
- package/src/runtime-test-support.ts +208 -0
- package/src/runtime.ts +3357 -0
- package/src/schema.ts +922 -0
- package/src/serve.ts +102 -0
- package/src/served-world.ts +812 -0
- package/src/service-exit.ts +175 -0
- package/src/service-recorder.ts +89 -0
- package/src/sibling.ts +10 -0
- package/src/signals.ts +10 -0
- package/src/storage-capacity.ts +60 -0
- package/src/tail.ts +205 -0
- package/src/tcp-port.ts +35 -0
- package/src/up-task-worker.ts +40 -0
- package/src/up-task.ts +45 -0
- 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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
|
+
}
|