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.
Files changed (43) hide show
  1. package/README.md +12 -1
  2. package/package.json +2 -1
  3. package/scripts/firecracker.json +11 -0
  4. package/src/index.ts +4 -0
  5. package/src/isolation/colima.ts +1 -1
  6. package/src/isolation/container.ts +2 -2
  7. package/src/isolation/firecracker-install.ts +72 -0
  8. package/src/isolation/firecracker.ts +4 -0
  9. package/src/isolation/host-user.ts +2 -2
  10. package/src/isolation/image.ts +29 -8
  11. package/src/isolation/index.ts +19 -3
  12. package/src/isolation/linux-network-setup.ts +335 -0
  13. package/src/isolation/macos.ts +423 -0
  14. package/src/isolation/network.ts +3 -3
  15. package/src/isolation/session.ts +29 -5
  16. package/src/isolation/vz.ts +32 -11
  17. package/src/mirab-main.ts +101 -6
  18. package/src/progress-line.ts +85 -0
  19. package/src/runner/aws-key.ts +36 -0
  20. package/src/runner/budget.ts +125 -0
  21. package/src/runner/define.ts +61 -10
  22. package/src/runner/image.ts +176 -8
  23. package/src/runner/index.ts +13 -0
  24. package/src/runner/serve.ts +161 -58
  25. package/src/runner/toolsets.ts +102 -14
  26. package/types/index.d.ts +1 -1
  27. package/types/isolation/container.d.ts +1 -1
  28. package/types/isolation/firecracker-install.d.ts +11 -0
  29. package/types/isolation/firecracker.d.ts +4 -0
  30. package/types/isolation/image.d.ts +14 -2
  31. package/types/isolation/index.d.ts +1 -0
  32. package/types/isolation/linux-network-setup.d.ts +49 -0
  33. package/types/isolation/macos.d.ts +64 -0
  34. package/types/isolation/network.d.ts +1 -1
  35. package/types/isolation/session.d.ts +4 -0
  36. package/types/isolation/vz.d.ts +10 -2
  37. package/types/runner/aws-key.d.ts +2 -0
  38. package/types/runner/budget.d.ts +35 -0
  39. package/types/runner/define.d.ts +36 -6
  40. package/types/runner/image.d.ts +10 -2
  41. package/types/runner/index.d.ts +5 -1
  42. package/types/runner/serve.d.ts +20 -7
  43. 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.0",
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 {
@@ -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: `Needs Sami: ${COLIMA_INSTALL}`,
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. Needs Sami: ${setup}`,
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 = 'sudo scripts/vm-network-setup.sh'
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 = `Needs Sami: ${setupCommand(user, probe.me.name)}`
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}. Needs Sami: ${setupCommand(marker.user, marker.owner)}`,
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}`)
@@ -40,7 +40,7 @@ export const MIRAB_RELEASE: string = (() => {
40
40
  }
41
41
  })()
42
42
 
43
- /** Where the guest kernel, rootfs and (macOS) vz helper live, published or
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': { kernel: 'Image', rootfs: 'rootfs.ext4', vz: 'mirab-vz' },
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({ kernel: entry, rootfs: entry, vz: entry })
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 rolesOf(platform).every((role) => {
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
- const roles = rolesOf(platform)
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(join(stage, role), role === 'vz' ? 0o755 : 0o644)
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 = {
@@ -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
- { ...vm, helper: vm.vz, port: 9002 },
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
- const cfg: FirecrackerConfig = { ...vm, port: 9002 }
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
- `Needs Sami: add it under mounts in ~/.colima/default/colima.yaml, then colima restart`,
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
+ }