@volter/world-core 3.0.66 → 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.
- package/app-route.cjs +46 -1
- package/app-route.d.cts +5 -0
- package/capability-decision.cjs +27 -14
- package/capability-decision.d.cts +3 -2
- package/dist/app-route.cjs +46 -1
- package/dist/app-route.d.cts +5 -0
- package/dist/capability-decision.cjs +27 -14
- package/dist/capability-decision.d.cts +3 -2
- package/dist/inject.cjs +19 -11
- package/dist/network-policy.cjs +14 -5
- package/dist/network-policy.d.cts +2 -0
- package/inject.cjs +19 -11
- package/network-policy.cjs +14 -5
- package/network-policy.d.cts +2 -0
- package/package.json +1 -1
package/app-route.cjs
CHANGED
|
@@ -272,4 +272,49 @@ async function followRedirects(value, init, send, extraDropped = []) {
|
|
|
272
272
|
}
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
|
|
275
|
+
/** The World's services as the instance `instanceFile` records them (id to record: its url, its sides' urls, an external
|
|
276
|
+
* service's discovered env), read at most once a second; empty without an instance. */
|
|
277
|
+
function instanceServices(instanceFile) {
|
|
278
|
+
return readRoutes(instanceFile).services || {};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Every endpoint the World's instance `instanceFile` records, classified as the World's capability decision reads it
|
|
282
|
+
* (docs/contributing/architecture.md, "Capability decisions"): a twin's (its URL and each side's; the record's `type`)
|
|
283
|
+
* is a door, which an attached guest reaches without declaring it; any other service's (its URL, a side's, an external
|
|
284
|
+
* service's discovered address) is a service, by its id, which a guest declares. The one classifier: the injector, a
|
|
285
|
+
* World Machine's forwarders and an image run's adapter all take endpoints from it. */
|
|
286
|
+
function instanceEndpoints(instanceFile) {
|
|
287
|
+
const out = [];
|
|
288
|
+
for (const [id, service] of Object.entries(instanceServices(instanceFile))) {
|
|
289
|
+
if (!service || typeof service !== 'object') continue;
|
|
290
|
+
const kind = service.type === 'twin' ? 'door' : 'service';
|
|
291
|
+
const values = [service.url, ...Object.values(service.sides || {}).map((side) => side && side.url),
|
|
292
|
+
...Object.values((service.external && service.external.discoveredEnv) || {})];
|
|
293
|
+
for (const value of values) {
|
|
294
|
+
let url;
|
|
295
|
+
try { url = new URL(String(value)); } catch { continue; }
|
|
296
|
+
if (!/^[a-z][a-z0-9+.-]*:$/.test(url.protocol) || !url.hostname) continue;
|
|
297
|
+
const port = Number(url.port || (url.protocol === 'https:' ? 443 : url.protocol === 'http:' ? 80 : 0));
|
|
298
|
+
if (port) out.push({ host: url.hostname.replace(/^\[|\]$/g, ''), port, kind, name: id, url: url.href });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Whether a dial to `dialed` reaches a listener recorded at `recorded`, as a resolver answers: `localhost` is
|
|
305
|
+
* 127.0.0.1 or ::1, either way round. 127.0.0.1 and ::1 are different listeners (a v4 bind does not answer v6). */
|
|
306
|
+
function sameListenerHost(dialed, recorded) {
|
|
307
|
+
const a = String(dialed).replace(/^\[|\]$/g, '').toLowerCase().replace(/\.$/, '');
|
|
308
|
+
const b = String(recorded).replace(/^\[|\]$/g, '').toLowerCase().replace(/\.$/, '');
|
|
309
|
+
if (a === b) return true;
|
|
310
|
+
const loopback = (h) => h === '127.0.0.1' || h === '::1';
|
|
311
|
+
return (a === 'localhost' && loopback(b)) || (b === 'localhost' && loopback(a));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** The recorded endpoint at `host`:`port` ({ kind, name }), or null: what the instance says listens there. */
|
|
315
|
+
function instanceEndpointAt(instanceFile, host, port) {
|
|
316
|
+
const found = instanceEndpoints(instanceFile).find((e) => e.port === port && sameListenerHost(host, e.host));
|
|
317
|
+
return found ? { kind: found.kind, name: found.name } : null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
module.exports = { sameListenerHost, instanceEndpoints, instanceEndpointAt, instanceServices, appRouteFor, appHostMatch, appForwardHeaders, appDestination, appRequestOptions, deliverToApp, appFetch, followRedirects, sessionCa };
|
package/app-route.d.cts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export interface AppRoute { readonly name: string; readonly origin: string; readonly url: string | null; readonly hosts: ReadonlySet<string> }
|
|
2
2
|
export interface AppAnswer { readonly status: number; readonly headers: Record<string, string>; readonly body: string }
|
|
3
|
+
export function instanceServices(instanceFile: string | undefined): Readonly<Record<string, { readonly type?: string; readonly url?: string; readonly sides?: Readonly<Record<string, { readonly url?: string }>>; readonly external?: { readonly discoveredEnv?: Readonly<Record<string, string>> } }>>;
|
|
4
|
+
export interface InstanceEndpoint { readonly host: string; readonly port: number; readonly kind: 'door' | 'service'; readonly name: string; readonly url: string }
|
|
5
|
+
export function sameListenerHost(dialed: string, recorded: string): boolean;
|
|
6
|
+
export function instanceEndpoints(instanceFile: string | undefined): InstanceEndpoint[];
|
|
7
|
+
export function instanceEndpointAt(instanceFile: string | undefined, host: string, port: number): { readonly kind: 'door' | 'service'; readonly name: string } | null;
|
|
3
8
|
export function appRouteFor(instanceFile: string | undefined, host: string, twins?: Readonly<Record<string, string>>, claimedAppHosts?: ReadonlySet<string> | null): AppRoute | null;
|
|
4
9
|
export function appHostMatch(hosts: ReadonlySet<string>, host: string): boolean;
|
|
5
10
|
export function appForwardHeaders(url: URL): Record<string, string>;
|
package/capability-decision.cjs
CHANGED
|
@@ -35,6 +35,10 @@ const GRANT_FIELDS = Object.freeze({
|
|
|
35
35
|
'network.services': 'services',
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
/** The page that says, for each refusal, what it means and the one change that fixes it, at the refusal's own rule. */
|
|
39
|
+
const REFUSALS_PAGE = 'https://world.volter.ai/docs/reference/refusals';
|
|
40
|
+
const refusalHelp = (rule) => `${REFUSALS_PAGE}#${rule}`;
|
|
41
|
+
|
|
38
42
|
const MISSING_WORDS = Object.freeze({
|
|
39
43
|
declaration: 'the guest does not declare it',
|
|
40
44
|
grant: 'the World does not grant it',
|
|
@@ -50,24 +54,26 @@ function refuse(capability, target, missing, rule, reason, declaration) {
|
|
|
50
54
|
const declaredBy = declaration && Array.isArray(declaration.derivedFrom)
|
|
51
55
|
? declaration.derivedFrom.find(entry => entry && entry.capability === capability) : undefined;
|
|
52
56
|
return Object.freeze({
|
|
53
|
-
outcome: 'refuse', capability, target, missing, rule, reason,
|
|
54
|
-
// the field that would grant it, named only where
|
|
55
|
-
|
|
57
|
+
outcome: 'refuse', capability, target, missing, rule, reason, see: refusalHelp(rule),
|
|
58
|
+
// the field that would grant it, named only where a grant in it is what is missing: no grant lifts a sandbox, an
|
|
59
|
+
// unreadable policy or an address that cannot be read
|
|
60
|
+
...(missing === 'grant' && GRANT_FIELDS[capability] && !['sealed', 'policy-unreadable', 'invalid'].includes(rule) ? { grantField: GRANT_FIELDS[capability] } : {}),
|
|
56
61
|
...(declaredBy ? { declaredBy } : {}),
|
|
57
62
|
});
|
|
58
63
|
}
|
|
59
64
|
|
|
60
65
|
/** A refusal as the one record every substrate reports. */
|
|
61
66
|
function refusalRecord(answer) {
|
|
62
|
-
const { capability, target, missing, reason, grantField, declaredBy } = answer;
|
|
63
|
-
return Object.freeze({ kind: 'volter-world-refusal', version: 1, capability, target, missing, reason,
|
|
64
|
-
...(grantField ? { grantField } : {}), ...(declaredBy ? { declaredBy } : {}) });
|
|
67
|
+
const { capability, target, missing, rule, reason, grantField, declaredBy } = answer;
|
|
68
|
+
return Object.freeze({ kind: 'volter-world-refusal', version: 1, capability, target, missing, rule, reason,
|
|
69
|
+
...(grantField ? { grantField } : {}), ...(declaredBy ? { declaredBy } : {}), see: answer.see ?? refusalHelp(rule) });
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
/** The side that refused, in words, for the end of an error message. */
|
|
68
73
|
function refusalSide(answer) {
|
|
69
74
|
const words = MISSING_WORDS[answer.missing] || answer.missing;
|
|
70
|
-
|
|
75
|
+
const side = answer.missing === 'grant' && answer.grantField ? `${words}: ${answer.grantField}` : words;
|
|
76
|
+
return `${side}; fix: ${answer.see ?? refusalHelp(answer.rule)}`;
|
|
71
77
|
}
|
|
72
78
|
|
|
73
79
|
function declaresEgress(declaration, url) {
|
|
@@ -110,7 +116,8 @@ function decideLoopback(request, context) {
|
|
|
110
116
|
if (facts.endpoint.kind !== 'service' || declaresService(declaration, facts.endpoint.name)) return pass('network.services', target, 'direct');
|
|
111
117
|
return refuse('network.services', target, 'declaration', 'service-undeclared', `the guest does not declare the World's service ${facts.endpoint.name} at ${target}`, declaration);
|
|
112
118
|
}
|
|
113
|
-
|
|
119
|
+
// own loopback is the run's own on either protocol: a container's loopback carries its datagrams as its connections
|
|
120
|
+
if (declaresOwnListener(declaration, facts)) {
|
|
114
121
|
return grant.policy && grant.policy.ownLoopback === false
|
|
115
122
|
? refuse('network.ownLoopback', target, 'grant', 'own-loopback-ungranted', `this World does not let a run reach its own listener at ${target}`, declaration)
|
|
116
123
|
: pass('network.ownLoopback', target, 'direct');
|
|
@@ -118,14 +125,14 @@ function decideLoopback(request, context) {
|
|
|
118
125
|
// neither a door of the World nor a listener this run opened: a service of the host, which no World grants a guest
|
|
119
126
|
// without a listener fact the two cannot be told apart here: a host service, or a listener of this run it did not declare
|
|
120
127
|
const hint = declaration && declaration.network && declaration.network.ownLoopback === true ? '' : '; if it is this run\'s own listener, the run declares ownLoopback';
|
|
121
|
-
return refuse('network.host', target, 'grant', 'guest-private', `a guest reaches only its World's
|
|
128
|
+
return refuse('network.host', target, 'grant', 'guest-private', `a guest reaches only its World's endpoints, its declared services and its own listeners, not ${target}${hint}`, declaration);
|
|
122
129
|
}
|
|
123
130
|
|
|
124
131
|
/** The World's listing of an external destination: sealed refuses, an unreadable policy refuses, a policy refuses what
|
|
125
132
|
* it does not list. null when listed, or when the World restricts nothing. */
|
|
126
133
|
function unlisted(url, target, method, { declaration, grant = {} }) {
|
|
127
134
|
const capability = 'network.egress';
|
|
128
|
-
if (grant.strict === true) return refuse(capability, target, 'grant', 'sealed', `this
|
|
135
|
+
if (grant.strict === true) return refuse(capability, target, 'grant', 'sealed', `this sandbox refuses untwinned host ${url.hostname}`, declaration);
|
|
129
136
|
if (grant.unreadable) return refuse(capability, target, 'grant', 'policy-unreadable', `the World network policy is unreadable (${grant.unreadable}); refusing ${url.hostname}`, declaration);
|
|
130
137
|
if (grant.policy && !allowsWorldNetworkEgress(grant.policy, url, method ?? 'GET')) {
|
|
131
138
|
return refuse(capability, target, 'grant', 'not-granted', `the World network policy does not allow ${String(method ?? 'GET').toUpperCase()} ${url.origin}`, declaration);
|
|
@@ -188,13 +195,14 @@ function decideEgress(request, context) {
|
|
|
188
195
|
|
|
189
196
|
if (!internal && !declaresEgress(declaration, url)) return refuse(capability, target, 'declaration', 'undeclared', `the guest does not declare egress to ${url.origin}`, declaration);
|
|
190
197
|
// an explicit public build grant wins over a twin: that exact path is inspected and sent to its real origin
|
|
191
|
-
const publicBuild =
|
|
198
|
+
const publicBuild = publicBuildGrant(grant, url);
|
|
192
199
|
if (!publicBuild && facts.twin) return Object.freeze({ outcome: 'twin', capability, target, twin: facts.twin });
|
|
193
200
|
if (!publicBuild && facts.twinnedHostVendors && facts.twinnedHostVendors.length > 0) {
|
|
194
201
|
return refuse(capability, target, 'twin', 'unclaimed-path', `no twin in this World claims ${url.pathname} on ${url.hostname} (${facts.twinnedHostVendors.join(', ')})`, declaration);
|
|
195
202
|
}
|
|
196
|
-
// a guest
|
|
197
|
-
|
|
203
|
+
// a guest's loopback is the loopback decision: a World service it must declare, and, where no guest listener carries
|
|
204
|
+
// its HTTP (an image run), its own listeners as its declaration says
|
|
205
|
+
if (internal && run.guest && (facts.endpoint ? facts.endpoint.kind === 'service' : !facts.guestProxy)) return decideLoopback({ host: url.hostname, port, transport: 'tcp' }, context);
|
|
198
206
|
if (internal || facts.twinOrigin) return pass(capability, target, 'direct');
|
|
199
207
|
const refused = unlisted(url, target, request.method, context);
|
|
200
208
|
if (refused) return refused;
|
|
@@ -208,6 +216,11 @@ function decideEgress(request, context) {
|
|
|
208
216
|
return pass(capability, target, policy && worldNetworkUsesPassthrough(policy, url) ? 'passthrough' : 'direct');
|
|
209
217
|
}
|
|
210
218
|
|
|
219
|
+
/** Whether the World's build-phase grant lists `url` itself, which then wins over a twin of its host. */
|
|
220
|
+
function publicBuildGrant(grant, url) {
|
|
221
|
+
return grant.strict !== true && !!grant.policy && grant.policy.phase === 'build' && allowsWorldNetworkEgress(grant.policy, url);
|
|
222
|
+
}
|
|
223
|
+
|
|
211
224
|
/** A path the guest writes as its data (a `VOLUME`). Declared, it passes run-scoped: writable for the run, not outliving
|
|
212
225
|
* it, as Docker gives an unmounted VOLUME an anonymous volume. Keeping it beyond the run needs a World grant, defined
|
|
213
226
|
* at that need. */
|
|
@@ -270,4 +283,4 @@ function decideCapabilitySet(declaration, grant = {}, world = {}) {
|
|
|
270
283
|
return Object.freeze({ network: Object.freeze({ egress, loopback, listen, endpoints }), files: Object.freeze({ persist }), refusals: Object.freeze(refusals) });
|
|
271
284
|
}
|
|
272
285
|
|
|
273
|
-
module.exports = { OPEN_DECLARATION, EMPTY_DECLARATION, WORLD_DECLARATION_ENV, GRANT_FIELDS, worldDeclarationFromEnv, decideCapability, decideCapabilitySet, sandboxRefusal, refusalRecord, refusalSide };
|
|
286
|
+
module.exports = { OPEN_DECLARATION, EMPTY_DECLARATION, WORLD_DECLARATION_ENV, GRANT_FIELDS, worldDeclarationFromEnv, publicBuildGrant, decideCapability, decideCapabilitySet, sandboxRefusal, refusalRecord, refusalSide };
|
|
@@ -38,9 +38,9 @@ export type CapabilityRequest =
|
|
|
38
38
|
export type CapabilityAnswer =
|
|
39
39
|
| { readonly outcome: 'pass'; readonly capability: CapabilityName; readonly target: string; readonly via: 'direct' | 'inspected' | 'passthrough' | 'guest-proxy' | 'run-scoped' }
|
|
40
40
|
| { readonly outcome: 'twin'; readonly capability: CapabilityName; readonly target: string; readonly twin: { readonly vendor: string; readonly origin: string } }
|
|
41
|
-
| { readonly outcome: 'refuse'; readonly capability: CapabilityName; readonly target: string; readonly missing: RefusalSide; readonly rule: string; readonly reason: string; readonly grantField?: string; readonly declaredBy?: DeclaredBy };
|
|
41
|
+
| { readonly outcome: 'refuse'; readonly capability: CapabilityName; readonly target: string; readonly missing: RefusalSide; readonly rule: string; readonly reason: string; readonly grantField?: string; readonly declaredBy?: DeclaredBy; readonly see?: string };
|
|
42
42
|
|
|
43
|
-
export interface WorldRefusalRecord { readonly kind: 'volter-world-refusal'; readonly version: 1; readonly capability: CapabilityName; readonly target: string; readonly missing: RefusalSide; readonly reason: string; readonly grantField?: string; readonly declaredBy?: DeclaredBy }
|
|
43
|
+
export interface WorldRefusalRecord { readonly kind: 'volter-world-refusal'; readonly version: 1; readonly capability: CapabilityName; readonly target: string; readonly missing: RefusalSide; readonly rule: string; readonly reason: string; readonly grantField?: string; readonly declaredBy?: DeclaredBy; readonly see: string }
|
|
44
44
|
|
|
45
45
|
export interface CapabilitySet {
|
|
46
46
|
readonly network: { readonly egress: 'none' | 'proxy'; readonly loopback: 'none' | 'own'; readonly listen: readonly number[]; readonly endpoints: readonly WorldEndpoint[] };
|
|
@@ -54,6 +54,7 @@ export const WORLD_DECLARATION_ENV: 'VOLTER_WORLD_DECLARATION';
|
|
|
54
54
|
export function worldDeclarationFromEnv(env: Readonly<Record<string, string | undefined>>): CapabilityDeclaration | undefined;
|
|
55
55
|
export function sandboxRefusal(target: string): Extract<CapabilityAnswer, { outcome: 'refuse' }>;
|
|
56
56
|
export const GRANT_FIELDS: Readonly<Partial<Record<CapabilityName, string>>>;
|
|
57
|
+
export function publicBuildGrant(grant: WorldGrant, url: URL): boolean;
|
|
57
58
|
export function decideCapability(request: CapabilityRequest, context: CapabilityContext): CapabilityAnswer;
|
|
58
59
|
export function decideCapabilitySet(declaration: CapabilityDeclaration | undefined, grant?: WorldGrant, world?: { readonly endpoints?: readonly WorldEndpoint[] }): CapabilitySet;
|
|
59
60
|
export function refusalRecord(answer: Extract<CapabilityAnswer, { outcome: 'refuse' }>): WorldRefusalRecord;
|
package/dist/app-route.cjs
CHANGED
|
@@ -272,4 +272,49 @@ async function followRedirects(value, init, send, extraDropped = []) {
|
|
|
272
272
|
}
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
|
|
275
|
+
/** The World's services as the instance `instanceFile` records them (id to record: its url, its sides' urls, an external
|
|
276
|
+
* service's discovered env), read at most once a second; empty without an instance. */
|
|
277
|
+
function instanceServices(instanceFile) {
|
|
278
|
+
return readRoutes(instanceFile).services || {};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Every endpoint the World's instance `instanceFile` records, classified as the World's capability decision reads it
|
|
282
|
+
* (docs/contributing/architecture.md, "Capability decisions"): a twin's (its URL and each side's; the record's `type`)
|
|
283
|
+
* is a door, which an attached guest reaches without declaring it; any other service's (its URL, a side's, an external
|
|
284
|
+
* service's discovered address) is a service, by its id, which a guest declares. The one classifier: the injector, a
|
|
285
|
+
* World Machine's forwarders and an image run's adapter all take endpoints from it. */
|
|
286
|
+
function instanceEndpoints(instanceFile) {
|
|
287
|
+
const out = [];
|
|
288
|
+
for (const [id, service] of Object.entries(instanceServices(instanceFile))) {
|
|
289
|
+
if (!service || typeof service !== 'object') continue;
|
|
290
|
+
const kind = service.type === 'twin' ? 'door' : 'service';
|
|
291
|
+
const values = [service.url, ...Object.values(service.sides || {}).map((side) => side && side.url),
|
|
292
|
+
...Object.values((service.external && service.external.discoveredEnv) || {})];
|
|
293
|
+
for (const value of values) {
|
|
294
|
+
let url;
|
|
295
|
+
try { url = new URL(String(value)); } catch { continue; }
|
|
296
|
+
if (!/^[a-z][a-z0-9+.-]*:$/.test(url.protocol) || !url.hostname) continue;
|
|
297
|
+
const port = Number(url.port || (url.protocol === 'https:' ? 443 : url.protocol === 'http:' ? 80 : 0));
|
|
298
|
+
if (port) out.push({ host: url.hostname.replace(/^\[|\]$/g, ''), port, kind, name: id, url: url.href });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Whether a dial to `dialed` reaches a listener recorded at `recorded`, as a resolver answers: `localhost` is
|
|
305
|
+
* 127.0.0.1 or ::1, either way round. 127.0.0.1 and ::1 are different listeners (a v4 bind does not answer v6). */
|
|
306
|
+
function sameListenerHost(dialed, recorded) {
|
|
307
|
+
const a = String(dialed).replace(/^\[|\]$/g, '').toLowerCase().replace(/\.$/, '');
|
|
308
|
+
const b = String(recorded).replace(/^\[|\]$/g, '').toLowerCase().replace(/\.$/, '');
|
|
309
|
+
if (a === b) return true;
|
|
310
|
+
const loopback = (h) => h === '127.0.0.1' || h === '::1';
|
|
311
|
+
return (a === 'localhost' && loopback(b)) || (b === 'localhost' && loopback(a));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** The recorded endpoint at `host`:`port` ({ kind, name }), or null: what the instance says listens there. */
|
|
315
|
+
function instanceEndpointAt(instanceFile, host, port) {
|
|
316
|
+
const found = instanceEndpoints(instanceFile).find((e) => e.port === port && sameListenerHost(host, e.host));
|
|
317
|
+
return found ? { kind: found.kind, name: found.name } : null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
module.exports = { sameListenerHost, instanceEndpoints, instanceEndpointAt, instanceServices, appRouteFor, appHostMatch, appForwardHeaders, appDestination, appRequestOptions, deliverToApp, appFetch, followRedirects, sessionCa };
|
package/dist/app-route.d.cts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export interface AppRoute { readonly name: string; readonly origin: string; readonly url: string | null; readonly hosts: ReadonlySet<string> }
|
|
2
2
|
export interface AppAnswer { readonly status: number; readonly headers: Record<string, string>; readonly body: string }
|
|
3
|
+
export function instanceServices(instanceFile: string | undefined): Readonly<Record<string, { readonly type?: string; readonly url?: string; readonly sides?: Readonly<Record<string, { readonly url?: string }>>; readonly external?: { readonly discoveredEnv?: Readonly<Record<string, string>> } }>>;
|
|
4
|
+
export interface InstanceEndpoint { readonly host: string; readonly port: number; readonly kind: 'door' | 'service'; readonly name: string; readonly url: string }
|
|
5
|
+
export function sameListenerHost(dialed: string, recorded: string): boolean;
|
|
6
|
+
export function instanceEndpoints(instanceFile: string | undefined): InstanceEndpoint[];
|
|
7
|
+
export function instanceEndpointAt(instanceFile: string | undefined, host: string, port: number): { readonly kind: 'door' | 'service'; readonly name: string } | null;
|
|
3
8
|
export function appRouteFor(instanceFile: string | undefined, host: string, twins?: Readonly<Record<string, string>>, claimedAppHosts?: ReadonlySet<string> | null): AppRoute | null;
|
|
4
9
|
export function appHostMatch(hosts: ReadonlySet<string>, host: string): boolean;
|
|
5
10
|
export function appForwardHeaders(url: URL): Record<string, string>;
|
|
@@ -35,6 +35,10 @@ const GRANT_FIELDS = Object.freeze({
|
|
|
35
35
|
'network.services': 'services',
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
/** The page that says, for each refusal, what it means and the one change that fixes it, at the refusal's own rule. */
|
|
39
|
+
const REFUSALS_PAGE = 'https://world.volter.ai/docs/reference/refusals';
|
|
40
|
+
const refusalHelp = (rule) => `${REFUSALS_PAGE}#${rule}`;
|
|
41
|
+
|
|
38
42
|
const MISSING_WORDS = Object.freeze({
|
|
39
43
|
declaration: 'the guest does not declare it',
|
|
40
44
|
grant: 'the World does not grant it',
|
|
@@ -50,24 +54,26 @@ function refuse(capability, target, missing, rule, reason, declaration) {
|
|
|
50
54
|
const declaredBy = declaration && Array.isArray(declaration.derivedFrom)
|
|
51
55
|
? declaration.derivedFrom.find(entry => entry && entry.capability === capability) : undefined;
|
|
52
56
|
return Object.freeze({
|
|
53
|
-
outcome: 'refuse', capability, target, missing, rule, reason,
|
|
54
|
-
// the field that would grant it, named only where
|
|
55
|
-
|
|
57
|
+
outcome: 'refuse', capability, target, missing, rule, reason, see: refusalHelp(rule),
|
|
58
|
+
// the field that would grant it, named only where a grant in it is what is missing: no grant lifts a sandbox, an
|
|
59
|
+
// unreadable policy or an address that cannot be read
|
|
60
|
+
...(missing === 'grant' && GRANT_FIELDS[capability] && !['sealed', 'policy-unreadable', 'invalid'].includes(rule) ? { grantField: GRANT_FIELDS[capability] } : {}),
|
|
56
61
|
...(declaredBy ? { declaredBy } : {}),
|
|
57
62
|
});
|
|
58
63
|
}
|
|
59
64
|
|
|
60
65
|
/** A refusal as the one record every substrate reports. */
|
|
61
66
|
function refusalRecord(answer) {
|
|
62
|
-
const { capability, target, missing, reason, grantField, declaredBy } = answer;
|
|
63
|
-
return Object.freeze({ kind: 'volter-world-refusal', version: 1, capability, target, missing, reason,
|
|
64
|
-
...(grantField ? { grantField } : {}), ...(declaredBy ? { declaredBy } : {}) });
|
|
67
|
+
const { capability, target, missing, rule, reason, grantField, declaredBy } = answer;
|
|
68
|
+
return Object.freeze({ kind: 'volter-world-refusal', version: 1, capability, target, missing, rule, reason,
|
|
69
|
+
...(grantField ? { grantField } : {}), ...(declaredBy ? { declaredBy } : {}), see: answer.see ?? refusalHelp(rule) });
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
/** The side that refused, in words, for the end of an error message. */
|
|
68
73
|
function refusalSide(answer) {
|
|
69
74
|
const words = MISSING_WORDS[answer.missing] || answer.missing;
|
|
70
|
-
|
|
75
|
+
const side = answer.missing === 'grant' && answer.grantField ? `${words}: ${answer.grantField}` : words;
|
|
76
|
+
return `${side}; fix: ${answer.see ?? refusalHelp(answer.rule)}`;
|
|
71
77
|
}
|
|
72
78
|
|
|
73
79
|
function declaresEgress(declaration, url) {
|
|
@@ -110,7 +116,8 @@ function decideLoopback(request, context) {
|
|
|
110
116
|
if (facts.endpoint.kind !== 'service' || declaresService(declaration, facts.endpoint.name)) return pass('network.services', target, 'direct');
|
|
111
117
|
return refuse('network.services', target, 'declaration', 'service-undeclared', `the guest does not declare the World's service ${facts.endpoint.name} at ${target}`, declaration);
|
|
112
118
|
}
|
|
113
|
-
|
|
119
|
+
// own loopback is the run's own on either protocol: a container's loopback carries its datagrams as its connections
|
|
120
|
+
if (declaresOwnListener(declaration, facts)) {
|
|
114
121
|
return grant.policy && grant.policy.ownLoopback === false
|
|
115
122
|
? refuse('network.ownLoopback', target, 'grant', 'own-loopback-ungranted', `this World does not let a run reach its own listener at ${target}`, declaration)
|
|
116
123
|
: pass('network.ownLoopback', target, 'direct');
|
|
@@ -118,14 +125,14 @@ function decideLoopback(request, context) {
|
|
|
118
125
|
// neither a door of the World nor a listener this run opened: a service of the host, which no World grants a guest
|
|
119
126
|
// without a listener fact the two cannot be told apart here: a host service, or a listener of this run it did not declare
|
|
120
127
|
const hint = declaration && declaration.network && declaration.network.ownLoopback === true ? '' : '; if it is this run\'s own listener, the run declares ownLoopback';
|
|
121
|
-
return refuse('network.host', target, 'grant', 'guest-private', `a guest reaches only its World's
|
|
128
|
+
return refuse('network.host', target, 'grant', 'guest-private', `a guest reaches only its World's endpoints, its declared services and its own listeners, not ${target}${hint}`, declaration);
|
|
122
129
|
}
|
|
123
130
|
|
|
124
131
|
/** The World's listing of an external destination: sealed refuses, an unreadable policy refuses, a policy refuses what
|
|
125
132
|
* it does not list. null when listed, or when the World restricts nothing. */
|
|
126
133
|
function unlisted(url, target, method, { declaration, grant = {} }) {
|
|
127
134
|
const capability = 'network.egress';
|
|
128
|
-
if (grant.strict === true) return refuse(capability, target, 'grant', 'sealed', `this
|
|
135
|
+
if (grant.strict === true) return refuse(capability, target, 'grant', 'sealed', `this sandbox refuses untwinned host ${url.hostname}`, declaration);
|
|
129
136
|
if (grant.unreadable) return refuse(capability, target, 'grant', 'policy-unreadable', `the World network policy is unreadable (${grant.unreadable}); refusing ${url.hostname}`, declaration);
|
|
130
137
|
if (grant.policy && !allowsWorldNetworkEgress(grant.policy, url, method ?? 'GET')) {
|
|
131
138
|
return refuse(capability, target, 'grant', 'not-granted', `the World network policy does not allow ${String(method ?? 'GET').toUpperCase()} ${url.origin}`, declaration);
|
|
@@ -188,13 +195,14 @@ function decideEgress(request, context) {
|
|
|
188
195
|
|
|
189
196
|
if (!internal && !declaresEgress(declaration, url)) return refuse(capability, target, 'declaration', 'undeclared', `the guest does not declare egress to ${url.origin}`, declaration);
|
|
190
197
|
// an explicit public build grant wins over a twin: that exact path is inspected and sent to its real origin
|
|
191
|
-
const publicBuild =
|
|
198
|
+
const publicBuild = publicBuildGrant(grant, url);
|
|
192
199
|
if (!publicBuild && facts.twin) return Object.freeze({ outcome: 'twin', capability, target, twin: facts.twin });
|
|
193
200
|
if (!publicBuild && facts.twinnedHostVendors && facts.twinnedHostVendors.length > 0) {
|
|
194
201
|
return refuse(capability, target, 'twin', 'unclaimed-path', `no twin in this World claims ${url.pathname} on ${url.hostname} (${facts.twinnedHostVendors.join(', ')})`, declaration);
|
|
195
202
|
}
|
|
196
|
-
// a guest
|
|
197
|
-
|
|
203
|
+
// a guest's loopback is the loopback decision: a World service it must declare, and, where no guest listener carries
|
|
204
|
+
// its HTTP (an image run), its own listeners as its declaration says
|
|
205
|
+
if (internal && run.guest && (facts.endpoint ? facts.endpoint.kind === 'service' : !facts.guestProxy)) return decideLoopback({ host: url.hostname, port, transport: 'tcp' }, context);
|
|
198
206
|
if (internal || facts.twinOrigin) return pass(capability, target, 'direct');
|
|
199
207
|
const refused = unlisted(url, target, request.method, context);
|
|
200
208
|
if (refused) return refused;
|
|
@@ -208,6 +216,11 @@ function decideEgress(request, context) {
|
|
|
208
216
|
return pass(capability, target, policy && worldNetworkUsesPassthrough(policy, url) ? 'passthrough' : 'direct');
|
|
209
217
|
}
|
|
210
218
|
|
|
219
|
+
/** Whether the World's build-phase grant lists `url` itself, which then wins over a twin of its host. */
|
|
220
|
+
function publicBuildGrant(grant, url) {
|
|
221
|
+
return grant.strict !== true && !!grant.policy && grant.policy.phase === 'build' && allowsWorldNetworkEgress(grant.policy, url);
|
|
222
|
+
}
|
|
223
|
+
|
|
211
224
|
/** A path the guest writes as its data (a `VOLUME`). Declared, it passes run-scoped: writable for the run, not outliving
|
|
212
225
|
* it, as Docker gives an unmounted VOLUME an anonymous volume. Keeping it beyond the run needs a World grant, defined
|
|
213
226
|
* at that need. */
|
|
@@ -270,4 +283,4 @@ function decideCapabilitySet(declaration, grant = {}, world = {}) {
|
|
|
270
283
|
return Object.freeze({ network: Object.freeze({ egress, loopback, listen, endpoints }), files: Object.freeze({ persist }), refusals: Object.freeze(refusals) });
|
|
271
284
|
}
|
|
272
285
|
|
|
273
|
-
module.exports = { OPEN_DECLARATION, EMPTY_DECLARATION, WORLD_DECLARATION_ENV, GRANT_FIELDS, worldDeclarationFromEnv, decideCapability, decideCapabilitySet, sandboxRefusal, refusalRecord, refusalSide };
|
|
286
|
+
module.exports = { OPEN_DECLARATION, EMPTY_DECLARATION, WORLD_DECLARATION_ENV, GRANT_FIELDS, worldDeclarationFromEnv, publicBuildGrant, decideCapability, decideCapabilitySet, sandboxRefusal, refusalRecord, refusalSide };
|
|
@@ -38,9 +38,9 @@ export type CapabilityRequest =
|
|
|
38
38
|
export type CapabilityAnswer =
|
|
39
39
|
| { readonly outcome: 'pass'; readonly capability: CapabilityName; readonly target: string; readonly via: 'direct' | 'inspected' | 'passthrough' | 'guest-proxy' | 'run-scoped' }
|
|
40
40
|
| { readonly outcome: 'twin'; readonly capability: CapabilityName; readonly target: string; readonly twin: { readonly vendor: string; readonly origin: string } }
|
|
41
|
-
| { readonly outcome: 'refuse'; readonly capability: CapabilityName; readonly target: string; readonly missing: RefusalSide; readonly rule: string; readonly reason: string; readonly grantField?: string; readonly declaredBy?: DeclaredBy };
|
|
41
|
+
| { readonly outcome: 'refuse'; readonly capability: CapabilityName; readonly target: string; readonly missing: RefusalSide; readonly rule: string; readonly reason: string; readonly grantField?: string; readonly declaredBy?: DeclaredBy; readonly see?: string };
|
|
42
42
|
|
|
43
|
-
export interface WorldRefusalRecord { readonly kind: 'volter-world-refusal'; readonly version: 1; readonly capability: CapabilityName; readonly target: string; readonly missing: RefusalSide; readonly reason: string; readonly grantField?: string; readonly declaredBy?: DeclaredBy }
|
|
43
|
+
export interface WorldRefusalRecord { readonly kind: 'volter-world-refusal'; readonly version: 1; readonly capability: CapabilityName; readonly target: string; readonly missing: RefusalSide; readonly rule: string; readonly reason: string; readonly grantField?: string; readonly declaredBy?: DeclaredBy; readonly see: string }
|
|
44
44
|
|
|
45
45
|
export interface CapabilitySet {
|
|
46
46
|
readonly network: { readonly egress: 'none' | 'proxy'; readonly loopback: 'none' | 'own'; readonly listen: readonly number[]; readonly endpoints: readonly WorldEndpoint[] };
|
|
@@ -54,6 +54,7 @@ export const WORLD_DECLARATION_ENV: 'VOLTER_WORLD_DECLARATION';
|
|
|
54
54
|
export function worldDeclarationFromEnv(env: Readonly<Record<string, string | undefined>>): CapabilityDeclaration | undefined;
|
|
55
55
|
export function sandboxRefusal(target: string): Extract<CapabilityAnswer, { outcome: 'refuse' }>;
|
|
56
56
|
export const GRANT_FIELDS: Readonly<Partial<Record<CapabilityName, string>>>;
|
|
57
|
+
export function publicBuildGrant(grant: WorldGrant, url: URL): boolean;
|
|
57
58
|
export function decideCapability(request: CapabilityRequest, context: CapabilityContext): CapabilityAnswer;
|
|
58
59
|
export function decideCapabilitySet(declaration: CapabilityDeclaration | undefined, grant?: WorldGrant, world?: { readonly endpoints?: readonly WorldEndpoint[] }): CapabilitySet;
|
|
59
60
|
export function refusalRecord(answer: Extract<CapabilityAnswer, { outcome: 'refuse' }>): WorldRefusalRecord;
|
package/dist/inject.cjs
CHANGED
|
@@ -45,7 +45,7 @@ const { Readable } = require('stream');
|
|
|
45
45
|
const Module = require('module');
|
|
46
46
|
const { urlToHttpOptions } = require('node:url');
|
|
47
47
|
const claimsRefreshChannel = require('node:diagnostics_channel').channel('volter.inject.claims-refresh');
|
|
48
|
-
const { worldNetworkPolicyFromEnv, isWorldInternalHost } = require('./network-policy.cjs');
|
|
48
|
+
const { worldNetworkPolicyFromEnv, worldGrantFromEnv, worldStrictEgress, isWorldInternalHost } = require('./network-policy.cjs');
|
|
49
49
|
const { OPEN_DECLARATION, worldDeclarationFromEnv, decideCapability, sandboxRefusal, refusalRecord, refusalSide } = require('./capability-decision.cjs');
|
|
50
50
|
const { createPublicKey, createVerify } = require('crypto');
|
|
51
51
|
|
|
@@ -246,7 +246,7 @@ function resolveTwin(hostname, map, pathname) {
|
|
|
246
246
|
// was made with (appForwardHeaders), so an application serving several hostnames answers each as in production.
|
|
247
247
|
const APP_INSTANCE = Symbol.for('volter.inject.appInstance');
|
|
248
248
|
// the route itself (the record, its wildcards, the forwarded headers) has one home, shared with the twins' deliveries
|
|
249
|
-
const { appRouteFor, appForwardHeaders, appRequestOptions, sessionCa } = require('./app-route.cjs');
|
|
249
|
+
const { appRouteFor, appForwardHeaders, appRequestOptions, sessionCa, instanceEndpointAt, sameListenerHost } = require('./app-route.cjs');
|
|
250
250
|
|
|
251
251
|
// HOSTS A TWIN CLAIMS WHILE IT RUNS (packRegistry `hostsClaimed`): no descriptor can list a name a person makes answer
|
|
252
252
|
// to a vendor, so each claiming twin lists its own at its door, read here every five seconds from the moment the
|
|
@@ -323,7 +323,7 @@ function isLocalOrPrivateHost(hostname) {
|
|
|
323
323
|
}
|
|
324
324
|
|
|
325
325
|
function strictEgressEnabled() {
|
|
326
|
-
return process.env
|
|
326
|
+
return worldStrictEgress(process.env);
|
|
327
327
|
}
|
|
328
328
|
|
|
329
329
|
// THE WORLD'S DECISION (docs/contributing/architecture.md, "Capability decisions"): every seam below asks
|
|
@@ -332,9 +332,7 @@ function strictEgressEnabled() {
|
|
|
332
332
|
// image's declaration (VOLTER_WORLD_DECLARATION) and is confined from the host's network. What the World grants is its
|
|
333
333
|
// published policy. The seams supply what they observe: the twin route, the vendors sharing a host, the World's doors.
|
|
334
334
|
function worldGrant() {
|
|
335
|
-
|
|
336
|
-
try { policy = worldNetworkPolicyFromEnv(process.env); } catch (error) { unreadable = error.message; }
|
|
337
|
-
return { policy, strict: strictEgressEnabled(), unreadable };
|
|
335
|
+
return worldGrantFromEnv(process.env);
|
|
338
336
|
}
|
|
339
337
|
/** This process's run: its declaration, and whether it is confined from the host's network. */
|
|
340
338
|
function worldRun() {
|
|
@@ -356,7 +354,9 @@ function httpFacts(url, pathname) {
|
|
|
356
354
|
twin: resolveTwin(url.hostname, MAP, pathname === undefined ? url.pathname : pathname),
|
|
357
355
|
twinnedHostVendors: twinnedHostVendors(url.hostname, MAP),
|
|
358
356
|
...(guestProxy && !/^https?:$/.test(url.protocol) ? { guestProxy: null } : {}),
|
|
359
|
-
endpoint: worldRun().guest
|
|
357
|
+
endpoint: !worldRun().guest ? null
|
|
358
|
+
: addressedToWorld(url) || explicitTwinEndpoint(url) || (proxy && url.origin === proxy.origin) ? { kind: 'door' }
|
|
359
|
+
: isLocalOrPrivateHost(url.hostname) ? guestEndpointAt(url.hostname, Number(url.port || (url.protocol === 'https:' ? 443 : 80))) : null,
|
|
360
360
|
};
|
|
361
361
|
}
|
|
362
362
|
/** A refusal as an Error: the seam's own message, the side that refused, and the World's refusal record. */
|
|
@@ -995,9 +995,9 @@ function twinnedHostVendors(hostname, map) {
|
|
|
995
995
|
function unclaimedTwinnedHostPathMessage(hostname, pathname, map) {
|
|
996
996
|
const vendors = twinnedHostVendors(hostname, map);
|
|
997
997
|
return `[twin-inject] host ${hostname} is twinned in this world (configured twin${vendors.length === 1 ? '' : 's'}: ${vendors.join(', ')}), `
|
|
998
|
-
+ `but path ${pathname} belongs to no
|
|
998
|
+
+ `but path ${pathname} belongs to no twin in this world — no twin serves that API yet. `
|
|
999
999
|
+ `Refusing loudly rather than answering from the wrong twin or leaking to the real vendor. `
|
|
1000
|
-
+ `If a twin for this API exists, set its *_TWIN_URL; otherwise this API needs a new
|
|
1000
|
+
+ `If a twin for this API exists, set its *_TWIN_URL; otherwise this API needs a new twin.`;
|
|
1001
1001
|
}
|
|
1002
1002
|
|
|
1003
1003
|
function blockedClientRequest(message) {
|
|
@@ -1210,7 +1210,7 @@ function socketRefusal(hostname, port, isTls, datagram = false) {
|
|
|
1210
1210
|
if (!url) return null;
|
|
1211
1211
|
const internal = isLocalOrPrivateHost(host);
|
|
1212
1212
|
const facts = internal
|
|
1213
|
-
? { endpoint: worldRun().guest
|
|
1213
|
+
? { endpoint: worldRun().guest ? guestEndpointAt(host, port) : null }
|
|
1214
1214
|
: { twin: resolveTwin(host, MAP), twinnedHostVendors: twinnedHostVendors(host, MAP), twinOrigin: isTwinOriginHost(host) };
|
|
1215
1215
|
const answer = decide(url, datagram ? 'datagram' : 'socket', facts, port);
|
|
1216
1216
|
if (answer.outcome !== 'refuse') return null;
|
|
@@ -1232,10 +1232,18 @@ function socketRefusal(hostname, port, isTls, datagram = false) {
|
|
|
1232
1232
|
return refusalError(answer, message);
|
|
1233
1233
|
}
|
|
1234
1234
|
|
|
1235
|
+
/** What a guest's loopback destination is to the World: one of its doors (its proxy, its origin, a twin, a twin's side),
|
|
1236
|
+
* a service it records, or neither. The instance's records classify it (app-route.cjs instanceEndpointAt), never the
|
|
1237
|
+
* application's own environment. */
|
|
1238
|
+
function guestEndpointAt(host, port) {
|
|
1239
|
+
if (guestDoorAt(host, port)) return { kind: 'door' };
|
|
1240
|
+
return instanceEndpointAt(process.env.VOLTER_WORLD_INSTANCE, host, port);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1235
1243
|
/** One of the World's doors a guest connects to directly: its proxy (a local guest's listener), the World's origin, a twin. */
|
|
1236
1244
|
function guestDoorAt(host, port) {
|
|
1237
1245
|
const doors = [worldProxy(), localGuestProxy(), worldBase() && new URL(worldBase().origin), ...Object.values(MAP).map(origin => { try { return new URL(origin); } catch { return null; } })].filter(Boolean);
|
|
1238
|
-
return doors.some(door =>
|
|
1246
|
+
return doors.some(door => sameListenerHost(host, door.hostname) && Number(door.port || (door.protocol === 'https:' ? 443 : 80)) === port);
|
|
1239
1247
|
}
|
|
1240
1248
|
|
|
1241
1249
|
function refusedSocket(refusal) {
|
package/dist/network-policy.cjs
CHANGED
|
@@ -86,6 +86,18 @@ function parseWorldNetworkPolicy(value) {
|
|
|
86
86
|
return createWorldNetworkPolicy(policy.world, { egress: policy.egress, writes: policy.writes, phase: policy.phase, passthrough: policy.passthrough, publicReads: policy.publicReads, ownLoopback: policy.ownLoopback });
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/** Whether `env` seals the World: no external egress, whatever its network policy lists. */
|
|
90
|
+
function worldStrictEgress(env) {
|
|
91
|
+
return env.VOLTER_TWIN_STRICT_EGRESS === '1' || env.VOLTER_TWIN_STRICT_EGRESS === 'true';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** What the World grants, read from `env` for the capability decision: an unreadable policy is a grant that refuses. */
|
|
95
|
+
function worldGrantFromEnv(env) {
|
|
96
|
+
let policy; let unreadable;
|
|
97
|
+
try { policy = worldNetworkPolicyFromEnv(env); } catch (error) { unreadable = error.message; }
|
|
98
|
+
return { policy, strict: worldStrictEgress(env), unreadable };
|
|
99
|
+
}
|
|
100
|
+
|
|
89
101
|
function worldNetworkPolicyFromEnv(env) {
|
|
90
102
|
const serialized = env[WORLD_NETWORK_POLICY_ENV];
|
|
91
103
|
if (serialized === undefined) return undefined;
|
|
@@ -183,13 +195,10 @@ function isWorldInternalHost(hostname) {
|
|
|
183
195
|
function worldEgressRefusal(value, env = process.env, method = 'GET') {
|
|
184
196
|
let url;
|
|
185
197
|
try { url = new URL(String(value)); } catch { return `unparseable destination ${String(value)}`; }
|
|
186
|
-
let policy; let unreadable;
|
|
187
|
-
try { policy = worldNetworkPolicyFromEnv(env); } catch (error) { unreadable = error.message; }
|
|
188
|
-
const strict = env.VOLTER_TWIN_STRICT_EGRESS === '1' || env.VOLTER_TWIN_STRICT_EGRESS === 'true';
|
|
189
198
|
const { OPEN_DECLARATION, decideCapability, refusalSide } = require('./capability-decision.cjs');
|
|
190
199
|
const answer = decideCapability({ kind: 'egress', url, method, transport: 'http' },
|
|
191
|
-
{ run: { guest: false }, declaration: OPEN_DECLARATION, grant:
|
|
200
|
+
{ run: { guest: false }, declaration: OPEN_DECLARATION, grant: worldGrantFromEnv(env), facts: {} });
|
|
192
201
|
return answer.outcome === 'refuse' ? `${answer.reason} (${refusalSide(answer)})` : null;
|
|
193
202
|
}
|
|
194
203
|
|
|
195
|
-
module.exports = { PUBLIC_BUILD_READS, READ_METHODS, intersectWorldNetwork, worldNetworkNeedsPathInspection, worldNetworkUsesPassthrough, isWorldInternalHost, worldEgressRefusal, WORLD_NETWORK_POLICY_ENV, normalizeWorldNetwork, createWorldNetworkPolicy, parseWorldNetworkPolicy, worldNetworkPolicyFromEnv, allowsWorldNetworkEgress };
|
|
204
|
+
module.exports = { worldStrictEgress, worldGrantFromEnv, PUBLIC_BUILD_READS, READ_METHODS, intersectWorldNetwork, worldNetworkNeedsPathInspection, worldNetworkUsesPassthrough, isWorldInternalHost, worldEgressRefusal, WORLD_NETWORK_POLICY_ENV, normalizeWorldNetwork, createWorldNetworkPolicy, parseWorldNetworkPolicy, worldNetworkPolicyFromEnv, allowsWorldNetworkEgress };
|
|
@@ -4,6 +4,8 @@ export const WORLD_NETWORK_POLICY_ENV: 'VOLTER_WORLD_NETWORK_POLICY';
|
|
|
4
4
|
export function normalizeWorldNetwork(value: unknown): WorldNetwork;
|
|
5
5
|
export function createWorldNetworkPolicy(world: string, network: WorldNetwork): WorldNetworkPolicy;
|
|
6
6
|
export function parseWorldNetworkPolicy(value: unknown): WorldNetworkPolicy;
|
|
7
|
+
export function worldStrictEgress(env: Readonly<Record<string, string | undefined>>): boolean;
|
|
8
|
+
export function worldGrantFromEnv(env: Readonly<Record<string, string | undefined>>): { readonly policy?: WorldNetworkPolicy; readonly strict: boolean; readonly unreadable?: string };
|
|
7
9
|
export function worldNetworkPolicyFromEnv(env: Readonly<Record<string, string | undefined>>): WorldNetworkPolicy | undefined;
|
|
8
10
|
export const READ_METHODS: readonly string[];
|
|
9
11
|
export const PUBLIC_BUILD_READS: readonly string[];
|
package/inject.cjs
CHANGED
|
@@ -45,7 +45,7 @@ const { Readable } = require('stream');
|
|
|
45
45
|
const Module = require('module');
|
|
46
46
|
const { urlToHttpOptions } = require('node:url');
|
|
47
47
|
const claimsRefreshChannel = require('node:diagnostics_channel').channel('volter.inject.claims-refresh');
|
|
48
|
-
const { worldNetworkPolicyFromEnv, isWorldInternalHost } = require('./network-policy.cjs');
|
|
48
|
+
const { worldNetworkPolicyFromEnv, worldGrantFromEnv, worldStrictEgress, isWorldInternalHost } = require('./network-policy.cjs');
|
|
49
49
|
const { OPEN_DECLARATION, worldDeclarationFromEnv, decideCapability, sandboxRefusal, refusalRecord, refusalSide } = require('./capability-decision.cjs');
|
|
50
50
|
const { createPublicKey, createVerify } = require('crypto');
|
|
51
51
|
|
|
@@ -246,7 +246,7 @@ function resolveTwin(hostname, map, pathname) {
|
|
|
246
246
|
// was made with (appForwardHeaders), so an application serving several hostnames answers each as in production.
|
|
247
247
|
const APP_INSTANCE = Symbol.for('volter.inject.appInstance');
|
|
248
248
|
// the route itself (the record, its wildcards, the forwarded headers) has one home, shared with the twins' deliveries
|
|
249
|
-
const { appRouteFor, appForwardHeaders, appRequestOptions, sessionCa } = require('./app-route.cjs');
|
|
249
|
+
const { appRouteFor, appForwardHeaders, appRequestOptions, sessionCa, instanceEndpointAt, sameListenerHost } = require('./app-route.cjs');
|
|
250
250
|
|
|
251
251
|
// HOSTS A TWIN CLAIMS WHILE IT RUNS (packRegistry `hostsClaimed`): no descriptor can list a name a person makes answer
|
|
252
252
|
// to a vendor, so each claiming twin lists its own at its door, read here every five seconds from the moment the
|
|
@@ -323,7 +323,7 @@ function isLocalOrPrivateHost(hostname) {
|
|
|
323
323
|
}
|
|
324
324
|
|
|
325
325
|
function strictEgressEnabled() {
|
|
326
|
-
return process.env
|
|
326
|
+
return worldStrictEgress(process.env);
|
|
327
327
|
}
|
|
328
328
|
|
|
329
329
|
// THE WORLD'S DECISION (docs/contributing/architecture.md, "Capability decisions"): every seam below asks
|
|
@@ -332,9 +332,7 @@ function strictEgressEnabled() {
|
|
|
332
332
|
// image's declaration (VOLTER_WORLD_DECLARATION) and is confined from the host's network. What the World grants is its
|
|
333
333
|
// published policy. The seams supply what they observe: the twin route, the vendors sharing a host, the World's doors.
|
|
334
334
|
function worldGrant() {
|
|
335
|
-
|
|
336
|
-
try { policy = worldNetworkPolicyFromEnv(process.env); } catch (error) { unreadable = error.message; }
|
|
337
|
-
return { policy, strict: strictEgressEnabled(), unreadable };
|
|
335
|
+
return worldGrantFromEnv(process.env);
|
|
338
336
|
}
|
|
339
337
|
/** This process's run: its declaration, and whether it is confined from the host's network. */
|
|
340
338
|
function worldRun() {
|
|
@@ -356,7 +354,9 @@ function httpFacts(url, pathname) {
|
|
|
356
354
|
twin: resolveTwin(url.hostname, MAP, pathname === undefined ? url.pathname : pathname),
|
|
357
355
|
twinnedHostVendors: twinnedHostVendors(url.hostname, MAP),
|
|
358
356
|
...(guestProxy && !/^https?:$/.test(url.protocol) ? { guestProxy: null } : {}),
|
|
359
|
-
endpoint: worldRun().guest
|
|
357
|
+
endpoint: !worldRun().guest ? null
|
|
358
|
+
: addressedToWorld(url) || explicitTwinEndpoint(url) || (proxy && url.origin === proxy.origin) ? { kind: 'door' }
|
|
359
|
+
: isLocalOrPrivateHost(url.hostname) ? guestEndpointAt(url.hostname, Number(url.port || (url.protocol === 'https:' ? 443 : 80))) : null,
|
|
360
360
|
};
|
|
361
361
|
}
|
|
362
362
|
/** A refusal as an Error: the seam's own message, the side that refused, and the World's refusal record. */
|
|
@@ -995,9 +995,9 @@ function twinnedHostVendors(hostname, map) {
|
|
|
995
995
|
function unclaimedTwinnedHostPathMessage(hostname, pathname, map) {
|
|
996
996
|
const vendors = twinnedHostVendors(hostname, map);
|
|
997
997
|
return `[twin-inject] host ${hostname} is twinned in this world (configured twin${vendors.length === 1 ? '' : 's'}: ${vendors.join(', ')}), `
|
|
998
|
-
+ `but path ${pathname} belongs to no
|
|
998
|
+
+ `but path ${pathname} belongs to no twin in this world — no twin serves that API yet. `
|
|
999
999
|
+ `Refusing loudly rather than answering from the wrong twin or leaking to the real vendor. `
|
|
1000
|
-
+ `If a twin for this API exists, set its *_TWIN_URL; otherwise this API needs a new
|
|
1000
|
+
+ `If a twin for this API exists, set its *_TWIN_URL; otherwise this API needs a new twin.`;
|
|
1001
1001
|
}
|
|
1002
1002
|
|
|
1003
1003
|
function blockedClientRequest(message) {
|
|
@@ -1210,7 +1210,7 @@ function socketRefusal(hostname, port, isTls, datagram = false) {
|
|
|
1210
1210
|
if (!url) return null;
|
|
1211
1211
|
const internal = isLocalOrPrivateHost(host);
|
|
1212
1212
|
const facts = internal
|
|
1213
|
-
? { endpoint: worldRun().guest
|
|
1213
|
+
? { endpoint: worldRun().guest ? guestEndpointAt(host, port) : null }
|
|
1214
1214
|
: { twin: resolveTwin(host, MAP), twinnedHostVendors: twinnedHostVendors(host, MAP), twinOrigin: isTwinOriginHost(host) };
|
|
1215
1215
|
const answer = decide(url, datagram ? 'datagram' : 'socket', facts, port);
|
|
1216
1216
|
if (answer.outcome !== 'refuse') return null;
|
|
@@ -1232,10 +1232,18 @@ function socketRefusal(hostname, port, isTls, datagram = false) {
|
|
|
1232
1232
|
return refusalError(answer, message);
|
|
1233
1233
|
}
|
|
1234
1234
|
|
|
1235
|
+
/** What a guest's loopback destination is to the World: one of its doors (its proxy, its origin, a twin, a twin's side),
|
|
1236
|
+
* a service it records, or neither. The instance's records classify it (app-route.cjs instanceEndpointAt), never the
|
|
1237
|
+
* application's own environment. */
|
|
1238
|
+
function guestEndpointAt(host, port) {
|
|
1239
|
+
if (guestDoorAt(host, port)) return { kind: 'door' };
|
|
1240
|
+
return instanceEndpointAt(process.env.VOLTER_WORLD_INSTANCE, host, port);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1235
1243
|
/** One of the World's doors a guest connects to directly: its proxy (a local guest's listener), the World's origin, a twin. */
|
|
1236
1244
|
function guestDoorAt(host, port) {
|
|
1237
1245
|
const doors = [worldProxy(), localGuestProxy(), worldBase() && new URL(worldBase().origin), ...Object.values(MAP).map(origin => { try { return new URL(origin); } catch { return null; } })].filter(Boolean);
|
|
1238
|
-
return doors.some(door =>
|
|
1246
|
+
return doors.some(door => sameListenerHost(host, door.hostname) && Number(door.port || (door.protocol === 'https:' ? 443 : 80)) === port);
|
|
1239
1247
|
}
|
|
1240
1248
|
|
|
1241
1249
|
function refusedSocket(refusal) {
|
package/network-policy.cjs
CHANGED
|
@@ -86,6 +86,18 @@ function parseWorldNetworkPolicy(value) {
|
|
|
86
86
|
return createWorldNetworkPolicy(policy.world, { egress: policy.egress, writes: policy.writes, phase: policy.phase, passthrough: policy.passthrough, publicReads: policy.publicReads, ownLoopback: policy.ownLoopback });
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/** Whether `env` seals the World: no external egress, whatever its network policy lists. */
|
|
90
|
+
function worldStrictEgress(env) {
|
|
91
|
+
return env.VOLTER_TWIN_STRICT_EGRESS === '1' || env.VOLTER_TWIN_STRICT_EGRESS === 'true';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** What the World grants, read from `env` for the capability decision: an unreadable policy is a grant that refuses. */
|
|
95
|
+
function worldGrantFromEnv(env) {
|
|
96
|
+
let policy; let unreadable;
|
|
97
|
+
try { policy = worldNetworkPolicyFromEnv(env); } catch (error) { unreadable = error.message; }
|
|
98
|
+
return { policy, strict: worldStrictEgress(env), unreadable };
|
|
99
|
+
}
|
|
100
|
+
|
|
89
101
|
function worldNetworkPolicyFromEnv(env) {
|
|
90
102
|
const serialized = env[WORLD_NETWORK_POLICY_ENV];
|
|
91
103
|
if (serialized === undefined) return undefined;
|
|
@@ -183,13 +195,10 @@ function isWorldInternalHost(hostname) {
|
|
|
183
195
|
function worldEgressRefusal(value, env = process.env, method = 'GET') {
|
|
184
196
|
let url;
|
|
185
197
|
try { url = new URL(String(value)); } catch { return `unparseable destination ${String(value)}`; }
|
|
186
|
-
let policy; let unreadable;
|
|
187
|
-
try { policy = worldNetworkPolicyFromEnv(env); } catch (error) { unreadable = error.message; }
|
|
188
|
-
const strict = env.VOLTER_TWIN_STRICT_EGRESS === '1' || env.VOLTER_TWIN_STRICT_EGRESS === 'true';
|
|
189
198
|
const { OPEN_DECLARATION, decideCapability, refusalSide } = require('./capability-decision.cjs');
|
|
190
199
|
const answer = decideCapability({ kind: 'egress', url, method, transport: 'http' },
|
|
191
|
-
{ run: { guest: false }, declaration: OPEN_DECLARATION, grant:
|
|
200
|
+
{ run: { guest: false }, declaration: OPEN_DECLARATION, grant: worldGrantFromEnv(env), facts: {} });
|
|
192
201
|
return answer.outcome === 'refuse' ? `${answer.reason} (${refusalSide(answer)})` : null;
|
|
193
202
|
}
|
|
194
203
|
|
|
195
|
-
module.exports = { PUBLIC_BUILD_READS, READ_METHODS, intersectWorldNetwork, worldNetworkNeedsPathInspection, worldNetworkUsesPassthrough, isWorldInternalHost, worldEgressRefusal, WORLD_NETWORK_POLICY_ENV, normalizeWorldNetwork, createWorldNetworkPolicy, parseWorldNetworkPolicy, worldNetworkPolicyFromEnv, allowsWorldNetworkEgress };
|
|
204
|
+
module.exports = { worldStrictEgress, worldGrantFromEnv, PUBLIC_BUILD_READS, READ_METHODS, intersectWorldNetwork, worldNetworkNeedsPathInspection, worldNetworkUsesPassthrough, isWorldInternalHost, worldEgressRefusal, WORLD_NETWORK_POLICY_ENV, normalizeWorldNetwork, createWorldNetworkPolicy, parseWorldNetworkPolicy, worldNetworkPolicyFromEnv, allowsWorldNetworkEgress };
|
package/network-policy.d.cts
CHANGED
|
@@ -4,6 +4,8 @@ export const WORLD_NETWORK_POLICY_ENV: 'VOLTER_WORLD_NETWORK_POLICY';
|
|
|
4
4
|
export function normalizeWorldNetwork(value: unknown): WorldNetwork;
|
|
5
5
|
export function createWorldNetworkPolicy(world: string, network: WorldNetwork): WorldNetworkPolicy;
|
|
6
6
|
export function parseWorldNetworkPolicy(value: unknown): WorldNetworkPolicy;
|
|
7
|
+
export function worldStrictEgress(env: Readonly<Record<string, string | undefined>>): boolean;
|
|
8
|
+
export function worldGrantFromEnv(env: Readonly<Record<string, string | undefined>>): { readonly policy?: WorldNetworkPolicy; readonly strict: boolean; readonly unreadable?: string };
|
|
7
9
|
export function worldNetworkPolicyFromEnv(env: Readonly<Record<string, string | undefined>>): WorldNetworkPolicy | undefined;
|
|
8
10
|
export const READ_METHODS: readonly string[];
|
|
9
11
|
export const PUBLIC_BUILD_READS: readonly string[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-core",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.68",
|
|
4
4
|
"description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|