@volter/world-runtime 3.0.67 → 3.0.68

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.
@@ -10,6 +10,8 @@ export type InjectModule = {
10
10
  twinUrlVendor(name: string): string | null;
11
11
  /** the headers a request routed to the application carries: the host and scheme it was made with */
12
12
  appForwardHeaders(url: URL): Record<string, string>;
13
+ /** the vendors whose twins this map runs and whose host rules name `host`, whatever the path */
14
+ twinnedHostVendors(host: string, map: Record<string, string>): string[];
13
15
  VENDOR_HOSTS: Record<string, (host: string, pathname?: string) => boolean>;
14
16
  restore(): void;
15
17
  };
@@ -42,6 +42,8 @@ export declare function guestDestination(host: string): Promise<{
42
42
  address: string;
43
43
  } | {
44
44
  refused: string;
45
+ hostNetwork?: true;
46
+ address?: string;
45
47
  }>;
46
48
  type CaPaths = {
47
49
  dir: string;
@@ -1,6 +1,7 @@
1
1
  import { errorMessage } from "./error-message.js";
2
2
  import { serveHttp } from '@volter/world-core/runtime';
3
- import { worldNetworkPolicyFromEnv, allowsWorldNetworkEgress, worldNetworkNeedsPathInspection, worldNetworkUsesPassthrough } from '@volter/world-core/network-policy';
3
+ import { worldNetworkPolicyFromEnv, worldGrantFromEnv, worldStrictEgress, worldNetworkNeedsPathInspection, worldNetworkUsesPassthrough } from '@volter/world-core/network-policy';
4
+ import { OPEN_DECLARATION, decideCapability, publicBuildGrant, refusalRecord, refusalSide } from '@volter/world-core/capability-decision';
4
5
  import { websocketRelay } from "./websocket-relay.js";
5
6
  // The ambient TLS redirect proxy — the substance of "any CLI, zero config" (WORLD_ACTIVATE.md §"How
6
7
  // redirection works", tier 1). A local TLS-terminating forward proxy that maps vendor API hosts
@@ -57,10 +58,10 @@ import { sessionTrustEnv } from "./ca-trust.js";
57
58
  */
58
59
  export function noTwinMessage(host, path) {
59
60
  return `host ${host} is twinned in this world, but path ${JSON.stringify(path)} belongs to no twin configured here.\n`
60
- + 'This host is shared between vendors, and no pack in this world serves this path. Either the\n'
61
+ + 'This host is shared between vendors, and no twin in this world serves this path. Either the\n'
61
62
  + 'vendor that owns this path has a twin that is not configured — set its *_TWIN_URL (for\n'
62
63
  + 'www.googleapis.com: YOUTUBE_TWIN_URL serves /youtube/v3/*, GOOGLEAUTH_TWIN_URL serves the\n'
63
- + 'OAuth2 token paths) — or this API has no twin pack yet (e.g. Google Drive /drive/v3/*),\n'
64
+ + 'OAuth2 token paths) — or this API has no twin yet (e.g. Google Drive /drive/v3/*),\n'
64
65
  + 'in which case this loud refusal is the honest outcome: the request is neither answered by the\n'
65
66
  + 'wrong twin nor leaked to the real vendor.\n';
66
67
  }
@@ -80,33 +81,6 @@ export function proxyTargetFor(hostname, env, pathname) {
80
81
  export function activeVendorMap(env) {
81
82
  return loadInject().readMap(env);
82
83
  }
83
- function strictEgressEnabled(env) {
84
- return env.VOLTER_TWIN_STRICT_EGRESS === '1' || env.VOLTER_TWIN_STRICT_EGRESS === 'true';
85
- }
86
- function isLocalOrPrivateHost(hostname) {
87
- const h = String(hostname || '').toLowerCase();
88
- if (!h)
89
- return true;
90
- if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.test'))
91
- return true;
92
- if (h === '0.0.0.0' || h === '127.0.0.1' || h === '::1' || h === '[::1]')
93
- return true;
94
- if (isIP(h) !== 4)
95
- return false;
96
- if (/^127\./.test(h))
97
- return true;
98
- if (/^10\./.test(h))
99
- return true;
100
- if (/^192\.168\./.test(h))
101
- return true;
102
- const match = h.match(/^172\.(\d+)\./);
103
- if (match) {
104
- const n = Number(match[1]);
105
- if (n >= 16 && n <= 31)
106
- return true;
107
- }
108
- return false;
109
- }
110
84
  /** A connection from anything but the proxy host's own loopback is a guest's (a World Machine: its forwarder connects
111
85
  * from 127.0.0.2 on this host, or from its VM's address): architecture, "The scoped proxy's local pass-through". */
112
86
  export function isGuestSource(address) {
@@ -177,21 +151,51 @@ export async function guestDestination(host) {
177
151
  }
178
152
  const bad = addresses.find(isGuestForbiddenAddress);
179
153
  if (bad || !addresses.length)
180
- return { refused: `${host} is on the machine's host network (${bad ?? 'no address'}), which a World Machine cannot reach` };
154
+ return { refused: `${host} is on the machine's host network (${bad ?? 'no address'}), which a World Machine cannot reach`, hostNetwork: true, address: bad ?? 'no address' };
181
155
  return { address: addresses[0] };
182
156
  }
183
- function shouldBlockUntwinned(value, env) {
157
+ /** The World's refusal of `value` (sealed, unlisted, an unreadable policy), or null: the World's capability decision
158
+ * (docs/contributing/architecture.md, "Capability decisions") for a host process, with the twin of its host as the fact.
159
+ * `method` is given where the proxy sees it, on the effective request it sends to the real origin itself: no twin
160
+ * answers that request, so its host's twin is no fact of it. */
161
+ function shouldBlockUntwinned(value, env, method) {
184
162
  let url;
185
163
  try {
186
164
  url = value instanceof URL ? value : new URL(value);
187
165
  }
188
166
  catch {
189
- return true;
167
+ return { outcome: 'refuse', capability: 'network.egress', target: String(value), missing: 'grant', rule: 'invalid', reason: 'the request has an invalid effective authority or path' };
190
168
  }
191
- const policy = worldNetworkPolicyFromEnv(env);
192
- if (isLocalOrPrivateHost(url.hostname) || proxyTargetFor(url.hostname, env))
193
- return false;
194
- return strictEgressEnabled(env) || (policy !== undefined && !allowsWorldNetworkEgress(policy, url));
169
+ const answer = decideCapability({ kind: 'egress', url, transport: 'http', ...(method ? { method } : {}) }, { run: { guest: false }, declaration: OPEN_DECLARATION, grant: worldGrantFromEnv(env), facts: { twin: method ? null : proxyTargetFor(url.hostname, env) } });
170
+ return answer.outcome === 'refuse' ? answer : null;
171
+ }
172
+ /** The World's refusal of a path no twin here claims on a host its twins serve. */
173
+ function twinGap(host, path, env) {
174
+ const inject = loadInject();
175
+ const vendors = inject.twinnedHostVendors(host, inject.readMap(env));
176
+ const named = vendors.length ? vendors : [proxyTargetFor(host, env)?.vendor ?? host];
177
+ return { outcome: 'refuse', capability: 'network.egress', target: `https://${host}${path}`, missing: 'twin', rule: 'unclaimed-path',
178
+ reason: `no twin in this World claims ${path} on ${host} (${named.join(', ')})` };
179
+ }
180
+ /** A refusal for a client that is not Node (curl, Python, Go): the decision's reason and side in words, its record as
181
+ * JSON on the line after, and the side in a header, as a Node client reads them from error.worldRefusal. */
182
+ function refusalText(refusal) {
183
+ return ` (${refusal.reason}: ${refusalSide(refusal)})\n${JSON.stringify(refusalRecord(refusal))}\n`;
184
+ }
185
+ const REFUSAL_HEADER = 'x-volter-world-refusal';
186
+ /** A World Machine guest's destination on the host's own networks (the address guestDestination found there): the
187
+ * World's decision for a guest's loopback or private destination that is neither a door nor a declared service. A
188
+ * name that does not resolve is no refusal of the World's, and carries none. */
189
+ function hostNetworkRefusal(host, port, address) {
190
+ const answer = decideCapability({ kind: 'loopback', host: address, port }, { run: { guest: true }, declaration: OPEN_DECLARATION, facts: {} });
191
+ if (answer.outcome !== 'refuse')
192
+ throw new Error(`the World's decision admits a guest's host-network destination ${host}:${port}`);
193
+ return { ...answer, target: `${host}:${port}` };
194
+ }
195
+ /** A World Machine guest's inspected egress: the proxy cannot keep the guest's checked destination pin on it. */
196
+ function guestPinRefusal(target) {
197
+ return { outcome: 'refuse', capability: 'network.egress', target, missing: 'transport', rule: 'guest-inspected',
198
+ reason: 'inspected egress cannot keep a guest\'s checked destination pin' };
195
199
  }
196
200
  function caPaths(tlsDir) {
197
201
  return { dir: tlsDir, caKey: join(tlsDir, 'ca-key.pem'), caCert: join(tlsDir, 'ca-cert.pem') };
@@ -313,18 +317,16 @@ export async function startRedirectProxy(options) {
313
317
  // Track raw client/upstream sockets so close() can forcibly drop tunnels (a blind tunnel to a slow
314
318
  // or real upstream would otherwise keep the server from closing).
315
319
  const openSockets = new Set();
316
- const publicBuildRequest = (target, env) => {
317
- const policy = worldNetworkPolicyFromEnv(env);
318
- return !strictEgressEnabled(env) && !!policy && policy.phase === 'build' && allowsWorldNetworkEgress(policy, target);
319
- };
320
+ const publicBuildRequest = (target, env) => publicBuildGrant(worldGrantFromEnv(env), target);
320
321
  // One upstream HTTP operation, shared by plain-proxy and decrypted CONNECT requests.
321
322
  // It never follows redirects and never lends the upstream socket to the caller.
322
323
  const externalRequest = async (target, method, incoming, body, signal) => {
323
324
  const env = currentEnv();
324
325
  const policy = worldNetworkPolicyFromEnv(env);
325
326
  // the method is visible here: an egress grant reads, a write needs `writes`
326
- if (!policy || !allowsWorldNetworkEgress(policy, target, method) || shouldBlockUntwinned(target, env))
327
- throw new Error('World policy refuses the effective request');
327
+ const refused = shouldBlockUntwinned(target, env, method);
328
+ if (!policy || refused)
329
+ throw Object.assign(new Error('World policy refuses the effective request'), refused ? { worldRefusal: refused } : {});
328
330
  const host = incoming.get('host');
329
331
  if (host && new URL(`${target.protocol}//${host}`).host !== target.host)
330
332
  throw new Error('request Host differs from authorized authority');
@@ -463,25 +465,28 @@ export async function startRedirectProxy(options) {
463
465
  // a person's browser shows where it was sent and why nothing is there, not an empty tunnel error; nothing
464
466
  // leaves the World
465
467
  const target = new URL(`https://${vendorHost}:${originPort}${u.pathname}${u.search}`);
466
- if (shouldBlockUntwinned(target.href, env))
467
- return outsideWorld(hostHeader, `${u.pathname}${u.search}`, req.headers.get('accept'));
468
+ const refused = shouldBlockUntwinned(target.href, env);
469
+ if (refused)
470
+ return outsideWorld(hostHeader, `${u.pathname}${u.search}`, req.headers.get('accept'), undefined, refused);
468
471
  if (!publicBuild && proxyTargetFor(vendorHost, env))
469
- return outsideWorld(vendorHost, target.pathname, req.headers.get('accept'));
472
+ return outsideWorld(vendorHost, target.pathname, req.headers.get('accept'), undefined, twinGap(vendorHost, target.pathname, env));
470
473
  const policy = worldNetworkPolicyFromEnv(env);
471
474
  if (policy && worldNetworkNeedsPathInspection(policy, target)) {
472
475
  if (guest)
473
- return outsideWorld(vendorHost, target.pathname, req.headers.get('accept'), 'guest inspected egress cannot preserve its checked destination pin');
476
+ return outsideWorld(vendorHost, target.pathname, req.headers.get('accept'), undefined, guestPinRefusal(target.href));
474
477
  try {
475
478
  return await externalRequest(target, req.method, req.headers, req.body, req.signal);
476
479
  }
477
480
  catch (error) {
478
- return new Response(note(`inspected egress refused: ${error.message}\n`), { status: 502 });
481
+ const refused = error.worldRefusal;
482
+ return new Response(note(`inspected egress refused: ${error.message}${refused ? refusalText(refused) : '\n'}`), { status: 502, headers: refused ? { [REFUSAL_HEADER]: refused.missing } : {} });
479
483
  }
480
484
  }
481
485
  const unreachable = unreachableHosts.get(hostHeader.toLowerCase());
482
486
  if (unreachable)
483
487
  return outsideWorld(hostHeader, `${u.pathname}${u.search}`, req.headers.get('accept'), unreachable);
484
- return new Response(note(noTwinMessage(hostHeader, u.pathname)), { status: 502, headers: { 'content-type': 'text/plain; charset=utf-8' } });
488
+ const gap = twinGap(hostHeader, u.pathname, env);
489
+ return new Response(note(noTwinMessage(hostHeader, u.pathname)) + refusalText(gap).trimStart(), { status: 502, headers: { 'content-type': 'text/plain; charset=utf-8', [REFUSAL_HEADER]: gap.missing } });
485
490
  }
486
491
  const origin = new URL(twin.origin);
487
492
  const headers = new Headers(req.headers);
@@ -579,21 +584,24 @@ export async function startRedirectProxy(options) {
579
584
  const guestServer = createNetServer(accept(true));
580
585
  /** A refused host's answer: 502, naming the host and why, as a page a person reads in a browser (the address bar
581
586
  * keeps where the request was sent) or as text for any other client. */
582
- const outsideWorld = (host, path, accept, unreachable) => {
587
+ const outsideWorld = (host, path, accept, unreachable, refusal) => {
583
588
  // the CONNECT that reached this server was noted when it was refused
584
589
  const why = unreachable
585
590
  ? `no twin serves it, and it did not answer from this World (${unreachable}).`
586
591
  : 'no twin serves it, and the World does not reach the internet for it.';
587
- const text = `${host} is outside this World: ${why}\n`;
592
+ const text = `${host} is outside this World: ${why}${refusal ? refusalText(refusal) : '\n'}`;
593
+ const refused = refusal ? { [REFUSAL_HEADER]: refusal.missing } : {};
588
594
  // connection: close, so a reload opens a new CONNECT, which reaches the origin once it answers (a kept-alive tunnel
589
595
  // would keep answering this page)
590
596
  if (!(accept ?? '').includes('text/html'))
591
- return new Response(text, { status: 502, headers: { 'content-type': 'text/plain; charset=utf-8', connection: 'close' } });
597
+ return new Response(text, { status: 502, headers: { 'content-type': 'text/plain; charset=utf-8', connection: 'close', ...refused } });
592
598
  const esc = (v) => v.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
593
599
  const page = `<!doctype html><html><head><meta charset="utf-8"><title>${esc(host)} is outside this World</title>`
594
600
  + '<style>body{font:15px system-ui,sans-serif;max-width:620px;margin:12vh auto;padding:0 16px;color:#222}code{background:#f2f2f2;padding:1px 4px;border-radius:4px}</style></head><body>'
595
- + `<h1>${esc(host)} is outside this World</h1><p>You were sent to <code>https://${esc(host)}${esc(path)}</code>. ${esc(why.charAt(0).toUpperCase() + why.slice(1))} There is nothing to show here.</p></body></html>`;
596
- return new Response(page, { status: 502, headers: { 'content-type': 'text/html; charset=utf-8', connection: 'close' } });
601
+ + `<h1>${esc(host)} is outside this World</h1><p>You were sent to <code>https://${esc(host)}${esc(path)}</code>. ${esc(why.charAt(0).toUpperCase() + why.slice(1))} There is nothing to show here.</p>`
602
+ + (refusal ? `<p>${esc(`${refusal.reason}: ${refusalSide(refusal)}`)}.</p><script type="application/json" id="volter-world-refusal">${esc(JSON.stringify(refusalRecord(refusal)))}</script>` : '')
603
+ + '</body></html>';
604
+ return new Response(page, { status: 502, headers: { 'content-type': 'text/html; charset=utf-8', connection: 'close', ...refused } });
597
605
  };
598
606
  // the refused hosts answered with a page, each holding a leaf and a listener until the proxy closes: bounded
599
607
  const REFUSED_PAGES_MAX = 64;
@@ -601,8 +609,9 @@ export async function startRedirectProxy(options) {
601
609
  // the names on 443 a tunnel could not reach, with why, answered by their host server's page
602
610
  const unreachableHosts = new Map();
603
611
  const isDnsName = (host) => /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$/i.test(host);
604
- const writeResponse = (socket, status, body) => {
605
- socket.write(`${status}\r\ncontent-type: text/plain\r\ncontent-length: ${Buffer.byteLength(body)}\r\nconnection: close\r\n\r\n${body}`);
612
+ const writeResponse = (socket, status, body, refusal) => {
613
+ const text = refusal ? body.replace(/\n$/, '') + refusalText(refusal) : body;
614
+ socket.write(`${status}\r\ncontent-type: text/plain\r\ncontent-length: ${Buffer.byteLength(text)}\r\n${refusal ? `${REFUSAL_HEADER}: ${refusal.missing}\r\n` : ''}connection: close\r\n\r\n${text}`);
606
615
  socket.end();
607
616
  };
608
617
  const forwardRawHttp = (origin, method, path, version, headers, socket, head, vendor, address) => {
@@ -650,11 +659,12 @@ export async function startRedirectProxy(options) {
650
659
  // an unclaimed slice of a shared, virtualized host: refuse loudly (same rule as the MITM
651
660
  // handler's noTwinMessage), never pass through to the real vendor.
652
661
  if (!publicBuild && proxyTargetFor(u.hostname, currentEnv())) {
653
- writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(noTwinMessage(u.hostname, u.pathname)));
662
+ writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(noTwinMessage(u.hostname, u.pathname)), twinGap(u.hostname, u.pathname, currentEnv()));
654
663
  return;
655
664
  }
656
- if (shouldBlockUntwinned(u, currentEnv())) {
657
- writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(`blocked untwinned external request to ${u.hostname}\n`));
665
+ const refused = shouldBlockUntwinned(u, currentEnv());
666
+ if (refused) {
667
+ writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(`blocked untwinned external request to ${u.hostname}\n`), refused);
658
668
  return;
659
669
  }
660
670
  const policy = worldNetworkPolicyFromEnv(currentEnv());
@@ -681,7 +691,7 @@ export async function startRedirectProxy(options) {
681
691
  if (guest) {
682
692
  void guestDestination(u.hostname).then((d) => {
683
693
  if ('refused' in d) {
684
- writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(`${d.refused}\n`));
694
+ writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(`${d.refused}\n`), d.hostNetwork ? hostNetworkRefusal(u.hostname, Number(u.port || (u.protocol === 'https:' ? 443 : 80)), d.address ?? d.refused) : undefined);
685
695
  return;
686
696
  }
687
697
  // to the address checked (TLS still names the host: SNI and the certificate)
@@ -733,21 +743,22 @@ export async function startRedirectProxy(options) {
733
743
  // MITM vendor hosts → tunnel to the per-host listening https server (terminates TLS, forwards to
734
744
  // the twin). Non-vendor hosts → blind-tunnel to the REAL origin, untouched.
735
745
  const scopedOrigin = connectHost && new URL(`https://${connectHost}:${connectPort}`);
746
+ const connectRefusal = connectHost ? shouldBlockUntwinned(`https://${connectHost}:${connectPort}`, currentEnv()) : null;
736
747
  const scopedPolicy = worldNetworkPolicyFromEnv(currentEnv());
737
748
  const inspected = scopedOrigin && scopedPolicy && worldNetworkNeedsPathInspection(scopedPolicy, scopedOrigin);
738
749
  const passthrough = scopedOrigin && scopedPolicy && worldNetworkUsesPassthrough(scopedPolicy, scopedOrigin);
739
750
  if (passthrough && !twin)
740
751
  note(`build TLS passthrough ${scopedOrigin.origin} (explicit origin scope)`);
741
752
  if (inspected && guest && !twin) {
742
- writeResponse(clientSocket, 'HTTP/1.1 502 Bad Gateway', note('guest inspected egress requires a destination-pinned transport\n'));
753
+ writeResponse(clientSocket, 'HTTP/1.1 502 Bad Gateway', note('guest inspected egress requires a destination-pinned transport\n'), guestPinRefusal(`https://${connectHost}:${connectPort}`));
743
754
  return;
744
755
  }
745
- if ((twin || (!strictEgressEnabled(currentEnv()) && inspected)) && connectHost) {
756
+ if ((twin || (!worldStrictEgress(currentEnv()) && inspected)) && connectHost) {
746
757
  ensureHostServer(connectHost, guest, connectPort)
747
758
  .then((port) => tunnel(port, '127.0.0.1'))
748
759
  .catch(() => clientSocket.destroy());
749
760
  }
750
- else if (connectHost && shouldBlockUntwinned(`https://${connectHost}:${connectPort}`, currentEnv())) {
761
+ else if (connectHost && connectRefusal) {
751
762
  note(`blocked untwinned external request to ${connectHost}:${connectPort}`);
752
763
  // a browser's https to a DNS name: terminated with the World's TLS and answered by the host server's page
753
764
  // (outsideWorld), so the person sees where they were sent. Anything else (another port or protocol, an address,
@@ -760,7 +771,7 @@ export async function startRedirectProxy(options) {
760
771
  .catch(() => clientSocket.destroy());
761
772
  }
762
773
  else {
763
- clientSocket.write('HTTP/1.1 502 Bad Gateway\r\ncontent-type: text/plain\r\n\r\nblocked untwinned external request\r\n');
774
+ clientSocket.write(`HTTP/1.1 502 Bad Gateway\r\ncontent-type: text/plain\r\n${REFUSAL_HEADER}: ${connectRefusal.missing}\r\n\r\nblocked untwinned external request${refusalText(connectRefusal)}`);
764
775
  clientSocket.destroy();
765
776
  }
766
777
  }
@@ -769,7 +780,8 @@ export async function startRedirectProxy(options) {
769
780
  void guestDestination(connectHost).then((d) => {
770
781
  if ('refused' in d) {
771
782
  note(`refused a machine's tunnel to ${connectHost}:${connectPort}: ${d.refused}`);
772
- clientSocket.write(`HTTP/1.1 502 Bad Gateway\r\ncontent-type: text/plain\r\n\r\n${d.refused}\r\n`);
783
+ const refusal = d.hostNetwork ? hostNetworkRefusal(connectHost, connectPort, d.address ?? d.refused) : null;
784
+ clientSocket.write(`HTTP/1.1 502 Bad Gateway\r\ncontent-type: text/plain\r\n${refusal ? `${REFUSAL_HEADER}: ${refusal.missing}\r\n` : ''}\r\n${d.refused}${refusal ? refusalText(refusal) : '\r\n'}`);
773
785
  clientSocket.destroy();
774
786
  }
775
787
  else
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "3.0.67",
3
+ "version": "3.0.68",
4
4
  "description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
5
5
  "keywords": [
6
6
  "twin",
@@ -69,14 +69,14 @@
69
69
  "node": ">=22.3"
70
70
  },
71
71
  "peerDependencies": {
72
- "@volter/world-core": "3.0.67",
73
- "@volter/world-console": "3.0.62"
72
+ "@volter/world-core": "3.0.68",
73
+ "@volter/world-console": "3.0.63"
74
74
  },
75
75
  "dependencies": {
76
76
  "@electric-sql/pglite": "0.5.8",
77
77
  "@electric-sql/pglite-pgvector": "0.0.9",
78
78
  "@volter/world-access": "3.0.1",
79
- "@volter/world-core": "3.0.67",
79
+ "@volter/world-core": "3.0.68",
80
80
  "mongodb": "~6.20.0",
81
81
  "pg-gateway": "0.3.0-beta.4",
82
82
  "smol-toml": "^1.8.0",
package/src/inject-map.ts CHANGED
@@ -15,6 +15,8 @@ export type InjectModule = {
15
15
  twinUrlVendor(name: string): string | null;
16
16
  /** the headers a request routed to the application carries: the host and scheme it was made with */
17
17
  appForwardHeaders(url: URL): Record<string, string>;
18
+ /** the vendors whose twins this map runs and whose host rules name `host`, whatever the path */
19
+ twinnedHostVendors(host: string, map: Record<string, string>): string[];
18
20
  VENDOR_HOSTS: Record<string, (host: string, pathname?: string) => boolean>;
19
21
  restore(): void;
20
22
  };
@@ -1,6 +1,7 @@
1
1
  import { errorMessage } from './error-message.ts';
2
2
  import { serveHttp, type HttpServer } from '@volter/world-core/runtime';
3
- import { worldNetworkPolicyFromEnv, allowsWorldNetworkEgress, worldNetworkNeedsPathInspection, worldNetworkUsesPassthrough } from '@volter/world-core/network-policy';
3
+ import { worldNetworkPolicyFromEnv, worldGrantFromEnv, worldStrictEgress, worldNetworkNeedsPathInspection, worldNetworkUsesPassthrough } from '@volter/world-core/network-policy';
4
+ import { OPEN_DECLARATION, decideCapability, publicBuildGrant, refusalRecord, refusalSide, type CapabilityAnswer } from '@volter/world-core/capability-decision';
4
5
  import { websocketRelay } from './websocket-relay.ts';
5
6
  // The ambient TLS redirect proxy — the substance of "any CLI, zero config" (WORLD_ACTIVATE.md §"How
6
7
  // redirection works", tier 1). A local TLS-terminating forward proxy that maps vendor API hosts
@@ -58,10 +59,10 @@ import { sessionTrustEnv, type CaTrustInputs } from './ca-trust.ts';
58
59
  */
59
60
  export function noTwinMessage(host: string, path: string): string {
60
61
  return `host ${host} is twinned in this world, but path ${JSON.stringify(path)} belongs to no twin configured here.\n`
61
- + 'This host is shared between vendors, and no pack in this world serves this path. Either the\n'
62
+ + 'This host is shared between vendors, and no twin in this world serves this path. Either the\n'
62
63
  + 'vendor that owns this path has a twin that is not configured — set its *_TWIN_URL (for\n'
63
64
  + 'www.googleapis.com: YOUTUBE_TWIN_URL serves /youtube/v3/*, GOOGLEAUTH_TWIN_URL serves the\n'
64
- + 'OAuth2 token paths) — or this API has no twin pack yet (e.g. Google Drive /drive/v3/*),\n'
65
+ + 'OAuth2 token paths) — or this API has no twin yet (e.g. Google Drive /drive/v3/*),\n'
65
66
  + 'in which case this loud refusal is the honest outcome: the request is neither answered by the\n'
66
67
  + 'wrong twin nor leaked to the real vendor.\n';
67
68
  }
@@ -88,27 +89,6 @@ export function activeVendorMap(env: Record<string, string | undefined>): Record
88
89
  return loadInject().readMap(env);
89
90
  }
90
91
 
91
- function strictEgressEnabled(env: Record<string, string | undefined>): boolean {
92
- return env.VOLTER_TWIN_STRICT_EGRESS === '1' || env.VOLTER_TWIN_STRICT_EGRESS === 'true';
93
- }
94
-
95
- function isLocalOrPrivateHost(hostname: string): boolean {
96
- const h = String(hostname || '').toLowerCase();
97
- if (!h) return true;
98
- if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.test')) return true;
99
- if (h === '0.0.0.0' || h === '127.0.0.1' || h === '::1' || h === '[::1]') return true;
100
- if (isIP(h) !== 4) return false;
101
- if (/^127\./.test(h)) return true;
102
- if (/^10\./.test(h)) return true;
103
- if (/^192\.168\./.test(h)) return true;
104
- const match = h.match(/^172\.(\d+)\./);
105
- if (match) {
106
- const n = Number(match[1]);
107
- if (n >= 16 && n <= 31) return true;
108
- }
109
- return false;
110
- }
111
-
112
92
  /** A connection from anything but the proxy host's own loopback is a guest's (a World Machine: its forwarder connects
113
93
  * from 127.0.0.2 on this host, or from its VM's address): architecture, "The scoped proxy's local pass-through". */
114
94
  export function isGuestSource(address: string | undefined): boolean {
@@ -161,23 +141,62 @@ export function isGuestForbiddenAddress(ip: string): boolean {
161
141
 
162
142
  /** Where a guest may be connected for `host`: the address checked (a literal, or what the name resolves to, every
163
143
  * answer checked), or the refusal. The caller connects to that address, so a second resolution cannot move it. */
164
- export async function guestDestination(host: string): Promise<{ address: string } | { refused: string }> {
144
+ export async function guestDestination(host: string): Promise<{ address: string } | { refused: string; hostNetwork?: true; address?: string }> {
165
145
  const h = host.replace(/^\[|\]$/g, '');
166
146
  let addresses: string[];
167
147
  try { addresses = isIP(h) ? [h] : (await dnsLookup(h, { all: true })).map((r) => r.address); } catch (error) {
168
148
  return { refused: `${host} does not resolve (${errorMessage(error)})` };
169
149
  }
170
150
  const bad = addresses.find(isGuestForbiddenAddress);
171
- if (bad || !addresses.length) return { refused: `${host} is on the machine's host network (${bad ?? 'no address'}), which a World Machine cannot reach` };
151
+ if (bad || !addresses.length) return { refused: `${host} is on the machine's host network (${bad ?? 'no address'}), which a World Machine cannot reach`, hostNetwork: true, address: bad ?? 'no address' };
172
152
  return { address: addresses[0]! };
173
153
  }
174
154
 
175
- function shouldBlockUntwinned(value: URL | string, env: Record<string, string | undefined>): boolean {
155
+ type WorldRefusal = Extract<CapabilityAnswer, { outcome: 'refuse' }>;
156
+
157
+ /** The World's refusal of `value` (sealed, unlisted, an unreadable policy), or null: the World's capability decision
158
+ * (docs/contributing/architecture.md, "Capability decisions") for a host process, with the twin of its host as the fact.
159
+ * `method` is given where the proxy sees it, on the effective request it sends to the real origin itself: no twin
160
+ * answers that request, so its host's twin is no fact of it. */
161
+ function shouldBlockUntwinned(value: URL | string, env: Record<string, string | undefined>, method?: string): WorldRefusal | null {
176
162
  let url: URL;
177
- try { url = value instanceof URL ? value : new URL(value); } catch { return true; }
178
- const policy = worldNetworkPolicyFromEnv(env);
179
- if (isLocalOrPrivateHost(url.hostname) || proxyTargetFor(url.hostname, env)) return false;
180
- return strictEgressEnabled(env) || (policy !== undefined && !allowsWorldNetworkEgress(policy, url));
163
+ try { url = value instanceof URL ? value : new URL(value); } catch {
164
+ return { outcome: 'refuse', capability: 'network.egress', target: String(value), missing: 'grant', rule: 'invalid', reason: 'the request has an invalid effective authority or path' };
165
+ }
166
+ const answer = decideCapability({ kind: 'egress', url, transport: 'http', ...(method ? { method } : {}) },
167
+ { run: { guest: false }, declaration: OPEN_DECLARATION, grant: worldGrantFromEnv(env), facts: { twin: method ? null : proxyTargetFor(url.hostname, env) } });
168
+ return answer.outcome === 'refuse' ? answer : null;
169
+ }
170
+
171
+ /** The World's refusal of a path no twin here claims on a host its twins serve. */
172
+ function twinGap(host: string, path: string, env: Record<string, string | undefined>): WorldRefusal {
173
+ const inject = loadInject();
174
+ const vendors = inject.twinnedHostVendors(host, inject.readMap(env));
175
+ const named = vendors.length ? vendors : [proxyTargetFor(host, env)?.vendor ?? host];
176
+ return { outcome: 'refuse', capability: 'network.egress', target: `https://${host}${path}`, missing: 'twin', rule: 'unclaimed-path',
177
+ reason: `no twin in this World claims ${path} on ${host} (${named.join(', ')})` };
178
+ }
179
+
180
+ /** A refusal for a client that is not Node (curl, Python, Go): the decision's reason and side in words, its record as
181
+ * JSON on the line after, and the side in a header, as a Node client reads them from error.worldRefusal. */
182
+ function refusalText(refusal: WorldRefusal): string {
183
+ return ` (${refusal.reason}: ${refusalSide(refusal)})\n${JSON.stringify(refusalRecord(refusal))}\n`;
184
+ }
185
+ const REFUSAL_HEADER = 'x-volter-world-refusal';
186
+
187
+ /** A World Machine guest's destination on the host's own networks (the address guestDestination found there): the
188
+ * World's decision for a guest's loopback or private destination that is neither a door nor a declared service. A
189
+ * name that does not resolve is no refusal of the World's, and carries none. */
190
+ function hostNetworkRefusal(host: string, port: number, address: string): WorldRefusal {
191
+ const answer = decideCapability({ kind: 'loopback', host: address, port }, { run: { guest: true }, declaration: OPEN_DECLARATION, facts: {} });
192
+ if (answer.outcome !== 'refuse') throw new Error(`the World's decision admits a guest's host-network destination ${host}:${port}`);
193
+ return { ...answer, target: `${host}:${port}` };
194
+ }
195
+
196
+ /** A World Machine guest's inspected egress: the proxy cannot keep the guest's checked destination pin on it. */
197
+ function guestPinRefusal(target: string): WorldRefusal {
198
+ return { outcome: 'refuse', capability: 'network.egress', target, missing: 'transport', rule: 'guest-inspected',
199
+ reason: 'inspected egress cannot keep a guest\'s checked destination pin' };
181
200
  }
182
201
 
183
202
  // --- session-scoped CA + on-the-fly leaf certs (via openssl) ---------------------------------
@@ -341,10 +360,7 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
341
360
  // or real upstream would otherwise keep the server from closing).
342
361
  const openSockets = new Set<Socket>();
343
362
 
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
- };
363
+ const publicBuildRequest = (target: URL, env: Record<string, string | undefined>): boolean => publicBuildGrant(worldGrantFromEnv(env), target);
348
364
 
349
365
  // One upstream HTTP operation, shared by plain-proxy and decrypted CONNECT requests.
350
366
  // It never follows redirects and never lends the upstream socket to the caller.
@@ -352,7 +368,8 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
352
368
  const env = currentEnv();
353
369
  const policy = worldNetworkPolicyFromEnv(env);
354
370
  // 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');
371
+ const refused = shouldBlockUntwinned(target, env, method);
372
+ if (!policy || refused) throw Object.assign(new Error('World policy refuses the effective request'), refused ? { worldRefusal: refused } : {});
356
373
  const host = incoming.get('host');
357
374
  if (host && new URL(`${target.protocol}//${host}`).host !== target.host) throw new Error('request Host differs from authorized authority');
358
375
  if (method === 'CONNECT' || incoming.has('upgrade')) throw new Error('inspected egress cannot upgrade its transport');
@@ -467,17 +484,22 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
467
484
  // a person's browser shows where it was sent and why nothing is there, not an empty tunnel error; nothing
468
485
  // leaves the World
469
486
  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'));
487
+ const refused = shouldBlockUntwinned(target.href, env);
488
+ if (refused) return outsideWorld(hostHeader, `${u.pathname}${u.search}`, req.headers.get('accept'), undefined, refused);
489
+ if (!publicBuild && proxyTargetFor(vendorHost, env)) return outsideWorld(vendorHost, target.pathname, req.headers.get('accept'), undefined, twinGap(vendorHost, target.pathname, env));
472
490
  const policy = worldNetworkPolicyFromEnv(env);
473
491
  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');
492
+ if (guest) return outsideWorld(vendorHost, target.pathname, req.headers.get('accept'), undefined, guestPinRefusal(target.href));
475
493
  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 }); }
494
+ catch (error) {
495
+ const refused = (error as { worldRefusal?: WorldRefusal }).worldRefusal;
496
+ return new Response(note(`inspected egress refused: ${(error as Error).message}${refused ? refusalText(refused) : '\n'}`), { status: 502, headers: refused ? { [REFUSAL_HEADER]: refused.missing } : {} });
497
+ }
477
498
  }
478
499
  const unreachable = unreachableHosts.get(hostHeader.toLowerCase());
479
500
  if (unreachable) return outsideWorld(hostHeader, `${u.pathname}${u.search}`, req.headers.get('accept'), unreachable);
480
- return new Response(note(noTwinMessage(hostHeader, u.pathname)), { status: 502, headers: { 'content-type': 'text/plain; charset=utf-8' } });
501
+ const gap = twinGap(hostHeader, u.pathname, env);
502
+ return new Response(note(noTwinMessage(hostHeader, u.pathname)) + refusalText(gap).trimStart(), { status: 502, headers: { 'content-type': 'text/plain; charset=utf-8', [REFUSAL_HEADER]: gap.missing } });
481
503
  }
482
504
  const origin = new URL(twin.origin);
483
505
 
@@ -568,20 +590,23 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
568
590
 
569
591
  /** A refused host's answer: 502, naming the host and why, as a page a person reads in a browser (the address bar
570
592
  * keeps where the request was sent) or as text for any other client. */
571
- const outsideWorld = (host: string, path: string, accept: string | null, unreachable?: string): Response => {
593
+ const outsideWorld = (host: string, path: string, accept: string | null, unreachable?: string, refusal?: WorldRefusal): Response => {
572
594
  // the CONNECT that reached this server was noted when it was refused
573
595
  const why = unreachable
574
596
  ? `no twin serves it, and it did not answer from this World (${unreachable}).`
575
597
  : 'no twin serves it, and the World does not reach the internet for it.';
576
- const text = `${host} is outside this World: ${why}\n`;
598
+ const text = `${host} is outside this World: ${why}${refusal ? refusalText(refusal) : '\n'}`;
599
+ const refused: Record<string, string> = refusal ? { [REFUSAL_HEADER]: refusal.missing } : {};
577
600
  // connection: close, so a reload opens a new CONNECT, which reaches the origin once it answers (a kept-alive tunnel
578
601
  // would keep answering this page)
579
- if (!(accept ?? '').includes('text/html')) return new Response(text, { status: 502, headers: { 'content-type': 'text/plain; charset=utf-8', connection: 'close' } });
602
+ if (!(accept ?? '').includes('text/html')) return new Response(text, { status: 502, headers: { 'content-type': 'text/plain; charset=utf-8', connection: 'close', ...refused } });
580
603
  const esc = (v: string) => v.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]!);
581
604
  const page = `<!doctype html><html><head><meta charset="utf-8"><title>${esc(host)} is outside this World</title>`
582
605
  + '<style>body{font:15px system-ui,sans-serif;max-width:620px;margin:12vh auto;padding:0 16px;color:#222}code{background:#f2f2f2;padding:1px 4px;border-radius:4px}</style></head><body>'
583
- + `<h1>${esc(host)} is outside this World</h1><p>You were sent to <code>https://${esc(host)}${esc(path)}</code>. ${esc(why.charAt(0).toUpperCase() + why.slice(1))} There is nothing to show here.</p></body></html>`;
584
- return new Response(page, { status: 502, headers: { 'content-type': 'text/html; charset=utf-8', connection: 'close' } });
606
+ + `<h1>${esc(host)} is outside this World</h1><p>You were sent to <code>https://${esc(host)}${esc(path)}</code>. ${esc(why.charAt(0).toUpperCase() + why.slice(1))} There is nothing to show here.</p>`
607
+ + (refusal ? `<p>${esc(`${refusal.reason}: ${refusalSide(refusal)}`)}.</p><script type="application/json" id="volter-world-refusal">${esc(JSON.stringify(refusalRecord(refusal)))}</script>` : '')
608
+ + '</body></html>';
609
+ return new Response(page, { status: 502, headers: { 'content-type': 'text/html; charset=utf-8', connection: 'close', ...refused } });
585
610
  };
586
611
 
587
612
  // the refused hosts answered with a page, each holding a leaf and a listener until the proxy closes: bounded
@@ -591,8 +616,9 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
591
616
  const unreachableHosts = new Map<string, string>();
592
617
  const isDnsName = (host: string): boolean => /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$/i.test(host);
593
618
 
594
- const writeResponse = (socket: Socket, status: string, body: string): void => {
595
- socket.write(`${status}\r\ncontent-type: text/plain\r\ncontent-length: ${Buffer.byteLength(body)}\r\nconnection: close\r\n\r\n${body}`);
619
+ const writeResponse = (socket: Socket, status: string, body: string, refusal?: WorldRefusal): void => {
620
+ const text = refusal ? body.replace(/\n$/, '') + refusalText(refusal) : body;
621
+ socket.write(`${status}\r\ncontent-type: text/plain\r\ncontent-length: ${Buffer.byteLength(text)}\r\n${refusal ? `${REFUSAL_HEADER}: ${refusal.missing}\r\n` : ''}connection: close\r\n\r\n${text}`);
596
622
  socket.end();
597
623
  };
598
624
 
@@ -659,11 +685,12 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
659
685
  // an unclaimed slice of a shared, virtualized host: refuse loudly (same rule as the MITM
660
686
  // handler's noTwinMessage), never pass through to the real vendor.
661
687
  if (!publicBuild && proxyTargetFor(u.hostname, currentEnv())) {
662
- writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(noTwinMessage(u.hostname, u.pathname)));
688
+ writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(noTwinMessage(u.hostname, u.pathname)), twinGap(u.hostname, u.pathname, currentEnv()));
663
689
  return;
664
690
  }
665
- if (shouldBlockUntwinned(u, currentEnv())) {
666
- writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(`blocked untwinned external request to ${u.hostname}\n`));
691
+ const refused = shouldBlockUntwinned(u, currentEnv());
692
+ if (refused) {
693
+ writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(`blocked untwinned external request to ${u.hostname}\n`), refused);
667
694
  return;
668
695
  }
669
696
  const policy = worldNetworkPolicyFromEnv(currentEnv());
@@ -686,7 +713,7 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
686
713
  // the address checked (architecture, "The scoped proxy's local pass-through")
687
714
  if (guest) {
688
715
  void guestDestination(u.hostname).then((d) => {
689
- if ('refused' in d) { writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(`${d.refused}\n`)); return; }
716
+ if ('refused' in d) { writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(`${d.refused}\n`), d.hostNetwork ? hostNetworkRefusal(u.hostname, Number(u.port || (u.protocol === 'https:' ? 443 : 80)), d.address ?? d.refused) : undefined); return; }
690
717
  // to the address checked (TLS still names the host: SNI and the certificate)
691
718
  forwardRawHttp(new URL(`${u.protocol}//${u.host}`), method, path, version, headers, socket, head, undefined, d.address);
692
719
  }).catch(() => writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', 'bad gateway\n')); // (a throw must not end the proxy)
@@ -733,19 +760,20 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
733
760
  // MITM vendor hosts → tunnel to the per-host listening https server (terminates TLS, forwards to
734
761
  // the twin). Non-vendor hosts → blind-tunnel to the REAL origin, untouched.
735
762
  const scopedOrigin = connectHost && new URL(`https://${connectHost}:${connectPort}`);
763
+ const connectRefusal = connectHost ? shouldBlockUntwinned(`https://${connectHost}:${connectPort}`, currentEnv()) : null;
736
764
  const scopedPolicy = worldNetworkPolicyFromEnv(currentEnv());
737
765
  const inspected = scopedOrigin && scopedPolicy && worldNetworkNeedsPathInspection(scopedPolicy, scopedOrigin);
738
766
  const passthrough = scopedOrigin && scopedPolicy && worldNetworkUsesPassthrough(scopedPolicy, scopedOrigin);
739
767
  if (passthrough && !twin) note(`build TLS passthrough ${scopedOrigin.origin} (explicit origin scope)`);
740
768
  if (inspected && guest && !twin) {
741
- writeResponse(clientSocket, 'HTTP/1.1 502 Bad Gateway', note('guest inspected egress requires a destination-pinned transport\n'));
769
+ writeResponse(clientSocket, 'HTTP/1.1 502 Bad Gateway', note('guest inspected egress requires a destination-pinned transport\n'), guestPinRefusal(`https://${connectHost}:${connectPort}`));
742
770
  return;
743
771
  }
744
- if ((twin || (!strictEgressEnabled(currentEnv()) && inspected)) && connectHost) {
772
+ if ((twin || (!worldStrictEgress(currentEnv()) && inspected)) && connectHost) {
745
773
  ensureHostServer(connectHost, guest, connectPort)
746
774
  .then((port) => tunnel(port, '127.0.0.1'))
747
775
  .catch(() => clientSocket.destroy());
748
- } else if (connectHost && shouldBlockUntwinned(`https://${connectHost}:${connectPort}`, currentEnv())) {
776
+ } else if (connectHost && connectRefusal) {
749
777
  note(`blocked untwinned external request to ${connectHost}:${connectPort}`);
750
778
  // a browser's https to a DNS name: terminated with the World's TLS and answered by the host server's page
751
779
  // (outsideWorld), so the person sees where they were sent. Anything else (another port or protocol, an address,
@@ -757,7 +785,7 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
757
785
  .then((port) => tunnel(port, '127.0.0.1'))
758
786
  .catch(() => clientSocket.destroy());
759
787
  } else {
760
- clientSocket.write('HTTP/1.1 502 Bad Gateway\r\ncontent-type: text/plain\r\n\r\nblocked untwinned external request\r\n');
788
+ clientSocket.write(`HTTP/1.1 502 Bad Gateway\r\ncontent-type: text/plain\r\n${REFUSAL_HEADER}: ${connectRefusal.missing}\r\n\r\nblocked untwinned external request${refusalText(connectRefusal)}`);
761
789
  clientSocket.destroy();
762
790
  }
763
791
  } else if (guest) {
@@ -765,7 +793,8 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
765
793
  void guestDestination(connectHost!).then((d) => {
766
794
  if ('refused' in d) {
767
795
  note(`refused a machine's tunnel to ${connectHost}:${connectPort}: ${d.refused}`);
768
- clientSocket.write(`HTTP/1.1 502 Bad Gateway\r\ncontent-type: text/plain\r\n\r\n${d.refused}\r\n`);
796
+ const refusal = d.hostNetwork ? hostNetworkRefusal(connectHost!, connectPort, d.address ?? d.refused) : null;
797
+ clientSocket.write(`HTTP/1.1 502 Bad Gateway\r\ncontent-type: text/plain\r\n${refusal ? `${REFUSAL_HEADER}: ${refusal.missing}\r\n` : ''}\r\n${d.refused}${refusal ? refusalText(refusal) : '\r\n'}`);
769
798
  clientSocket.destroy();
770
799
  } else tunnel(connectPort, d.address);
771
800
  }).catch(() => clientSocket.destroy()); // (outside the handler's own try: a throw here must not end the proxy)