@volter/twin-world 0.1.0 → 0.1.2

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.
@@ -18,43 +18,55 @@
18
18
  // but cannot mint X.509 certs without a third-party lib). This keeps world-runtime dependency-light.
19
19
  import { spawnSync } from 'node:child_process';
20
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
21
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
26
22
  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;
23
+ // The host→twin table is loaded through the shared inject-map module (one world-runtime home
24
+ // for the injector's data), so this proxy, `covers`, and `up`'s inert-injectEnv warning can
25
+ // never re-encode the vendor host predicates they stay one source of truth with the Node
26
+ // injector. External callers go through `proxyTargetFor`/`activeVendorMap`.
27
+ import { loadInject } from './inject-map.ts';
28
+
29
+ /**
30
+ * The body served when a host was MITM'd but no twin serves the PATH.
31
+ *
32
+ * Two deliberate choices, both §9 round 2 findings:
33
+ * • The request path is JSON-escaped and the response is sent as `text/plain`. `req.url` may
34
+ * legally carry `<` and `>`, and an untyped body is MIME-sniffable — so on a machine that has
35
+ * trusted the session CA, a crafted URL could otherwise execute in the vendor's origin. The
36
+ * sibling raw-socket error path already set `text/plain`; this matches it.
37
+ * It names the SITUATION, not just the miss (the Cal.com finding): this handler only runs after
38
+ * the host was MITM'd, i.e. the host IS twinned in this world so the bare "no twin for host"
39
+ * sent readers hunting for a `*_TWIN_URL` that was already set. On a shared host the path may
40
+ * belong to a vendor whose twin merely is not configured (youtube vs googleauth), OR to no pack
41
+ * at all (Google Calendar on www.googleapis.com has no twin yet) the message states both,
42
+ * because this refusal is exactly what replaced the silent mis-route that answered Calendar
43
+ * calls with plausible Google-shaped 404s from the gemini pack.
44
+ */
45
+ export function noTwinMessage(host: string, path: string): string {
46
+ return `host ${host} is twinned in this world, but path ${JSON.stringify(path)} belongs to no twin configured here.\n`
47
+ + 'This host is shared between vendors, and no pack in this world serves this path. Either the\n'
48
+ + 'vendor that owns this path has a twin that is not configured — set its *_TWIN_URL (for\n'
49
+ + 'www.googleapis.com: YOUTUBE_TWIN_URL serves /youtube/v3/*, GOOGLEAUTH_TWIN_URL serves the\n'
50
+ + 'OAuth2 token paths) — or this API has no twin pack yet (e.g. Google Calendar /calendar/v3/*),\n'
51
+ + 'in which case this loud refusal is the honest outcome: the request is neither answered by the\n'
52
+ + 'wrong twin nor leaked to the real vendor.\n';
51
53
  }
52
54
 
53
55
  /** 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
+ * `*_TWIN_URL` vars). Returns null for hosts the world doesn't twin — those are tunneled untouched.
57
+ *
58
+ * `pathname` is optional and matters only for hosts TWO vendors share (see `VENDOR_HOSTS` in
59
+ * inject.cjs — `www.googleapis.com` is served by both `googleauth` and `youtube`, split by path).
60
+ * At CONNECT time there is no path yet, so it is omitted and the answer is host-level candidacy:
61
+ * "MITM this host". Once TLS is terminated and the real request line is readable, the caller
62
+ * re-resolves WITH the path so each request reaches the twin that actually serves it. */
63
+ export function proxyTargetFor(
64
+ hostname: string,
65
+ env: Record<string, string | undefined>,
66
+ pathname?: string,
67
+ ): { vendor: string; origin: string } | null {
56
68
  const inject = loadInject();
57
- return inject.resolveTwin(hostname, inject.readMap(env));
69
+ return inject.resolveTwin(hostname, inject.readMap(env), pathname);
58
70
  }
59
71
 
60
72
  /** The vendor→twin map this world actively redirects (for diagnostics / activate output). */
@@ -128,6 +140,11 @@ export function ensureCa(tlsDir: string): CaPaths {
128
140
  'req', '-x509', '-new', '-nodes', '-key', paths.caKey,
129
141
  '-sha256', '-days', '3650', '-out', paths.caCert,
130
142
  '-subj', '/CN=volter-world session CA/O=volter-world',
143
+ // A CA cert without keyUsage is refused by OpenSSL 3 (Python, curl on Debian 13: "CA cert does not
144
+ // include key usage extension"), so the CA states what a CA is for.
145
+ '-addext', 'basicConstraints=critical,CA:TRUE',
146
+ '-addext', 'keyUsage=critical,keyCertSign,cRLSign',
147
+ '-addext', 'subjectKeyIdentifier=hash',
131
148
  ]);
132
149
  return paths;
133
150
  }
@@ -194,36 +211,6 @@ type RedirectProxyHandle = {
194
211
  close(): Promise<void>;
195
212
  };
196
213
 
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
214
  /** Start the ambient redirect proxy. Returns a handle with the listen port + the env to export.
228
215
  * - Plain `http://` proxied requests to a vendor host → forwarded to the twin.
229
216
  * - `CONNECT host:443` to a vendor host → MITM-terminated with a session-signed leaf, decrypted, and
@@ -236,31 +223,63 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
236
223
  // or real upstream would otherwise keep the server from closing).
237
224
  const openSockets = new Set<Socket>();
238
225
 
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>();
226
+ // The MITM TLS endpoint(s): a REAL listening TLS server PER vendor host (loopback, ephemeral port), each pinned
227
+ // to that host's leaf cert. On CONNECT to a vendor host we tunnel the client's raw bytes to the matching per-host
228
+ // server, which terminates TLS and forwards the decrypted request to the resolved twin. (One server per host
229
+ // rather than SNI: hosts are few and known from the world's vendor map.)
230
+ // Bun-native (Bun.serve with tls): Bun's node:http never emits `upgrade`, and a websocket on a twinned host
231
+ // discord.py's gateway at wss://gateway.discord.gg/ must reach the twin. HTTP is forwarded with fetch; a
232
+ // websocket is relayed message by message to the twin's own websocket at the same path.
233
+ type Relay = { target: string; up?: WebSocket; ready: boolean; queue: Array<string | ArrayBuffer | Uint8Array> };
234
+ const tlsServers = new Map<string, ReturnType<typeof Bun.serve>>();
246
235
  const tlsPorts = new Map<string, number>();
247
236
  const ensureHostServer = async (vendorHost: string): Promise<number> => {
248
237
  const existing = tlsPorts.get(vendorHost);
249
238
  if (existing) return existing;
250
239
  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;
240
+ const hostServer = Bun.serve<Relay>({
241
+ hostname: '127.0.0.1',
242
+ port: 0,
243
+ tls: { key: leaf.key, cert: leaf.cert },
244
+ async fetch(req, server) {
245
+ const u = new URL(req.url);
246
+ const hostHeader = (req.headers.get('host') ?? vendorHost).split(':')[0] ?? vendorHost;
255
247
  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);
248
+ // The request line is readable now, so resolve WITH the path: this is the point where a host two vendors
249
+ // share (www.googleapis.com → googleauth vs youtube) gets split correctly; on a shared host there IS a twin
250
+ // for the host and merely not for this path, and the bare message sends the next reader hunting.
251
+ const twin = proxyTargetFor(hostHeader, env, u.pathname) ?? proxyTargetFor(vendorHost, env, u.pathname);
252
+ if (!twin) return new Response(noTwinMessage(hostHeader, u.pathname), { status: 502, headers: { 'content-type': 'text/plain; charset=utf-8' } });
253
+ const origin = new URL(twin.origin);
254
+ if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
255
+ const target = `${origin.protocol === 'https:' ? 'wss' : 'ws'}://${origin.host}${u.pathname}${u.search}`;
256
+ return server.upgrade(req, { data: { target, ready: false, queue: [] } }) ? undefined : new Response('websocket upgrade failed', { status: 500 });
257
+ }
258
+ const headers = new Headers(req.headers);
259
+ for (const h of ['proxy-connection', 'accept-encoding', 'host', 'connection']) headers.delete(h);
260
+ try {
261
+ const upRes = await fetch(new URL(u.pathname + u.search, origin), { method: req.method, headers, body: req.method === 'GET' || req.method === 'HEAD' ? undefined : req.body, redirect: 'manual' });
262
+ const out = new Headers(upRes.headers);
263
+ for (const h of ['content-encoding', 'content-length', 'transfer-encoding']) out.delete(h);
264
+ return new Response(upRes.body, { status: upRes.status, headers: out });
265
+ } catch (err) {
266
+ return new Response(`twin unreachable: ${(err as Error).message}`, { status: 502 });
267
+ }
268
+ },
269
+ websocket: {
270
+ open(ws) {
271
+ const up = new WebSocket(ws.data.target);
272
+ ws.data.up = up;
273
+ up.onopen = () => { ws.data.ready = true; for (const m of ws.data.queue) up.send(m); ws.data.queue = []; };
274
+ up.onmessage = (e) => { try { ws.send(e.data as string | ArrayBuffer | Uint8Array); } catch { /* the client went away */ } };
275
+ up.onclose = (e) => { try { ws.close(e.code, e.reason); } catch { /* already closed */ } };
276
+ up.onerror = () => { try { ws.close(1011, 'twin websocket error'); } catch { /* already closed */ } };
277
+ },
278
+ message(ws, m) { if (ws.data.ready && ws.data.up) ws.data.up.send(m); else ws.data.queue.push(m); },
279
+ close(ws, code, reason) { try { ws.data.up?.close(code, reason); } catch { /* already closed */ } },
259
280
  },
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;
281
+ });
282
+ const p = hostServer.port ?? 0;
264
283
  tlsServers.set(vendorHost, hostServer);
265
284
  tlsPorts.set(vendorHost, p);
266
285
  return p;
@@ -341,13 +360,20 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
341
360
  ): void => {
342
361
  try {
343
362
  const u = new URL(urlStr);
344
- const twin = proxyTargetFor(u.hostname, currentEnv());
363
+ const twin = proxyTargetFor(u.hostname, currentEnv(), u.pathname);
345
364
  const path = u.pathname + u.search;
346
365
  if (twin) {
347
366
  const target = new URL(twin.origin);
348
367
  forwardRawHttp(target, method, path, version, headers, socket, head);
349
368
  return;
350
369
  }
370
+ // No twin claims this PATH — but if the HOST is twinned (host-level candidacy), the path is
371
+ // an unclaimed slice of a shared, virtualized host: refuse loudly (same rule as the MITM
372
+ // handler's noTwinMessage), never pass through to the real vendor.
373
+ if (proxyTargetFor(u.hostname, currentEnv())) {
374
+ writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', noTwinMessage(u.hostname, u.pathname));
375
+ return;
376
+ }
351
377
  if (shouldBlockUntwinned(u.hostname, currentEnv())) {
352
378
  writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', `blocked untwinned external request to ${u.hostname}\n`);
353
379
  return;
@@ -360,7 +386,8 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
360
386
  };
361
387
 
362
388
  const handleConnect = (urlStr: string, clientSocket: Socket, head: Buffer): void => {
363
- const [connectHost, connectPortRaw] = urlStr.split(':');
389
+ const [connectHostRaw, connectPortRaw] = urlStr.split(':');
390
+ const connectHost = connectHostRaw?.toLowerCase(); // hostnames are case-insensitive; the host rules are lowercase
364
391
  const connectPort = Number(connectPortRaw || 443);
365
392
  const twin = connectHost ? proxyTargetFor(connectHost, currentEnv()) : null;
366
393
  const tunnel = (destPort: number, destHost: string): void => {
@@ -408,10 +435,7 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
408
435
  // tunnel to a slow/real upstream.
409
436
  for (const s of openSockets) { try { s.destroy(); } catch { /* ignore */ } }
410
437
  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
- }
438
+ for (const hostServer of tlsServers.values()) { try { hostServer.stop(true); } catch { /* ignore */ } }
415
439
  server.close(() => resolveClose());
416
440
  }),
417
441
  };