mirab 0.2.0 → 0.2.1-canary-b0b093c
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/README.md +12 -1
- package/package.json +2 -1
- package/scripts/firecracker.json +11 -0
- package/src/index.ts +4 -0
- package/src/isolation/colima.ts +1 -1
- package/src/isolation/container.ts +2 -2
- package/src/isolation/firecracker-install.ts +72 -0
- package/src/isolation/firecracker.ts +4 -0
- package/src/isolation/host-user.ts +2 -2
- package/src/isolation/image.ts +29 -8
- package/src/isolation/index.ts +19 -3
- package/src/isolation/linux-network-setup.ts +335 -0
- package/src/isolation/macos.ts +423 -0
- package/src/isolation/network.ts +3 -3
- package/src/isolation/session.ts +29 -5
- package/src/isolation/vz.ts +32 -11
- package/src/mirab-main.ts +101 -6
- package/src/progress-line.ts +85 -0
- package/src/runner/aws-key.ts +36 -0
- package/src/runner/budget.ts +125 -0
- package/src/runner/define.ts +61 -10
- package/src/runner/image.ts +176 -8
- package/src/runner/index.ts +13 -0
- package/src/runner/serve.ts +161 -58
- package/src/runner/toolsets.ts +102 -14
- package/types/index.d.ts +1 -1
- package/types/isolation/container.d.ts +1 -1
- package/types/isolation/firecracker-install.d.ts +11 -0
- package/types/isolation/firecracker.d.ts +4 -0
- package/types/isolation/image.d.ts +14 -2
- package/types/isolation/index.d.ts +1 -0
- package/types/isolation/linux-network-setup.d.ts +49 -0
- package/types/isolation/macos.d.ts +64 -0
- package/types/isolation/network.d.ts +1 -1
- package/types/isolation/session.d.ts +4 -0
- package/types/isolation/vz.d.ts +10 -2
- package/types/runner/aws-key.d.ts +2 -0
- package/types/runner/budget.d.ts +35 -0
- package/types/runner/define.d.ts +36 -6
- package/types/runner/image.d.ts +10 -2
- package/types/runner/index.d.ts +5 -1
- package/types/runner/serve.d.ts +20 -7
- package/types/runner/toolsets.d.ts +12 -2
package/README.md
CHANGED
|
@@ -25,7 +25,18 @@ It installs mirab into `~/.mirab` (the release in `~/.mirab/releases/`, the
|
|
|
25
25
|
release's manifest, with no sudo. It brings its own Bun, and on Linux its own
|
|
26
26
|
Firecracker. The VM image is downloaded on first use. Builds: macOS (Apple
|
|
27
27
|
silicon and Intel) and Linux x86_64. `sh -s -- --canary` installs the newest
|
|
28
|
-
build of `main`.
|
|
28
|
+
nightly build of `main`.
|
|
29
|
+
|
|
30
|
+
### Releases
|
|
31
|
+
|
|
32
|
+
- **Canary, nightly.** If `main` changed, a nightly build publishes
|
|
33
|
+
`X.Y.Z-canary-<sha>` to mirab-releases (binaries and VM image), then the
|
|
34
|
+
library to npm as `mirab@X.Y.Z-canary-<sha>` on the `canary` tag.
|
|
35
|
+
- **Stable, promoted.** Actions → Promote → Run workflow with a canary tag
|
|
36
|
+
copies the same bytes to `X.Y.Z`, tags `vX.Y.Z`, and publishes
|
|
37
|
+
`mirab@X.Y.Z` on `latest`. The next canary needs a version bump.
|
|
38
|
+
- An npm version is always the name of a release on mirab-releases, so the
|
|
39
|
+
library finds its own VM image by its version.
|
|
29
40
|
|
|
30
41
|
## Make an agent
|
|
31
42
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mirab",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1-canary-b0b093c",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "bun run src/mirab.ts",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"!src/**/*.test.ts",
|
|
55
55
|
"!src/**/*.e2e.test.ts",
|
|
56
56
|
"types",
|
|
57
|
+
"scripts/firecracker.json",
|
|
57
58
|
"README.md",
|
|
58
59
|
"LICENSE"
|
|
59
60
|
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.17.0",
|
|
3
|
+
"tarball": "https://github.com/firecracker-microvm/firecracker/releases/download/v1.17.0/firecracker-v1.17.0-x86_64.tgz",
|
|
4
|
+
"sha256": "06094a1108ae9e82aa4c23a775aa92758f53f1175d422270d9d6162cb9ade558",
|
|
5
|
+
"files": {
|
|
6
|
+
"firecracker-v1.17.0-x86_64": "99ad0f5cd0514a88aad0e9ae8cfdb3cc3b4ab9d190e1194602406c786b5de7a5",
|
|
7
|
+
"LICENSE": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30",
|
|
8
|
+
"NOTICE": "0f443af83ca8226c7ec44e2ade3a09fa3eed8c9b923b6c014b6d439816b6f1c2",
|
|
9
|
+
"THIRD-PARTY": "a6d3ebd1c2f37d4fd83d0676621f695fc0cc2d8c6e646cdbb831b46e0650c208"
|
|
10
|
+
}
|
|
11
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -5,9 +5,13 @@ export { loadManifest, manifestSchema, parseManifest } from './agent/manifest'
|
|
|
5
5
|
export type { Manifest } from './agent/manifest'
|
|
6
6
|
export {
|
|
7
7
|
defineRunner,
|
|
8
|
+
dropToRunner,
|
|
9
|
+
isDarwin,
|
|
8
10
|
RUNNER_PLATFORMS,
|
|
9
11
|
RUNNER_SETPRIV,
|
|
10
12
|
RUNNER_USER,
|
|
13
|
+
runnerUser,
|
|
14
|
+
secretDir,
|
|
11
15
|
TOOLSETS,
|
|
12
16
|
} from './runner/define'
|
|
13
17
|
export type {
|
package/src/isolation/colima.ts
CHANGED
|
@@ -95,7 +95,7 @@ export function detectColima(p: ColimaProbe): ColimaState {
|
|
|
95
95
|
return {
|
|
96
96
|
running: false,
|
|
97
97
|
reason: 'Colima is not installed',
|
|
98
|
-
fix: `
|
|
98
|
+
fix: `To fix: ${COLIMA_INSTALL}`,
|
|
99
99
|
}
|
|
100
100
|
const out = p.status()
|
|
101
101
|
let s: Record<string, unknown> | undefined
|
|
@@ -57,13 +57,13 @@ export function containerNetwork(
|
|
|
57
57
|
if (mode === 'block' || (!ready && mode === 'auto')) return 'none'
|
|
58
58
|
if (!ready)
|
|
59
59
|
throw new Error(
|
|
60
|
-
`egress is set to allow but the ${EGRESS_NETWORK} network is not set up.
|
|
60
|
+
`egress is set to allow but the ${EGRESS_NETWORK} network is not set up. To fix: ${setup}`,
|
|
61
61
|
)
|
|
62
62
|
return EGRESS_NETWORK
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
export const GUEST_WORK = '/workspace'
|
|
66
|
-
export const NETWORK_SETUP =
|
|
66
|
+
export const NETWORK_SETUP = `sudo mirab vm network setup --user ${process.env.USER ?? '<name>'}`
|
|
67
67
|
/** The same script, run inside Colima's VM, where the containers' network lives. */
|
|
68
68
|
export const COLIMA_NETWORK_SETUP =
|
|
69
69
|
'colima ssh -- sudo sh -s up < scripts/vm-network-setup.sh'
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Installs the Firecracker release mirab is pinned to (scripts/firecracker.json)
|
|
2
|
+
// for a host that runs VMs without the npm package next to it: woreda's Linux
|
|
3
|
+
// service install calls this. The tarball and the binary are both checked
|
|
4
|
+
// against the pinned sha256 before the binary is put in place.
|
|
5
|
+
import { spawnSync } from 'node:child_process'
|
|
6
|
+
import { createHash } from 'node:crypto'
|
|
7
|
+
import { chmodSync, existsSync, readFileSync, renameSync } from 'node:fs'
|
|
8
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import pin from '../../scripts/firecracker.json'
|
|
11
|
+
|
|
12
|
+
export const FIRECRACKER_PIN = pin as {
|
|
13
|
+
version: string
|
|
14
|
+
tarball: string
|
|
15
|
+
sha256: string
|
|
16
|
+
files: Record<string, string>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const sha = (b: Uint8Array) => createHash('sha256').update(b).digest('hex')
|
|
20
|
+
|
|
21
|
+
/** `<dir>/firecracker`, the pinned release; a matching one already there is kept. */
|
|
22
|
+
export async function installFirecracker(
|
|
23
|
+
dir: string,
|
|
24
|
+
o: { fetcher?: typeof fetch; log?: (line: string) => void } = {},
|
|
25
|
+
): Promise<string> {
|
|
26
|
+
const log = o.log ?? (() => {})
|
|
27
|
+
const name = `firecracker-v${FIRECRACKER_PIN.version}-x86_64`
|
|
28
|
+
const want = FIRECRACKER_PIN.files[name]
|
|
29
|
+
if (!want) throw new Error(`scripts/firecracker.json names no ${name}`)
|
|
30
|
+
const dest = join(dir, 'firecracker')
|
|
31
|
+
if (existsSync(dest) && sha(readFileSync(dest)) === want) return dest
|
|
32
|
+
if (process.arch !== 'x64')
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Firecracker ${FIRECRACKER_PIN.version} is pinned for x86_64 hosts; this one is ${process.arch}`,
|
|
35
|
+
)
|
|
36
|
+
log(`downloading Firecracker ${FIRECRACKER_PIN.version}`)
|
|
37
|
+
const res = await (o.fetcher ?? fetch)(FIRECRACKER_PIN.tarball)
|
|
38
|
+
if (!res.ok)
|
|
39
|
+
throw new Error(
|
|
40
|
+
`could not fetch ${FIRECRACKER_PIN.tarball}: HTTP ${res.status}`,
|
|
41
|
+
)
|
|
42
|
+
const tgz = new Uint8Array(await res.arrayBuffer())
|
|
43
|
+
if (sha(tgz) !== FIRECRACKER_PIN.sha256)
|
|
44
|
+
throw new Error(
|
|
45
|
+
`refusing ${FIRECRACKER_PIN.tarball}: its sha256 is not the pinned ${FIRECRACKER_PIN.sha256}`,
|
|
46
|
+
)
|
|
47
|
+
await mkdir(dir, { recursive: true, mode: 0o755 })
|
|
48
|
+
const work = await mkdtemp(join(dir, '.firecracker-'))
|
|
49
|
+
try {
|
|
50
|
+
await writeFile(join(work, 'fc.tgz'), tgz)
|
|
51
|
+
const member = `release-v${FIRECRACKER_PIN.version}-x86_64/${name}`
|
|
52
|
+
const r = spawnSync('tar', [
|
|
53
|
+
'-xzf',
|
|
54
|
+
join(work, 'fc.tgz'),
|
|
55
|
+
'-C',
|
|
56
|
+
work,
|
|
57
|
+
member,
|
|
58
|
+
])
|
|
59
|
+
if (r.status !== 0)
|
|
60
|
+
throw new Error(`could not unpack ${member}: ${String(r.stderr).trim()}`)
|
|
61
|
+
const bin = join(work, member)
|
|
62
|
+
if (sha(readFileSync(bin)) !== want)
|
|
63
|
+
throw new Error(
|
|
64
|
+
`refusing ${member}: its sha256 is not the pinned ${want}`,
|
|
65
|
+
)
|
|
66
|
+
chmodSync(bin, 0o755)
|
|
67
|
+
renameSync(bin, dest)
|
|
68
|
+
return dest
|
|
69
|
+
} finally {
|
|
70
|
+
await rm(work, { recursive: true, force: true })
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -224,6 +224,10 @@ export interface VmStartOptions {
|
|
|
224
224
|
* [] for a VM that must not reach the host at all. Firecracker forwards a
|
|
225
225
|
* port only when someone serves it (onGuestConnect), so it needs no list. */
|
|
226
226
|
listen?: number[]
|
|
227
|
+
/** vz on Apple silicon: boot this VM as macOS from a provisioned base
|
|
228
|
+
* directory (its disk is `<macos>/disk.img`). Only for this VM: a host's
|
|
229
|
+
* `vm.macos` never turns its other VMs into macOS. */
|
|
230
|
+
macos?: string
|
|
227
231
|
}
|
|
228
232
|
|
|
229
233
|
/** Sparse-grows a disk image file to `gib` GiB; refuses to shrink one. */
|
|
@@ -99,7 +99,7 @@ export const setupCommand = (user: string, owner: string) =>
|
|
|
99
99
|
|
|
100
100
|
/** Is the host tier for `user` set up, and set up safely? Never runs sudo. */
|
|
101
101
|
export function hostUserState(user: string, probe: HostProbe): HostUserState {
|
|
102
|
-
const fix = `
|
|
102
|
+
const fix = `To fix: ${setupCommand(user, probe.me.name)}`
|
|
103
103
|
const no = (reason: string): HostUserState => ({ ok: false, reason, fix })
|
|
104
104
|
const raw = probe.marker(user)
|
|
105
105
|
if (!raw) return no(`${user} is not set up (no ${MARKER_DIR}/${user}.json)`)
|
|
@@ -215,7 +215,7 @@ export async function startHostUser(
|
|
|
215
215
|
await guestPing(open)
|
|
216
216
|
} catch (e) {
|
|
217
217
|
throw new Error(
|
|
218
|
-
`cannot run the agent as ${marker.user}: ${(e as Error).message}.
|
|
218
|
+
`cannot run the agent as ${marker.user}: ${(e as Error).message}. To fix: ${setupCommand(marker.user, marker.owner)}`,
|
|
219
219
|
)
|
|
220
220
|
}
|
|
221
221
|
log(`steps run as OS user ${marker.user} in ${staged}`)
|
package/src/isolation/image.ts
CHANGED
|
@@ -40,7 +40,7 @@ export const MIRAB_RELEASE: string = (() => {
|
|
|
40
40
|
}
|
|
41
41
|
})()
|
|
42
42
|
|
|
43
|
-
/** Where the guest kernel, rootfs
|
|
43
|
+
/** Where the guest kernel, rootfs, (macOS) vz helper and macOS guest agent live, published or
|
|
44
44
|
* built by `bun run vm:image`; `vm` uses them when isolation.json names no
|
|
45
45
|
* other files. */
|
|
46
46
|
export function vmImagePaths(configDir: string) {
|
|
@@ -50,6 +50,8 @@ export function vmImagePaths(configDir: string) {
|
|
|
50
50
|
kernel: join(dir, 'vmlinux'),
|
|
51
51
|
rootfs: join(dir, 'rootfs.ext4'),
|
|
52
52
|
vz: join(dir, 'mirab-vz'),
|
|
53
|
+
/** The guest agent macOS guests run (mirab image pull macos installs it). */
|
|
54
|
+
agent: join(dir, 'mirab-guest-agent-macos'),
|
|
53
55
|
record: join(dir, 'image.json'),
|
|
54
56
|
}
|
|
55
57
|
}
|
|
@@ -58,10 +60,15 @@ export function vmImagePaths(configDir: string) {
|
|
|
58
60
|
export const VM_PLATFORMS = {
|
|
59
61
|
'linux-x64': { kernel: 'vmlinux', rootfs: 'rootfs.ext4' },
|
|
60
62
|
'darwin-x64': { kernel: 'bzImage', rootfs: 'rootfs.ext4', vz: 'mirab-vz' },
|
|
61
|
-
'darwin-arm64': {
|
|
63
|
+
'darwin-arm64': {
|
|
64
|
+
kernel: 'Image',
|
|
65
|
+
rootfs: 'rootfs.ext4',
|
|
66
|
+
vz: 'mirab-vz',
|
|
67
|
+
agent: 'mirab-guest-agent',
|
|
68
|
+
},
|
|
62
69
|
} as const
|
|
63
70
|
export type VmPlatform = keyof typeof VM_PLATFORMS
|
|
64
|
-
export type VmRole = 'kernel' | 'rootfs' | 'vz'
|
|
71
|
+
export type VmRole = 'kernel' | 'rootfs' | 'vz' | 'agent'
|
|
65
72
|
|
|
66
73
|
export const vmPlatform = (
|
|
67
74
|
platform: string = process.platform,
|
|
@@ -93,7 +100,12 @@ export const vmManifestSchema = z.object({
|
|
|
93
100
|
.object({ kernel: entry, rootfs: entry, vz: entry })
|
|
94
101
|
.optional(),
|
|
95
102
|
'darwin-arm64': z
|
|
96
|
-
.object({
|
|
103
|
+
.object({
|
|
104
|
+
kernel: entry,
|
|
105
|
+
rootfs: entry,
|
|
106
|
+
vz: entry,
|
|
107
|
+
agent: entry.optional(),
|
|
108
|
+
})
|
|
97
109
|
.optional(),
|
|
98
110
|
}),
|
|
99
111
|
})
|
|
@@ -131,6 +143,12 @@ function readRecord(configDir: string): ImageRecord | 'local' | undefined {
|
|
|
131
143
|
|
|
132
144
|
const rolesOf = (platform: VmPlatform) =>
|
|
133
145
|
Object.keys(VM_PLATFORMS[platform]) as VmRole[]
|
|
146
|
+
/** Roles a release may lack: releases before macOS guests have no agent,
|
|
147
|
+
* and their image still runs Linux VMs (`mirab image pull macos` says why it
|
|
148
|
+
* can't go on). */
|
|
149
|
+
const OPTIONAL_ROLES: VmRole[] = ['agent']
|
|
150
|
+
const requiredRoles = (platform: VmPlatform) =>
|
|
151
|
+
rolesOf(platform).filter((r) => !OPTIONAL_ROLES.includes(r))
|
|
134
152
|
|
|
135
153
|
/** The published image for this CLI version and platform is installed and intact (cheap: sizes). */
|
|
136
154
|
export function releaseImageInstalled(
|
|
@@ -147,7 +165,7 @@ export function releaseImageInstalled(
|
|
|
147
165
|
)
|
|
148
166
|
return false
|
|
149
167
|
const paths = vmImagePaths(configDir)
|
|
150
|
-
return
|
|
168
|
+
return requiredRoles(platform).every((role) => {
|
|
151
169
|
try {
|
|
152
170
|
return statSync(paths[role]).size === record.files[role]?.size
|
|
153
171
|
} catch {
|
|
@@ -224,12 +242,12 @@ export async function ensureVmImage(
|
|
|
224
242
|
)
|
|
225
243
|
const files = manifest.platforms[platform] as
|
|
226
244
|
Partial<Record<VmRole, VmFileEntry>> | undefined
|
|
227
|
-
|
|
228
|
-
if (!files || roles.some((r) => !files[r]))
|
|
245
|
+
if (!files || requiredRoles(platform).some((r) => !files[r]))
|
|
229
246
|
throw new VmImageError(
|
|
230
247
|
`the VM image manifest ${url} has no complete ${platform} image`,
|
|
231
248
|
)
|
|
232
249
|
|
|
250
|
+
const roles = rolesOf(platform).filter((r) => files[r])
|
|
233
251
|
const paths = vmImagePaths(o.configDir)
|
|
234
252
|
await mkdir(paths.dir, { recursive: true, mode: 0o700 })
|
|
235
253
|
const stage = await mkdtemp(join(paths.dir, '.download-'))
|
|
@@ -247,7 +265,10 @@ export async function ensureVmImage(
|
|
|
247
265
|
log,
|
|
248
266
|
)
|
|
249
267
|
for (const role of roles) {
|
|
250
|
-
await chmod(
|
|
268
|
+
await chmod(
|
|
269
|
+
join(stage, role),
|
|
270
|
+
role === 'vz' || role === 'agent' ? 0o755 : 0o644,
|
|
271
|
+
)
|
|
251
272
|
await rename(join(stage, role), paths[role])
|
|
252
273
|
}
|
|
253
274
|
const record: ImageRecord = {
|
package/src/isolation/index.ts
CHANGED
|
@@ -79,6 +79,9 @@ export const hostConfigSchema = (configDir: string) =>
|
|
|
79
79
|
memoryMiB: z.number().int().min(128).default(1024),
|
|
80
80
|
vcpus: z.number().int().min(1).default(2),
|
|
81
81
|
egress: z.enum(['auto', 'block', 'allow']).default('auto'),
|
|
82
|
+
/** macOS guests (Apple silicon): a provisioned base directory;
|
|
83
|
+
* by default the newest one `mirab image pull macos` made. */
|
|
84
|
+
macos: z.string().optional(),
|
|
82
85
|
})
|
|
83
86
|
.optional(),
|
|
84
87
|
container: z
|
|
@@ -127,16 +130,29 @@ export function startVm(
|
|
|
127
130
|
platform: NodeJS.Platform = process.platform,
|
|
128
131
|
opts: VmStartOptions = {},
|
|
129
132
|
): Promise<Isolation> {
|
|
133
|
+
// `vm.macos` names where the host's macOS base is; a VM boots macOS only
|
|
134
|
+
// when its caller asks for it (opts.macos).
|
|
135
|
+
const { macos: _base, ...linux } = vm
|
|
130
136
|
if (vmBackend(platform) === 'vz')
|
|
131
137
|
return startVz(
|
|
132
|
-
|
|
138
|
+
opts.macos
|
|
139
|
+
? {
|
|
140
|
+
...linux,
|
|
141
|
+
helper: vm.vz,
|
|
142
|
+
port: 9002,
|
|
143
|
+
macos: opts.macos,
|
|
144
|
+
rootfs: join(opts.macos, 'disk.img'),
|
|
145
|
+
}
|
|
146
|
+
: { ...linux, helper: vm.vz, port: 9002 },
|
|
133
147
|
work,
|
|
134
148
|
vmDir,
|
|
135
149
|
log,
|
|
136
150
|
undefined,
|
|
137
151
|
opts,
|
|
138
152
|
)
|
|
139
|
-
|
|
153
|
+
if (opts.macos)
|
|
154
|
+
throw new Error('macOS guests need a Mac with Apple silicon (vz)')
|
|
155
|
+
const cfg: FirecrackerConfig = { ...linux, port: 9002 }
|
|
140
156
|
return startFirecracker(cfg, work, vmDir, log, opts)
|
|
141
157
|
}
|
|
142
158
|
|
|
@@ -381,7 +397,7 @@ export async function startIsolation(
|
|
|
381
397
|
if (!colimaMountFor(work, colima.mounts, true))
|
|
382
398
|
throw new Error(
|
|
383
399
|
`container: ${work} is not inside a writable Colima mount (${colima.mounts.map((m) => m.location).join(', ')}). ` +
|
|
384
|
-
`
|
|
400
|
+
`To fix: add it under mounts in ~/.colima/default/colima.yaml, then colima restart`,
|
|
385
401
|
)
|
|
386
402
|
return startOci(
|
|
387
403
|
{ ...c, host: colima.socket, where: `Colima ${colima.vmType} VM` },
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// `sudo mirab vm network setup --user <name>` on Linux: the one-time owner
|
|
2
|
+
// step that gives Firecracker guests filtered egress, from the released binary
|
|
3
|
+
// instead of a loose script (the macOS twin is vz-network-setup.ts).
|
|
4
|
+
//
|
|
5
|
+
// Setup writes three root-owned files whose content this binary generates
|
|
6
|
+
// (nothing comes from a user-writable checkout), then runs them:
|
|
7
|
+
// 1. /etc/mirab/vm-network.nft: table `inet mirab_vm`. Guests reach the
|
|
8
|
+
// internet; never the host, 10/8, 172.16/12, 192.168/16, the tailnet
|
|
9
|
+
// (100.64/10), link-local (169.254/16, the metadata address), 127/8,
|
|
10
|
+
// multicast, IPv6, or each other. Loaded before any bridge or tap exists,
|
|
11
|
+
// so a broken rule set never leaves a device to leak through.
|
|
12
|
+
// 2. /usr/local/libexec/mirab-vm-network: `up` / `down` with plain ip, nft
|
|
13
|
+
// and sysctl calls: bridge mirabbr0 (172.31.250.1/24), N isolated taps
|
|
14
|
+
// owned by <user>, and Docker's mirab-egress network when Docker is there.
|
|
15
|
+
// 3. /etc/systemd/system/mirab-vm-network.service: runs `up` at boot.
|
|
16
|
+
// The taps belong to one user: the one whose VMs use them (woreda's service
|
|
17
|
+
// user, or a person running `mirab spawn --vm`). networkState reads them.
|
|
18
|
+
import { execFileSync } from 'node:child_process'
|
|
19
|
+
import {
|
|
20
|
+
chmodSync,
|
|
21
|
+
chownSync,
|
|
22
|
+
existsSync,
|
|
23
|
+
mkdirSync,
|
|
24
|
+
renameSync,
|
|
25
|
+
rmSync,
|
|
26
|
+
writeFileSync,
|
|
27
|
+
} from 'node:fs'
|
|
28
|
+
import { dirname } from 'node:path'
|
|
29
|
+
import { BRIDGE } from './network'
|
|
30
|
+
|
|
31
|
+
export const NFT_FILE = '/etc/mirab/vm-network.nft'
|
|
32
|
+
export const UP_SCRIPT = '/usr/local/libexec/mirab-vm-network'
|
|
33
|
+
export const UNIT_NAME = 'mirab-vm-network.service'
|
|
34
|
+
export const UNIT_FILE = `/etc/systemd/system/${UNIT_NAME}`
|
|
35
|
+
export const SYSCTL_FILE = '/etc/sysctl.d/90-mirab-vm.conf'
|
|
36
|
+
export const DEFAULT_SLOTS = 16
|
|
37
|
+
|
|
38
|
+
/** Everything a guest must never reach. */
|
|
39
|
+
export const DENIED4 = [
|
|
40
|
+
'0.0.0.0/8',
|
|
41
|
+
'10.0.0.0/8',
|
|
42
|
+
'100.64.0.0/10', // the tailnet
|
|
43
|
+
'127.0.0.0/8',
|
|
44
|
+
'169.254.0.0/16', // link-local, the cloud metadata address
|
|
45
|
+
'172.16.0.0/12',
|
|
46
|
+
'192.168.0.0/16',
|
|
47
|
+
'224.0.0.0/4',
|
|
48
|
+
'240.0.0.0/4',
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
export interface LinuxNetworkFile {
|
|
52
|
+
path: string
|
|
53
|
+
mode: number
|
|
54
|
+
content: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface LinuxNetworkPlan {
|
|
58
|
+
user: string
|
|
59
|
+
slots: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A user name the generated script can carry without quoting games. */
|
|
63
|
+
const USER = /^[a-z_][a-z0-9_-]{0,31}$/
|
|
64
|
+
|
|
65
|
+
export function checkPlan(p: LinuxNetworkPlan) {
|
|
66
|
+
if (!USER.test(p.user) || p.user === 'root')
|
|
67
|
+
throw new Error(
|
|
68
|
+
`--user ${p.user}: give the (non-root) user whose VMs use the network, e.g. --user woreda`,
|
|
69
|
+
)
|
|
70
|
+
if (!Number.isInteger(p.slots) || p.slots < 1 || p.slots > 256)
|
|
71
|
+
throw new Error(`--slots ${p.slots}: a whole number from 1 to 256`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The files setup writes, in the order it writes them. */
|
|
75
|
+
export function linuxNetworkFiles(p: LinuxNetworkPlan): LinuxNetworkFile[] {
|
|
76
|
+
checkPlan(p)
|
|
77
|
+
const bridges = `{ "${BRIDGE}", "mirabbr1" }`
|
|
78
|
+
return [
|
|
79
|
+
{
|
|
80
|
+
path: SYSCTL_FILE,
|
|
81
|
+
mode: 0o644,
|
|
82
|
+
content: 'net.ipv4.ip_forward=1\n',
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
path: NFT_FILE,
|
|
86
|
+
mode: 0o644,
|
|
87
|
+
content: `# mirab microVMs and containers: internet yes; host, LAN, tailnet, metadata and each other no.
|
|
88
|
+
table inet mirab_vm {
|
|
89
|
+
set denied4 {
|
|
90
|
+
type ipv4_addr
|
|
91
|
+
flags interval
|
|
92
|
+
elements = { ${DENIED4.join(', ')} }
|
|
93
|
+
}
|
|
94
|
+
# Guests never reach the host itself (no DNS, no services, no ICMP).
|
|
95
|
+
chain input {
|
|
96
|
+
type filter hook input priority -10; policy accept;
|
|
97
|
+
iifname ${bridges} drop
|
|
98
|
+
}
|
|
99
|
+
chain forward {
|
|
100
|
+
type filter hook forward priority -10; policy accept;
|
|
101
|
+
iifname ${bridges} meta nfproto ipv6 drop
|
|
102
|
+
iifname ${bridges} ip daddr @denied4 drop
|
|
103
|
+
oifname ${bridges} ct state established,related accept
|
|
104
|
+
oifname ${bridges} drop
|
|
105
|
+
}
|
|
106
|
+
chain postrouting {
|
|
107
|
+
type nat hook postrouting priority 100; policy accept;
|
|
108
|
+
iifname ${bridges} oifname != ${bridges} masquerade
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
`,
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
path: UP_SCRIPT,
|
|
115
|
+
mode: 0o755,
|
|
116
|
+
content: `#!/bin/sh
|
|
117
|
+
# Written by \`mirab vm network setup\` (do not edit; rerun setup instead).
|
|
118
|
+
# up: rules first, then mirabbr0 and ${p.slots} isolated taps owned by ${p.user}. down: all of it gone.
|
|
119
|
+
set -eu
|
|
120
|
+
PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
|
121
|
+
case "\${1:-up}" in
|
|
122
|
+
up)
|
|
123
|
+
sysctl -qw net.ipv4.ip_forward=1
|
|
124
|
+
nft delete table inet mirab_vm 2>/dev/null || true
|
|
125
|
+
nft -f ${NFT_FILE}
|
|
126
|
+
ip link show ${BRIDGE} >/dev/null 2>&1 || ip link add ${BRIDGE} type bridge
|
|
127
|
+
ip addr replace 172.31.250.1/24 dev ${BRIDGE}
|
|
128
|
+
ip link set ${BRIDGE} up
|
|
129
|
+
i=0
|
|
130
|
+
while [ $i -lt ${p.slots} ]; do
|
|
131
|
+
t=mirabtap$i
|
|
132
|
+
# A tap left by another owner is recreated for ${p.user}.
|
|
133
|
+
if ip link show "$t" >/dev/null 2>&1 && [ "$(cat /sys/class/net/$t/owner 2>/dev/null)" != "$(id -u ${p.user})" ]; then ip link del "$t"; fi
|
|
134
|
+
ip link show "$t" >/dev/null 2>&1 || ip tuntap add dev "$t" mode tap user ${p.user}
|
|
135
|
+
ip link set "$t" master ${BRIDGE}
|
|
136
|
+
bridge link set dev "$t" isolated on
|
|
137
|
+
ip link set "$t" up
|
|
138
|
+
i=$((i + 1))
|
|
139
|
+
done
|
|
140
|
+
if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
|
141
|
+
docker network inspect mirab-egress >/dev/null 2>&1 || docker network create --driver bridge \\
|
|
142
|
+
--subnet 172.31.249.0/24 -o com.docker.network.bridge.name=mirabbr1 \\
|
|
143
|
+
-o com.docker.network.bridge.enable_icc=false mirab-egress >/dev/null
|
|
144
|
+
fi
|
|
145
|
+
# Docker's FORWARD policy is DROP: let the VM bridge through (the nft drops
|
|
146
|
+
# above still win; they run first and a drop is final).
|
|
147
|
+
if command -v iptables >/dev/null 2>&1 && iptables -n -L DOCKER-USER >/dev/null 2>&1; then
|
|
148
|
+
iptables -C DOCKER-USER -i ${BRIDGE} -j ACCEPT 2>/dev/null || iptables -I DOCKER-USER -i ${BRIDGE} -j ACCEPT
|
|
149
|
+
iptables -C DOCKER-USER -o ${BRIDGE} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null ||
|
|
150
|
+
iptables -I DOCKER-USER -o ${BRIDGE} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
|
151
|
+
fi
|
|
152
|
+
;;
|
|
153
|
+
down)
|
|
154
|
+
nft delete table inet mirab_vm 2>/dev/null || true
|
|
155
|
+
if command -v iptables >/dev/null 2>&1; then
|
|
156
|
+
iptables -D DOCKER-USER -i ${BRIDGE} -j ACCEPT 2>/dev/null || true
|
|
157
|
+
iptables -D DOCKER-USER -o ${BRIDGE} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true
|
|
158
|
+
fi
|
|
159
|
+
for t in /sys/class/net/mirabtap*; do [ -e "$t" ] && ip link del "\${t##*/}"; done
|
|
160
|
+
ip link del ${BRIDGE} 2>/dev/null || true
|
|
161
|
+
if command -v docker >/dev/null 2>&1; then docker network rm mirab-egress >/dev/null 2>&1 || true; fi
|
|
162
|
+
;;
|
|
163
|
+
*) echo "usage: $0 up|down" >&2; exit 2 ;;
|
|
164
|
+
esac
|
|
165
|
+
`,
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
path: UNIT_FILE,
|
|
169
|
+
mode: 0o644,
|
|
170
|
+
content: `[Unit]
|
|
171
|
+
Description=mirab microVM network (bridge ${BRIDGE}, ${p.slots} taps for ${p.user}, filtered egress)
|
|
172
|
+
After=network-online.target docker.service
|
|
173
|
+
Wants=network-online.target
|
|
174
|
+
[Service]
|
|
175
|
+
Type=oneshot
|
|
176
|
+
RemainAfterExit=yes
|
|
177
|
+
ExecStart=${UP_SCRIPT} up
|
|
178
|
+
ExecStop=${UP_SCRIPT} down
|
|
179
|
+
[Install]
|
|
180
|
+
WantedBy=multi-user.target
|
|
181
|
+
`,
|
|
182
|
+
},
|
|
183
|
+
]
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** What setup and remove touch on the machine; tests pass their own. */
|
|
187
|
+
export interface LinuxSetupSystem {
|
|
188
|
+
isRoot(): boolean
|
|
189
|
+
platform: NodeJS.Platform
|
|
190
|
+
/** NixOS: /etc is generated from the system configuration (read-only). */
|
|
191
|
+
nixos: boolean
|
|
192
|
+
/** Whether a user exists (setup refuses a typo). */
|
|
193
|
+
userExists(name: string): boolean
|
|
194
|
+
/** A command on PATH, for the prerequisites check. */
|
|
195
|
+
has(command: string): boolean
|
|
196
|
+
/** Runs a command; throws when it fails. */
|
|
197
|
+
run(argv: string[]): void
|
|
198
|
+
/** Atomically installs a root:root file with this mode. */
|
|
199
|
+
install(file: LinuxNetworkFile): void
|
|
200
|
+
remove(path: string): void
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export const realLinuxSystem: LinuxSetupSystem = {
|
|
204
|
+
isRoot: () => process.getuid?.() === 0,
|
|
205
|
+
platform: process.platform,
|
|
206
|
+
nixos: existsSync('/etc/NIXOS'),
|
|
207
|
+
userExists(name) {
|
|
208
|
+
try {
|
|
209
|
+
execFileSync('id', ['-u', name], { stdio: 'ignore' })
|
|
210
|
+
return true
|
|
211
|
+
} catch {
|
|
212
|
+
return false
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
has(command) {
|
|
216
|
+
try {
|
|
217
|
+
execFileSync('sh', ['-c', `command -v ${command}`], { stdio: 'ignore' })
|
|
218
|
+
return true
|
|
219
|
+
} catch {
|
|
220
|
+
return false
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
run(argv) {
|
|
224
|
+
execFileSync(argv[0]!, argv.slice(1), { stdio: 'inherit' })
|
|
225
|
+
},
|
|
226
|
+
install({ path, mode, content }) {
|
|
227
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o755 })
|
|
228
|
+
const tmp = `${path}.new`
|
|
229
|
+
writeFileSync(tmp, content, { mode })
|
|
230
|
+
chmodSync(tmp, mode)
|
|
231
|
+
chownSync(tmp, 0, 0)
|
|
232
|
+
renameSync(tmp, path)
|
|
233
|
+
},
|
|
234
|
+
remove(path) {
|
|
235
|
+
rmSync(path, { force: true })
|
|
236
|
+
},
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function precheck(sys: LinuxSetupSystem, dryRun: boolean, verb: string) {
|
|
240
|
+
if (sys.platform !== 'linux')
|
|
241
|
+
throw new Error('this is the Linux (Firecracker) network setup')
|
|
242
|
+
if (sys.nixos)
|
|
243
|
+
throw new Error(
|
|
244
|
+
'on NixOS, /etc comes from your configuration: enable the network declaratively with the mirab NixOS module (services.mirab-vm-network) instead',
|
|
245
|
+
)
|
|
246
|
+
if (!dryRun && !sys.isRoot())
|
|
247
|
+
throw new Error(
|
|
248
|
+
`run with sudo: sudo mirab vm network ${verb}${verb === 'setup' ? ' --user <name>' : ''} (or add --dry-run to see what it does)`,
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Installs the network for `user`'s VMs, or prints what it would do. Safe to run again. */
|
|
253
|
+
export function setupLinuxNetwork(
|
|
254
|
+
opts: {
|
|
255
|
+
user?: string
|
|
256
|
+
slots?: number
|
|
257
|
+
dryRun?: boolean
|
|
258
|
+
log?: (line: string) => void
|
|
259
|
+
},
|
|
260
|
+
sys: LinuxSetupSystem = realLinuxSystem,
|
|
261
|
+
) {
|
|
262
|
+
const dryRun = !!opts.dryRun
|
|
263
|
+
const log = opts.log ?? console.log
|
|
264
|
+
precheck(sys, dryRun, 'setup')
|
|
265
|
+
const user = opts.user ?? process.env.SUDO_USER
|
|
266
|
+
if (!user)
|
|
267
|
+
throw new Error(
|
|
268
|
+
'give the user whose VMs use the network: sudo mirab vm network setup --user <name>',
|
|
269
|
+
)
|
|
270
|
+
const plan = { user, slots: opts.slots ?? DEFAULT_SLOTS }
|
|
271
|
+
checkPlan(plan)
|
|
272
|
+
if (!sys.userExists(user))
|
|
273
|
+
throw new Error(`no user ${user} on this machine (create it first)`)
|
|
274
|
+
const missing = ['ip', 'nft', 'bridge', 'systemctl'].filter(
|
|
275
|
+
(c) => !sys.has(c),
|
|
276
|
+
)
|
|
277
|
+
if (missing.length)
|
|
278
|
+
throw new Error(
|
|
279
|
+
`missing ${missing.join(', ')}: install iproute2 and nftables (systemd is required)`,
|
|
280
|
+
)
|
|
281
|
+
const files = linuxNetworkFiles(plan)
|
|
282
|
+
const step = (what: string, act: () => void) =>
|
|
283
|
+
dryRun ? log(`+ ${what}`) : act()
|
|
284
|
+
for (const f of files) {
|
|
285
|
+
if (dryRun) log(`--- ${f.path} (${f.mode.toString(8)})\n${f.content}`)
|
|
286
|
+
else sys.install(f)
|
|
287
|
+
}
|
|
288
|
+
// A broken rule set must never be half-applied: check it before `up` loads it.
|
|
289
|
+
step(`nft -c -f ${NFT_FILE}`, () => sys.run(['nft', '-c', '-f', NFT_FILE]))
|
|
290
|
+
step('systemctl daemon-reload', () => sys.run(['systemctl', 'daemon-reload']))
|
|
291
|
+
// restart, not start: a rerun (new user, more slots) applies now.
|
|
292
|
+
step(
|
|
293
|
+
`systemctl enable ${UNIT_NAME} && systemctl restart ${UNIT_NAME}`,
|
|
294
|
+
() => {
|
|
295
|
+
sys.run(['systemctl', 'enable', UNIT_NAME])
|
|
296
|
+
sys.run(['systemctl', 'restart', UNIT_NAME])
|
|
297
|
+
},
|
|
298
|
+
)
|
|
299
|
+
log(
|
|
300
|
+
`${dryRun ? 'would install' : 'installed'}: mirab VM network (${plan.slots} taps for ${user}, filtered egress), kept across reboots by ${UNIT_NAME}. Check with: mirab vm network status`,
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Undoes setup: the devices and rules first, then the files. */
|
|
305
|
+
export function removeLinuxNetwork(
|
|
306
|
+
opts: { dryRun?: boolean; log?: (line: string) => void },
|
|
307
|
+
sys: LinuxSetupSystem = realLinuxSystem,
|
|
308
|
+
) {
|
|
309
|
+
const dryRun = !!opts.dryRun
|
|
310
|
+
const log = opts.log ?? console.log
|
|
311
|
+
precheck(sys, dryRun, 'remove')
|
|
312
|
+
const step = (what: string, act: () => void) =>
|
|
313
|
+
dryRun ? log(`+ ${what}`) : act()
|
|
314
|
+
step(`systemctl disable --now ${UNIT_NAME}`, () => {
|
|
315
|
+
try {
|
|
316
|
+
sys.run(['systemctl', 'disable', '--now', UNIT_NAME])
|
|
317
|
+
} catch {
|
|
318
|
+
// never installed, or already stopped
|
|
319
|
+
}
|
|
320
|
+
})
|
|
321
|
+
step(`${UP_SCRIPT} down`, () => {
|
|
322
|
+
try {
|
|
323
|
+
sys.run([UP_SCRIPT, 'down'])
|
|
324
|
+
} catch {
|
|
325
|
+
// the script is gone already
|
|
326
|
+
}
|
|
327
|
+
})
|
|
328
|
+
step(`rm ${UNIT_FILE} ${UP_SCRIPT} ${NFT_FILE} ${SYSCTL_FILE}`, () => {
|
|
329
|
+
for (const f of [UNIT_FILE, UP_SCRIPT, NFT_FILE, SYSCTL_FILE]) sys.remove(f)
|
|
330
|
+
})
|
|
331
|
+
step('systemctl daemon-reload', () => sys.run(['systemctl', 'daemon-reload']))
|
|
332
|
+
log(
|
|
333
|
+
`${dryRun ? 'would remove' : 'removed'}: mirab VM network; VMs get no network until setup runs again`,
|
|
334
|
+
)
|
|
335
|
+
}
|