@volter/twin-world 0.1.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/package.json +61 -0
- package/src/browser-proxy-cli.ts +43 -0
- package/src/cli.ts +226 -0
- package/src/configs.ts +24 -0
- package/src/host-cli.ts +86 -0
- package/src/host-worker.ts +22 -0
- package/src/host.ts +142 -0
- package/src/index.ts +42 -0
- package/src/prerequisites.ts +109 -0
- package/src/proxy-daemon.ts +21 -0
- package/src/redirect-proxy.ts +437 -0
- package/src/runtime.ts +1888 -0
- package/src/schema.ts +471 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
// The ambient TLS redirect proxy — the substance of "any CLI, zero config" (WORLD_ACTIVATE.md §"How
|
|
2
|
+
// redirection works", tier 1). A local TLS-terminating forward proxy that maps vendor API hosts
|
|
3
|
+
// (api.stripe.com, api.github.com, …) → the world's twin services, so an UNMODIFIED `gh`/`stripe`/
|
|
4
|
+
// `curl`/app — anything that honors HTTPS_PROXY + a CA bundle env — transparently lands in the twins
|
|
5
|
+
// with no per-tool config.
|
|
6
|
+
//
|
|
7
|
+
// Mechanism:
|
|
8
|
+
// - The host→twin table is reused from `@volter/twin/inject` (`VENDOR_HOSTS` + `readMap` +
|
|
9
|
+
// `resolveTwin`) so this proxy and the Node injector never drift. No vendor names live here.
|
|
10
|
+
// - A throwaway, session-scoped CA (per instance dir) signs per-host leaf certs on the fly. The CA
|
|
11
|
+
// is trusted ONLY via the per-shell env (`NODE_EXTRA_CA_CERTS`, `CURL_CA_BUNDLE`, …) that
|
|
12
|
+
// `activate`/`shell` export — never installed system-wide. `down` drops it.
|
|
13
|
+
// - On CONNECT to a matched vendor host, the proxy MITM-terminates TLS (leaf cert for that SNI) and
|
|
14
|
+
// forwards the decrypted request to the twin over plain HTTP. CONNECT to any OTHER host is blind-
|
|
15
|
+
// tunneled to the real origin untouched, so non-vendor traffic in the shell still works normally.
|
|
16
|
+
//
|
|
17
|
+
// Cert tooling: we shell out to `openssl` (already a dev prerequisite; node:crypto can generate keys
|
|
18
|
+
// but cannot mint X.509 certs without a third-party lib). This keeps world-runtime dependency-light.
|
|
19
|
+
import { spawnSync } from 'node:child_process';
|
|
20
|
+
import { Socket, connect as netConnect, createServer as createNetServer } from 'node:net';
|
|
21
|
+
import { createServer as createHttpsServer } from 'node:https';
|
|
22
|
+
import type { Server as HttpsServer } from 'node:https';
|
|
23
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
24
|
+
import { request as httpRequest } from 'node:http';
|
|
25
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
import { createRequire } from 'node:module';
|
|
28
|
+
|
|
29
|
+
/** The slice of `@volter/twin/inject` we reuse for the host→twin table (so we never re-encode the
|
|
30
|
+
* vendor host predicates here — they stay one source of truth with the Node injector). */
|
|
31
|
+
type InjectModule = {
|
|
32
|
+
readMap(env: Record<string, string | undefined>): Record<string, string>;
|
|
33
|
+
resolveTwin(host: string, map: Record<string, string>): { vendor: string; origin: string } | null;
|
|
34
|
+
VENDOR_HOSTS: Record<string, (host: string) => boolean>;
|
|
35
|
+
restore(): void;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
let injectCache: InjectModule | null = null;
|
|
39
|
+
|
|
40
|
+
/** Lazily load the injector's host→twin table. Loading the CJS auto-installs its http/fetch patches
|
|
41
|
+
* as a side effect; we immediately `restore()` so requiring it here is inert (we only want the data
|
|
42
|
+
* + pure functions, not to patch THIS process). Not exported: no importer outside this file uses it
|
|
43
|
+
* directly (external callers go through `proxyTargetFor`/`activeVendorMap`). */
|
|
44
|
+
function loadInject(): InjectModule {
|
|
45
|
+
if (injectCache) return injectCache;
|
|
46
|
+
const require = createRequire(import.meta.url);
|
|
47
|
+
const mod = require('@volter/twin/inject') as InjectModule;
|
|
48
|
+
try { mod.restore(); } catch { /* nothing was installed */ }
|
|
49
|
+
injectCache = mod;
|
|
50
|
+
return mod;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Resolve which twin origin (if any) a vendor host maps to, given the world env (which carries the
|
|
54
|
+
* `*_TWIN_URL` vars). Returns null for hosts the world doesn't twin — those are tunneled untouched. */
|
|
55
|
+
export function proxyTargetFor(hostname: string, env: Record<string, string | undefined>): { vendor: string; origin: string } | null {
|
|
56
|
+
const inject = loadInject();
|
|
57
|
+
return inject.resolveTwin(hostname, inject.readMap(env));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The vendor→twin map this world actively redirects (for diagnostics / activate output). */
|
|
61
|
+
export function activeVendorMap(env: Record<string, string | undefined>): Record<string, string> {
|
|
62
|
+
return loadInject().readMap(env);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function strictEgressEnabled(env: Record<string, string | undefined>): boolean {
|
|
66
|
+
return env.VOLTER_TWIN_STRICT_EGRESS === '1' || env.VOLTER_TWIN_STRICT_EGRESS === 'true';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isLocalOrPrivateHost(hostname: string): boolean {
|
|
70
|
+
const h = String(hostname || '').toLowerCase();
|
|
71
|
+
if (!h) return true;
|
|
72
|
+
if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.test')) return true;
|
|
73
|
+
if (h === '0.0.0.0' || h === '127.0.0.1' || h === '::1' || h === '[::1]') return true;
|
|
74
|
+
if (/^127\./.test(h)) return true;
|
|
75
|
+
if (/^10\./.test(h)) return true;
|
|
76
|
+
if (/^192\.168\./.test(h)) return true;
|
|
77
|
+
const match = h.match(/^172\.(\d+)\./);
|
|
78
|
+
if (match) {
|
|
79
|
+
const n = Number(match[1]);
|
|
80
|
+
if (n >= 16 && n <= 31) return true;
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function shouldBlockUntwinned(hostname: string, env: Record<string, string | undefined>): boolean {
|
|
86
|
+
return strictEgressEnabled(env) && !isLocalOrPrivateHost(hostname) && !proxyTargetFor(hostname, env);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// --- session-scoped CA + on-the-fly leaf certs (via openssl) ---------------------------------
|
|
90
|
+
|
|
91
|
+
// Not exported: no importer outside this file references the type by name (`ensureCa`/`leafCertFor`
|
|
92
|
+
// callers rely on inference).
|
|
93
|
+
type CaPaths = { dir: string; caKey: string; caCert: string };
|
|
94
|
+
|
|
95
|
+
function caPaths(tlsDir: string): CaPaths {
|
|
96
|
+
return { dir: tlsDir, caKey: join(tlsDir, 'ca-key.pem'), caCert: join(tlsDir, 'ca-cert.pem') };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function opensslAvailable(): boolean {
|
|
100
|
+
// TEST-ONLY seam (TWIN-64): forces "openssl unavailable" deterministically. A PATH-shim (fake
|
|
101
|
+
// `openssl` on PATH) doesn't reliably work here because `spawnSync('openssl', …)` below is called
|
|
102
|
+
// with no explicit `env`, and some runtimes resolve the executable against a PATH snapshotted at
|
|
103
|
+
// process start rather than a same-process `process.env.PATH` mutation made mid-test. Never set
|
|
104
|
+
// this env var in real usage.
|
|
105
|
+
if (process.env.VOLTER_TEST_NO_OPENSSL === '1') return false;
|
|
106
|
+
const r = spawnSync('openssl', ['version'], { stdio: 'ignore' });
|
|
107
|
+
return r.status === 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function runOpenssl(args: string[]): string {
|
|
111
|
+
const r = spawnSync('openssl', args, { encoding: 'utf8' });
|
|
112
|
+
if (r.status !== 0) throw new Error(`openssl ${args[0]} failed: ${(r.stderr || r.stdout || '').trim()}`);
|
|
113
|
+
return r.stdout;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Generate (once) a throwaway CA in `tlsDir`. Idempotent: reuses an existing CA so a re-activate in
|
|
117
|
+
* the same world trusts the same cert. Session/project-scoped — it lives under the instance dir and
|
|
118
|
+
* is removed by `tearDownCa` on `down`. */
|
|
119
|
+
export function ensureCa(tlsDir: string): CaPaths {
|
|
120
|
+
if (!opensslAvailable()) {
|
|
121
|
+
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.');
|
|
122
|
+
}
|
|
123
|
+
const paths = caPaths(tlsDir);
|
|
124
|
+
if (existsSync(paths.caKey) && existsSync(paths.caCert)) return paths;
|
|
125
|
+
mkdirSync(tlsDir, { recursive: true });
|
|
126
|
+
runOpenssl(['genrsa', '-out', paths.caKey, '2048']);
|
|
127
|
+
runOpenssl([
|
|
128
|
+
'req', '-x509', '-new', '-nodes', '-key', paths.caKey,
|
|
129
|
+
'-sha256', '-days', '3650', '-out', paths.caCert,
|
|
130
|
+
'-subj', '/CN=volter-world session CA/O=volter-world',
|
|
131
|
+
]);
|
|
132
|
+
return paths;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Remove the session CA + any minted leaf certs. Called on `down` so the trusted CA never outlives
|
|
136
|
+
* the world (it was only ever trusted via the per-shell env, never system-wide). */
|
|
137
|
+
export function tearDownCa(tlsDir: string): void {
|
|
138
|
+
leafCache.clear();
|
|
139
|
+
rmSync(tlsDir, { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const leafCache = new Map<string, { key: string; cert: string }>();
|
|
143
|
+
|
|
144
|
+
/** Mint (and cache) a leaf cert for `host`, signed by the session CA. SAN = the host so the client
|
|
145
|
+
* accepts it for that SNI. */
|
|
146
|
+
export function leafCertFor(host: string, ca: CaPaths): { key: string; cert: string } {
|
|
147
|
+
const cached = leafCache.get(host);
|
|
148
|
+
if (cached) return cached;
|
|
149
|
+
const safe = host.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
150
|
+
const keyPath = join(ca.dir, `leaf-${safe}-key.pem`);
|
|
151
|
+
const csrPath = join(ca.dir, `leaf-${safe}.csr`);
|
|
152
|
+
const certPath = join(ca.dir, `leaf-${safe}.pem`);
|
|
153
|
+
const extPath = join(ca.dir, `leaf-${safe}.ext`);
|
|
154
|
+
runOpenssl(['genrsa', '-out', keyPath, '2048']);
|
|
155
|
+
runOpenssl(['req', '-new', '-key', keyPath, '-out', csrPath, '-subj', `/CN=${host}`]);
|
|
156
|
+
writeFileSync(extPath, `subjectAltName=DNS:${host}\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n`);
|
|
157
|
+
runOpenssl([
|
|
158
|
+
// 397 days, NOT the CA's 3650: macOS's system verifier (used by Go — `gh`, and any Go/Swift
|
|
159
|
+
// client) rejects a LEAF cert valid >398 days as "certificate is not standards compliant"
|
|
160
|
+
// (CA/Browser-Forum max). The session CA itself is exempt and stays long-lived.
|
|
161
|
+
'x509', '-req', '-in', csrPath, '-CA', ca.caCert, '-CAkey', ca.caKey,
|
|
162
|
+
'-CAcreateserial', '-out', certPath, '-days', '397', '-sha256', '-extfile', extPath,
|
|
163
|
+
]);
|
|
164
|
+
const minted = { key: readFileSync(keyPath, 'utf8'), cert: readFileSync(certPath, 'utf8') };
|
|
165
|
+
leafCache.set(host, minted);
|
|
166
|
+
return minted;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --- the proxy server ------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
// Neither type below is exported: `startRedirectProxy` is the only consumer of these names outside
|
|
172
|
+
// this file, and its callers (runtime.ts) rely on inference (`Awaited<ReturnType<typeof
|
|
173
|
+
// startRedirectProxy>>`) rather than importing the type names.
|
|
174
|
+
type RedirectProxyOptions = {
|
|
175
|
+
/** The world env — supplies the `*_TWIN_URL` host→twin map. */
|
|
176
|
+
env?: Record<string, string | undefined>;
|
|
177
|
+
/** Optional dynamic env source. Used by the detached world daemon while services are still adding
|
|
178
|
+
* `*_TWIN_URL` vars during `up`; each proxied request resolves against the latest file-backed env. */
|
|
179
|
+
envLoader?: () => Record<string, string | undefined>;
|
|
180
|
+
/** Where the session CA + leaf certs live (instance dir/tls). */
|
|
181
|
+
tlsDir: string;
|
|
182
|
+
/** Listen host (default 127.0.0.1) and port (default 0 = ephemeral). */
|
|
183
|
+
host?: string;
|
|
184
|
+
port?: number;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
type RedirectProxyHandle = {
|
|
188
|
+
port: number;
|
|
189
|
+
host: string;
|
|
190
|
+
caCertPath: string;
|
|
191
|
+
url: string;
|
|
192
|
+
/** The cert-trust + proxy env an activate/shell session exports to route through this proxy. */
|
|
193
|
+
proxyEnv(): Record<string, string>;
|
|
194
|
+
close(): Promise<void>;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/** Forward a single decrypted (or plain-HTTP) request to the resolved twin origin and pipe the
|
|
198
|
+
* response back. The twin speaks plain HTTP locally, so we strip TLS here and re-issue http. */
|
|
199
|
+
function forwardToTwin(origin: string, clientReq: IncomingMessage, clientRes: ServerResponse): void {
|
|
200
|
+
let target: URL;
|
|
201
|
+
try {
|
|
202
|
+
target = new URL(origin);
|
|
203
|
+
} catch {
|
|
204
|
+
clientRes.writeHead(502); clientRes.end('bad twin origin'); return;
|
|
205
|
+
}
|
|
206
|
+
const headers = { ...clientReq.headers };
|
|
207
|
+
delete headers['proxy-connection'];
|
|
208
|
+
delete headers['accept-encoding'];
|
|
209
|
+
const upstream = httpRequest(
|
|
210
|
+
{
|
|
211
|
+
protocol: 'http:',
|
|
212
|
+
hostname: target.hostname,
|
|
213
|
+
port: target.port || 80,
|
|
214
|
+
method: clientReq.method,
|
|
215
|
+
path: clientReq.url,
|
|
216
|
+
headers,
|
|
217
|
+
},
|
|
218
|
+
(upRes) => {
|
|
219
|
+
clientRes.writeHead(upRes.statusCode ?? 502, upRes.headers);
|
|
220
|
+
upRes.pipe(clientRes);
|
|
221
|
+
},
|
|
222
|
+
);
|
|
223
|
+
upstream.on('error', (err) => { clientRes.writeHead(502); clientRes.end(`twin unreachable: ${err.message}`); });
|
|
224
|
+
clientReq.pipe(upstream);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Start the ambient redirect proxy. Returns a handle with the listen port + the env to export.
|
|
228
|
+
* - Plain `http://` proxied requests to a vendor host → forwarded to the twin.
|
|
229
|
+
* - `CONNECT host:443` to a vendor host → MITM-terminated with a session-signed leaf, decrypted, and
|
|
230
|
+
* forwarded to the twin. CONNECT to any other host → blind-tunneled to the real origin untouched. */
|
|
231
|
+
export async function startRedirectProxy(options: RedirectProxyOptions): Promise<RedirectProxyHandle> {
|
|
232
|
+
const host = options.host ?? '127.0.0.1';
|
|
233
|
+
const currentEnv = (): Record<string, string | undefined> => options.envLoader?.() ?? options.env ?? {};
|
|
234
|
+
const ca = ensureCa(options.tlsDir);
|
|
235
|
+
// Track raw client/upstream sockets so close() can forcibly drop tunnels (a blind tunnel to a slow
|
|
236
|
+
// or real upstream would otherwise keep the server from closing).
|
|
237
|
+
const openSockets = new Set<Socket>();
|
|
238
|
+
|
|
239
|
+
// The MITM TLS endpoint(s): a REAL listening https server PER vendor host (loopback, ephemeral
|
|
240
|
+
// port), each pinned to that host's leaf cert as its default cert. On CONNECT to a vendor host we
|
|
241
|
+
// tunnel the client's raw bytes to the matching per-host server, which terminates TLS and forwards
|
|
242
|
+
// the decrypted request to the resolved twin. (A single SNICallback server is the textbook design,
|
|
243
|
+
// but Node-compatible SNICallback isn't honored on Bun — so we bind one server per host instead,
|
|
244
|
+
// which works on both runtimes. Hosts are few and known from the world's vendor map.)
|
|
245
|
+
const tlsServers = new Map<string, HttpsServer>();
|
|
246
|
+
const tlsPorts = new Map<string, number>();
|
|
247
|
+
const ensureHostServer = async (vendorHost: string): Promise<number> => {
|
|
248
|
+
const existing = tlsPorts.get(vendorHost);
|
|
249
|
+
if (existing) return existing;
|
|
250
|
+
const leaf = leafCertFor(vendorHost, ca);
|
|
251
|
+
const hostServer = createHttpsServer(
|
|
252
|
+
{ key: leaf.key, cert: leaf.cert },
|
|
253
|
+
(req: IncomingMessage, res: ServerResponse) => {
|
|
254
|
+
const hostHeader = (req.headers.host ?? vendorHost).split(':')[0] ?? vendorHost;
|
|
255
|
+
const env = currentEnv();
|
|
256
|
+
const twin = proxyTargetFor(hostHeader, env) ?? proxyTargetFor(vendorHost, env);
|
|
257
|
+
if (!twin) { res.writeHead(502); res.end('no twin for host'); return; }
|
|
258
|
+
forwardToTwin(twin.origin, req, res);
|
|
259
|
+
},
|
|
260
|
+
);
|
|
261
|
+
await new Promise<void>((r, reject) => { hostServer.once('error', reject); hostServer.listen(0, '127.0.0.1', () => r()); });
|
|
262
|
+
const a = hostServer.address();
|
|
263
|
+
const p = typeof a === 'object' && a ? a.port : 0;
|
|
264
|
+
tlsServers.set(vendorHost, hostServer);
|
|
265
|
+
tlsPorts.set(vendorHost, p);
|
|
266
|
+
return p;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const server = createNetServer((clientSocket: Socket) => {
|
|
270
|
+
openSockets.add(clientSocket);
|
|
271
|
+
clientSocket.on('close', () => openSockets.delete(clientSocket));
|
|
272
|
+
clientSocket.on('error', () => clientSocket.destroy());
|
|
273
|
+
|
|
274
|
+
let buffered = Buffer.alloc(0);
|
|
275
|
+
const onData = (chunk: Buffer) => {
|
|
276
|
+
buffered = Buffer.concat([buffered, chunk]);
|
|
277
|
+
const headerEnd = buffered.indexOf('\r\n\r\n');
|
|
278
|
+
if (headerEnd === -1) return;
|
|
279
|
+
clientSocket.removeListener('data', onData);
|
|
280
|
+
|
|
281
|
+
const headerBytes = buffered.subarray(0, headerEnd + 4);
|
|
282
|
+
const head = buffered.subarray(headerEnd + 4);
|
|
283
|
+
const headerText = headerBytes.toString('latin1');
|
|
284
|
+
const [requestLine = '', ...headerLines] = headerText.split('\r\n');
|
|
285
|
+
const [method = '', urlStr = '', version = 'HTTP/1.1'] = requestLine.split(' ');
|
|
286
|
+
const headers: Record<string, string> = {};
|
|
287
|
+
for (const line of headerLines) {
|
|
288
|
+
if (!line) continue;
|
|
289
|
+
const idx = line.indexOf(':');
|
|
290
|
+
if (idx === -1) continue;
|
|
291
|
+
headers[line.slice(0, idx).toLowerCase()] = line.slice(idx + 1).trim();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (method.toUpperCase() === 'CONNECT') {
|
|
295
|
+
handleConnect(urlStr, clientSocket, head);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
handlePlainProxyRequest(method, urlStr, version, headers, clientSocket, head);
|
|
300
|
+
};
|
|
301
|
+
clientSocket.on('data', onData);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const writeResponse = (socket: Socket, status: string, body: string): void => {
|
|
305
|
+
socket.write(`${status}\r\ncontent-type: text/plain\r\ncontent-length: ${Buffer.byteLength(body)}\r\nconnection: close\r\n\r\n${body}`);
|
|
306
|
+
socket.end();
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const forwardRawHttp = (
|
|
310
|
+
origin: URL,
|
|
311
|
+
method: string,
|
|
312
|
+
path: string,
|
|
313
|
+
version: string,
|
|
314
|
+
headers: Record<string, string>,
|
|
315
|
+
socket: Socket,
|
|
316
|
+
head: Buffer,
|
|
317
|
+
): void => {
|
|
318
|
+
const upstream = netConnect(Number(origin.port || 80), origin.hostname, () => {
|
|
319
|
+
const outHeaders = { ...headers };
|
|
320
|
+
delete outHeaders['proxy-connection'];
|
|
321
|
+
delete outHeaders['accept-encoding'];
|
|
322
|
+
upstream.write(`${method} ${path} ${version}\r\n`);
|
|
323
|
+
for (const [name, value] of Object.entries(outHeaders)) upstream.write(`${name}: ${value}\r\n`);
|
|
324
|
+
upstream.write('\r\n');
|
|
325
|
+
if (head.length) upstream.write(head);
|
|
326
|
+
socket.pipe(upstream);
|
|
327
|
+
upstream.pipe(socket);
|
|
328
|
+
});
|
|
329
|
+
openSockets.add(upstream);
|
|
330
|
+
upstream.on('close', () => openSockets.delete(upstream));
|
|
331
|
+
upstream.on('error', (err) => writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', `upstream error: ${err.message}\n`));
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const handlePlainProxyRequest = (
|
|
335
|
+
method: string,
|
|
336
|
+
urlStr: string,
|
|
337
|
+
version: string,
|
|
338
|
+
headers: Record<string, string>,
|
|
339
|
+
socket: Socket,
|
|
340
|
+
head: Buffer,
|
|
341
|
+
): void => {
|
|
342
|
+
try {
|
|
343
|
+
const u = new URL(urlStr);
|
|
344
|
+
const twin = proxyTargetFor(u.hostname, currentEnv());
|
|
345
|
+
const path = u.pathname + u.search;
|
|
346
|
+
if (twin) {
|
|
347
|
+
const target = new URL(twin.origin);
|
|
348
|
+
forwardRawHttp(target, method, path, version, headers, socket, head);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (shouldBlockUntwinned(u.hostname, currentEnv())) {
|
|
352
|
+
writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', `blocked untwinned external request to ${u.hostname}\n`);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
// Non-vendor: plain pass-through to the real host.
|
|
356
|
+
forwardRawHttp(new URL(`${u.protocol}//${u.host}`), method, path, version, headers, socket, head);
|
|
357
|
+
} catch {
|
|
358
|
+
writeResponse(socket, 'HTTP/1.1 400 Bad Request', 'bad request\n');
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const handleConnect = (urlStr: string, clientSocket: Socket, head: Buffer): void => {
|
|
363
|
+
const [connectHost, connectPortRaw] = urlStr.split(':');
|
|
364
|
+
const connectPort = Number(connectPortRaw || 443);
|
|
365
|
+
const twin = connectHost ? proxyTargetFor(connectHost, currentEnv()) : null;
|
|
366
|
+
const tunnel = (destPort: number, destHost: string): void => {
|
|
367
|
+
const upstream = netConnect(destPort, destHost, () => {
|
|
368
|
+
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
369
|
+
if (head && head.length) upstream.write(head);
|
|
370
|
+
upstream.pipe(clientSocket);
|
|
371
|
+
clientSocket.pipe(upstream);
|
|
372
|
+
});
|
|
373
|
+
openSockets.add(upstream);
|
|
374
|
+
upstream.on('close', () => openSockets.delete(upstream));
|
|
375
|
+
upstream.on('error', () => clientSocket.destroy());
|
|
376
|
+
};
|
|
377
|
+
// MITM vendor hosts → tunnel to the per-host listening https server (terminates TLS, forwards to
|
|
378
|
+
// the twin). Non-vendor hosts → blind-tunnel to the REAL origin, untouched.
|
|
379
|
+
if (twin && connectHost) {
|
|
380
|
+
ensureHostServer(connectHost)
|
|
381
|
+
.then((port) => tunnel(port, '127.0.0.1'))
|
|
382
|
+
.catch(() => clientSocket.destroy());
|
|
383
|
+
} else if (connectHost && shouldBlockUntwinned(connectHost, currentEnv())) {
|
|
384
|
+
clientSocket.write('HTTP/1.1 502 Bad Gateway\r\ncontent-type: text/plain\r\n\r\nblocked untwinned external request\r\n');
|
|
385
|
+
clientSocket.destroy();
|
|
386
|
+
} else {
|
|
387
|
+
tunnel(connectPort, connectHost!);
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
await new Promise<void>((resolveListen, reject) => {
|
|
392
|
+
server.once('error', reject);
|
|
393
|
+
server.listen(options.port ?? 0, host, () => resolveListen());
|
|
394
|
+
});
|
|
395
|
+
const addr = server.address();
|
|
396
|
+
const port = typeof addr === 'object' && addr ? addr.port : (options.port ?? 0);
|
|
397
|
+
const url = `http://${host}:${port}`;
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
port,
|
|
401
|
+
host,
|
|
402
|
+
caCertPath: ca.caCert,
|
|
403
|
+
url,
|
|
404
|
+
proxyEnv: () => proxyEnvFor(url, ca.caCert),
|
|
405
|
+
close: () =>
|
|
406
|
+
new Promise<void>((resolveClose) => {
|
|
407
|
+
// Forcibly drop any in-flight tunnels/keep-alive sockets so close() can't hang on a blind
|
|
408
|
+
// tunnel to a slow/real upstream.
|
|
409
|
+
for (const s of openSockets) { try { s.destroy(); } catch { /* ignore */ } }
|
|
410
|
+
openSockets.clear();
|
|
411
|
+
for (const hostServer of tlsServers.values()) {
|
|
412
|
+
try { (hostServer as unknown as { closeAllConnections?: () => void }).closeAllConnections?.(); } catch { /* ignore */ }
|
|
413
|
+
try { hostServer.close(); } catch { /* ignore */ }
|
|
414
|
+
}
|
|
415
|
+
server.close(() => resolveClose());
|
|
416
|
+
}),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** The cert-trust + proxy env that points an unmodified tool through the proxy and trusts the session
|
|
421
|
+
* CA — and ONLY this session (these are per-shell exports; nothing is installed system-wide). Mirrors
|
|
422
|
+
* the env list in WORLD_ACTIVATE.md §"The honest cost: a scoped CA". */
|
|
423
|
+
export function proxyEnvFor(proxyUrl: string, caCertPath: string): Record<string, string> {
|
|
424
|
+
return {
|
|
425
|
+
HTTPS_PROXY: proxyUrl,
|
|
426
|
+
HTTP_PROXY: proxyUrl,
|
|
427
|
+
https_proxy: proxyUrl,
|
|
428
|
+
http_proxy: proxyUrl,
|
|
429
|
+
NODE_EXTRA_CA_CERTS: caCertPath,
|
|
430
|
+
AWS_CA_BUNDLE: caCertPath,
|
|
431
|
+
CURL_CA_BUNDLE: caCertPath,
|
|
432
|
+
REQUESTS_CA_BUNDLE: caCertPath,
|
|
433
|
+
SSL_CERT_FILE: caCertPath,
|
|
434
|
+
VOLTER_WORLD_PROXY: proxyUrl,
|
|
435
|
+
VOLTER_WORLD_CA: caCertPath,
|
|
436
|
+
};
|
|
437
|
+
}
|