@namzu/sandbox 16.0.0 → 17.0.1
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/CHANGELOG.md +331 -0
- package/README.md +164 -0
- package/dist/backends/aci-standby-pool/index.d.ts +22 -4
- package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
- package/dist/backends/aci-standby-pool/index.js +31 -7
- package/dist/backends/aci-standby-pool/index.js.map +1 -1
- package/dist/backends/docker/index.d.ts +243 -24
- package/dist/backends/docker/index.d.ts.map +1 -1
- package/dist/backends/docker/index.js +717 -108
- package/dist/backends/docker/index.js.map +1 -1
- package/dist/backends/firecracker/transport.d.ts +156 -1
- package/dist/backends/firecracker/transport.d.ts.map +1 -1
- package/dist/backends/firecracker/transport.js +223 -29
- package/dist/backends/firecracker/transport.js.map +1 -1
- package/dist/backends/http-worker-client.d.ts +64 -2
- package/dist/backends/http-worker-client.d.ts.map +1 -1
- package/dist/backends/http-worker-client.js +78 -7
- package/dist/backends/http-worker-client.js.map +1 -1
- package/dist/backends/kubernetes/egress-policy.d.ts +4 -1
- package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -1
- package/dist/backends/kubernetes/egress-policy.js +54 -3
- package/dist/backends/kubernetes/egress-policy.js.map +1 -1
- package/dist/backends/kubernetes/transport.d.ts +7 -0
- package/dist/backends/kubernetes/transport.d.ts.map +1 -1
- package/dist/backends/kubernetes/transport.js.map +1 -1
- package/dist/egress/proxy.d.ts +47 -2
- package/dist/egress/proxy.d.ts.map +1 -1
- package/dist/egress/proxy.js +31 -7
- package/dist/egress/proxy.js.map +1 -1
- package/dist/index.d.ts +67 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/backends/aci-standby-pool/index.ts +37 -7
- package/src/backends/docker/index.ts +909 -126
- package/src/backends/firecracker/transport.ts +387 -36
- package/src/backends/http-worker-client.ts +89 -5
- package/src/backends/kubernetes/egress-policy.ts +56 -3
- package/src/backends/kubernetes/transport.ts +7 -0
- package/src/egress/proxy.ts +65 -8
- package/src/index.ts +89 -0
|
@@ -21,6 +21,68 @@ type WorkerEvent =
|
|
|
21
21
|
}
|
|
22
22
|
| { readonly type: 'error'; readonly error: string }
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* What every call to a worker's control API carries, if the worker has a
|
|
26
|
+
* credential at all.
|
|
27
|
+
*
|
|
28
|
+
* The worker (`packages/sandbox/worker/server.js`) requires
|
|
29
|
+
* `Authorization: Bearer <token>` on every route but `/healthz`, where the
|
|
30
|
+
* token is the per-instance `NAMZU_SANDBOX_TOKEN` it was started with. An
|
|
31
|
+
* absent token here sends no header, which is exactly right for a worker
|
|
32
|
+
* that has none: a loopback-bound dev worker, or a warm-pool worker whose
|
|
33
|
+
* profile authenticates nothing.
|
|
34
|
+
*
|
|
35
|
+
* A worker the host did NOT create is the case this cannot solve by
|
|
36
|
+
* itself. The token is minted by whoever starts the container and travels
|
|
37
|
+
* in its environment, so a warm pool has to be provisioned with the same
|
|
38
|
+
* token before a host can claim it — there is no channel back. Constructing
|
|
39
|
+
* this client with no token against such a worker fails at the first call
|
|
40
|
+
* with a `401`, not silently.
|
|
41
|
+
*/
|
|
42
|
+
export function workerAuthorization(token: string | undefined): Record<string, string> {
|
|
43
|
+
return token ? { authorization: `Bearer ${token}` } : {}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The `401` a worker answers with, and what to do about it.
|
|
48
|
+
*
|
|
49
|
+
* Hung on every path that can be a caller's FIRST request to a worker —
|
|
50
|
+
* `reserve`, `execute`, and the container backend's direct `read-file` /
|
|
51
|
+
* `write-file` — because the bare status is the one failure whose cause is
|
|
52
|
+
* never visible from the sandbox the caller thinks it is talking to: the
|
|
53
|
+
* container is up, the port is open, and every command fails.
|
|
54
|
+
*/
|
|
55
|
+
export const WORKER_UNAUTHORIZED_HINT =
|
|
56
|
+
'The worker requires the per-instance token it was started with, as `Authorization: Bearer <token>`. If this host created the worker, the token it minted and the token the client sends have diverged. If it did not — a warm pool, a shared profile, a container someone else started — that worker must be provisioned with the token by whoever builds it: the worker has no channel back to hand one over.'
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The same failure, for the one backend that can never fix it.
|
|
60
|
+
*
|
|
61
|
+
* The standby pool has no way to present a token. The claim API admits
|
|
62
|
+
* exactly one property override, and it is not `env`: it is a config map,
|
|
63
|
+
* and a config map reaches the container as a FILE MOUNT under
|
|
64
|
+
* `/mnt/configmap/<containername>/<key>`, not as an environment variable.
|
|
65
|
+
* This worker reads its credential from `process.env` once at startup, so
|
|
66
|
+
* a value delivered that way is not read at all — and Microsoft's own
|
|
67
|
+
* guidance is that config map values are not validated by the runtime and
|
|
68
|
+
* that a value affecting application security belongs in an environment
|
|
69
|
+
* variable instead. The channel exists; this credential is declined for
|
|
70
|
+
* it, at both ends. See `docs/sdk/container-sandbox-worker.md` for the
|
|
71
|
+
* answer, the reason, and the change that would close the gap.
|
|
72
|
+
*
|
|
73
|
+
* A token on the shared profile would make the worker boot and then refuse
|
|
74
|
+
* every call the backend makes — an unrecoverable loop that looks like a
|
|
75
|
+
* broken worker. So what works there is the address first and the
|
|
76
|
+
* worker-side escape second, and the hint says which of the two situations
|
|
77
|
+
* the caller is in.
|
|
78
|
+
*
|
|
79
|
+
* `HttpWorkerClient` uses {@link WORKER_UNAUTHORIZED_HINT} rather than this
|
|
80
|
+
* one: the client is shared by two backends, and a 401 it sees could be
|
|
81
|
+
* either situation.
|
|
82
|
+
*/
|
|
83
|
+
export const STANDBY_POOL_UNAUTHORIZED_HINT =
|
|
84
|
+
'The worker requires a per-instance token, and this backend cannot present one: the claim API admits a config map and nothing else, and a config map arrives as a file mount under /mnt/configmap, not as an environment variable this worker reads. Putting a token on the shared container group profile does NOT fix this — the worker boots, the backend sends no header, and every call 401s. What works today is the address and then the worker-side escape: claim the group with `subnetId` so it sits on a private network, and set `NAMZU_SANDBOX_ALLOW_UNAUTHENTICATED=1` on that group profile, which is the only configuration in which a pooled worker starts and this backend can talk to it. Any other workload should run on a backend that can carry a credential. See `docs/sdk/container-sandbox-worker.md`.'
|
|
85
|
+
|
|
24
86
|
function parseWorkerEvent(line: string): WorkerEvent {
|
|
25
87
|
let parsed: unknown
|
|
26
88
|
try {
|
|
@@ -57,6 +119,7 @@ function parseWorkerEvent(line: string): WorkerEvent {
|
|
|
57
119
|
|
|
58
120
|
async function readExecution(
|
|
59
121
|
baseUrl: string,
|
|
122
|
+
token: string | undefined,
|
|
60
123
|
executionId: string | undefined,
|
|
61
124
|
command: string,
|
|
62
125
|
argv: string[] | undefined,
|
|
@@ -67,7 +130,7 @@ async function readExecution(
|
|
|
67
130
|
try {
|
|
68
131
|
response = await fetch(`${baseUrl}/execute`, {
|
|
69
132
|
method: 'POST',
|
|
70
|
-
headers: { 'content-type': 'application/json' },
|
|
133
|
+
headers: { 'content-type': 'application/json', ...workerAuthorization(token) },
|
|
71
134
|
signal: transportSignal,
|
|
72
135
|
body: JSON.stringify({
|
|
73
136
|
...(executionId ? { executionId } : {}),
|
|
@@ -94,6 +157,12 @@ async function readExecution(
|
|
|
94
157
|
'The worker was reachable when the sandbox started, so it has most likely exited, been killed, or become unreachable since. Check the container logs and runtime exit state.',
|
|
95
158
|
)
|
|
96
159
|
}
|
|
160
|
+
if (response.status === 401) {
|
|
161
|
+
throw withHint(
|
|
162
|
+
new Error(`execute failed: HTTP 401 ${await response.text()}`),
|
|
163
|
+
WORKER_UNAUTHORIZED_HINT,
|
|
164
|
+
)
|
|
165
|
+
}
|
|
97
166
|
if (!response.ok || !response.body) {
|
|
98
167
|
throw new Error(`execute failed: HTTP ${response.status} ${await response.text()}`)
|
|
99
168
|
}
|
|
@@ -163,16 +232,24 @@ async function readExecution(
|
|
|
163
232
|
/**
|
|
164
233
|
* A per-sandbox HTTP worker client. Every command must reserve an identity
|
|
165
234
|
* through the exact worker protocol before it can be admitted.
|
|
235
|
+
*
|
|
236
|
+
* `token` is the per-instance credential the worker was started with, and
|
|
237
|
+
* it rides on every request this client makes. It is optional because a
|
|
238
|
+
* worker that was never given one — a loopback dev worker — requires none;
|
|
239
|
+
* say what happens when it is missing from the wrong side rather than
|
|
240
|
+
* sending nothing quietly: the worker answers `401` and the failure names
|
|
241
|
+
* the reason.
|
|
166
242
|
*/
|
|
167
243
|
export class HttpWorkerClient {
|
|
168
244
|
private readonly controller: RemoteExecutionController
|
|
169
245
|
|
|
170
|
-
constructor(baseUrl: string) {
|
|
246
|
+
constructor(baseUrl: string, token?: string) {
|
|
171
247
|
const adapter: RemoteExecutionAdapter = {
|
|
172
248
|
label: 'HTTP worker',
|
|
173
249
|
reserve: async (signal) => {
|
|
174
250
|
const response = await fetch(`${baseUrl}/executions/reserve`, {
|
|
175
251
|
method: 'POST',
|
|
252
|
+
headers: workerAuthorization(token),
|
|
176
253
|
signal,
|
|
177
254
|
})
|
|
178
255
|
if (response.status === 404) {
|
|
@@ -180,6 +257,12 @@ export class HttpWorkerClient {
|
|
|
180
257
|
'The sandbox worker does not implement the required execution protocol. Rebuild the worker image or standby-pool profile from the same Namzu release before admitting commands.',
|
|
181
258
|
)
|
|
182
259
|
}
|
|
260
|
+
if (response.status === 401) {
|
|
261
|
+
throw withHint(
|
|
262
|
+
new Error(`execution reservation failed: HTTP 401 ${await response.text()}`),
|
|
263
|
+
WORKER_UNAUTHORIZED_HINT,
|
|
264
|
+
)
|
|
265
|
+
}
|
|
183
266
|
if (!response.ok) {
|
|
184
267
|
throw new Error(
|
|
185
268
|
`execution reservation failed: HTTP ${response.status} ${await response.text()}`,
|
|
@@ -190,7 +273,7 @@ export class HttpWorkerClient {
|
|
|
190
273
|
cancel: async (executionId, signal) => {
|
|
191
274
|
const response = await fetch(`${baseUrl}/cancel`, {
|
|
192
275
|
method: 'POST',
|
|
193
|
-
headers: { 'content-type': 'application/json' },
|
|
276
|
+
headers: { 'content-type': 'application/json', ...workerAuthorization(token) },
|
|
194
277
|
body: JSON.stringify({ executionId }),
|
|
195
278
|
signal,
|
|
196
279
|
})
|
|
@@ -200,7 +283,7 @@ export class HttpWorkerClient {
|
|
|
200
283
|
return await response.json()
|
|
201
284
|
},
|
|
202
285
|
execute: async (executionId, command, argv, opts, signal) =>
|
|
203
|
-
await readExecution(baseUrl, executionId, command, argv, opts, signal),
|
|
286
|
+
await readExecution(baseUrl, token, executionId, command, argv, opts, signal),
|
|
204
287
|
}
|
|
205
288
|
this.controller = new RemoteExecutionController(adapter)
|
|
206
289
|
}
|
|
@@ -220,6 +303,7 @@ export async function execViaHttpWorker(
|
|
|
220
303
|
command: string,
|
|
221
304
|
argv: string[] | undefined,
|
|
222
305
|
opts: SandboxExecOptions | undefined,
|
|
306
|
+
token?: string,
|
|
223
307
|
): Promise<SandboxExecResult> {
|
|
224
|
-
return await new HttpWorkerClient(baseUrl).exec(command, argv, opts)
|
|
308
|
+
return await new HttpWorkerClient(baseUrl, token).exec(command, argv, opts)
|
|
225
309
|
}
|
|
@@ -2081,7 +2081,10 @@ export class KubernetesEgressPolicyMismatchError extends Error {
|
|
|
2081
2081
|
* - `NetworkPolicy` additionally declares `policyTypes` including
|
|
2082
2082
|
* `'Egress'` — a `NetworkPolicy` with an `egress` array but no `'Egress'`
|
|
2083
2083
|
* in `policyTypes` enforces nothing on egress at all;
|
|
2084
|
-
* - the `egress` rule array matches the translation exactly
|
|
2084
|
+
* - the `egress` rule array matches the translation exactly, where an ABSENT
|
|
2085
|
+
* array is the empty one — for a core `NetworkPolicy` the only form a
|
|
2086
|
+
* cluster stores an empty list in, and the same policy either way. See the
|
|
2087
|
+
* comparison itself for where that equivalence stops;
|
|
2085
2088
|
* - and, ONLY when the translation carries `metadata.ownerReferences` (the
|
|
2086
2089
|
* per-sandbox policies of `per-sandbox-policy.ts`, never the operator's
|
|
2087
2090
|
* own object), that the live object still carries each of them — an owner
|
|
@@ -2167,11 +2170,61 @@ export async function verifyEgressPolicyApplied(
|
|
|
2167
2170
|
}
|
|
2168
2171
|
}
|
|
2169
2172
|
|
|
2170
|
-
|
|
2173
|
+
// An absent `spec.egress` is read as the empty list it IS, because for a
|
|
2174
|
+
// core `NetworkPolicy` that is the only form a cluster keeps. The API
|
|
2175
|
+
// server never STORES an empty one: `egress` is `omitempty` on the wire
|
|
2176
|
+
// struct, so an object applied with `egress: []` reads back with no
|
|
2177
|
+
// `egress` key at all, and a merge patch cannot put one there — it is
|
|
2178
|
+
// dropped again on the way in (measured against a live cluster; see
|
|
2179
|
+
// `docs/sdk/kubernetes-sandbox.md`). A `CiliumNetworkPolicy` is a CRD and
|
|
2180
|
+
// DOES keep the empty array — also measured — but the equivalence is only
|
|
2181
|
+
// ever effectual for `'no-network'`, which always translates to a core
|
|
2182
|
+
// `NetworkPolicy`, so the two kinds need no separate reading here. The two
|
|
2183
|
+
// spellings are one policy, and not only in shape: `policyTypes` has
|
|
2184
|
+
// already been required to include `'Egress'` a few lines up, and that
|
|
2185
|
+
// alone denies every egress — which is exactly what an empty rule list
|
|
2186
|
+
// says. So this is not the comparison suddenly accepting something
|
|
2187
|
+
// looser; it is the only way the intent it checks can be READ BACK at
|
|
2188
|
+
// all. `'no-network'`, whose whole translation IS that empty list,
|
|
2189
|
+
// otherwise has no object any cluster could store that this check could
|
|
2190
|
+
// pass, and every `create()` against it is refused.
|
|
2191
|
+
//
|
|
2192
|
+
// Nothing else is forgiven, since what the comparison is FOR — an object
|
|
2193
|
+
// enforcing something other than what config asked for — is untouched: a
|
|
2194
|
+
// live list with a rule in it is still compared rule for rule, so a
|
|
2195
|
+
// translation that meant `[]` and an object that lets something out is
|
|
2196
|
+
// refused; and a translation that meant rules over a live object carrying
|
|
2197
|
+
// none is refused too, because an absent list means deny-all, which is not
|
|
2198
|
+
// what config asked for when it asked for rules.
|
|
2199
|
+
//
|
|
2200
|
+
// Read narrowly, on purpose: only the translation's OWN list being empty
|
|
2201
|
+
// makes the two readings differ, and only `'no-network'` translates to an
|
|
2202
|
+
// empty list — `buildCoreNetworkPolicy` always emits a core `NetworkPolicy`
|
|
2203
|
+
// for it, under either engine, which is the kind the `policyTypes` check
|
|
2204
|
+
// above just covered.
|
|
2205
|
+
const liveEgress = actualSpec.egress
|
|
2206
|
+
// `null` included, which is how a serialiser spells absent — the same
|
|
2207
|
+
// reading `readList` gives the field everywhere else in this module.
|
|
2208
|
+
const egressIsAbsent = liveEgress === undefined || liveEgress === null
|
|
2209
|
+
const actualEgress = egressIsAbsent ? [] : liveEgress
|
|
2210
|
+
// Why the live list holds no rules is the translation's business, not the
|
|
2211
|
+
// field's: against an empty translation, absence is how the API server
|
|
2212
|
+
// STORES that empty list; against any other, the operator's object simply
|
|
2213
|
+
// carries no rules, and saying otherwise would explain a refusal with a
|
|
2214
|
+
// fact about storage that had nothing to do with it.
|
|
2215
|
+
const expectedIsEmpty = Array.isArray(expectedSpec.egress) && expectedSpec.egress.length === 0
|
|
2216
|
+
const liveEgressText = egressIsAbsent
|
|
2217
|
+
? `absent — ${
|
|
2218
|
+
expectedIsEmpty
|
|
2219
|
+
? 'the API server stores an empty rule list as no field at all'
|
|
2220
|
+
: 'the object carries no egress rule at all'
|
|
2221
|
+
}`
|
|
2222
|
+
: JSON.stringify(liveEgress)
|
|
2223
|
+
if (!isDeepStrictEqual(actualEgress, expectedSpec.egress)) {
|
|
2171
2224
|
throw new KubernetesEgressPolicyMismatchError(
|
|
2172
2225
|
translated.kind,
|
|
2173
2226
|
path,
|
|
2174
|
-
`spec.egress is ${
|
|
2227
|
+
`spec.egress is ${liveEgressText}, expected ${JSON.stringify(expectedSpec.egress)}`,
|
|
2175
2228
|
)
|
|
2176
2229
|
}
|
|
2177
2230
|
|
|
@@ -530,6 +530,13 @@ export interface KubernetesTransportOptions
|
|
|
530
530
|
* Fires once per completed `exec()` call (success or failure) with
|
|
531
531
|
* the four phase durations above. The payload is exactly those four
|
|
532
532
|
* numbers — never the token, never a command, argv, or output.
|
|
533
|
+
*
|
|
534
|
+
* THIS is the timing hook for this tier. The base options also carry
|
|
535
|
+
* `VsockTransportOptions.onExecTiming`, which is inherited here and is
|
|
536
|
+
* INERT: it belongs to the firecracker tier, whose `exec()` reports into
|
|
537
|
+
* it, while this transport drives the shared wire through
|
|
538
|
+
* `executeStreamed` and never calls that `exec()`. Setting it on a
|
|
539
|
+
* Kubernetes transport starts nothing.
|
|
533
540
|
*/
|
|
534
541
|
readonly onTiming?: (timing: KubernetesTransportTiming) => void
|
|
535
542
|
/**
|
package/src/egress/proxy.ts
CHANGED
|
@@ -71,13 +71,46 @@ export interface EgressProxyOptions {
|
|
|
71
71
|
* reach — which is the hole this screen exists to close.
|
|
72
72
|
*/
|
|
73
73
|
readonly allowInwardFor?: readonly string[]
|
|
74
|
+
/**
|
|
75
|
+
* Address this proxy listens on. Default `127.0.0.1`.
|
|
76
|
+
*
|
|
77
|
+
* The default is loopback because a proxy holding real credentials and
|
|
78
|
+
* bound to every interface is reachable by anything on the network, which
|
|
79
|
+
* is the opposite of what it exists for. There is exactly one deployment
|
|
80
|
+
* where that reasoning flips, and it is the whole reason this is
|
|
81
|
+
* configurable: the docker tier runs the proxy as a container of its own
|
|
82
|
+
* (`egress-proxy/server.mjs`), the sandbox is attached to an `--internal`
|
|
83
|
+
* network with no route out, and this container is the only way its traffic
|
|
84
|
+
* reaches the internet. There, `0.0.0.0` inside the proxy container is not
|
|
85
|
+
* "every interface on the host" — the container IS the boundary — and what
|
|
86
|
+
* else the sandbox can reach on that network is whatever other containers a
|
|
87
|
+
* host attached to it, which `docs/sdk/sandbox-egress.md` states.
|
|
88
|
+
*/
|
|
89
|
+
readonly bindHost?: string
|
|
90
|
+
/**
|
|
91
|
+
* Names that denote THIS proxy, for the loop guard in `listen`.
|
|
92
|
+
*
|
|
93
|
+
* `isSelf` refuses a request whose target is the proxy itself, because
|
|
94
|
+
* forwarding it makes the proxy call itself until the process runs out of
|
|
95
|
+
* sockets — a failure that arrives as a hang rather than as an error. On
|
|
96
|
+
* loopback the target is one of `127.0.0.1`, `localhost`, `::1` and the
|
|
97
|
+
* check is closed by construction. Bound to another address it is not:
|
|
98
|
+
* anything that names this proxy by a name it answers to — its container
|
|
99
|
+
* name or network alias, say — would walk straight into that loop. So
|
|
100
|
+
* every such name is listed here by whoever knows it.
|
|
101
|
+
*/
|
|
102
|
+
readonly selfNames?: readonly string[]
|
|
74
103
|
/** Injected in tests. Defaults to the platform resolver. */
|
|
75
104
|
readonly resolveAddresses?: ScreeningLookupOptions['resolve']
|
|
76
105
|
}
|
|
77
106
|
|
|
78
107
|
export interface RunningEgressProxy {
|
|
79
108
|
readonly port: number
|
|
80
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* `http://<the address this proxy bound>:<port>` — what a sandbox sets
|
|
111
|
+
* `HTTP_PROXY` to. Loopback by default; the address is whatever
|
|
112
|
+
* {@link EgressProxyOptions.bindHost} said.
|
|
113
|
+
*/
|
|
81
114
|
readonly url: string
|
|
82
115
|
/** Swap the allowlist on a live proxy. See `setNetworkPolicy`. */
|
|
83
116
|
setAllowedHosts(resolve: () => Promise<readonly string[]>): void
|
|
@@ -86,6 +119,12 @@ export interface RunningEgressProxy {
|
|
|
86
119
|
|
|
87
120
|
const DENIED_STATUS = 403
|
|
88
121
|
|
|
122
|
+
/** The default bind address. See {@link EgressProxyOptions.bindHost}. */
|
|
123
|
+
const LOOPBACK_BIND_HOST = '127.0.0.1'
|
|
124
|
+
|
|
125
|
+
/** Names that mean "this proxy" on loopback, whatever it bound to. */
|
|
126
|
+
const LOOPBACK_SELF_NAMES: readonly string[] = ['127.0.0.1', 'localhost', '::1']
|
|
127
|
+
|
|
89
128
|
export class EgressProxy {
|
|
90
129
|
private resolveAllowed: () => Promise<readonly string[]>
|
|
91
130
|
private readonly credentials: readonly BrokeredCredential[]
|
|
@@ -101,6 +140,8 @@ export class EgressProxy {
|
|
|
101
140
|
*/
|
|
102
141
|
private readonly lookup: ReturnType<typeof createScreeningLookup>
|
|
103
142
|
private readonly inwardAllowed: readonly string[]
|
|
143
|
+
private readonly bindHost: string
|
|
144
|
+
private readonly selfNames: readonly string[]
|
|
104
145
|
|
|
105
146
|
constructor(options: EgressProxyOptions) {
|
|
106
147
|
this.resolveAllowed = options.allowedHosts
|
|
@@ -108,6 +149,8 @@ export class EgressProxy {
|
|
|
108
149
|
this.upgradeToHttps = options.upgradeToHttps ?? true
|
|
109
150
|
this.onDenied = options.onDenied
|
|
110
151
|
this.inwardAllowed = options.allowInwardFor ?? []
|
|
152
|
+
this.bindHost = options.bindHost ?? LOOPBACK_BIND_HOST
|
|
153
|
+
this.selfNames = options.selfNames ?? []
|
|
111
154
|
this.lookup = createScreeningLookup(
|
|
112
155
|
{
|
|
113
156
|
...(options.allowInwardFor ? { allowInwardFor: options.allowInwardFor } : {}),
|
|
@@ -131,10 +174,12 @@ export class EgressProxy {
|
|
|
131
174
|
})
|
|
132
175
|
await new Promise<void>((resolve, reject) => {
|
|
133
176
|
server.once('error', reject)
|
|
134
|
-
// Loopback
|
|
135
|
-
// every interface is reachable by anything on the network, which
|
|
136
|
-
//
|
|
137
|
-
|
|
177
|
+
// Loopback by default. A proxy that holds real credentials and binds
|
|
178
|
+
// every interface is reachable by anything on the network, which is
|
|
179
|
+
// the opposite of what it exists for — see
|
|
180
|
+
// {@link EgressProxyOptions.bindHost} for the one deployment where
|
|
181
|
+
// the container itself is the boundary and this is not that.
|
|
182
|
+
server.listen(port, this.bindHost, () => {
|
|
138
183
|
server.off('error', reject)
|
|
139
184
|
resolve()
|
|
140
185
|
})
|
|
@@ -153,7 +198,7 @@ export class EgressProxy {
|
|
|
153
198
|
|
|
154
199
|
return {
|
|
155
200
|
port: boundPort,
|
|
156
|
-
url: `http
|
|
201
|
+
url: `http://${this.bindHost}:${boundPort}`,
|
|
157
202
|
setAllowedHosts: (resolve) => {
|
|
158
203
|
this.resolveAllowed = resolve
|
|
159
204
|
},
|
|
@@ -363,10 +408,22 @@ export class EgressProxy {
|
|
|
363
408
|
})
|
|
364
409
|
}
|
|
365
410
|
|
|
366
|
-
/**
|
|
411
|
+
/**
|
|
412
|
+
* Whether a target names this proxy. See the loop guard in `listen`.
|
|
413
|
+
*
|
|
414
|
+
* The loopback spellings are always this proxy — a client inside the same
|
|
415
|
+
* network namespace reaches it at one of them whatever it bound to, and
|
|
416
|
+
* the bound address when that address is a literal is one more. Anything
|
|
417
|
+
* else has to be named by the caller, through
|
|
418
|
+
* {@link EgressProxyOptions.selfNames}: this process cannot know which
|
|
419
|
+
* names resolve to it on the network it is attached to, and a guard that
|
|
420
|
+
* guessed would either miss the loop or refuse a legitimate target.
|
|
421
|
+
*/
|
|
367
422
|
private isSelf(host: string, port?: number): boolean {
|
|
368
423
|
if (port === undefined || port !== this.selfPort) return false
|
|
369
|
-
|
|
424
|
+
if (host === this.bindHost) return true
|
|
425
|
+
if (LOOPBACK_SELF_NAMES.includes(host)) return true
|
|
426
|
+
return this.selfNames.includes(host)
|
|
370
427
|
}
|
|
371
428
|
|
|
372
429
|
private credentialFor(host: string): BrokeredCredential | undefined {
|
package/src/index.ts
CHANGED
|
@@ -62,6 +62,7 @@ import type {
|
|
|
62
62
|
import { buildAciStandbyPoolBackend } from './backends/aci-standby-pool/index.js'
|
|
63
63
|
import { buildDockerBackend, resolveLayout } from './backends/docker/index.js'
|
|
64
64
|
import { buildFirecrackerBackend } from './backends/firecracker/index.js'
|
|
65
|
+
import type { FirecrackerTransportTiming } from './backends/firecracker/transport.js'
|
|
65
66
|
import type { KubernetesEgressConfig } from './backends/kubernetes/egress-policy.js'
|
|
66
67
|
import {
|
|
67
68
|
type KubernetesAgentAddressMode,
|
|
@@ -126,6 +127,7 @@ export {
|
|
|
126
127
|
AgentWriteFileTooLargeError,
|
|
127
128
|
DEFAULT_MAX_WRITE_FILE_BYTES,
|
|
128
129
|
FIRECRACKER_AGENT_PROTOCOL_VERSION,
|
|
130
|
+
type FirecrackerTransportTiming,
|
|
129
131
|
GUEST_FRAME_LIMIT_BYTES,
|
|
130
132
|
type SandboxAgentHandle,
|
|
131
133
|
TCP_PREAUTH_FRAME_LIMIT_BYTES,
|
|
@@ -659,8 +661,51 @@ export interface ContainerBackendConfig {
|
|
|
659
661
|
*
|
|
660
662
|
* Egress from the sandbox is governed separately by `EgressPolicy`,
|
|
661
663
|
* which is checked against this network rather than trusted.
|
|
664
|
+
*
|
|
665
|
+
* An egress policy of `static` or `resolver` — a host allowlist —
|
|
666
|
+
* additionally REQUIRES this network to be `--internal`, and requires
|
|
667
|
+
* `hostReachability: 'container-network'`. The allowlist is enforced by
|
|
668
|
+
* the egress proxy running as a sibling container on this network: the
|
|
669
|
+
* sandbox is attached to this network alone, and an internal network has
|
|
670
|
+
* no route off it, so the only way the sandbox's traffic reaches the
|
|
671
|
+
* internet is through that container. It is a container on a subnet like
|
|
672
|
+
* any other there, so what else a host attaches to this network — a second
|
|
673
|
+
* sandbox, and that sandbox's proxy — is reachable from this one too; on a
|
|
674
|
+
* network with a route out the allowlist is a proxy environment variable a
|
|
675
|
+
* workload may decline to read, and `create()` refuses rather than
|
|
676
|
+
* reporting a boundary that is not there.
|
|
662
677
|
*/
|
|
663
678
|
readonly network?: 'none' | 'bridge' | string
|
|
679
|
+
/**
|
|
680
|
+
* Image the egress proxy runs as, when the policy needs one.
|
|
681
|
+
*
|
|
682
|
+
* Required for a `static` or `resolver` policy, refused without it. The
|
|
683
|
+
* image is `packages/sandbox/egress-proxy/Dockerfile`:
|
|
684
|
+
*
|
|
685
|
+
* pnpm --filter @namzu/sandbox build
|
|
686
|
+
* docker build -f packages/sandbox/egress-proxy/Dockerfile \
|
|
687
|
+
* -t <tag> packages/sandbox
|
|
688
|
+
*
|
|
689
|
+
* It is a second image rather than the sandbox's own because the
|
|
690
|
+
* sandbox image is a string this backend cannot read: there is no way to
|
|
691
|
+
* know whether it contains the proxy module, and the bind-mount
|
|
692
|
+
* alternative fails on exactly the remote-daemon deployment
|
|
693
|
+
* `hostReachability: 'container-network'` exists for. `deny-all` and
|
|
694
|
+
* `allow-all` need no image.
|
|
695
|
+
*/
|
|
696
|
+
readonly egressProxyImage?: string
|
|
697
|
+
/**
|
|
698
|
+
* Network the egress proxy joins for its route to the internet. Default
|
|
699
|
+
* `'bridge'`, docker's own default bridge.
|
|
700
|
+
*
|
|
701
|
+
* The proxy is dual-homed: it sits on the internal network the sandbox
|
|
702
|
+
* is on, and on this one, which is how it reaches the world. Name a
|
|
703
|
+
* dedicated network when the daemon is shared, because anything else
|
|
704
|
+
* attached to this one can reach the proxy — and this proxy enforces its
|
|
705
|
+
* allowlist for whoever asks and stamps brokered credentials on what it
|
|
706
|
+
* forwards.
|
|
707
|
+
*/
|
|
708
|
+
readonly egressProxyUpstreamNetwork?: string
|
|
664
709
|
/**
|
|
665
710
|
* Maximum time spent waiting for the container worker's `/healthz`
|
|
666
711
|
* readiness probe. Must be a positive integer within Node's timer range.
|
|
@@ -796,6 +841,28 @@ export type MicroVMBackendConfig = {
|
|
|
796
841
|
readonly readyTimeoutMs?: number
|
|
797
842
|
/** Delay between guest-agent health probes. Default 250ms. */
|
|
798
843
|
readonly readyPollIntervalMs?: number
|
|
844
|
+
/**
|
|
845
|
+
* Fires once per completed `exec()` on this provider's sandboxes with
|
|
846
|
+
* that call's wall-time breakdown — the reserve round trip, the execute
|
|
847
|
+
* round trip, the dials inside them, the first reply frame, the
|
|
848
|
+
* terminator frame and the peer's own close. See
|
|
849
|
+
* {@link FirecrackerTransportTiming} for what each number is and is not,
|
|
850
|
+
* and {@link VsockTransportOptions.onExecTiming} for why the field is
|
|
851
|
+
* named this rather than the kubernetes tier's `onTiming`.
|
|
852
|
+
*
|
|
853
|
+
* Opt-in and additive, with no default: a host that sets nothing sends,
|
|
854
|
+
* receives and waits for exactly what it did before. The intended use is
|
|
855
|
+
* attribution — a relay or a guest that adds a fixed cost to every call
|
|
856
|
+
* moves a named phase, and one that adds none leaves the numbers at the
|
|
857
|
+
* cost of a command's own runtime.
|
|
858
|
+
*
|
|
859
|
+
* It is an OBSERVER, not a control: it cannot change a command's result,
|
|
860
|
+
* it is called after the call has settled, and a listener that throws is
|
|
861
|
+
* that listener's problem. The payload is durations only — never the
|
|
862
|
+
* agent token, a command, its arguments, or any output — so a host may
|
|
863
|
+
* log it without leaking what the sandbox ran.
|
|
864
|
+
*/
|
|
865
|
+
readonly onExecTiming?: (timing: FirecrackerTransportTiming) => void
|
|
799
866
|
/**
|
|
800
867
|
* NETWORK-mode mTLS client material (ses_051 P4 client-proxy
|
|
801
868
|
* bridge). When present, the orchestrator returns an `mtls` agent
|
|
@@ -1317,6 +1384,12 @@ function pickBackend(config: SandboxProviderConfig): SandboxBackend {
|
|
|
1317
1384
|
: {}),
|
|
1318
1385
|
...(backend.network !== undefined ? { network: backend.network } : {}),
|
|
1319
1386
|
...(backend.allowInwardFor !== undefined ? { allowInwardFor: backend.allowInwardFor } : {}),
|
|
1387
|
+
...(backend.egressProxyImage !== undefined
|
|
1388
|
+
? { egressProxyImage: backend.egressProxyImage }
|
|
1389
|
+
: {}),
|
|
1390
|
+
...(backend.egressProxyUpstreamNetwork !== undefined
|
|
1391
|
+
? { egressProxyUpstreamNetwork: backend.egressProxyUpstreamNetwork }
|
|
1392
|
+
: {}),
|
|
1320
1393
|
...(backend.labels !== undefined ? { labels: backend.labels } : {}),
|
|
1321
1394
|
...(backend.cpuLimit !== undefined ? { cpuLimit: backend.cpuLimit } : {}),
|
|
1322
1395
|
...(backend.readOnlyRootfs !== undefined ? { readOnlyRootfs: backend.readOnlyRootfs } : {}),
|
|
@@ -1342,6 +1415,12 @@ function pickBackend(config: SandboxProviderConfig): SandboxBackend {
|
|
|
1342
1415
|
: {}),
|
|
1343
1416
|
...(backend.network !== undefined ? { network: backend.network } : {}),
|
|
1344
1417
|
...(backend.allowInwardFor !== undefined ? { allowInwardFor: backend.allowInwardFor } : {}),
|
|
1418
|
+
...(backend.egressProxyImage !== undefined
|
|
1419
|
+
? { egressProxyImage: backend.egressProxyImage }
|
|
1420
|
+
: {}),
|
|
1421
|
+
...(backend.egressProxyUpstreamNetwork !== undefined
|
|
1422
|
+
? { egressProxyUpstreamNetwork: backend.egressProxyUpstreamNetwork }
|
|
1423
|
+
: {}),
|
|
1345
1424
|
...(backend.labels !== undefined ? { labels: backend.labels } : {}),
|
|
1346
1425
|
...(backend.cpuLimit !== undefined ? { cpuLimit: backend.cpuLimit } : {}),
|
|
1347
1426
|
...(backend.readOnlyRootfs !== undefined ? { readOnlyRootfs: backend.readOnlyRootfs } : {}),
|
|
@@ -1372,6 +1451,16 @@ function pickBackend(config: SandboxProviderConfig): SandboxBackend {
|
|
|
1372
1451
|
...(backend.readyPollIntervalMs !== undefined
|
|
1373
1452
|
? { readyPollIntervalMs: backend.readyPollIntervalMs }
|
|
1374
1453
|
: {}),
|
|
1454
|
+
// Forwarded into the transport's own options rather than onto the
|
|
1455
|
+
// backend's config: the backend reads nothing here, and the phase
|
|
1456
|
+
// numbers are the transport's to report — see
|
|
1457
|
+
// `VsockTransportOptions.onExecTiming`, which is where a host that
|
|
1458
|
+
// builds a `VsockAgentTransport` directly sets it too. Absent ⇒
|
|
1459
|
+
// this spread adds no key at all and the transport is built with
|
|
1460
|
+
// the options it was always built with.
|
|
1461
|
+
...(backend.onExecTiming !== undefined
|
|
1462
|
+
? { transport: { onExecTiming: backend.onExecTiming } }
|
|
1463
|
+
: {}),
|
|
1375
1464
|
...(backend.mtls !== undefined ? { mtls: backend.mtls } : {}),
|
|
1376
1465
|
...(backend.controlPlaneMtls !== undefined
|
|
1377
1466
|
? { controlPlaneMtls: backend.controlPlaneMtls }
|