mirab 0.0.0 → 0.2.0
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/LICENSE +201 -0
- package/README.md +223 -3
- package/package.json +56 -7
- package/src/agent/approved.ts +261 -0
- package/src/agent/define.ts +235 -0
- package/src/agent/guest.ts +138 -0
- package/src/agent/init.ts +150 -0
- package/src/agent/manifest.ts +141 -0
- package/src/agent/modules.ts +23 -0
- package/src/agent/runtime.ts +239 -0
- package/src/agent-profiles.ts +327 -0
- package/src/api/bootstrap-contract.ts +95 -0
- package/src/api/channel-contract.ts +19 -0
- package/src/api/client.ts +100 -0
- package/src/api/contract.ts +2454 -0
- package/src/api/events-contract.ts +103 -0
- package/src/api/import-contract.ts +22 -0
- package/src/api/orchestrator-contract.ts +73 -0
- package/src/api/runner-contract.ts +25 -0
- package/src/api/scopes.ts +41 -0
- package/src/api/sessions-contract.ts +71 -0
- package/src/api/settings-contract.ts +50 -0
- package/src/channel/bridge.ts +97 -0
- package/src/channel/local.ts +154 -0
- package/src/channel/state.ts +11 -0
- package/src/channel/wire.ts +104 -0
- package/src/dev-sync.ts +206 -0
- package/src/dev.ts +453 -0
- package/src/events-tail.ts +75 -0
- package/src/images.ts +498 -0
- package/src/index.ts +21 -0
- package/src/invocation.ts +34 -0
- package/src/isolation/anchored-fs.ts +284 -0
- package/src/isolation/colima.ts +159 -0
- package/src/isolation/container.ts +189 -0
- package/src/isolation/firecracker.ts +414 -0
- package/src/isolation/guest-protocol.json +1 -0
- package/src/isolation/host-user.ts +247 -0
- package/src/isolation/image.ts +336 -0
- package/src/isolation/index.ts +477 -0
- package/src/isolation/multipass.ts +541 -0
- package/src/isolation/network.ts +167 -0
- package/src/isolation/policy.ts +86 -0
- package/src/isolation/session.ts +385 -0
- package/src/isolation/sync.ts +329 -0
- package/src/isolation/vz-network-setup.ts +247 -0
- package/src/isolation/vz.ts +312 -0
- package/src/mirab-main.ts +536 -0
- package/src/mirab.ts +13 -0
- package/src/runner/define.ts +187 -0
- package/src/runner/image.ts +239 -0
- package/src/runner/index.ts +37 -0
- package/src/runner/serve.ts +491 -0
- package/src/runner/toolsets.ts +205 -0
- package/src/spawn-adapters/claude.ts +112 -0
- package/src/spawn-adapters/codex-logs.ts +130 -0
- package/src/spawn-adapters/codex-notify.ts +73 -0
- package/src/spawn-adapters/codex.ts +89 -0
- package/src/spawn-adapters/index.ts +40 -0
- package/src/spawn-adapters/types.ts +55 -0
- package/src/spawn-attach.ts +243 -0
- package/src/spawn-binary.ts +90 -0
- package/src/spawn-bridge.ts +504 -0
- package/src/spawn-bundle.ts +16 -0
- package/src/spawn-detach.ts +301 -0
- package/src/spawn-events.ts +35 -0
- package/src/spawn-hook.ts +86 -0
- package/src/spawn-input.ts +107 -0
- package/src/spawn-native.ts +49 -0
- package/src/spawn-nudge-worker.ts +61 -0
- package/src/spawn-nudge.ts +446 -0
- package/src/spawn-output.ts +45 -0
- package/src/spawn-queue.ts +135 -0
- package/src/spawn-quiet.ts +69 -0
- package/src/spawn-report.ts +114 -0
- package/src/spawn-sessions.ts +166 -0
- package/src/spawn-vm.ts +356 -0
- package/src/spawn-worker.ts +766 -0
- package/src/spawn.ts +468 -0
- package/src/station/config.ts +89 -0
- package/src/station/gateway.ts +94 -0
- package/src/station/vault.ts +96 -0
- package/src/test-cli.ts +90 -0
- package/src/tools.ts +3 -0
- package/types/agent/define.d.ts +76 -0
- package/types/agent/manifest.d.ts +53 -0
- package/types/agent/modules.d.ts +1 -0
- package/types/images.d.ts +124 -0
- package/types/index.d.ts +6 -0
- package/types/isolation/colima.d.ts +37 -0
- package/types/isolation/container.d.ts +25 -0
- package/types/isolation/firecracker.d.ts +55 -0
- package/types/isolation/host-user.d.ts +47 -0
- package/types/isolation/image.d.ts +158 -0
- package/types/isolation/index.d.ts +128 -0
- package/types/isolation/network.d.ts +33 -0
- package/types/isolation/policy.d.ts +29 -0
- package/types/isolation/session.d.ts +99 -0
- package/types/isolation/vz.d.ts +63 -0
- package/types/runner/define.d.ts +101 -0
- package/types/runner/image.d.ts +52 -0
- package/types/runner/index.d.ts +13 -0
- package/types/runner/serve.d.ts +69 -0
- package/types/runner/toolsets.d.ts +35 -0
- package/types/tools.d.ts +2 -0
- package/index.js +0 -1
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// Working-directory sync for `mirab spawn --vm`. Stock Firecracker has no
|
|
2
|
+
// virtio-fs, so the guest works on a copy of the directory (an ext4 drive) and
|
|
3
|
+
// its changes come back to the host at exit and on demand (`mirab sync <id>`).
|
|
4
|
+
//
|
|
5
|
+
// The rule, per path, three-way against the baseline (the tree as it was copied
|
|
6
|
+
// into the guest, or as of the last sync):
|
|
7
|
+
// - the guest changed it and the host still matches the baseline: applied;
|
|
8
|
+
// - the host changed it too: the host copy is kept and the guest's version is
|
|
9
|
+
// saved under `conflictDir`, never merged and never silently dropped;
|
|
10
|
+
// - only the host changed it: untouched.
|
|
11
|
+
// The guest is untrusted: its paths are validated, nothing is written through a
|
|
12
|
+
// symlink or outside the root, and file bytes must match the hash it listed.
|
|
13
|
+
import { createHash } from 'node:crypto'
|
|
14
|
+
import { AnchoredDirectory } from './anchored-fs'
|
|
15
|
+
import { readdir } from 'node:fs/promises'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
import { mkdirSync } from 'node:fs'
|
|
18
|
+
import type { Entry, GuestFiles } from './session'
|
|
19
|
+
|
|
20
|
+
export type Manifest = Map<string, Entry>
|
|
21
|
+
export type RecoveryLocation = { directory: string; note: string }
|
|
22
|
+
|
|
23
|
+
export interface SyncResult {
|
|
24
|
+
written: string[]
|
|
25
|
+
deleted: string[]
|
|
26
|
+
conflicts: { path: string; reason: string; saved?: string }[]
|
|
27
|
+
/** What the agent changed about itself (its definition, `.env`): saved
|
|
28
|
+
* aside, never applied, for the person to review. */
|
|
29
|
+
held: { path: string; reason: string; saved?: string }[]
|
|
30
|
+
rejected: { path: string; reason: string }[]
|
|
31
|
+
/** Final VM sync only: private retained workspace and recovery note. */
|
|
32
|
+
recovery?: RecoveryLocation
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The host side of the guest agent's `list` op: same walk, same fields. */
|
|
36
|
+
export async function hostManifest(root: string): Promise<Manifest> {
|
|
37
|
+
const out: Manifest = new Map(),
|
|
38
|
+
anchored = AnchoredDirectory.open(root)
|
|
39
|
+
const walk = async (
|
|
40
|
+
directory: AnchoredDirectory,
|
|
41
|
+
path: string,
|
|
42
|
+
prefix: string,
|
|
43
|
+
) => {
|
|
44
|
+
for (const name of (await readdir(path)).sort()) {
|
|
45
|
+
const rel = prefix ? `${prefix}/${name}` : name
|
|
46
|
+
const e = directory.entry(name, rel, true)
|
|
47
|
+
if (!e) continue
|
|
48
|
+
out.set(rel, e)
|
|
49
|
+
if (e.k === 'd') {
|
|
50
|
+
const child = directory.directory(name)
|
|
51
|
+
try {
|
|
52
|
+
await walk(child, join(path, name), rel)
|
|
53
|
+
} finally {
|
|
54
|
+
child.close()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
await walk(anchored, root, '')
|
|
61
|
+
} finally {
|
|
62
|
+
anchored.close()
|
|
63
|
+
}
|
|
64
|
+
return out
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Equal for sync purposes: kind, content or link target, and permission bits. */
|
|
68
|
+
export function sameEntry(a?: Entry, b?: Entry) {
|
|
69
|
+
if (!a || !b) return a === b
|
|
70
|
+
if (a.k !== b.k) return false
|
|
71
|
+
if (a.k === 'l') return a.t === b.t
|
|
72
|
+
if ((a.m & 0o777) !== (b.m & 0o777)) return false
|
|
73
|
+
return a.k === 'd' || a.h === b.h
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function validPath(p: unknown): p is string {
|
|
77
|
+
return (
|
|
78
|
+
typeof p === 'string' &&
|
|
79
|
+
p.length > 0 &&
|
|
80
|
+
p.length < 4096 &&
|
|
81
|
+
!p.startsWith('/') &&
|
|
82
|
+
!p.includes('\0') &&
|
|
83
|
+
p.split('/').every((c) => c !== '' && c !== '.' && c !== '..')
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function validEntry(e: Entry) {
|
|
88
|
+
if (!validPath(e.p) || !Number.isInteger(e.m) || e.m < 0 || e.m > 0o7777)
|
|
89
|
+
return false
|
|
90
|
+
if (e.k === 'f')
|
|
91
|
+
return (
|
|
92
|
+
Number.isSafeInteger(e.s) &&
|
|
93
|
+
e.s! >= 0 &&
|
|
94
|
+
typeof e.h === 'string' &&
|
|
95
|
+
/^[0-9a-f]{64}$/.test(e.h)
|
|
96
|
+
)
|
|
97
|
+
if (e.k === 'l')
|
|
98
|
+
return typeof e.t === 'string' && e.t.length > 0 && !e.t.includes('\0')
|
|
99
|
+
return e.k === 'd'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** What an agent is, and what it may reach: its definition (`agent/`: the
|
|
103
|
+
* manifest's domains, packages and tools, its instructions) and the
|
|
104
|
+
* project's `.env` (which secrets it names). A VM's change to these never
|
|
105
|
+
* lands on its own: it would be the agent widening itself for its next
|
|
106
|
+
* session. Held aside for the person instead. */
|
|
107
|
+
export function selfDefinition(path: string) {
|
|
108
|
+
// Case-folded: on a Mac's disk Agent/ and .ENV are agent/ and .env. Every
|
|
109
|
+
// .env file, since tools read .env.local and the like too.
|
|
110
|
+
const first = path.split('/')[0].normalize('NFC').toLowerCase()
|
|
111
|
+
return first === 'agent' || first.startsWith('.env')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Guest Git state is never adopted as trusted host repository metadata. */
|
|
115
|
+
export function protectedMetadata(path: string) {
|
|
116
|
+
return path.split('/').some((part) => part.toLowerCase() === '.git')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface SyncOptions {
|
|
120
|
+
/** Host directory the guest's copy came from. */
|
|
121
|
+
root: string
|
|
122
|
+
/** Where the guest sees it. */
|
|
123
|
+
guestRoot: string
|
|
124
|
+
files: GuestFiles
|
|
125
|
+
/** Updated only for applied/resolved changes; conflicts stay pending. */
|
|
126
|
+
baseline: Manifest
|
|
127
|
+
/** Conflicting bytes and adjacent metadata are saved outside the root. */
|
|
128
|
+
conflictDir: string
|
|
129
|
+
/** Refuse a sync that would copy more than this many bytes. */
|
|
130
|
+
maxBytes?: number
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function syncBack(o: SyncOptions): Promise<SyncResult> {
|
|
134
|
+
const result: SyncResult = {
|
|
135
|
+
written: [],
|
|
136
|
+
deleted: [],
|
|
137
|
+
conflicts: [],
|
|
138
|
+
held: [],
|
|
139
|
+
rejected: [],
|
|
140
|
+
}
|
|
141
|
+
const guest: Manifest = new Map()
|
|
142
|
+
for (const e of await o.files.list(o.guestRoot)) {
|
|
143
|
+
// mke2fs puts lost+found at the root of the copy; it was never the user's.
|
|
144
|
+
if (
|
|
145
|
+
!o.baseline.has('lost+found') &&
|
|
146
|
+
(e.p === 'lost+found' || e.p?.startsWith?.('lost+found/'))
|
|
147
|
+
)
|
|
148
|
+
continue
|
|
149
|
+
if (!validEntry(e)) {
|
|
150
|
+
result.rejected.push({
|
|
151
|
+
path: String(e.p),
|
|
152
|
+
reason: 'invalid entry from the guest',
|
|
153
|
+
})
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
guest.set(e.p, e)
|
|
157
|
+
}
|
|
158
|
+
const changed = [...new Set([...o.baseline.keys(), ...guest.keys()])].filter(
|
|
159
|
+
(p) => {
|
|
160
|
+
if (sameEntry(o.baseline.get(p), guest.get(p))) return false
|
|
161
|
+
if (!validPath(p) || protectedMetadata(p)) {
|
|
162
|
+
result.rejected.push({
|
|
163
|
+
path: p,
|
|
164
|
+
reason:
|
|
165
|
+
'host Git metadata is protected; retained in the guest workspace',
|
|
166
|
+
})
|
|
167
|
+
return false
|
|
168
|
+
}
|
|
169
|
+
return true
|
|
170
|
+
},
|
|
171
|
+
)
|
|
172
|
+
const bytes = changed.reduce((n, p) => {
|
|
173
|
+
const e = guest.get(p)
|
|
174
|
+
return n + (e?.k === 'f' ? (e.s ?? 0) : 0)
|
|
175
|
+
}, 0)
|
|
176
|
+
if (!Number.isSafeInteger(bytes) || bytes > (o.maxBytes ?? 2 * 1024 ** 3))
|
|
177
|
+
throw new Error(`sync would copy ${bytes} bytes; refusing`)
|
|
178
|
+
// Deletions deepest first, then creations and updates parents first.
|
|
179
|
+
const deletions = changed
|
|
180
|
+
.filter((p) => !guest.has(p))
|
|
181
|
+
.sort()
|
|
182
|
+
.reverse()
|
|
183
|
+
const updates = changed.filter((p) => guest.has(p)).sort()
|
|
184
|
+
|
|
185
|
+
const root = AnchoredDirectory.open(o.root)
|
|
186
|
+
const fetch = async (rel: string, after: Entry) => {
|
|
187
|
+
const data = await o.files.read(`${o.guestRoot}/${rel}`)
|
|
188
|
+
if (
|
|
189
|
+
data.length !== after.s ||
|
|
190
|
+
createHash('sha256').update(data).digest('hex') !== after.h
|
|
191
|
+
)
|
|
192
|
+
throw new Error('guest file changed while syncing')
|
|
193
|
+
return data
|
|
194
|
+
}
|
|
195
|
+
const conflict = (
|
|
196
|
+
rel: string,
|
|
197
|
+
reason: string,
|
|
198
|
+
after?: Entry,
|
|
199
|
+
data?: Buffer,
|
|
200
|
+
into: SyncResult['conflicts'] = result.conflicts,
|
|
201
|
+
) => {
|
|
202
|
+
let saved: string | undefined
|
|
203
|
+
if (after?.k === 'f' && data) {
|
|
204
|
+
// The private recovery root is host-owned. Child paths use the same
|
|
205
|
+
// anchored primitives as normal sync, never a guest-provided pathname.
|
|
206
|
+
mkdirSync(o.conflictDir, { recursive: true, mode: 0o700 })
|
|
207
|
+
const recovery = AnchoredDirectory.open(o.conflictDir)
|
|
208
|
+
let parent: AnchoredDirectory | undefined
|
|
209
|
+
try {
|
|
210
|
+
parent = recovery.parent(rel, true)
|
|
211
|
+
parent.put(rel.split('/').at(-1)!, { ...after, m: 0o600 }, data)
|
|
212
|
+
saved = join(o.conflictDir, rel)
|
|
213
|
+
} finally {
|
|
214
|
+
if (parent && parent !== recovery) parent.close()
|
|
215
|
+
recovery.close()
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// Never materialize a conflicting link/directory/deletion in recovery.
|
|
219
|
+
// Store its exact entry as private JSON, with a host-chosen hashed name.
|
|
220
|
+
const metadataDir = o.conflictDir + '.metadata'
|
|
221
|
+
mkdirSync(metadataDir, { recursive: true, mode: 0o700 })
|
|
222
|
+
const metadata = AnchoredDirectory.open(metadataDir)
|
|
223
|
+
const name = createHash('sha256').update(rel).digest('hex') + '.json'
|
|
224
|
+
try {
|
|
225
|
+
metadata.put(
|
|
226
|
+
name,
|
|
227
|
+
{ p: name, k: 'f', m: 0o600 },
|
|
228
|
+
Buffer.from(
|
|
229
|
+
JSON.stringify({ path: rel, reason, entry: after ?? null }) + '\n',
|
|
230
|
+
),
|
|
231
|
+
)
|
|
232
|
+
saved ??= join(metadataDir, name)
|
|
233
|
+
} finally {
|
|
234
|
+
metadata.close()
|
|
235
|
+
}
|
|
236
|
+
into.push({ path: rel, reason, ...(saved ? { saved } : {}) })
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
for (const rel of [...deletions, ...updates]) {
|
|
240
|
+
const before = o.baseline.get(rel),
|
|
241
|
+
after = guest.get(rel)
|
|
242
|
+
let parent: AnchoredDirectory | undefined
|
|
243
|
+
try {
|
|
244
|
+
// Finish asynchronous guest retrieval before opening/checking the host
|
|
245
|
+
// destination. All host validation and mutation below are synchronous
|
|
246
|
+
// and relative to directory descriptors, including temp and rename.
|
|
247
|
+
const data = after?.k === 'f' ? await fetch(rel, after) : undefined
|
|
248
|
+
// The agent's change to itself: saved aside, never applied; the
|
|
249
|
+
// baseline stays, so it is still pending next time.
|
|
250
|
+
if (selfDefinition(rel)) {
|
|
251
|
+
conflict(
|
|
252
|
+
rel,
|
|
253
|
+
'the agent changed its own definition; held for you to review',
|
|
254
|
+
after,
|
|
255
|
+
data,
|
|
256
|
+
result.held,
|
|
257
|
+
)
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
parent = root.parent(rel)
|
|
262
|
+
} catch {
|
|
263
|
+
conflict(rel, 'a parent is not a directory on the host', after, data)
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
const name = rel.split('/').at(-1)!
|
|
267
|
+
const current = parent.entry(name, rel)
|
|
268
|
+
if (sameEntry(current, after)) {
|
|
269
|
+
setBaseline(o.baseline, rel, after)
|
|
270
|
+
continue
|
|
271
|
+
}
|
|
272
|
+
if (!sameEntry(current, before)) {
|
|
273
|
+
conflict(rel, 'changed on the host and in the guest', after, data)
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
if (!after) {
|
|
277
|
+
parent.remove(name, current!.k === 'd')
|
|
278
|
+
result.deleted.push(rel)
|
|
279
|
+
} else if (after.k === 'd') {
|
|
280
|
+
if (current && current.k !== 'd') parent.remove(name, false)
|
|
281
|
+
const directory = parent.directory(name, true)
|
|
282
|
+
directory.close()
|
|
283
|
+
parent.mode(name, after.m & 0o777, true)
|
|
284
|
+
result.written.push(rel)
|
|
285
|
+
} else if (
|
|
286
|
+
after.k === 'f' &&
|
|
287
|
+
current?.k === 'f' &&
|
|
288
|
+
current.h === after.h
|
|
289
|
+
) {
|
|
290
|
+
parent.mode(name, after.m & 0o777, false)
|
|
291
|
+
result.written.push(rel)
|
|
292
|
+
} else {
|
|
293
|
+
parent.put(name, after, data, current?.k === 'd')
|
|
294
|
+
result.written.push(rel)
|
|
295
|
+
}
|
|
296
|
+
setBaseline(o.baseline, rel, after)
|
|
297
|
+
} catch (e) {
|
|
298
|
+
result.conflicts.push({
|
|
299
|
+
path: rel,
|
|
300
|
+
reason: `not applied: ${(e as Error).message}`,
|
|
301
|
+
})
|
|
302
|
+
} finally {
|
|
303
|
+
if (parent && parent !== root) parent.close()
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
} finally {
|
|
307
|
+
root.close()
|
|
308
|
+
}
|
|
309
|
+
return result
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function setBaseline(m: Manifest, rel: string, e?: Entry) {
|
|
313
|
+
if (e) m.set(rel, e)
|
|
314
|
+
else m.delete(rel)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function describeSync(r: SyncResult) {
|
|
318
|
+
const parts = [
|
|
319
|
+
`${r.written.length} written`,
|
|
320
|
+
`${r.deleted.length} deleted`,
|
|
321
|
+
`${r.conflicts.length} conflicts`,
|
|
322
|
+
]
|
|
323
|
+
if (r.held.length)
|
|
324
|
+
parts.push(
|
|
325
|
+
`${r.held.length} held (the agent changed its own definition: ${r.held.map((h) => h.saved ?? h.path).join(', ')})`,
|
|
326
|
+
)
|
|
327
|
+
if (r.rejected.length) parts.push(`${r.rejected.length} rejected`)
|
|
328
|
+
return parts.join(', ')
|
|
329
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// `sudo mirab vm network setup`: the one-time owner step that gives macOS `vz`
|
|
2
|
+
// guests filtered egress, from the released binary instead of a loose script.
|
|
3
|
+
//
|
|
4
|
+
// VZ NAT (vmnet shared mode) puts guests on a host bridge (192.168.64.0/24 by
|
|
5
|
+
// default) and NATs them out of the Mac, which also reaches the Mac itself,
|
|
6
|
+
// its LAN, the tailnet and link-local. Only pf (root) can deny that. Setup:
|
|
7
|
+
// 1. writes the pf anchor: guests may use the vmnet DNS forwarder on the
|
|
8
|
+
// gateway and DHCP, and nothing else in 10/8, 172.16/12, 192.168/16,
|
|
9
|
+
// 100.64/10, 169.254/16, 127/8 or multicast; the internet passes;
|
|
10
|
+
// 2. checks it with `pfctl -n` before anything loads it;
|
|
11
|
+
// 3. loads it under com.apple/mirab-vz (stock pf.conf already evaluates
|
|
12
|
+
// anchor "com.apple/*", so pf.conf is not edited) from a LaunchDaemon
|
|
13
|
+
// that runs only /sbin/pfctl, so it survives reboots;
|
|
14
|
+
// 4. writes the root-owned marker vzNetworkState reads (never pfctl) to
|
|
15
|
+
// decide that a VM may get a NAT device.
|
|
16
|
+
import { execFileSync } from 'node:child_process'
|
|
17
|
+
import {
|
|
18
|
+
chmodSync,
|
|
19
|
+
chownSync,
|
|
20
|
+
mkdirSync,
|
|
21
|
+
renameSync,
|
|
22
|
+
rmSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
} from 'node:fs'
|
|
25
|
+
import { dirname } from 'node:path'
|
|
26
|
+
import { VZ_LAN_DENIED, VZ_NETWORK_MARKER } from './vz'
|
|
27
|
+
|
|
28
|
+
export const VZ_ANCHOR = 'com.apple/mirab-vz'
|
|
29
|
+
export const VZ_ANCHOR_FILE = '/etc/pf.anchors/mirab-vz'
|
|
30
|
+
export const VZ_DAEMON_LABEL = 'gg.mirab.vz-net'
|
|
31
|
+
export const VZ_DAEMON_PLIST = `/Library/LaunchDaemons/${VZ_DAEMON_LABEL}.plist`
|
|
32
|
+
const VMNET_PLIST =
|
|
33
|
+
'/Library/Preferences/SystemConfiguration/com.apple.vmnet.plist'
|
|
34
|
+
|
|
35
|
+
/** Everything a guest must never reach through VZ NAT. */
|
|
36
|
+
export const DENIED_RANGES = [
|
|
37
|
+
'10.0.0.0/8',
|
|
38
|
+
'172.16.0.0/12',
|
|
39
|
+
'192.168.0.0/16',
|
|
40
|
+
'100.64.0.0/10', // the tailnet
|
|
41
|
+
'169.254.0.0/16',
|
|
42
|
+
'127.0.0.0/8',
|
|
43
|
+
'224.0.0.0/4',
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
export interface VmnetNetwork {
|
|
47
|
+
gateway: string
|
|
48
|
+
subnet: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 255.255.255.0 → 24. Refuses a mask that isn't contiguous ones then zeros. */
|
|
52
|
+
export function maskPrefix(mask: string): number {
|
|
53
|
+
const octets = mask.split('.').map(Number)
|
|
54
|
+
if (
|
|
55
|
+
octets.length !== 4 ||
|
|
56
|
+
octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)
|
|
57
|
+
)
|
|
58
|
+
throw new Error(`not a netmask: ${mask}`)
|
|
59
|
+
const bits = octets.map((o) => o.toString(2).padStart(8, '0')).join('')
|
|
60
|
+
if (!/^1*0*$/.test(bits)) throw new Error(`not a contiguous netmask: ${mask}`)
|
|
61
|
+
return bits.indexOf('0') === -1 ? 32 : bits.indexOf('0')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface VzNetworkFile {
|
|
65
|
+
path: string
|
|
66
|
+
mode: number
|
|
67
|
+
content: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The three files setup writes, in the order it writes them. */
|
|
71
|
+
export function vzNetworkFiles(net: VmnetNetwork): VzNetworkFile[] {
|
|
72
|
+
const { gateway: gw, subnet } = net
|
|
73
|
+
return [
|
|
74
|
+
{
|
|
75
|
+
path: VZ_ANCHOR_FILE,
|
|
76
|
+
mode: 0o644,
|
|
77
|
+
content: `# mirab vz guests (vmnet shared network ${subnet}): internet yes, LAN/host/metadata no.
|
|
78
|
+
pass in quick inet proto { tcp udp } from ${subnet} to ${gw} port 53
|
|
79
|
+
pass in quick inet proto udp from any port 68 to any port 67
|
|
80
|
+
block drop in quick inet from ${subnet} to { ${DENIED_RANGES.join(' ')} }
|
|
81
|
+
`,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
path: VZ_DAEMON_PLIST,
|
|
85
|
+
mode: 0o644,
|
|
86
|
+
content: `<?xml version="1.0" encoding="UTF-8"?>
|
|
87
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
88
|
+
<plist version="1.0"><dict>
|
|
89
|
+
<key>Label</key><string>${VZ_DAEMON_LABEL}</string>
|
|
90
|
+
<key>ProgramArguments</key><array>
|
|
91
|
+
<string>/sbin/pfctl</string><string>-E</string>
|
|
92
|
+
<string>-a</string><string>${VZ_ANCHOR}</string>
|
|
93
|
+
<string>-f</string><string>${VZ_ANCHOR_FILE}</string>
|
|
94
|
+
</array>
|
|
95
|
+
<key>RunAtLoad</key><true/>
|
|
96
|
+
</dict></plist>
|
|
97
|
+
`,
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
path: VZ_NETWORK_MARKER,
|
|
101
|
+
mode: 0o644,
|
|
102
|
+
content:
|
|
103
|
+
JSON.stringify({
|
|
104
|
+
version: 1,
|
|
105
|
+
network: VZ_LAN_DENIED,
|
|
106
|
+
gateway: gw,
|
|
107
|
+
subnet,
|
|
108
|
+
anchor: VZ_ANCHOR,
|
|
109
|
+
}) + '\n',
|
|
110
|
+
},
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** What setup and remove touch on the machine; tests pass their own. */
|
|
115
|
+
export interface VzSetupSystem {
|
|
116
|
+
isRoot(): boolean
|
|
117
|
+
platform: NodeJS.Platform
|
|
118
|
+
/** vmnet's configured shared network, if the owner changed it. */
|
|
119
|
+
readDefault(key: 'Shared_Net_Address' | 'Shared_Net_Mask'): string | undefined
|
|
120
|
+
/** Runs a command; throws when it fails. */
|
|
121
|
+
run(argv: string[]): void
|
|
122
|
+
/** Exit status of a command, for checks that may fail. */
|
|
123
|
+
succeeds(argv: string[]): boolean
|
|
124
|
+
/** Atomically installs a root:wheel file with this mode. */
|
|
125
|
+
install(file: VzNetworkFile): void
|
|
126
|
+
remove(path: string): void
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const realVzSystem: VzSetupSystem = {
|
|
130
|
+
isRoot: () => process.getuid?.() === 0,
|
|
131
|
+
platform: process.platform,
|
|
132
|
+
readDefault(key) {
|
|
133
|
+
try {
|
|
134
|
+
return execFileSync('/usr/bin/defaults', ['read', VMNET_PLIST, key], {
|
|
135
|
+
encoding: 'utf8',
|
|
136
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
137
|
+
}).trim()
|
|
138
|
+
} catch {
|
|
139
|
+
return undefined
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
run(argv) {
|
|
143
|
+
execFileSync(argv[0]!, argv.slice(1), { stdio: 'inherit' })
|
|
144
|
+
},
|
|
145
|
+
succeeds(argv) {
|
|
146
|
+
try {
|
|
147
|
+
execFileSync(argv[0]!, argv.slice(1), { stdio: 'ignore' })
|
|
148
|
+
return true
|
|
149
|
+
} catch {
|
|
150
|
+
return false
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
install({ path, mode, content }) {
|
|
154
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o755 })
|
|
155
|
+
const tmp = `${path}.new`
|
|
156
|
+
writeFileSync(tmp, content, { mode })
|
|
157
|
+
chmodSync(tmp, mode)
|
|
158
|
+
chownSync(tmp, 0, 0)
|
|
159
|
+
renameSync(tmp, path)
|
|
160
|
+
},
|
|
161
|
+
remove(path) {
|
|
162
|
+
rmSync(path, { force: true })
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function vmnetNetwork(sys: VzSetupSystem): VmnetNetwork {
|
|
167
|
+
const gateway = sys.readDefault('Shared_Net_Address') ?? '192.168.64.1'
|
|
168
|
+
const mask = sys.readDefault('Shared_Net_Mask') ?? '255.255.255.0'
|
|
169
|
+
return { gateway, subnet: `${gateway}/${maskPrefix(mask)}` }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function precheck(sys: VzSetupSystem, dryRun: boolean, verb: string) {
|
|
173
|
+
if (sys.platform !== 'darwin')
|
|
174
|
+
throw new Error(
|
|
175
|
+
'mirab vm network is for macOS vz guests; Firecracker hosts are set up separately',
|
|
176
|
+
)
|
|
177
|
+
if (!dryRun && !sys.isRoot())
|
|
178
|
+
throw new Error(
|
|
179
|
+
`run with sudo: sudo ~/.mirab/bin/mirab vm network ${verb} (or add --dry-run to see what it does)`,
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const loaded = (sys: VzSetupSystem) =>
|
|
184
|
+
sys.succeeds(['/bin/launchctl', 'print', `system/${VZ_DAEMON_LABEL}`])
|
|
185
|
+
|
|
186
|
+
/** Installs the filter, or prints what it would do. Safe to run again. */
|
|
187
|
+
export function setupVzNetwork(
|
|
188
|
+
opts: { dryRun?: boolean; log?: (line: string) => void },
|
|
189
|
+
sys: VzSetupSystem = realVzSystem,
|
|
190
|
+
) {
|
|
191
|
+
const dryRun = !!opts.dryRun
|
|
192
|
+
const log = opts.log ?? console.log
|
|
193
|
+
precheck(sys, dryRun, 'setup')
|
|
194
|
+
const net = vmnetNetwork(sys)
|
|
195
|
+
const [anchor, daemon, marker] = vzNetworkFiles(net)
|
|
196
|
+
const step = (what: string, act: () => void) =>
|
|
197
|
+
dryRun ? log(`+ ${what}`) : act()
|
|
198
|
+
const write = (f: VzNetworkFile) => {
|
|
199
|
+
if (dryRun) log(`--- ${f.path} (${f.mode.toString(8)})\n${f.content}`)
|
|
200
|
+
else sys.install(f)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
write(anchor!)
|
|
204
|
+
// A broken anchor must never reach the running firewall.
|
|
205
|
+
step(`pfctl -n -a ${VZ_ANCHOR} -f ${VZ_ANCHOR_FILE}`, () =>
|
|
206
|
+
sys.run(['/sbin/pfctl', '-n', '-a', VZ_ANCHOR, '-f', VZ_ANCHOR_FILE]),
|
|
207
|
+
)
|
|
208
|
+
write(daemon!)
|
|
209
|
+
if (!dryRun && loaded(sys))
|
|
210
|
+
// Already installed: rerun the daemon so the new anchor is loaded now.
|
|
211
|
+
sys.run(['/bin/launchctl', 'kickstart', '-k', `system/${VZ_DAEMON_LABEL}`])
|
|
212
|
+
else
|
|
213
|
+
step(`launchctl bootstrap system ${VZ_DAEMON_PLIST}`, () =>
|
|
214
|
+
sys.run(['/bin/launchctl', 'bootstrap', 'system', VZ_DAEMON_PLIST]),
|
|
215
|
+
)
|
|
216
|
+
// Last: mirab trusts the marker, so it appears only once the filter is live.
|
|
217
|
+
write(marker!)
|
|
218
|
+
log(
|
|
219
|
+
`${dryRun ? 'would install' : 'installed'}: mirab vz network filter (anchor ${VZ_ANCHOR}, guests on ${net.subnet}). Check with: mirab vm network status`,
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Undoes setup: the marker first, so no VM is offered NAT without the filter. */
|
|
224
|
+
export function removeVzNetwork(
|
|
225
|
+
opts: { dryRun?: boolean; log?: (line: string) => void },
|
|
226
|
+
sys: VzSetupSystem = realVzSystem,
|
|
227
|
+
) {
|
|
228
|
+
const dryRun = !!opts.dryRun
|
|
229
|
+
const log = opts.log ?? console.log
|
|
230
|
+
precheck(sys, dryRun, 'remove')
|
|
231
|
+
const step = (what: string, act: () => void) =>
|
|
232
|
+
dryRun ? log(`+ ${what}`) : act()
|
|
233
|
+
step(`rm ${VZ_NETWORK_MARKER}`, () => sys.remove(VZ_NETWORK_MARKER))
|
|
234
|
+
if (dryRun) log(`+ launchctl bootout system/${VZ_DAEMON_LABEL}`)
|
|
235
|
+
else if (loaded(sys))
|
|
236
|
+
sys.run(['/bin/launchctl', 'bootout', `system/${VZ_DAEMON_LABEL}`])
|
|
237
|
+
step(`pfctl -a ${VZ_ANCHOR} -F all`, () =>
|
|
238
|
+
sys.succeeds(['/sbin/pfctl', '-a', VZ_ANCHOR, '-F', 'all']),
|
|
239
|
+
)
|
|
240
|
+
step(`rm ${VZ_DAEMON_PLIST} ${VZ_ANCHOR_FILE}`, () => {
|
|
241
|
+
sys.remove(VZ_DAEMON_PLIST)
|
|
242
|
+
sys.remove(VZ_ANCHOR_FILE)
|
|
243
|
+
})
|
|
244
|
+
log(
|
|
245
|
+
`${dryRun ? 'would remove' : 'removed'}: mirab vz network filter; vz VMs get no network until setup runs again`,
|
|
246
|
+
)
|
|
247
|
+
}
|