@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.
- package/known-external-services.json +1192 -0
- package/package.json +12 -5
- package/src/app-url.ts +175 -0
- package/src/attach.ts +113 -0
- package/src/browser-proxy-cli.ts +2 -1
- package/src/changeset.ts +416 -0
- package/src/cli.ts +589 -10
- package/src/covers.ts +724 -0
- package/src/fixture-env.ts +207 -0
- package/src/host-cli.ts +2 -1
- package/src/host-worker.ts +4 -3
- package/src/host.ts +26 -4
- package/src/index.ts +97 -1
- package/src/init.ts +1142 -0
- package/src/inject-map.ts +70 -0
- package/src/managed-infra-cli.ts +208 -0
- package/src/pack-facts.ts +136 -0
- package/src/pglite-backing.ts +125 -0
- package/src/pglite-host.mjs +147 -0
- package/src/prerequisites.ts +15 -58
- package/src/project-inspect.ts +683 -0
- package/src/redirect-proxy.ts +109 -85
- package/src/reflect.ts +443 -0
- package/src/resource-holder.ts +11 -0
- package/src/resources.ts +160 -0
- package/src/runtime-test-support.ts +208 -0
- package/src/runtime.ts +667 -106
- package/src/schema.ts +67 -0
- package/src/serve.ts +102 -0
- package/src/tail.ts +203 -0
package/src/redirect-proxy.ts
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return
|
|
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
|
-
|
|
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
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
|
|
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 =
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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 [
|
|
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
|
};
|