@namzu/sandbox 2.0.1 → 2.0.3
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 +58 -0
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +12 -2
- package/src/index.ts +1 -1
- package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.d.ts +0 -2
- package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.d.ts.map +0 -1
- package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.js +0 -61
- package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.js.map +0 -1
- package/dist/backends/docker/__tests__/hardening.test.d.ts +0 -2
- package/dist/backends/docker/__tests__/hardening.test.d.ts.map +0 -1
- package/dist/backends/docker/__tests__/hardening.test.js +0 -32
- package/dist/backends/docker/__tests__/hardening.test.js.map +0 -1
- package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.d.ts +0 -40
- package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.d.ts.map +0 -1
- package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.js +0 -157
- package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.js.map +0 -1
- package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.d.ts +0 -16
- package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.d.ts.map +0 -1
- package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.js +0 -37
- package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.js.map +0 -1
- package/dist/backends/firecracker/__tests__/backend.test.d.ts +0 -13
- package/dist/backends/firecracker/__tests__/backend.test.d.ts.map +0 -1
- package/dist/backends/firecracker/__tests__/backend.test.js +0 -361
- package/dist/backends/firecracker/__tests__/backend.test.js.map +0 -1
- package/dist/backends/firecracker/__tests__/control-plane-mtls.test.d.ts +0 -19
- package/dist/backends/firecracker/__tests__/control-plane-mtls.test.d.ts.map +0 -1
- package/dist/backends/firecracker/__tests__/control-plane-mtls.test.js +0 -209
- package/dist/backends/firecracker/__tests__/control-plane-mtls.test.js.map +0 -1
- package/dist/backends/firecracker/__tests__/egress-policy.test.d.ts +0 -2
- package/dist/backends/firecracker/__tests__/egress-policy.test.d.ts.map +0 -1
- package/dist/backends/firecracker/__tests__/egress-policy.test.js +0 -67
- package/dist/backends/firecracker/__tests__/egress-policy.test.js.map +0 -1
- package/dist/backends/firecracker/__tests__/fixtures/ipc-path.d.ts +0 -21
- package/dist/backends/firecracker/__tests__/fixtures/ipc-path.d.ts.map +0 -1
- package/dist/backends/firecracker/__tests__/fixtures/ipc-path.js +0 -30
- package/dist/backends/firecracker/__tests__/fixtures/ipc-path.js.map +0 -1
- package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.d.ts +0 -39
- package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.d.ts.map +0 -1
- package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.js +0 -149
- package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.js.map +0 -1
- package/dist/backends/firecracker/__tests__/protocol.test.d.ts +0 -6
- package/dist/backends/firecracker/__tests__/protocol.test.d.ts.map +0 -1
- package/dist/backends/firecracker/__tests__/protocol.test.js +0 -67
- package/dist/backends/firecracker/__tests__/protocol.test.js.map +0 -1
- package/dist/backends/firecracker/__tests__/transport.test.d.ts +0 -20
- package/dist/backends/firecracker/__tests__/transport.test.d.ts.map +0 -1
- package/dist/backends/firecracker/__tests__/transport.test.js +0 -457
- package/dist/backends/firecracker/__tests__/transport.test.js.map +0 -1
- package/dist/egress/__tests__/allowlist.test.d.ts +0 -2
- package/dist/egress/__tests__/allowlist.test.d.ts.map +0 -1
- package/dist/egress/__tests__/allowlist.test.js +0 -85
- package/dist/egress/__tests__/allowlist.test.js.map +0 -1
- package/dist/egress/__tests__/proxy.test.d.ts +0 -2
- package/dist/egress/__tests__/proxy.test.d.ts.map +0 -1
- package/dist/egress/__tests__/proxy.test.js +0 -177
- package/dist/egress/__tests__/proxy.test.js.map +0 -1
- package/dist/index.test.d.ts +0 -28
- package/dist/index.test.d.ts.map +0 -1
- package/dist/index.test.js +0 -649
- package/dist/index.test.js.map +0 -1
- package/src/backends/aci-standby-pool/__tests__/unenforceable-controls.test.ts +0 -69
- package/src/backends/docker/__tests__/hardening.test.ts +0 -43
- package/src/backends/docker/__tests__/leaf-permissions.smoke.test.ts +0 -169
- package/src/backends/firecracker/__tests__/agent-timeout-clamp.test.ts +0 -48
- package/src/backends/firecracker/__tests__/backend.test.ts +0 -429
- package/src/backends/firecracker/__tests__/control-plane-mtls.test.ts +0 -261
- package/src/backends/firecracker/__tests__/egress-policy.test.ts +0 -91
- package/src/backends/firecracker/__tests__/fixtures/ipc-path.ts +0 -31
- package/src/backends/firecracker/__tests__/fixtures/mtls-pki.ts +0 -166
- package/src/backends/firecracker/__tests__/protocol.test.ts +0 -75
- package/src/backends/firecracker/__tests__/transport.test.ts +0 -534
- package/src/egress/__tests__/allowlist.test.ts +0 -103
- package/src/egress/__tests__/proxy.test.ts +0 -212
- package/src/index.test.ts +0 -709
|
@@ -1,429 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Firecracker `SandboxBackend` end-to-end over the loopback agent.
|
|
3
|
-
*
|
|
4
|
-
* Stubs the orchestrator control plane (`fetch` for `/sandboxes` create
|
|
5
|
-
* + `:delete`) and points the returned vsock handle at a unix-socket
|
|
6
|
-
* loopback running the REAL `agent/agent.cjs`. Proves the full
|
|
7
|
-
* {@link Sandbox} handle the SDK consumes — `exec` / `writeFile` /
|
|
8
|
-
* `readFile` / `listFiles` / `destroy` / `status` — speaks the NDJSON
|
|
9
|
-
* wire over the vsock transport, and that the readiness fence waits on
|
|
10
|
-
* the agent's healthz (not the orchestrator 2xx).
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
14
|
-
import { createRequire } from 'node:module'
|
|
15
|
-
import { type Server, type Socket, createServer } from 'node:net'
|
|
16
|
-
import { tmpdir } from 'node:os'
|
|
17
|
-
import { join } from 'node:path'
|
|
18
|
-
|
|
19
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
20
|
-
|
|
21
|
-
import { buildFirecrackerBackend, normalizeHandle } from '../index.js'
|
|
22
|
-
import type { WireSandboxAgentHandle } from '../transport.js'
|
|
23
|
-
import { localIpcPath } from './fixtures/ipc-path.js'
|
|
24
|
-
import {
|
|
25
|
-
CA_CRT,
|
|
26
|
-
CLIENT_CRT,
|
|
27
|
-
CLIENT_KEY,
|
|
28
|
-
type RelayHandle,
|
|
29
|
-
startMtlsRelay,
|
|
30
|
-
} from './fixtures/mtls-pki.js'
|
|
31
|
-
// The loopback agent under test is the guest-side agent for a Linux microVM:
|
|
32
|
-
// it spawns `/bin/sh` to run commands, and that binary does not exist on
|
|
33
|
-
// Windows. These cases assert behavior the platform cannot produce, so they
|
|
34
|
-
// skip there rather than leaving the suite permanently red for Windows
|
|
35
|
-
// contributors. The socket-address fixture IS platform-correct, so the
|
|
36
|
-
// transport is still exercised wherever it can be.
|
|
37
|
-
const IS_WINDOWS = process.platform === 'win32'
|
|
38
|
-
|
|
39
|
-
const require_ = createRequire(import.meta.url)
|
|
40
|
-
|
|
41
|
-
interface AgentModule {
|
|
42
|
-
handleConnection(socket: Socket): void
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
let workDir: string
|
|
46
|
-
let sockPath: string
|
|
47
|
-
let server: Server | undefined
|
|
48
|
-
let relay: RelayHandle | undefined
|
|
49
|
-
let agent: AgentModule
|
|
50
|
-
const realFetch = globalThis.fetch
|
|
51
|
-
let realPath: string | undefined
|
|
52
|
-
|
|
53
|
-
beforeEach(() => {
|
|
54
|
-
workDir = mkdtempSync(join(tmpdir(), 'fc-backend-test-'))
|
|
55
|
-
sockPath = localIpcPath(workDir)
|
|
56
|
-
realPath = process.env.PATH
|
|
57
|
-
process.env.NAMZU_SANDBOX_WORKSPACE = workDir
|
|
58
|
-
delete require_.cache[require_.resolve('../../../../agent/agent.cjs')]
|
|
59
|
-
agent = require_('../../../../agent/agent.cjs') as AgentModule
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
afterEach(async () => {
|
|
63
|
-
globalThis.fetch = realFetch
|
|
64
|
-
process.env.PATH = realPath
|
|
65
|
-
if (relay) {
|
|
66
|
-
await new Promise<void>((r) => relay?.server.close(() => r()))
|
|
67
|
-
relay = undefined
|
|
68
|
-
}
|
|
69
|
-
if (server) {
|
|
70
|
-
await new Promise<void>((r) => server?.close(() => r()))
|
|
71
|
-
server = undefined
|
|
72
|
-
}
|
|
73
|
-
rmSync(workDir, { recursive: true, force: true })
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
function startAgent(): Promise<Server> {
|
|
77
|
-
return new Promise((resolve, reject) => {
|
|
78
|
-
const s = createServer(agent.handleConnection)
|
|
79
|
-
s.on('error', reject)
|
|
80
|
-
s.listen(sockPath, () => resolve(s))
|
|
81
|
-
})
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Stub the orchestrator: `POST /sandboxes` returns a handle pointing at
|
|
86
|
-
* the loopback unix socket; `:delete` returns 204. Records calls so the
|
|
87
|
-
* test can assert the create body + destroy round-trip.
|
|
88
|
-
*/
|
|
89
|
-
function stubOrchestrator(handle: WireSandboxAgentHandle): {
|
|
90
|
-
calls: Array<{ url: string; method: string; body?: unknown }>
|
|
91
|
-
} {
|
|
92
|
-
const calls: Array<{ url: string; method: string; body?: unknown }> = []
|
|
93
|
-
globalThis.fetch = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
|
94
|
-
const url = String(input)
|
|
95
|
-
const method = init?.method ?? 'GET'
|
|
96
|
-
const body = init?.body ? JSON.parse(String(init.body)) : undefined
|
|
97
|
-
calls.push({ url, method, body })
|
|
98
|
-
if (method === 'POST' && url.endsWith('/sandboxes')) {
|
|
99
|
-
return new Response(
|
|
100
|
-
JSON.stringify({ sandboxId: 'sbx_fc_test', agent: handle, rootDir: workDir }),
|
|
101
|
-
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
102
|
-
)
|
|
103
|
-
}
|
|
104
|
-
if (method === 'DELETE' && url.includes(':delete')) {
|
|
105
|
-
return new Response(null, { status: 204 })
|
|
106
|
-
}
|
|
107
|
-
return new Response('unexpected', { status: 500 })
|
|
108
|
-
}) as typeof fetch
|
|
109
|
-
return { calls }
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
describe.skipIf(IS_WINDOWS)('buildFirecrackerBackend (loopback agent)', () => {
|
|
113
|
-
it('creates a Sandbox handle and round-trips exec/write/read/listFiles/destroy', async () => {
|
|
114
|
-
server = await startAgent()
|
|
115
|
-
const { calls } = stubOrchestrator({ kind: 'unix', path: sockPath })
|
|
116
|
-
|
|
117
|
-
const backend = buildFirecrackerBackend({
|
|
118
|
-
orchestratorEndpoint: 'https://orchestrator.test/',
|
|
119
|
-
getToken: async () => 'token-123',
|
|
120
|
-
template: 'golden-rev-7',
|
|
121
|
-
readyTimeoutMs: 5_000,
|
|
122
|
-
readyPollIntervalMs: 50,
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
const sandbox = await backend.create({
|
|
126
|
-
workingDirectory: workDir,
|
|
127
|
-
memoryLimitMb: 512,
|
|
128
|
-
maxProcesses: 64,
|
|
129
|
-
timeoutMs: 60_000,
|
|
130
|
-
egress: { kind: 'static', allowedHosts: ['api.example.com'] },
|
|
131
|
-
})
|
|
132
|
-
|
|
133
|
-
// Handle shape.
|
|
134
|
-
expect(sandbox.id).toBe('sbx_fc_test')
|
|
135
|
-
expect(sandbox.rootDir).toBe(workDir)
|
|
136
|
-
expect(sandbox.environment).toBe('linux-namespace')
|
|
137
|
-
expect(sandbox.status).toBe('ready')
|
|
138
|
-
|
|
139
|
-
// Create body forwarded the resolved knobs + egress allowlist.
|
|
140
|
-
const createCall = calls.find((c) => c.method === 'POST')
|
|
141
|
-
expect(createCall?.body).toMatchObject({
|
|
142
|
-
template: 'golden-rev-7',
|
|
143
|
-
memoryLimitMb: 512,
|
|
144
|
-
maxProcesses: 64,
|
|
145
|
-
timeoutMs: 60_000,
|
|
146
|
-
egressAllowlist: ['api.example.com'],
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
// exec over the wire.
|
|
150
|
-
const r = await sandbox.exec('/bin/sh', ['-c', 'echo hello-fc'])
|
|
151
|
-
expect(r.stdout).toContain('hello-fc')
|
|
152
|
-
expect(r.exitCode).toBe(0)
|
|
153
|
-
|
|
154
|
-
// writeFile + readFile round-trip.
|
|
155
|
-
await sandbox.writeFile('out/result.txt', 'persisted')
|
|
156
|
-
const read = await sandbox.readFile('out/result.txt')
|
|
157
|
-
expect(read.toString('utf8')).toBe('persisted')
|
|
158
|
-
|
|
159
|
-
// listFiles uses the same `find -printf '%p\t%s\n'` wire as
|
|
160
|
-
// docker/aci. On a GNU-find host (the Ubuntu golden rootfs) it
|
|
161
|
-
// enumerates the file we wrote; on a BSD-find host (macOS CI
|
|
162
|
-
// runners) `-printf` is unsupported, find exits non-zero, and the
|
|
163
|
-
// contract maps that to an empty listing — exactly as docker/aci
|
|
164
|
-
// do. Assert the call path returns the SDK shape either way, and
|
|
165
|
-
// the file when GNU find is present.
|
|
166
|
-
const files = await sandbox.listFiles(workDir)
|
|
167
|
-
expect(Array.isArray(files)).toBe(true)
|
|
168
|
-
if (files.length > 0) {
|
|
169
|
-
expect(files.some((f) => f.path.endsWith('out/result.txt'))).toBe(true)
|
|
170
|
-
expect(files.every((f) => Number.isFinite(f.size))).toBe(true)
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// destroy calls the orchestrator :delete and flips status.
|
|
174
|
-
await sandbox.destroy()
|
|
175
|
-
expect(sandbox.status).toBe('destroyed')
|
|
176
|
-
expect(calls.some((c) => c.method === 'DELETE' && c.url.includes(':delete'))).toBe(true)
|
|
177
|
-
})
|
|
178
|
-
|
|
179
|
-
it('tears down the microVM when the readiness fence times out (no orphan)', async () => {
|
|
180
|
-
// No agent listening → healthz never succeeds → readiness fence
|
|
181
|
-
// times out → backend must DELETE to avoid orphaning the microVM.
|
|
182
|
-
const { calls } = stubOrchestrator({ kind: 'unix', path: sockPath })
|
|
183
|
-
const backend = buildFirecrackerBackend({
|
|
184
|
-
orchestratorEndpoint: 'https://orchestrator.test/',
|
|
185
|
-
getToken: async () => 'tok',
|
|
186
|
-
readyTimeoutMs: 300,
|
|
187
|
-
readyPollIntervalMs: 50,
|
|
188
|
-
transport: { connectRetryBudgetMs: 100, connectTimeoutMs: 80, connectRetryIntervalMs: 30 },
|
|
189
|
-
})
|
|
190
|
-
await expect(backend.create({ workingDirectory: workDir })).rejects.toThrow(
|
|
191
|
-
/did not become ready/,
|
|
192
|
-
)
|
|
193
|
-
expect(calls.some((c) => c.method === 'DELETE')).toBe(true)
|
|
194
|
-
})
|
|
195
|
-
|
|
196
|
-
it('parses listFiles output into {path,size} entries (GNU-find-shaped wire)', async () => {
|
|
197
|
-
// Make `find` deterministic across host platforms: shim a `find`
|
|
198
|
-
// on PATH that emits the GNU `-printf '%p\t%s\n'` wire the Ubuntu
|
|
199
|
-
// golden rootfs produces, so the backend's tab-split parser is
|
|
200
|
-
// proven without depending on the host having GNU findutils.
|
|
201
|
-
const binDir = mkdtempSync(join(tmpdir(), 'fc-bin-'))
|
|
202
|
-
const findShim = join(binDir, 'find')
|
|
203
|
-
writeFileSync(
|
|
204
|
-
findShim,
|
|
205
|
-
[
|
|
206
|
-
'#!/bin/sh',
|
|
207
|
-
`printf '%s\\t%s\\n' '${workDir}/out/result.txt' 9`,
|
|
208
|
-
`printf '%s\\t%s\\n' '${workDir}/out/other.bin' 42`,
|
|
209
|
-
].join('\n'),
|
|
210
|
-
{ mode: 0o755 },
|
|
211
|
-
)
|
|
212
|
-
chmodSync(findShim, 0o755)
|
|
213
|
-
process.env.PATH = `${binDir}:${process.env.PATH}`
|
|
214
|
-
|
|
215
|
-
server = await startAgent()
|
|
216
|
-
stubOrchestrator({ kind: 'unix', path: sockPath })
|
|
217
|
-
const backend = buildFirecrackerBackend({
|
|
218
|
-
orchestratorEndpoint: 'https://orchestrator.test/',
|
|
219
|
-
getToken: async () => 'tok',
|
|
220
|
-
readyTimeoutMs: 3_000,
|
|
221
|
-
readyPollIntervalMs: 50,
|
|
222
|
-
})
|
|
223
|
-
const sandbox = await backend.create({ workingDirectory: workDir })
|
|
224
|
-
const files = await sandbox.listFiles(workDir)
|
|
225
|
-
expect(files).toEqual([
|
|
226
|
-
{ path: `${workDir}/out/result.txt`, size: 9 },
|
|
227
|
-
{ path: `${workDir}/out/other.bin`, size: 42 },
|
|
228
|
-
])
|
|
229
|
-
await sandbox.destroy()
|
|
230
|
-
rmSync(binDir, { recursive: true, force: true })
|
|
231
|
-
})
|
|
232
|
-
|
|
233
|
-
it('fills the contract vsock port when the orchestrator omits it', async () => {
|
|
234
|
-
// A vsock handle whose port is 0 should be normalized to the
|
|
235
|
-
// configured agentVsockPort. We can't bind a real AF_VSOCK here, so
|
|
236
|
-
// assert the normalization path via a unix handle is unaffected and
|
|
237
|
-
// that a portless vsock handle does not crash create's pre-dial.
|
|
238
|
-
// (Full vsock dial is the live increment.)
|
|
239
|
-
server = await startAgent()
|
|
240
|
-
stubOrchestrator({ kind: 'unix', path: sockPath })
|
|
241
|
-
const backend = buildFirecrackerBackend({
|
|
242
|
-
orchestratorEndpoint: 'https://orchestrator.test/',
|
|
243
|
-
getToken: async () => 'tok',
|
|
244
|
-
agentVsockPort: 1024,
|
|
245
|
-
readyTimeoutMs: 3_000,
|
|
246
|
-
readyPollIntervalMs: 50,
|
|
247
|
-
})
|
|
248
|
-
const sandbox = await backend.create({ workingDirectory: workDir })
|
|
249
|
-
expect(sandbox.status).toBe('ready')
|
|
250
|
-
await sandbox.destroy()
|
|
251
|
-
})
|
|
252
|
-
|
|
253
|
-
it('carries agentSnapshot in the create body when the backend config sets it', async () => {
|
|
254
|
-
// OPEN-2 — the per-agent snapshot ref is a first-class, provider-agnostic
|
|
255
|
-
// field on the create contract (sibling to `template`). When set it must
|
|
256
|
-
// reach the orchestrator POST body verbatim.
|
|
257
|
-
server = await startAgent()
|
|
258
|
-
const { calls } = stubOrchestrator({ kind: 'unix', path: sockPath })
|
|
259
|
-
const backend = buildFirecrackerBackend({
|
|
260
|
-
orchestratorEndpoint: 'https://orchestrator.test/',
|
|
261
|
-
getToken: async () => 'tok',
|
|
262
|
-
template: 'golden-rev-7',
|
|
263
|
-
agentSnapshot: { orgId: 'org1', agentId: 'agent9', version: '3' },
|
|
264
|
-
readyTimeoutMs: 3_000,
|
|
265
|
-
readyPollIntervalMs: 50,
|
|
266
|
-
})
|
|
267
|
-
const sandbox = await backend.create({ workingDirectory: workDir })
|
|
268
|
-
const createCall = calls.find((c) => c.method === 'POST')
|
|
269
|
-
expect(createCall?.body).toMatchObject({
|
|
270
|
-
template: 'golden-rev-7',
|
|
271
|
-
agentSnapshot: { orgId: 'org1', agentId: 'agent9', version: '3' },
|
|
272
|
-
})
|
|
273
|
-
await sandbox.destroy()
|
|
274
|
-
})
|
|
275
|
-
|
|
276
|
-
it('OMITS agentSnapshot from the create body when absent (byte-identical generic path)', async () => {
|
|
277
|
-
// DORMANT contract: absent ⇒ the POST body must NOT carry an
|
|
278
|
-
// `agentSnapshot` key at all (a JSON `undefined` would not serialise, but
|
|
279
|
-
// the conditional spread must drop the key entirely so the body is
|
|
280
|
-
// byte-identical to the pre-field generic create).
|
|
281
|
-
server = await startAgent()
|
|
282
|
-
const { calls } = stubOrchestrator({ kind: 'unix', path: sockPath })
|
|
283
|
-
const backend = buildFirecrackerBackend({
|
|
284
|
-
orchestratorEndpoint: 'https://orchestrator.test/',
|
|
285
|
-
getToken: async () => 'tok',
|
|
286
|
-
template: 'golden-rev-7',
|
|
287
|
-
readyTimeoutMs: 3_000,
|
|
288
|
-
readyPollIntervalMs: 50,
|
|
289
|
-
})
|
|
290
|
-
const sandbox = await backend.create({ workingDirectory: workDir })
|
|
291
|
-
const createCall = calls.find((c) => c.method === 'POST')
|
|
292
|
-
expect(createCall?.body).toEqual({ template: 'golden-rev-7' })
|
|
293
|
-
expect(Object.prototype.hasOwnProperty.call(createCall?.body ?? {}, 'agentSnapshot')).toBe(
|
|
294
|
-
false,
|
|
295
|
-
)
|
|
296
|
-
await sandbox.destroy()
|
|
297
|
-
})
|
|
298
|
-
})
|
|
299
|
-
|
|
300
|
-
// ---------------------------------------------------------------------------
|
|
301
|
-
// Cert-injection seam (ses_051 P4 Track C) — the orchestrator returns a WIRE
|
|
302
|
-
// `mtls` handle (host/port/sandboxId, NO certs); the consumer injects the
|
|
303
|
-
// client CA/cert/key via `config.mtls`, which `normalizeHandle` MERGES onto
|
|
304
|
-
// the handle before the transport dials the relay. Certs NEVER transit the
|
|
305
|
-
// control plane.
|
|
306
|
-
// ---------------------------------------------------------------------------
|
|
307
|
-
|
|
308
|
-
describe('normalizeHandle (mtls cert injection)', () => {
|
|
309
|
-
const WIRE_MTLS = {
|
|
310
|
-
kind: 'mtls',
|
|
311
|
-
host: '10.60.1.7',
|
|
312
|
-
port: 8443,
|
|
313
|
-
sandboxId: 'sb-net',
|
|
314
|
-
} as const satisfies WireSandboxAgentHandle
|
|
315
|
-
|
|
316
|
-
it('merges the injected cert material onto a wire mtls handle', () => {
|
|
317
|
-
const handle = normalizeHandle(WIRE_MTLS, 1024, {
|
|
318
|
-
ca: CA_CRT,
|
|
319
|
-
cert: CLIENT_CRT,
|
|
320
|
-
key: CLIENT_KEY,
|
|
321
|
-
servername: 'sandbox.fc.internal',
|
|
322
|
-
})
|
|
323
|
-
expect(handle).toEqual({
|
|
324
|
-
kind: 'mtls',
|
|
325
|
-
host: '10.60.1.7',
|
|
326
|
-
port: 8443,
|
|
327
|
-
sandboxId: 'sb-net',
|
|
328
|
-
tls: {
|
|
329
|
-
ca: CA_CRT,
|
|
330
|
-
cert: CLIENT_CRT,
|
|
331
|
-
key: CLIENT_KEY,
|
|
332
|
-
servername: 'sandbox.fc.internal',
|
|
333
|
-
},
|
|
334
|
-
})
|
|
335
|
-
})
|
|
336
|
-
|
|
337
|
-
it('throws when an mtls handle arrives with NO injected cert material', () => {
|
|
338
|
-
// A network-mode backend that cannot present a client cert would be
|
|
339
|
-
// rejected by the relay — fail loud at handle-normalization instead.
|
|
340
|
-
expect(() => normalizeHandle(WIRE_MTLS, 1024, undefined)).toThrow(
|
|
341
|
-
/no client cert material was injected/,
|
|
342
|
-
)
|
|
343
|
-
})
|
|
344
|
-
|
|
345
|
-
it('leaves vsock + unix handles untouched (cert material is ignored)', () => {
|
|
346
|
-
expect(normalizeHandle({ kind: 'unix', path: '/tmp/a.sock' }, 1024, undefined)).toEqual({
|
|
347
|
-
kind: 'unix',
|
|
348
|
-
path: '/tmp/a.sock',
|
|
349
|
-
})
|
|
350
|
-
// A vsock handle with a 0 port still normalizes to the contract port.
|
|
351
|
-
expect(
|
|
352
|
-
normalizeHandle({ kind: 'vsock', udsPath: '/v.sock', port: 0 }, 1024, {
|
|
353
|
-
ca: CA_CRT,
|
|
354
|
-
cert: CLIENT_CRT,
|
|
355
|
-
key: CLIENT_KEY,
|
|
356
|
-
}),
|
|
357
|
-
).toEqual({ kind: 'vsock', udsPath: '/v.sock', port: 1024 })
|
|
358
|
-
})
|
|
359
|
-
})
|
|
360
|
-
|
|
361
|
-
describe.skipIf(IS_WINDOWS)(
|
|
362
|
-
'buildFirecrackerBackend (network mode over a loopback mTLS relay)',
|
|
363
|
-
() => {
|
|
364
|
-
it('injects the client cert + round-trips exec/file-IO through the relay', async () => {
|
|
365
|
-
// The agent on a unix socket; a loopback mTLS relay in front of it.
|
|
366
|
-
server = await startAgent()
|
|
367
|
-
relay = await startMtlsRelay(sockPath)
|
|
368
|
-
// The orchestrator returns a WIRE mtls handle (NO cert material) whose
|
|
369
|
-
// host:port point at the relay; the relay resolves the sandboxId.
|
|
370
|
-
const { calls } = stubOrchestrator({
|
|
371
|
-
kind: 'mtls',
|
|
372
|
-
host: '127.0.0.1',
|
|
373
|
-
port: relay.port,
|
|
374
|
-
sandboxId: 'sb-net',
|
|
375
|
-
})
|
|
376
|
-
|
|
377
|
-
const backend = buildFirecrackerBackend({
|
|
378
|
-
orchestratorEndpoint: 'https://orchestrator.test/',
|
|
379
|
-
getToken: async () => 'tok',
|
|
380
|
-
readyTimeoutMs: 5_000,
|
|
381
|
-
readyPollIntervalMs: 50,
|
|
382
|
-
// The CONSUMER-injected client material — never returned by the
|
|
383
|
-
// orchestrator. Merged onto the handle's `tls` block.
|
|
384
|
-
mtls: {
|
|
385
|
-
ca: CA_CRT,
|
|
386
|
-
cert: CLIENT_CRT,
|
|
387
|
-
key: CLIENT_KEY,
|
|
388
|
-
servername: 'sandbox.fc.internal',
|
|
389
|
-
},
|
|
390
|
-
})
|
|
391
|
-
|
|
392
|
-
const sandbox = await backend.create({ workingDirectory: workDir })
|
|
393
|
-
expect(sandbox.status).toBe('ready')
|
|
394
|
-
|
|
395
|
-
// The dialer wrote the `SANDBOX <id>` routing preamble (the relay's key).
|
|
396
|
-
expect(await relay.preamble()).toBe('sb-net')
|
|
397
|
-
|
|
398
|
-
// Full exec + file-IO round-trip over the mTLS tunnel.
|
|
399
|
-
const r = await sandbox.exec('/bin/sh', ['-c', 'echo hello-mtls'])
|
|
400
|
-
expect(r.stdout).toContain('hello-mtls')
|
|
401
|
-
await sandbox.writeFile('out/r.txt', 'via-relay')
|
|
402
|
-
expect((await sandbox.readFile('out/r.txt')).toString('utf8')).toBe('via-relay')
|
|
403
|
-
|
|
404
|
-
await sandbox.destroy()
|
|
405
|
-
expect(calls.some((c) => c.method === 'DELETE')).toBe(true)
|
|
406
|
-
})
|
|
407
|
-
|
|
408
|
-
it('rejects a create when the orchestrator returns mtls but no cert material is injected', async () => {
|
|
409
|
-
server = await startAgent()
|
|
410
|
-
relay = await startMtlsRelay(sockPath)
|
|
411
|
-
stubOrchestrator({
|
|
412
|
-
kind: 'mtls',
|
|
413
|
-
host: '127.0.0.1',
|
|
414
|
-
port: relay.port,
|
|
415
|
-
sandboxId: 'sb-net',
|
|
416
|
-
})
|
|
417
|
-
const backend = buildFirecrackerBackend({
|
|
418
|
-
orchestratorEndpoint: 'https://orchestrator.test/',
|
|
419
|
-
getToken: async () => 'tok',
|
|
420
|
-
readyTimeoutMs: 2_000,
|
|
421
|
-
readyPollIntervalMs: 50,
|
|
422
|
-
// No `mtls` material → normalizeHandle must throw at create time.
|
|
423
|
-
})
|
|
424
|
-
await expect(backend.create({ workingDirectory: workDir })).rejects.toThrow(
|
|
425
|
-
/no client cert material was injected/,
|
|
426
|
-
)
|
|
427
|
-
})
|
|
428
|
-
},
|
|
429
|
-
)
|
|
@@ -1,261 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Control-plane mTLS dial for the Firecracker backend (additive
|
|
3
|
-
* `config.controlPlaneMtls`).
|
|
4
|
-
*
|
|
5
|
-
* A loopback `https.createServer` with `requestCert + rejectUnauthorized`
|
|
6
|
-
* (pinned to the test CA) stands in for the orchestrator over the public hop.
|
|
7
|
-
* It serves the real `POST /sandboxes` + `:delete` routes; the create response
|
|
8
|
-
* points the agent handle at a unix-socket loopback running the REAL
|
|
9
|
-
* `agent/agent.cjs`, so the FULL create flow (control-plane POST over mTLS →
|
|
10
|
-
* vsock readiness fence → exec) runs end to end.
|
|
11
|
-
*
|
|
12
|
-
* Proves:
|
|
13
|
-
* - with `controlPlaneMtls` injected, the create POST succeeds over mTLS and
|
|
14
|
-
* the sandbox round-trips an exec.
|
|
15
|
-
* - WITHOUT the client cert (no `controlPlaneMtls`, so the backend uses plain
|
|
16
|
-
* `fetch` against the https listener), create is rejected at the TLS layer.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { mkdtempSync, rmSync } from 'node:fs'
|
|
20
|
-
import {
|
|
21
|
-
type Server as HttpsServer,
|
|
22
|
-
createServer as createHttpsServer,
|
|
23
|
-
request as httpsRequest,
|
|
24
|
-
} from 'node:https'
|
|
25
|
-
import { createRequire } from 'node:module'
|
|
26
|
-
import { type Server, type Socket, createServer } from 'node:net'
|
|
27
|
-
import { tmpdir } from 'node:os'
|
|
28
|
-
import { join } from 'node:path'
|
|
29
|
-
|
|
30
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
31
|
-
|
|
32
|
-
import { buildFirecrackerBackend } from '../index.js'
|
|
33
|
-
import { localIpcPath } from './fixtures/ipc-path.js'
|
|
34
|
-
import {
|
|
35
|
-
CA_CRT,
|
|
36
|
-
CLIENT_CRT,
|
|
37
|
-
CLIENT_KEY,
|
|
38
|
-
ROGUE_CLIENT_CRT,
|
|
39
|
-
ROGUE_CLIENT_KEY,
|
|
40
|
-
SERVER_CRT,
|
|
41
|
-
SERVER_KEY,
|
|
42
|
-
} from './fixtures/mtls-pki.js'
|
|
43
|
-
// The loopback agent under test is the guest-side agent for a Linux microVM:
|
|
44
|
-
// it spawns `/bin/sh` to run commands, and that binary does not exist on
|
|
45
|
-
// Windows. These cases assert behavior the platform cannot produce, so they
|
|
46
|
-
// skip there rather than leaving the suite permanently red for Windows
|
|
47
|
-
// contributors. The socket-address fixture IS platform-correct, so the
|
|
48
|
-
// transport is still exercised wherever it can be.
|
|
49
|
-
const IS_WINDOWS = process.platform === 'win32'
|
|
50
|
-
|
|
51
|
-
const require_ = createRequire(import.meta.url)
|
|
52
|
-
|
|
53
|
-
interface AgentModule {
|
|
54
|
-
handleConnection(socket: Socket): void
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
let workDir: string
|
|
58
|
-
let sockPath: string
|
|
59
|
-
let agentServer: Server | undefined
|
|
60
|
-
let orchestrator: HttpsServer | undefined
|
|
61
|
-
let agent: AgentModule
|
|
62
|
-
|
|
63
|
-
beforeEach(() => {
|
|
64
|
-
workDir = mkdtempSync(join(tmpdir(), 'fc-cp-mtls-'))
|
|
65
|
-
sockPath = localIpcPath(workDir)
|
|
66
|
-
process.env.NAMZU_SANDBOX_WORKSPACE = workDir
|
|
67
|
-
delete require_.cache[require_.resolve('../../../../agent/agent.cjs')]
|
|
68
|
-
agent = require_('../../../../agent/agent.cjs') as AgentModule
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
afterEach(async () => {
|
|
72
|
-
if (orchestrator) {
|
|
73
|
-
await new Promise<void>((r) => orchestrator?.close(() => r()))
|
|
74
|
-
orchestrator = undefined
|
|
75
|
-
}
|
|
76
|
-
if (agentServer) {
|
|
77
|
-
await new Promise<void>((r) => agentServer?.close(() => r()))
|
|
78
|
-
agentServer = undefined
|
|
79
|
-
}
|
|
80
|
-
rmSync(workDir, { recursive: true, force: true })
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
function startAgent(): Promise<Server> {
|
|
84
|
-
return new Promise((resolve, reject) => {
|
|
85
|
-
const s = createServer(agent.handleConnection)
|
|
86
|
-
s.on('error', reject)
|
|
87
|
-
s.listen(sockPath, () => resolve(s))
|
|
88
|
-
})
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* A loopback mTLS orchestrator. Requires + verifies a client cert chaining to
|
|
93
|
-
* the test CA, then serves the control-plane routes: `POST /sandboxes` returns
|
|
94
|
-
* a unix agent handle, `:delete` returns 204. Records calls so the test can
|
|
95
|
-
* assert the create round-trip.
|
|
96
|
-
*/
|
|
97
|
-
function startMtlsOrchestrator(handlePath: string): Promise<{
|
|
98
|
-
server: HttpsServer
|
|
99
|
-
port: number
|
|
100
|
-
calls: Array<{ method: string; url: string }>
|
|
101
|
-
}> {
|
|
102
|
-
const calls: Array<{ method: string; url: string }> = []
|
|
103
|
-
return new Promise((resolve, reject) => {
|
|
104
|
-
const server = createHttpsServer(
|
|
105
|
-
{
|
|
106
|
-
cert: SERVER_CRT,
|
|
107
|
-
key: SERVER_KEY,
|
|
108
|
-
ca: CA_CRT,
|
|
109
|
-
requestCert: true,
|
|
110
|
-
rejectUnauthorized: true,
|
|
111
|
-
minVersion: 'TLSv1.3',
|
|
112
|
-
},
|
|
113
|
-
(req, res) => {
|
|
114
|
-
const method = req.method ?? 'GET'
|
|
115
|
-
const url = req.url ?? '/'
|
|
116
|
-
calls.push({ method, url })
|
|
117
|
-
// Drain the body, then respond per route.
|
|
118
|
-
req.on('data', () => {})
|
|
119
|
-
req.on('end', () => {
|
|
120
|
-
if (method === 'POST' && url.endsWith('/sandboxes')) {
|
|
121
|
-
res.statusCode = 200
|
|
122
|
-
res.setHeader('content-type', 'application/json')
|
|
123
|
-
res.end(
|
|
124
|
-
JSON.stringify({
|
|
125
|
-
sandboxId: 'sbx_cp_mtls',
|
|
126
|
-
agent: { kind: 'unix', path: handlePath },
|
|
127
|
-
rootDir: workDir,
|
|
128
|
-
}),
|
|
129
|
-
)
|
|
130
|
-
return
|
|
131
|
-
}
|
|
132
|
-
if (method === 'DELETE' && url.includes(':delete')) {
|
|
133
|
-
res.statusCode = 204
|
|
134
|
-
res.end()
|
|
135
|
-
return
|
|
136
|
-
}
|
|
137
|
-
res.statusCode = 500
|
|
138
|
-
res.end('unexpected')
|
|
139
|
-
})
|
|
140
|
-
},
|
|
141
|
-
)
|
|
142
|
-
server.on('error', reject)
|
|
143
|
-
server.listen(0, '127.0.0.1', () => {
|
|
144
|
-
const addr = server.address()
|
|
145
|
-
if (addr === null || typeof addr === 'string') {
|
|
146
|
-
reject(new Error('orchestrator: no TCP port'))
|
|
147
|
-
return
|
|
148
|
-
}
|
|
149
|
-
resolve({ server, port: addr.port, calls })
|
|
150
|
-
})
|
|
151
|
-
})
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
describe.skipIf(IS_WINDOWS)('buildFirecrackerBackend (control-plane mTLS dial)', () => {
|
|
155
|
-
it('create POST succeeds over mTLS and round-trips an exec', async () => {
|
|
156
|
-
agentServer = await startAgent()
|
|
157
|
-
const orch = await startMtlsOrchestrator(sockPath)
|
|
158
|
-
orchestrator = orch.server
|
|
159
|
-
|
|
160
|
-
const backend = buildFirecrackerBackend({
|
|
161
|
-
// An https:// endpoint over the (loopback stand-in for the) public hop.
|
|
162
|
-
orchestratorEndpoint: `https://127.0.0.1:${orch.port}/`,
|
|
163
|
-
getToken: async () => 'tok',
|
|
164
|
-
readyTimeoutMs: 5_000,
|
|
165
|
-
readyPollIntervalMs: 50,
|
|
166
|
-
// CONTROL-plane client material — injected by the consumer, never
|
|
167
|
-
// shipped by the orchestrator. Drives the node:https mTLS dial.
|
|
168
|
-
controlPlaneMtls: {
|
|
169
|
-
ca: CA_CRT,
|
|
170
|
-
cert: CLIENT_CRT,
|
|
171
|
-
key: CLIENT_KEY,
|
|
172
|
-
servername: 'sandbox.fc.internal',
|
|
173
|
-
},
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
const sandbox = await backend.create({ workingDirectory: workDir })
|
|
177
|
-
expect(sandbox.id).toBe('sbx_cp_mtls')
|
|
178
|
-
expect(sandbox.status).toBe('ready')
|
|
179
|
-
|
|
180
|
-
// The control-plane POST reached the mTLS orchestrator.
|
|
181
|
-
expect(orch.calls.some((c) => c.method === 'POST' && c.url.endsWith('/sandboxes'))).toBe(true)
|
|
182
|
-
|
|
183
|
-
// Exec over the vsock tunnel (the data plane), proving the whole flow.
|
|
184
|
-
const r = await sandbox.exec('/bin/sh', ['-c', 'echo hello-cp-mtls'])
|
|
185
|
-
expect(r.stdout).toContain('hello-cp-mtls')
|
|
186
|
-
|
|
187
|
-
// destroy DELETE also rides the mTLS control plane.
|
|
188
|
-
await sandbox.destroy()
|
|
189
|
-
expect(orch.calls.some((c) => c.method === 'DELETE' && c.url.includes(':delete'))).toBe(true)
|
|
190
|
-
})
|
|
191
|
-
|
|
192
|
-
it('the backend without controlPlaneMtls cannot reach the mTLS orchestrator (plain fetch fails the handshake)', async () => {
|
|
193
|
-
agentServer = await startAgent()
|
|
194
|
-
const orch = await startMtlsOrchestrator(sockPath)
|
|
195
|
-
orchestrator = orch.server
|
|
196
|
-
|
|
197
|
-
// No `controlPlaneMtls` → the backend uses plain `fetch`, which neither
|
|
198
|
-
// presents a client cert nor trusts the test CA, so the TLS handshake
|
|
199
|
-
// fails and create rejects BEFORE any route runs.
|
|
200
|
-
const backend = buildFirecrackerBackend({
|
|
201
|
-
orchestratorEndpoint: `https://127.0.0.1:${orch.port}/`,
|
|
202
|
-
getToken: async () => 'tok',
|
|
203
|
-
readyTimeoutMs: 2_000,
|
|
204
|
-
readyPollIntervalMs: 50,
|
|
205
|
-
})
|
|
206
|
-
|
|
207
|
-
await expect(backend.create({ workingDirectory: workDir })).rejects.toThrow(
|
|
208
|
-
/failed to create microVM sandbox/,
|
|
209
|
-
)
|
|
210
|
-
expect(orch.calls.some((c) => c.method === 'POST' && c.url.endsWith('/sandboxes'))).toBe(false)
|
|
211
|
-
})
|
|
212
|
-
|
|
213
|
-
it("the orchestrator's requestCert rejects a client presenting NO cert (server-side proof)", async () => {
|
|
214
|
-
// Isolate the SERVER's requestCert+rejectUnauthorized from any client-side
|
|
215
|
-
// CA distrust: dial directly with node:https TRUSTING the server CA but
|
|
216
|
-
// presenting NO client cert. The handshake must be rejected by the server.
|
|
217
|
-
const orch = await startMtlsOrchestrator(sockPath)
|
|
218
|
-
orchestrator = orch.server
|
|
219
|
-
const err = await new Promise<Error | undefined>((resolve) => {
|
|
220
|
-
const req = httpsRequest(
|
|
221
|
-
{
|
|
222
|
-
host: '127.0.0.1',
|
|
223
|
-
port: orch.port,
|
|
224
|
-
method: 'POST',
|
|
225
|
-
path: '/sandboxes',
|
|
226
|
-
ca: CA_CRT,
|
|
227
|
-
servername: 'sandbox.fc.internal',
|
|
228
|
-
},
|
|
229
|
-
() => resolve(undefined),
|
|
230
|
-
)
|
|
231
|
-
req.on('error', (e: Error) => resolve(e))
|
|
232
|
-
req.end()
|
|
233
|
-
})
|
|
234
|
-
expect(err).toBeInstanceOf(Error)
|
|
235
|
-
expect(orch.calls.some((c) => c.method === 'POST')).toBe(false)
|
|
236
|
-
})
|
|
237
|
-
|
|
238
|
-
it("the orchestrator's requestCert rejects a client cert from a DIFFERENT CA", async () => {
|
|
239
|
-
const orch = await startMtlsOrchestrator(sockPath)
|
|
240
|
-
orchestrator = orch.server
|
|
241
|
-
const err = await new Promise<Error | undefined>((resolve) => {
|
|
242
|
-
const req = httpsRequest(
|
|
243
|
-
{
|
|
244
|
-
host: '127.0.0.1',
|
|
245
|
-
port: orch.port,
|
|
246
|
-
method: 'POST',
|
|
247
|
-
path: '/sandboxes',
|
|
248
|
-
ca: CA_CRT,
|
|
249
|
-
cert: ROGUE_CLIENT_CRT,
|
|
250
|
-
key: ROGUE_CLIENT_KEY,
|
|
251
|
-
servername: 'sandbox.fc.internal',
|
|
252
|
-
},
|
|
253
|
-
() => resolve(undefined),
|
|
254
|
-
)
|
|
255
|
-
req.on('error', (e: Error) => resolve(e))
|
|
256
|
-
req.end()
|
|
257
|
-
})
|
|
258
|
-
expect(err).toBeInstanceOf(Error)
|
|
259
|
-
expect(orch.calls.some((c) => c.method === 'POST')).toBe(false)
|
|
260
|
-
})
|
|
261
|
-
})
|