@volter/world-runtime 3.0.38 → 3.0.40
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/dist/known-external-services.json +3 -10
- package/dist/src/actions-runner-cli.js +3 -3
- package/dist/src/actions-runner.js +2 -1
- package/dist/src/app-url.js +2 -1
- package/dist/src/backings.d.ts +10 -1
- package/dist/src/backings.js +66 -7
- package/dist/src/branch.js +85 -52
- package/dist/src/browser-routes.cjs +59 -0
- package/dist/src/ca-trust.js +4 -3
- package/dist/src/changeset.js +4 -3
- package/dist/src/cli-commands.js +25 -9
- package/dist/src/cli.js +2 -1
- package/dist/src/command-lifetime.d.mts +12 -0
- package/dist/src/command-lifetime.d.ts +1 -15
- package/dist/src/command-lifetime.js +1 -98
- package/dist/src/command-lifetime.mjs +90 -0
- package/dist/src/configs.js +1 -1
- package/dist/src/console-apart.js +3 -2
- package/dist/src/covers.d.ts +3 -2
- package/dist/src/covers.js +95 -18
- package/dist/src/error-message.d.mts +1 -0
- package/dist/src/error-message.d.ts +1 -0
- package/dist/src/error-message.js +2 -0
- package/dist/src/error-message.mjs +10 -0
- package/dist/src/guest-command.d.mts +4 -0
- package/dist/src/guest-command.mjs +26 -0
- package/dist/src/guest-control.d.mts +1 -0
- package/dist/src/guest-control.mjs +17 -0
- package/dist/src/guest-entry.mjs +146 -0
- package/dist/src/guest-processes.d.mts +9 -0
- package/dist/src/guest-processes.mjs +47 -0
- package/dist/src/guest-runner.d.ts +6 -0
- package/dist/src/guest-runner.js +567 -0
- package/dist/src/host.js +5 -4
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/infra-cli.js +4 -0
- package/dist/src/infra.js +133 -23
- package/dist/src/init.js +60 -23
- package/dist/src/lifecycle-record.d.ts +3 -1
- package/dist/src/lifecycle-record.js +29 -6
- package/dist/src/livekit-backing.mjs +37 -0
- package/dist/src/local-branches.js +11 -3
- package/dist/src/mongodb-backing.d.ts +7 -0
- package/dist/src/mongodb-backing.js +58 -0
- package/dist/src/native-postgres-backing.d.ts +4 -0
- package/dist/src/native-postgres-backing.js +404 -0
- package/dist/src/origin.js +2 -1
- package/dist/src/owned-path.d.ts +4 -0
- package/dist/src/owned-path.js +37 -0
- package/dist/src/pack-facts.d.ts +1 -0
- package/dist/src/pglite-backing.d.ts +4 -13
- package/dist/src/pglite-backing.js +198 -106
- package/dist/src/pglite-host.mjs +47 -17
- package/dist/src/placeholder.d.ts +3 -2
- package/dist/src/placeholder.js +9 -4
- package/dist/src/process-groups.d.mts +2 -0
- package/dist/src/process-groups.d.ts +1 -4
- package/dist/src/process-groups.js +1 -49
- package/dist/src/process-groups.mjs +59 -0
- package/dist/src/process-receipt.d.mts +6 -0
- package/dist/src/process-receipt.d.ts +11 -0
- package/dist/src/process-receipt.js +3 -0
- package/dist/src/process-receipt.mjs +45 -0
- package/dist/src/process-signal.d.mts +9 -0
- package/dist/src/process-signal.mjs +92 -0
- package/dist/src/project-inspect.d.ts +1 -1
- package/dist/src/project-inspect.js +4 -2
- package/dist/src/proxy-daemon.js +2 -1
- package/dist/src/redirect-proxy.js +186 -19
- package/dist/src/redis-backing.js +99 -35
- package/dist/src/reflect.js +2 -1
- package/dist/src/root.js +7 -6
- package/dist/src/run-task-worker.js +2 -1
- package/dist/src/runtime.d.ts +24 -7
- package/dist/src/runtime.js +703 -286
- package/dist/src/schema.d.ts +12 -1
- package/dist/src/schema.js +20 -5
- package/dist/src/served-world.d.ts +1 -0
- package/dist/src/served-world.js +26 -14
- package/dist/src/service-recorder.js +8 -3
- package/dist/src/signals.d.mts +1 -0
- package/dist/src/signals.d.ts +1 -1
- package/dist/src/signals.js +1 -11
- package/dist/src/signals.mjs +10 -0
- package/dist/src/tail.d.ts +2 -0
- package/dist/src/tail.js +9 -2
- package/dist/src/tcp-port.js +27 -13
- package/dist/src/temporal-backing.mjs +28 -0
- package/dist/src/up-cli.js +8 -3
- package/dist/src/up-task-worker.js +2 -1
- package/dist/src/websocket-relay.d.ts +1 -1
- package/dist/src/websocket-relay.js +23 -10
- package/dist/src/world-view.js +2 -2
- package/known-external-services.json +3 -10
- package/package.json +12 -7
- package/src/actions-runner-cli.ts +3 -3
- package/src/actions-runner.ts +2 -1
- package/src/app-url.ts +2 -1
- package/src/backings.ts +65 -7
- package/src/branch.ts +73 -34
- package/src/browser-routes.cjs +59 -0
- package/src/ca-trust.ts +4 -3
- package/src/changeset.ts +4 -3
- package/src/cli-commands.ts +21 -9
- package/src/cli.ts +2 -1
- package/src/command-lifetime.d.mts +12 -0
- package/src/command-lifetime.mjs +90 -0
- package/src/command-lifetime.ts +1 -79
- package/src/configs.ts +1 -1
- package/src/console-apart.ts +2 -1
- package/src/covers.ts +97 -19
- package/src/error-message.d.mts +1 -0
- package/src/error-message.mjs +10 -0
- package/src/error-message.ts +2 -0
- package/src/guest-command.d.mts +4 -0
- package/src/guest-command.mjs +26 -0
- package/src/guest-control.d.mts +1 -0
- package/src/guest-control.mjs +17 -0
- package/src/guest-entry.mjs +146 -0
- package/src/guest-processes.d.mts +9 -0
- package/src/guest-processes.mjs +47 -0
- package/src/guest-runner.ts +414 -0
- package/src/host.ts +5 -4
- package/src/index.ts +1 -0
- package/src/infra-cli.ts +4 -0
- package/src/infra.ts +103 -22
- package/src/init.ts +55 -22
- package/src/lifecycle-record.ts +21 -6
- package/src/livekit-backing.mjs +37 -0
- package/src/local-branches.ts +6 -3
- package/src/mongodb-backing.ts +55 -0
- package/src/native-postgres-backing.ts +329 -0
- package/src/origin.ts +2 -1
- package/src/owned-path.ts +32 -0
- package/src/pack-facts.ts +1 -0
- package/src/pglite-backing.ts +155 -79
- package/src/pglite-host.mjs +47 -17
- package/src/placeholder.ts +10 -5
- package/src/process-groups.d.mts +2 -0
- package/src/process-groups.mjs +59 -0
- package/src/process-groups.ts +1 -33
- package/src/process-receipt.d.mts +6 -0
- package/src/process-receipt.mjs +45 -0
- package/src/process-receipt.ts +5 -0
- package/src/process-signal.d.mts +9 -0
- package/src/process-signal.mjs +92 -0
- package/src/project-inspect.ts +4 -2
- package/src/proxy-daemon.ts +2 -1
- package/src/redirect-proxy.ts +153 -18
- package/src/redis-backing.ts +74 -25
- package/src/reflect.ts +2 -1
- package/src/root.ts +7 -6
- package/src/run-task-worker.ts +2 -1
- package/src/runtime.ts +596 -265
- package/src/schema.ts +30 -6
- package/src/served-world.ts +21 -15
- package/src/service-recorder.ts +4 -2
- package/src/signals.d.mts +1 -0
- package/src/signals.mjs +10 -0
- package/src/signals.ts +1 -10
- package/src/tail.ts +10 -3
- package/src/tcp-port.ts +24 -13
- package/src/temporal-backing.mjs +28 -0
- package/src/up-cli.ts +6 -3
- package/src/up-task-worker.ts +2 -1
- package/src/websocket-relay.ts +18 -11
- package/src/world-view.ts +2 -2
package/src/process-groups.ts
CHANGED
|
@@ -1,33 +1 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
/** Inspect only groups already owned by the lifecycle. A dead leader does not prove that its
|
|
4
|
-
* descendants exited. Zombies hold no resources; unknown inspection remains live. This is
|
|
5
|
-
* retirement evidence, never an ownership token or a way to discover processes to kill. */
|
|
6
|
-
export function survivingOwnedGroups(pids: number[]): number[] {
|
|
7
|
-
const candidates = pids.filter(pid => {
|
|
8
|
-
try { process.kill(process.platform === 'win32' ? pid : -pid, 0); return true; }
|
|
9
|
-
catch (error) {
|
|
10
|
-
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') return true;
|
|
11
|
-
try { process.kill(pid, 0); return true; }
|
|
12
|
-
catch (cause) { return (cause as NodeJS.ErrnoException).code !== 'ESRCH'; }
|
|
13
|
-
}
|
|
14
|
-
});
|
|
15
|
-
if (!candidates.length || process.platform === 'win32') return candidates;
|
|
16
|
-
const result = spawnSync('ps', ['-axo', 'pid=,pgid=,stat='], { windowsHide: true,
|
|
17
|
-
encoding: 'utf8', timeout: 1000, killSignal: 'SIGKILL', maxBuffer: 4 * 1024 * 1024,
|
|
18
|
-
});
|
|
19
|
-
if (result.status !== 0 || result.error) return candidates;
|
|
20
|
-
const rows = result.stdout.trim().split('\n').map(line => line.trim().split(/\s+/));
|
|
21
|
-
return candidates.filter(pid => {
|
|
22
|
-
const members = rows.filter(([id, group]) => Number(group) === pid || Number(id) === pid);
|
|
23
|
-
// Visibility restrictions can omit a live group. Only ESRCH proves absence.
|
|
24
|
-
if (!members.length) {
|
|
25
|
-
for (const target of [-pid, pid]) {
|
|
26
|
-
try { process.kill(target, 0); return true; }
|
|
27
|
-
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') return true; }
|
|
28
|
-
}
|
|
29
|
-
return false;
|
|
30
|
-
}
|
|
31
|
-
return members.some(([, , state]) => !state || !state.startsWith('Z'));
|
|
32
|
-
});
|
|
33
|
-
}
|
|
1
|
+
export { survivingOwnedGroups, processStarts } from './process-groups.mjs';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ReceiptRead, SpawnIdentity } from './process-receipt.js';
|
|
2
|
+
export { spawnIdentity, verifySpawnIdentity, signalOwnedProcess } from './process-signal.mjs';
|
|
3
|
+
export function publishProcessReceipt(path: string, text: string): void;
|
|
4
|
+
export function readProcessReceipt<T>(path: string, parse: (text: string) => T): ReceiptRead<T>;
|
|
5
|
+
export function spawnReceiptPath(path: string): string;
|
|
6
|
+
export function readSpawnReceipt(path: string): ReceiptRead<SpawnIdentity>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
export { spawnIdentity, verifySpawnIdentity, signalOwnedProcess } from './process-signal.mjs';
|
|
5
|
+
|
|
6
|
+
/** A failed publication never truncates the previous receipt. */
|
|
7
|
+
export function publishProcessReceipt(path, text) {
|
|
8
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
9
|
+
let descriptor;
|
|
10
|
+
const failures = [];
|
|
11
|
+
try {
|
|
12
|
+
descriptor = fs.openSync(temporary, 'wx', 0o600);
|
|
13
|
+
fs.writeFileSync(descriptor, text);
|
|
14
|
+
fs.fsyncSync(descriptor);
|
|
15
|
+
fs.closeSync(descriptor); descriptor = undefined;
|
|
16
|
+
fs.renameSync(temporary, path);
|
|
17
|
+
descriptor = fs.openSync(dirname(path), 'r');
|
|
18
|
+
fs.fsyncSync(descriptor);
|
|
19
|
+
} catch (error) { failures.push(error); }
|
|
20
|
+
if (descriptor !== undefined) {
|
|
21
|
+
try { fs.closeSync(descriptor); } catch (error) { failures.push(error); }
|
|
22
|
+
}
|
|
23
|
+
try { fs.rmSync(temporary, { force: true }); } catch (error) { failures.push(error); }
|
|
24
|
+
if (failures.length) throw new AggregateError(failures, `Process receipt publication failed: ${path}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Unreadable is uncertainty, not absence; a separate spawn receipt may still establish ownership. */
|
|
28
|
+
export function readProcessReceipt(path, parse) {
|
|
29
|
+
try { return { present: true, value: parse(fs.readFileSync(path, 'utf8')) }; }
|
|
30
|
+
catch (cause) {
|
|
31
|
+
if (cause.code === 'ENOENT') return { present: false };
|
|
32
|
+
return { present: true, error: new Error(`Process receipt unreadable; ownership uncertain: ${path}`, { cause }) };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const spawnReceiptPath = (path) => `${path}.spawn.json`;
|
|
37
|
+
export function readSpawnReceipt(path) {
|
|
38
|
+
return readProcessReceipt(spawnReceiptPath(path), text => {
|
|
39
|
+
const value = JSON.parse(text);
|
|
40
|
+
if (!Number.isInteger(value?.pid) || value.pid <= 0 || (typeof value.start !== 'string' || !value.start.trim())) {
|
|
41
|
+
throw new Error('Invalid process spawn identity');
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Native Node guests and the World use one atomic process-receipt implementation.
|
|
2
|
+
export { spawnIdentity, verifySpawnIdentity, signalOwnedProcess } from './process-signal.mjs';
|
|
3
|
+
export { publishProcessReceipt, readProcessReceipt, spawnReceiptPath, readSpawnReceipt } from './process-receipt.mjs';
|
|
4
|
+
export interface ReceiptRead<T> { present: boolean; value?: T; error?: Error }
|
|
5
|
+
export interface SpawnIdentity { pid: number; start?: string }
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SpawnIdentity } from './process-receipt.js';
|
|
2
|
+
import type { ChildProcess } from 'node:child_process';
|
|
3
|
+
export function processBirth(pid: number): Promise<string | undefined>;
|
|
4
|
+
export function spawnIdentity(pid: number): Promise<SpawnIdentity>;
|
|
5
|
+
export function verifySpawnIdentity(identity: SpawnIdentity | undefined): Promise<void>;
|
|
6
|
+
export function signalOwnedProcess(identity: SpawnIdentity | undefined, signal: NodeJS.Signals, group?: boolean): Promise<boolean>;
|
|
7
|
+
export function signalChild(child: ChildProcess, signal: NodeJS.Signals, group?: boolean): boolean;
|
|
8
|
+
export function signalHeldChild(child: ChildProcess, signal: NodeJS.Signals, group?: boolean, birth?: Promise<SpawnIdentity | undefined>): Promise<boolean>;
|
|
9
|
+
export function childIdentity(child: ChildProcess): Promise<SpawnIdentity | undefined>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
// Retain the existing 1000 ms inspection ceiling, not a new timeout chosen from one sample.
|
|
5
|
+
// Measured 2026-10-03 on shared Darwin 25.4.0 ARM64, Bun 1.3.11 (process.version
|
|
6
|
+
// v24.3.0), parent 38b5290 plus this change; no Cal server, one live driver PID:
|
|
7
|
+
// `/usr/bin/time -p /bin/ps -o pid=,lstart= -p <driver-pid>` with LC_ALL/LANG=C,
|
|
8
|
+
// TZ=UTC took 9.26 ms from spawn to close; time printed real/user/sys 0.00 s.
|
|
9
|
+
// This includes the time wrapper/event-loop overhead, not an isolated benchmark.
|
|
10
|
+
// Full command/output: cookbook/calcom/output/held-backing-lifecycle.txt.
|
|
11
|
+
const BIRTH_LOOKUP_MS = 1000;
|
|
12
|
+
/** No subprocess on Linux; elsewhere the lookup never blocks the lifecycle event loop. */
|
|
13
|
+
export async function processBirth(pid) {
|
|
14
|
+
if (process.platform === 'win32') return undefined;
|
|
15
|
+
if (process.platform === 'linux') {
|
|
16
|
+
try {
|
|
17
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
18
|
+
const tick = stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19];
|
|
19
|
+
return /^\d+$/.test(tick ?? '') ? `tick ${tick}` : undefined;
|
|
20
|
+
} catch { return undefined; }
|
|
21
|
+
}
|
|
22
|
+
return new Promise(resolve => {
|
|
23
|
+
const child = spawn('ps', ['-o', 'pid=,lstart=', '-p', String(pid)], {
|
|
24
|
+
windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'],
|
|
25
|
+
env: { ...process.env, LC_ALL: 'C', LANG: 'C', TZ: 'UTC' },
|
|
26
|
+
});
|
|
27
|
+
let output = '', settled = false;
|
|
28
|
+
const finish = value => { if (!settled) { settled = true; clearTimeout(timer); child.stdout.destroy(); resolve(value); } };
|
|
29
|
+
// This is our still-held inspector, not a PID from a receipt.
|
|
30
|
+
const timer = setTimeout(() => {
|
|
31
|
+
try { signalChild(child, 'SIGKILL'); } catch { /* failed inspection remains unavailable */ }
|
|
32
|
+
finish(undefined); // an exited wrapper's descendant may keep stdout open indefinitely
|
|
33
|
+
}, BIRTH_LOOKUP_MS);
|
|
34
|
+
child.stdout.on('data', chunk => { output += chunk; });
|
|
35
|
+
child.once('error', () => finish(undefined));
|
|
36
|
+
child.once('close', code => {
|
|
37
|
+
const match = /^\s*(\d+)\s+(.+?)\s*$/.exec(output.trim());
|
|
38
|
+
finish(code === 0 && match && Number(match[1]) === pid ? match[2] : undefined);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/** Capture at spawn for durable ownership, never when retrying a retained PID. */
|
|
43
|
+
export async function spawnIdentity(pid) {
|
|
44
|
+
const start = await processBirth(pid);
|
|
45
|
+
return { pid, ...(start === undefined ? {} : { start }) };
|
|
46
|
+
}
|
|
47
|
+
/** An async capture is accepted only while its original child is still held and live. */
|
|
48
|
+
export async function childIdentity(child) {
|
|
49
|
+
if (!child.pid) return undefined;
|
|
50
|
+
const identity = await spawnIdentity(child.pid);
|
|
51
|
+
return child.exitCode === null && child.signalCode === null ? identity : undefined;
|
|
52
|
+
}
|
|
53
|
+
/** A birth token is required even when an absent leader leaves a surviving group. */
|
|
54
|
+
export async function verifySpawnIdentity(identity) {
|
|
55
|
+
if (!identity || !Number.isInteger(identity.pid) || identity.pid <= 0 || typeof identity.start !== 'string' || !identity.start.trim()) {
|
|
56
|
+
throw new Error('Process recorded birth token missing; retirement refused; ownership retained');
|
|
57
|
+
}
|
|
58
|
+
const current = await processBirth(identity.pid);
|
|
59
|
+
if (current !== undefined && current !== identity.start) {
|
|
60
|
+
throw new Error(`Process ${identity.pid} no longer matches its recorded spawn identity; retirement refused`);
|
|
61
|
+
}
|
|
62
|
+
if (current === undefined) {
|
|
63
|
+
try { process.kill(identity.pid, 0); }
|
|
64
|
+
catch (error) { if (error.code === 'ESRCH') return; throw error; }
|
|
65
|
+
throw new Error(`Process ${identity.pid} identity cannot be established; ownership retained`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Stored or retained numbers use birth verification before every delivery. */
|
|
69
|
+
export async function signalOwnedProcess(identity, signal, group = false) {
|
|
70
|
+
await verifySpawnIdentity(identity);
|
|
71
|
+
try { return process.kill(group && process.platform !== 'win32' ? -identity.pid : identity.pid, signal); }
|
|
72
|
+
catch (error) { if (error.code === 'ESRCH') return false; throw error; }
|
|
73
|
+
}
|
|
74
|
+
/** A live ChildProcess is ownership until exit. A group additionally needs its leader alive.
|
|
75
|
+
* False means no held delivery was possible; a caller with surviving descendants needs a birth. */
|
|
76
|
+
export function signalChild(child, signal, group = false) {
|
|
77
|
+
if (!child.pid || child.exitCode !== null || child.signalCode !== null) return false;
|
|
78
|
+
if (!group || process.platform === 'win32') return child.kill(signal);
|
|
79
|
+
try { process.kill(child.pid, 0); }
|
|
80
|
+
catch (error) { if (error.code === 'ESRCH') return false; throw error; }
|
|
81
|
+
try { return process.kill(-child.pid, signal); }
|
|
82
|
+
catch (error) { if (error.code === 'ESRCH') return false; throw error; }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Held first; an orphaned group then becomes retained numeric ownership. */
|
|
86
|
+
export async function signalHeldChild(child, signal, group = false, birth = Promise.resolve(undefined)) {
|
|
87
|
+
if (signalChild(child, signal, group)) return true;
|
|
88
|
+
if (!group || process.platform === 'win32' || !child.pid) return false;
|
|
89
|
+
try { process.kill(-child.pid, 0); }
|
|
90
|
+
catch (error) { if (error.code === 'ESRCH') return false; throw error; }
|
|
91
|
+
return signalOwnedProcess(await birth, signal, true);
|
|
92
|
+
}
|
package/src/project-inspect.ts
CHANGED
|
@@ -554,7 +554,7 @@ export function connectionProtocol(value: string): string | null {
|
|
|
554
554
|
/** Native connections are independent of the vendor's HTTP SDK. Inspect ordinary URL schemes
|
|
555
555
|
* and Prisma datasource declarations; never infer a selected Prisma adapter from its presence
|
|
556
556
|
* in package.json. Dynamic/config-file-only datasources remain unknown, as do unbound drivers. */
|
|
557
|
-
export function projectConnections(input = process.cwd(), attributedEnv = new Set<string>()): ProjectConnection[] {
|
|
557
|
+
export function projectConnections(input = process.cwd(), attributedEnv = new Set<string>(), boundProtocols: ReadonlyMap<string, string> = new Map()): ProjectConnection[] {
|
|
558
558
|
const root = resolve(input);
|
|
559
559
|
const found: ProjectConnection[] = [];
|
|
560
560
|
const dependencies = projectDependencies(root);
|
|
@@ -575,7 +575,9 @@ export function projectConnections(input = process.cwd(), attributedEnv = new Se
|
|
|
575
575
|
// DATABASE_URL has none and must stay unknown, even beside an unrelated HTTP SDK.
|
|
576
576
|
if (connectionProtocol(resolved) === null && attributedEnv.has(match[1]!) &&
|
|
577
577
|
(/^https?:/i.test(resolved) || /_REST_URL$/.test(match[1]!))) continue;
|
|
578
|
-
|
|
578
|
+
// An empty example leaves the actual value to environment injection. Only an owned binding supplies its protocol.
|
|
579
|
+
const protocol = connectionProtocol(resolved) ?? (value.trim() === '' ? boundProtocols.get(match[1]!) ?? null : null);
|
|
580
|
+
found.push({ protocol, env: match[1]!, sources: [`env: ${relative(root, resolve(dir, file))}:${index + 1} ${match[1]}`] });
|
|
579
581
|
}
|
|
580
582
|
}
|
|
581
583
|
}
|
package/src/proxy-daemon.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Detached entry for the ambient TLS redirect proxy. `activate` spawns this as its own process so a
|
|
3
3
|
// long-lived proxy can serve the activated shell(s) for the world's lifetime; it records its
|
|
4
4
|
// pid/url/CA in the instance dir and is stopped by `volter-world down`. Not a user-facing command.
|
|
5
|
+
import { errorMessage } from './error-message.ts';
|
|
5
6
|
import { runProxyDaemon } from './runtime.ts';
|
|
6
7
|
|
|
7
8
|
const [name, ...rest] = process.argv.slice(2);
|
|
@@ -16,6 +17,6 @@ if (!name) {
|
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
runProxyDaemon(name, root, envFile).catch((error) => {
|
|
19
|
-
process.stderr.write(`proxy-daemon: ${
|
|
20
|
+
process.stderr.write(`proxy-daemon: ${errorMessage(error)}\n`);
|
|
20
21
|
process.exit(1);
|
|
21
22
|
});
|
package/src/redirect-proxy.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { errorMessage } from './error-message.ts';
|
|
1
2
|
import { serveHttp, type HttpServer } from '@volter/world-core/runtime';
|
|
2
|
-
import { worldNetworkPolicyFromEnv, allowsWorldNetworkEgress } from '@volter/world-core/network-policy';
|
|
3
|
+
import { worldNetworkPolicyFromEnv, allowsWorldNetworkEgress, worldNetworkNeedsPathInspection, worldNetworkUsesPassthrough } from '@volter/world-core/network-policy';
|
|
3
4
|
import { websocketRelay } from './websocket-relay.ts';
|
|
4
5
|
// The ambient TLS redirect proxy — the substance of "any CLI, zero config" (WORLD_ACTIVATE.md §"How
|
|
5
6
|
// redirection works", tier 1). A local TLS-terminating forward proxy that maps vendor API hosts
|
|
@@ -26,6 +27,9 @@ import { lookup as dnsLookup } from 'node:dns/promises';
|
|
|
26
27
|
import { BlockList, Socket, isIP, connect as netConnect, createServer as createNetServer } from 'node:net';
|
|
27
28
|
import { networkInterfaces } from 'node:os';
|
|
28
29
|
import { connect as tlsConnect } from 'node:tls';
|
|
30
|
+
import { request as httpsRequest } from 'node:https';
|
|
31
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
32
|
+
import { Readable } from 'node:stream';
|
|
29
33
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
30
34
|
import { join } from 'node:path';
|
|
31
35
|
// The host→twin table is loaded through the shared inject-map module (one world-runtime home
|
|
@@ -33,6 +37,7 @@ import { join } from 'node:path';
|
|
|
33
37
|
// never re-encode the vendor host predicates — they stay one source of truth with the Node
|
|
34
38
|
// injector. External callers go through `proxyTargetFor`/`activeVendorMap`.
|
|
35
39
|
import { loadInject } from './inject-map.ts';
|
|
40
|
+
import { packFacts } from './pack-facts.ts';
|
|
36
41
|
import { sessionTrustEnv, type CaTrustInputs } from './ca-trust.ts';
|
|
37
42
|
|
|
38
43
|
/**
|
|
@@ -160,7 +165,7 @@ export async function guestDestination(host: string): Promise<{ address: string
|
|
|
160
165
|
const h = host.replace(/^\[|\]$/g, '');
|
|
161
166
|
let addresses: string[];
|
|
162
167
|
try { addresses = isIP(h) ? [h] : (await dnsLookup(h, { all: true })).map((r) => r.address); } catch (error) {
|
|
163
|
-
return { refused: `${host} does not resolve (${(error
|
|
168
|
+
return { refused: `${host} does not resolve (${errorMessage(error)})` };
|
|
164
169
|
}
|
|
165
170
|
const bad = addresses.find(isGuestForbiddenAddress);
|
|
166
171
|
if (bad || !addresses.length) return { refused: `${host} is on the machine's host network (${bad ?? 'no address'}), which a World Machine cannot reach` };
|
|
@@ -309,7 +314,7 @@ type RedirectProxyHandle = {
|
|
|
309
314
|
* of which World to look at (`doctor`). Shared by every front that relays to twins. */
|
|
310
315
|
export function twinUnreachableMessage(vendor: string, origin: string, err: Error, world?: string): string {
|
|
311
316
|
const cause = (err as { cause?: { code?: string; message?: string } }).cause;
|
|
312
|
-
return `twin "${vendor}" at ${origin} is unreachable${world ? ` in World "${world}"` : ''}: ${err
|
|
317
|
+
return `twin "${vendor}" at ${origin} is unreachable${world ? ` in World "${world}"` : ''}: ${errorMessage(err)}${cause?.code ? ` (${cause.code})` : cause?.message ? ` (${errorMessage(cause)})` : ''}; check \`volter-world doctor${world ? ` ${world}` : ''}\`\n`;
|
|
313
318
|
}
|
|
314
319
|
|
|
315
320
|
/** Start the ambient redirect proxy. Returns a handle with the listen port + the env to export.
|
|
@@ -336,6 +341,94 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
|
|
|
336
341
|
// or real upstream would otherwise keep the server from closing).
|
|
337
342
|
const openSockets = new Set<Socket>();
|
|
338
343
|
|
|
344
|
+
const publicBuildRequest = (target: URL, env: Record<string, string | undefined>): boolean => {
|
|
345
|
+
const policy = worldNetworkPolicyFromEnv(env);
|
|
346
|
+
return !strictEgressEnabled(env) && !!policy && policy.phase === 'build' && allowsWorldNetworkEgress(policy, target);
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
// One upstream HTTP operation, shared by plain-proxy and decrypted CONNECT requests.
|
|
350
|
+
// It never follows redirects and never lends the upstream socket to the caller.
|
|
351
|
+
const externalRequest = async (target: URL, method: string, incoming: Headers, body: ReadableStream | null, signal?: AbortSignal): Promise<Response> => {
|
|
352
|
+
const env = currentEnv();
|
|
353
|
+
const policy = worldNetworkPolicyFromEnv(env);
|
|
354
|
+
// the method is visible here: an egress grant reads, a write needs `writes`
|
|
355
|
+
if (!policy || !allowsWorldNetworkEgress(policy, target, method) || shouldBlockUntwinned(target, env)) throw new Error('World policy refuses the effective request');
|
|
356
|
+
const host = incoming.get('host');
|
|
357
|
+
if (host && new URL(`${target.protocol}//${host}`).host !== target.host) throw new Error('request Host differs from authorized authority');
|
|
358
|
+
if (method === 'CONNECT' || incoming.has('upgrade')) throw new Error('inspected egress cannot upgrade its transport');
|
|
359
|
+
if (!body && (Number(incoming.get('content-length') || 0) > 0 || incoming.has('transfer-encoding'))) throw new Error('transport cannot mediate this request body');
|
|
360
|
+
const headers = new Headers(incoming);
|
|
361
|
+
const hopHeaders = (headers.get('connection') ?? '').split(',').map(h => h.trim().toLowerCase()).filter(Boolean);
|
|
362
|
+
for (const h of [...hopHeaders, 'host', 'proxy-authorization', 'proxy-authenticate', 'proxy-connection', 'connection', 'keep-alive', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'x-twins-key', 'x-volter-twin-original-host']) headers.delete(h);
|
|
363
|
+
return await new Promise<Response>((resolve, reject) => {
|
|
364
|
+
const upstream = httpsRequest({ hostname: target.hostname, port: Number(target.port || 443), servername: target.hostname,
|
|
365
|
+
path: target.pathname + target.search, method, headers: { ...Object.fromEntries(headers), host: target.host },
|
|
366
|
+
agent: false, ca: readFileSync(trust.SSL_CERT_FILE!), signal,
|
|
367
|
+
}, res => {
|
|
368
|
+
const out = new Headers();
|
|
369
|
+
const nominated = String(res.headers.connection ?? '').split(',').map(h => h.trim().toLowerCase());
|
|
370
|
+
for (const [key, value] of Object.entries(res.headers)) if (value !== undefined && ![...nominated, 'connection', 'transfer-encoding', 'keep-alive', 'trailer', 'upgrade'].includes(key)) {
|
|
371
|
+
for (const part of Array.isArray(value) ? value : [value]) out.append(key, String(part));
|
|
372
|
+
}
|
|
373
|
+
const empty = method === 'HEAD' || [204, 205, 304].includes(res.statusCode!);
|
|
374
|
+
if (empty) res.resume();
|
|
375
|
+
note(`inspected egress ${method} ${target.origin}${target.pathname} -> ${res.statusCode}`);
|
|
376
|
+
resolve(new Response(empty ? null : Readable.toWeb(res) as unknown as ReadableStream, { status: res.statusCode!, headers: out }));
|
|
377
|
+
});
|
|
378
|
+
upstream.on('socket', socket => { openSockets.add(socket); socket.on('close', () => openSockets.delete(socket)); });
|
|
379
|
+
upstream.on('error', reject);
|
|
380
|
+
upstream.on('upgrade', (_res, socket) => { socket.destroy(); reject(new Error('upstream upgrade refused')); });
|
|
381
|
+
if (body) {
|
|
382
|
+
const source = Readable.fromWeb(body as unknown as import('node:stream/web').ReadableStream);
|
|
383
|
+
source.on('error', error => upstream.destroy(error));
|
|
384
|
+
upstream.on('error', () => source.destroy());
|
|
385
|
+
source.pipe(upstream);
|
|
386
|
+
} else upstream.end();
|
|
387
|
+
});
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// Use the HTTP parser for framing; never forward trailing bytes as a second raw request.
|
|
391
|
+
const usedPlainSockets = new WeakSet<Socket>();
|
|
392
|
+
const inspectedPlain = createHttpServer((req, res) => {
|
|
393
|
+
if (usedPlainSockets.has(req.socket)) { note('inspected egress refused extra plain request'); req.socket.destroy(); return; }
|
|
394
|
+
usedPlainSockets.add(req.socket);
|
|
395
|
+
res.shouldKeepAlive = false;
|
|
396
|
+
res.setHeader('connection', 'close');
|
|
397
|
+
res.on('finish', () => req.socket.end());
|
|
398
|
+
const abort = new AbortController();
|
|
399
|
+
req.on('aborted', () => abort.abort());
|
|
400
|
+
res.on('close', () => { if (!res.writableFinished) abort.abort(); });
|
|
401
|
+
void (async () => {
|
|
402
|
+
if (/\\/.test(req.url!.split(/[?#]/, 1)[0]!)) throw new Error('backslash in proxy target');
|
|
403
|
+
const target = new URL(req.url!);
|
|
404
|
+
if (proxyTargetFor(target.hostname, currentEnv()) && !publicBuildRequest(target, currentEnv())) throw new Error('inspected external request names a twinned host');
|
|
405
|
+
const headers = new Headers();
|
|
406
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) headers.append(req.rawHeaders[i]!, req.rawHeaders[i + 1]!);
|
|
407
|
+
const body = headers.has('transfer-encoding') || Number(headers.get('content-length') || 0) > 0 ? Readable.toWeb(req) as unknown as ReadableStream : null;
|
|
408
|
+
const response = await externalRequest(target, req.method!, headers, body, abort.signal);
|
|
409
|
+
res.statusCode = response.status;
|
|
410
|
+
for (const [key, value] of response.headers) res.setHeader(key, value);
|
|
411
|
+
if (response.body) {
|
|
412
|
+
const stream = Readable.fromWeb(response.body as unknown as import('node:stream/web').ReadableStream);
|
|
413
|
+
stream.on('error', () => res.destroy());
|
|
414
|
+
res.on('close', () => stream.destroy());
|
|
415
|
+
stream.pipe(res);
|
|
416
|
+
} else res.end();
|
|
417
|
+
})().catch(error => { if (!res.headersSent) { res.statusCode = 502; res.end(note(`${(error as Error).message}\n`)); } else res.destroy(); });
|
|
418
|
+
});
|
|
419
|
+
inspectedPlain.maxRequestsPerSocket = 1;
|
|
420
|
+
inspectedPlain.on('dropRequest', (req, socket) => { note('inspected egress refused extra plain request'); socket.destroy(); });
|
|
421
|
+
inspectedPlain.on('clientError', (_error, socket) => socket.destroy());
|
|
422
|
+
// Bun's Node HTTP adapter parses only on a listening server, not a synthetic connection event.
|
|
423
|
+
// Relays below carry bytes solely to this local parser; only externalRequest opens public sockets.
|
|
424
|
+
await new Promise<void>((resolve, reject) => {
|
|
425
|
+
inspectedPlain.once('error', reject);
|
|
426
|
+
inspectedPlain.listen(0, '127.0.0.1', resolve);
|
|
427
|
+
});
|
|
428
|
+
const inspectedAddress = inspectedPlain.address();
|
|
429
|
+
if (!inspectedAddress || typeof inspectedAddress === 'string') throw new Error('inspected HTTP parser has no TCP address');
|
|
430
|
+
const inspectedPort = inspectedAddress.port;
|
|
431
|
+
|
|
339
432
|
// The MITM TLS endpoint(s): a REAL listening TLS server PER vendor host (loopback, ephemeral port), each pinned
|
|
340
433
|
// to that host's leaf cert. On CONNECT to a vendor host we tunnel the client's raw bytes to the matching per-host
|
|
341
434
|
// server, which terminates TLS and forwards the decrypted request to the resolved twin. (One server per host
|
|
@@ -345,14 +438,17 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
|
|
|
345
438
|
// websocket is relayed message by message to the twin's own websocket at the same path.
|
|
346
439
|
const tlsServers = new Map<string, HttpServer>();
|
|
347
440
|
const tlsPorts = new Map<string, number>();
|
|
348
|
-
const ensureHostServer = async (vendorHost: string): Promise<number> => {
|
|
349
|
-
const
|
|
441
|
+
const ensureHostServer = async (vendorHost: string, guest = false, originPort = 443): Promise<number> => {
|
|
442
|
+
const serverKey = `${vendorHost}:${originPort}:${guest}`;
|
|
443
|
+
const existing = tlsPorts.get(serverKey);
|
|
350
444
|
if (existing) return existing;
|
|
351
445
|
const leaf = leafCertFor(vendorHost, ca);
|
|
352
446
|
const hostServer = await serveHttp({
|
|
353
447
|
hostname: '127.0.0.1',
|
|
354
448
|
port: 0,
|
|
355
449
|
tls: { key: leaf.key, cert: leaf.cert },
|
|
450
|
+
// Carry a twin's declared transport cap through TLS termination; public grants add no cap.
|
|
451
|
+
maxRequestBodySize: (packFacts()[proxyTargetFor(vendorHost, currentEnv())?.vendor ?? ''] as { maxRequestBodySize?: number } | undefined)?.maxRequestBodySize,
|
|
356
452
|
// 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
|
|
357
453
|
// default cut those connections and the client read an empty reply. Bun's maximum, as reflect.ts serves.
|
|
358
454
|
idleTimeout: 255,
|
|
@@ -363,12 +459,22 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
|
|
|
363
459
|
// The request line is readable now, so resolve WITH the path: this is the point where a host two vendors
|
|
364
460
|
// share (www.googleapis.com → googleauth vs youtube) gets split correctly; on a shared host there IS a twin
|
|
365
461
|
// for the host and merely not for this path, and the bare message sends the next reader hunting.
|
|
366
|
-
const
|
|
462
|
+
const effective = new URL(`https://${vendorHost}:${originPort}${u.pathname}${u.search}`);
|
|
463
|
+
const publicBuild = publicBuildRequest(effective, env);
|
|
464
|
+
const twin = publicBuild ? null : proxyTargetFor(hostHeader, env, u.pathname) ?? proxyTargetFor(vendorHost, env, u.pathname);
|
|
367
465
|
if (!twin) {
|
|
368
466
|
// a host the World refuses (sealed, or outside its network policy): answered here, over the World's TLS, so
|
|
369
467
|
// a person's browser shows where it was sent and why nothing is there, not an empty tunnel error; nothing
|
|
370
468
|
// leaves the World
|
|
371
|
-
|
|
469
|
+
const target = new URL(`https://${vendorHost}:${originPort}${u.pathname}${u.search}`);
|
|
470
|
+
if (shouldBlockUntwinned(target.href, env)) return outsideWorld(hostHeader, `${u.pathname}${u.search}`, req.headers.get('accept'));
|
|
471
|
+
if (!publicBuild && proxyTargetFor(vendorHost, env)) return outsideWorld(vendorHost, target.pathname, req.headers.get('accept'));
|
|
472
|
+
const policy = worldNetworkPolicyFromEnv(env);
|
|
473
|
+
if (policy && worldNetworkNeedsPathInspection(policy, target)) {
|
|
474
|
+
if (guest) return outsideWorld(vendorHost, target.pathname, req.headers.get('accept'), 'guest inspected egress cannot preserve its checked destination pin');
|
|
475
|
+
try { return await externalRequest(target, req.method, req.headers, req.body, req.signal); }
|
|
476
|
+
catch (error) { return new Response(note(`inspected egress refused: ${(error as Error).message}\n`), { status: 502 }); }
|
|
477
|
+
}
|
|
372
478
|
const unreachable = unreachableHosts.get(hostHeader.toLowerCase());
|
|
373
479
|
if (unreachable) return outsideWorld(hostHeader, `${u.pathname}${u.search}`, req.headers.get('accept'), unreachable);
|
|
374
480
|
return new Response(note(noTwinMessage(hostHeader, u.pathname)), { status: 502, headers: { 'content-type': 'text/plain; charset=utf-8' } });
|
|
@@ -414,8 +520,8 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
|
|
|
414
520
|
}),
|
|
415
521
|
});
|
|
416
522
|
const p = hostServer.port ?? 0;
|
|
417
|
-
tlsServers.set(
|
|
418
|
-
tlsPorts.set(
|
|
523
|
+
tlsServers.set(serverKey, hostServer);
|
|
524
|
+
tlsPorts.set(serverKey, p);
|
|
419
525
|
return p;
|
|
420
526
|
};
|
|
421
527
|
|
|
@@ -453,7 +559,7 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
|
|
|
453
559
|
return;
|
|
454
560
|
}
|
|
455
561
|
|
|
456
|
-
handlePlainProxyRequest(method, urlStr, version, headers, clientSocket, head, guest);
|
|
562
|
+
handlePlainProxyRequest(method, urlStr, version, headers, clientSocket, head, guest, buffered);
|
|
457
563
|
};
|
|
458
564
|
clientSocket.on('data', onData);
|
|
459
565
|
};
|
|
@@ -524,7 +630,7 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
|
|
|
524
630
|
: netConnect(Number(origin.port || 80), connectHost, connected);
|
|
525
631
|
openSockets.add(upstream);
|
|
526
632
|
upstream.on('close', () => openSockets.delete(upstream));
|
|
527
|
-
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
|
|
633
|
+
upstream.on('error', (err: Error) => writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', vendor ? twinUnreachable(vendor, origin.origin, err) : note(`upstream ${origin.host} unreachable: ${errorMessage(err)}\n`)));
|
|
528
634
|
};
|
|
529
635
|
|
|
530
636
|
const handlePlainProxyRequest = (
|
|
@@ -535,10 +641,12 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
|
|
|
535
641
|
socket: Socket,
|
|
536
642
|
head: Buffer,
|
|
537
643
|
guest: boolean,
|
|
644
|
+
raw: Buffer,
|
|
538
645
|
): void => {
|
|
539
646
|
try {
|
|
540
647
|
const u = new URL(urlStr);
|
|
541
|
-
const
|
|
648
|
+
const publicBuild = publicBuildRequest(u, currentEnv());
|
|
649
|
+
const twin = publicBuild ? null : proxyTargetFor(u.hostname, currentEnv(), u.pathname);
|
|
542
650
|
const path = u.pathname + u.search;
|
|
543
651
|
if (twin) {
|
|
544
652
|
const target = new URL(twin.origin);
|
|
@@ -550,7 +658,7 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
|
|
|
550
658
|
// No twin claims this PATH — but if the HOST is twinned (host-level candidacy), the path is
|
|
551
659
|
// an unclaimed slice of a shared, virtualized host: refuse loudly (same rule as the MITM
|
|
552
660
|
// handler's noTwinMessage), never pass through to the real vendor.
|
|
553
|
-
if (proxyTargetFor(u.hostname, currentEnv())) {
|
|
661
|
+
if (!publicBuild && proxyTargetFor(u.hostname, currentEnv())) {
|
|
554
662
|
writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(noTwinMessage(u.hostname, u.pathname)));
|
|
555
663
|
return;
|
|
556
664
|
}
|
|
@@ -558,6 +666,22 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
|
|
|
558
666
|
writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(`blocked untwinned external request to ${u.hostname}\n`));
|
|
559
667
|
return;
|
|
560
668
|
}
|
|
669
|
+
const policy = worldNetworkPolicyFromEnv(currentEnv());
|
|
670
|
+
if (policy && worldNetworkNeedsPathInspection(policy, u)) {
|
|
671
|
+
if (guest) { writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note('guest inspected egress requires a destination-pinned transport\n')); return; }
|
|
672
|
+
socket.pause();
|
|
673
|
+
const parser = netConnect(inspectedPort, '127.0.0.1', () => {
|
|
674
|
+
parser.write(raw);
|
|
675
|
+
socket.pipe(parser);
|
|
676
|
+
parser.pipe(socket);
|
|
677
|
+
socket.resume();
|
|
678
|
+
});
|
|
679
|
+
openSockets.add(parser);
|
|
680
|
+
parser.on('close', () => { openSockets.delete(parser); socket.destroy(); });
|
|
681
|
+
parser.on('error', () => socket.destroy());
|
|
682
|
+
socket.on('close', () => parser.destroy());
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
561
685
|
// Non-vendor: plain pass-through to the real host; for a World Machine, only off its host's own network, and to
|
|
562
686
|
// the address checked (architecture, "The scoped proxy's local pass-through")
|
|
563
687
|
if (guest) {
|
|
@@ -593,7 +717,7 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
|
|
|
593
717
|
// a destination that cannot be reached at all (a name that does not resolve, a refused port) is answered by the
|
|
594
718
|
// caller's fallback when it has one, before the tunnel was ever established; otherwise the client's socket ends
|
|
595
719
|
upstream.on('error', (error: NodeJS.ErrnoException) => {
|
|
596
|
-
if (!connected && unreachable && unreachable(error.code ?? error
|
|
720
|
+
if (!connected && unreachable && unreachable(error.code ?? errorMessage(error))) return;
|
|
597
721
|
clientSocket.destroy();
|
|
598
722
|
});
|
|
599
723
|
};
|
|
@@ -603,13 +727,22 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
|
|
|
603
727
|
note(`${connectHost}:${connectPort} did not answer (${reason})`);
|
|
604
728
|
refusedPages.add(connectHost);
|
|
605
729
|
unreachableHosts.set(connectHost, reason);
|
|
606
|
-
ensureHostServer(connectHost).then((port) => tunnel(port, '127.0.0.1')).catch(() => clientSocket.destroy());
|
|
730
|
+
ensureHostServer(connectHost, guest, connectPort).then((port) => tunnel(port, '127.0.0.1')).catch(() => clientSocket.destroy());
|
|
607
731
|
return true;
|
|
608
732
|
};
|
|
609
733
|
// MITM vendor hosts → tunnel to the per-host listening https server (terminates TLS, forwards to
|
|
610
734
|
// the twin). Non-vendor hosts → blind-tunnel to the REAL origin, untouched.
|
|
611
|
-
|
|
612
|
-
|
|
735
|
+
const scopedOrigin = connectHost && new URL(`https://${connectHost}:${connectPort}`);
|
|
736
|
+
const scopedPolicy = worldNetworkPolicyFromEnv(currentEnv());
|
|
737
|
+
const inspected = scopedOrigin && scopedPolicy && worldNetworkNeedsPathInspection(scopedPolicy, scopedOrigin);
|
|
738
|
+
const passthrough = scopedOrigin && scopedPolicy && worldNetworkUsesPassthrough(scopedPolicy, scopedOrigin);
|
|
739
|
+
if (passthrough && !twin) note(`build TLS passthrough ${scopedOrigin.origin} (explicit origin scope)`);
|
|
740
|
+
if (inspected && guest && !twin) {
|
|
741
|
+
writeResponse(clientSocket, 'HTTP/1.1 502 Bad Gateway', note('guest inspected egress requires a destination-pinned transport\n'));
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
if ((twin || (!strictEgressEnabled(currentEnv()) && inspected)) && connectHost) {
|
|
745
|
+
ensureHostServer(connectHost, guest, connectPort)
|
|
613
746
|
.then((port) => tunnel(port, '127.0.0.1'))
|
|
614
747
|
.catch(() => clientSocket.destroy());
|
|
615
748
|
} else if (connectHost && shouldBlockUntwinned(`https://${connectHost}:${connectPort}`, currentEnv())) {
|
|
@@ -620,7 +753,7 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
|
|
|
620
753
|
// line, as before
|
|
621
754
|
if (connectPort === 443 && isDnsName(connectHost) && (refusedPages.has(connectHost) || refusedPages.size < REFUSED_PAGES_MAX)) {
|
|
622
755
|
refusedPages.add(connectHost);
|
|
623
|
-
ensureHostServer(connectHost)
|
|
756
|
+
ensureHostServer(connectHost, guest, connectPort)
|
|
624
757
|
.then((port) => tunnel(port, '127.0.0.1'))
|
|
625
758
|
.catch(() => clientSocket.destroy());
|
|
626
759
|
} else {
|
|
@@ -669,6 +802,8 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
|
|
|
669
802
|
for (const s of openSockets) { try { s.destroy(); } catch { /* ignore */ } }
|
|
670
803
|
openSockets.clear();
|
|
671
804
|
for (const hostServer of tlsServers.values()) { try { hostServer.stop(true); } catch { /* ignore */ } }
|
|
805
|
+
inspectedPlain.closeAllConnections();
|
|
806
|
+
inspectedPlain.close();
|
|
672
807
|
guestServer.close();
|
|
673
808
|
server.close(() => resolveClose());
|
|
674
809
|
}),
|