vmsan 0.1.0-alpha.2 → 0.1.0-alpha.21

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.
@@ -0,0 +1,2380 @@
1
+ import { n as vmsanPaths } from "./paths.mjs";
2
+ import { B as mutuallyExclusiveFlagsError, C as vmNotStoppedError, S as vmNotRunningError, U as VmsanError, _ as chrootNotFoundError, a as noKernelDirError, d as socketTimeoutError, i as noExt4RootfsError, o as noKernelError, p as defaultInterfaceNotFoundError, r as missingBinaryError, s as noRootfsDirError, u as lockTimeoutError, x as vmNotFoundError, y as snapshotNotFoundError } from "./errors.mjs";
3
+ import { c as safeKill, f as toError, i as generateVmId, t as FileVmStateStore } from "./vm-state.mjs";
4
+ import { t as FirecrackerClient } from "./firecracker.mjs";
5
+ import { t as spawnTimeoutKiller } from "./timeout-killer.mjs";
6
+ import { createHooks } from "hookable";
7
+ import { dirname, join } from "node:path";
8
+ import { execFileSync, execSync } from "node:child_process";
9
+ import { copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
10
+ import { consola } from "consola";
11
+ import { randomBytes } from "node:crypto";
12
+ import { lock, lockSync } from "proper-lockfile";
13
+ import { connect } from "node:net";
14
+ import { fileURLToPath } from "node:url";
15
+ function createDefaultLogger() {
16
+ return wrapConsola(consola);
17
+ }
18
+ function wrapConsola(instance) {
19
+ return {
20
+ debug: instance.debug.bind(instance),
21
+ info: instance.info.bind(instance),
22
+ success: instance.success.bind(instance),
23
+ warn: instance.warn.bind(instance),
24
+ error: instance.error.bind(instance),
25
+ start: instance.start.bind(instance),
26
+ box: (message) => instance.box(message),
27
+ withTag: (tag) => wrapConsola(instance.withTag(tag))
28
+ };
29
+ }
30
+ const noop = () => {};
31
+ function createSilentLogger() {
32
+ const silent = {
33
+ debug: noop,
34
+ info: noop,
35
+ success: noop,
36
+ warn: noop,
37
+ error: noop,
38
+ start: noop,
39
+ box: noop,
40
+ withTag: () => silent
41
+ };
42
+ return silent;
43
+ }
44
+ const DNS_RESOLVERS = ["8.8.8.8", "8.8.4.4"];
45
+ const DOH_RESOLVER_IPS = [
46
+ "8.8.8.8",
47
+ "8.8.4.4",
48
+ "1.1.1.1",
49
+ "1.0.0.1",
50
+ "9.9.9.9",
51
+ "149.112.112.112",
52
+ "208.67.222.222",
53
+ "208.67.220.220",
54
+ "185.228.168.168",
55
+ "185.228.169.168"
56
+ ];
57
+ function runArgs(bin, args) {
58
+ execFileSync(bin, args, { stdio: "pipe" });
59
+ }
60
+ function sudo(args) {
61
+ runArgs("sudo", args);
62
+ }
63
+ function sudoNetns(nsName, args) {
64
+ sudo([
65
+ "ip",
66
+ "netns",
67
+ "exec",
68
+ nsName,
69
+ ...args
70
+ ]);
71
+ }
72
+ function getDefaultInterface() {
73
+ const match = execFileSync("ip", [
74
+ "route",
75
+ "show",
76
+ "default"
77
+ ], {
78
+ encoding: "utf-8",
79
+ stdio: "pipe"
80
+ }).trim().match(/dev\s+(\S+)/);
81
+ if (!match) throw defaultInterfaceNotFoundError();
82
+ return match[1];
83
+ }
84
+ /**
85
+ * Detect effective policy mode:
86
+ * - "deny-all": explicit deny-all, no outbound
87
+ * - "custom": any of allowedDomains/allowedCidrs/deniedCidrs present
88
+ * - "allow-all": default, unrestricted
89
+ */
90
+ function effectivePolicy(config) {
91
+ if (config.networkPolicy === "deny-all") return "deny-all";
92
+ if (config.allowedDomains.length > 0 || config.allowedCidrs.length > 0 || config.deniedCidrs.length > 0) return "custom";
93
+ return "allow-all";
94
+ }
95
+ var NetworkManager = class NetworkManager {
96
+ config;
97
+ constructor(slot, networkPolicy, allowedDomains, allowedCidrs, deniedCidrs, publishedPorts, bandwidthMbit, netnsName) {
98
+ this.config = {
99
+ slot,
100
+ tapDevice: `fhvm${slot}`,
101
+ hostIp: `172.16.${slot}.1`,
102
+ guestIp: `172.16.${slot}.2`,
103
+ subnetMask: "255.255.255.252",
104
+ macAddress: `AA:FC:00:00:00:${(slot + 1).toString(16).padStart(2, "0").toUpperCase()}`,
105
+ networkPolicy,
106
+ allowedDomains,
107
+ allowedCidrs,
108
+ deniedCidrs,
109
+ publishedPorts,
110
+ bandwidthMbit,
111
+ netnsName
112
+ };
113
+ }
114
+ static bootArgs(slot) {
115
+ return `console=ttyS0 reboot=k panic=1 pci=off ip=172.16.${slot}.2::${`172.16.${slot}.1`}:255.255.255.252::eth0:off:${DNS_RESOLVERS[0]}`;
116
+ }
117
+ static fromConfig(config) {
118
+ const mgr = Object.create(NetworkManager.prototype);
119
+ mgr.config = config;
120
+ return mgr;
121
+ }
122
+ static fromVmNetwork(network) {
123
+ const slot = Number(network.hostIp.split(".")[2]);
124
+ if (!Number.isInteger(slot)) throw new Error(`invalid network slot derived from hostIp: ${network.hostIp}`);
125
+ return NetworkManager.fromConfig({
126
+ slot,
127
+ tapDevice: network.tapDevice,
128
+ hostIp: network.hostIp,
129
+ guestIp: network.guestIp,
130
+ subnetMask: network.subnetMask,
131
+ macAddress: network.macAddress,
132
+ networkPolicy: network.networkPolicy,
133
+ allowedDomains: network.allowedDomains,
134
+ allowedCidrs: network.allowedCidrs || [],
135
+ deniedCidrs: network.deniedCidrs || [],
136
+ publishedPorts: network.publishedPorts,
137
+ bandwidthMbit: network.bandwidthMbit,
138
+ netnsName: network.netnsName
139
+ });
140
+ }
141
+ nsRun(args) {
142
+ const { netnsName } = this.config;
143
+ if (netnsName) sudoNetns(netnsName, args);
144
+ else sudo(args);
145
+ }
146
+ setupNamespace() {
147
+ const { slot, netnsName } = this.config;
148
+ if (!netnsName) return;
149
+ const vethHost = `veth-h-${slot}`;
150
+ const vethGuest = `veth-g-${slot}`;
151
+ const transitHostIp = `10.200.${slot}.1`;
152
+ const transitGuestIp = `10.200.${slot}.2`;
153
+ if (existsSync(`/sys/class/net/${vethHost}`)) try {
154
+ sudo([
155
+ "ip",
156
+ "link",
157
+ "delete",
158
+ vethHost
159
+ ]);
160
+ } catch {}
161
+ sudo([
162
+ "ip",
163
+ "netns",
164
+ "add",
165
+ netnsName
166
+ ]);
167
+ sudo([
168
+ "ip",
169
+ "link",
170
+ "add",
171
+ vethHost,
172
+ "type",
173
+ "veth",
174
+ "peer",
175
+ "name",
176
+ vethGuest
177
+ ]);
178
+ sudo([
179
+ "ip",
180
+ "link",
181
+ "set",
182
+ vethGuest,
183
+ "netns",
184
+ netnsName
185
+ ]);
186
+ sudo([
187
+ "ip",
188
+ "addr",
189
+ "add",
190
+ `${transitHostIp}/30`,
191
+ "dev",
192
+ vethHost
193
+ ]);
194
+ sudo([
195
+ "ip",
196
+ "link",
197
+ "set",
198
+ vethHost,
199
+ "up"
200
+ ]);
201
+ sudoNetns(netnsName, [
202
+ "ip",
203
+ "addr",
204
+ "add",
205
+ `${transitGuestIp}/30`,
206
+ "dev",
207
+ vethGuest
208
+ ]);
209
+ sudoNetns(netnsName, [
210
+ "ip",
211
+ "link",
212
+ "set",
213
+ vethGuest,
214
+ "up"
215
+ ]);
216
+ sudoNetns(netnsName, [
217
+ "ip",
218
+ "link",
219
+ "set",
220
+ "lo",
221
+ "up"
222
+ ]);
223
+ sudoNetns(netnsName, [
224
+ "ip",
225
+ "route",
226
+ "add",
227
+ "default",
228
+ "via",
229
+ transitHostIp
230
+ ]);
231
+ sudoNetns(netnsName, [
232
+ "sysctl",
233
+ "-w",
234
+ "net.ipv4.ip_forward=1"
235
+ ]);
236
+ sudo([
237
+ "ip",
238
+ "route",
239
+ "add",
240
+ `172.16.${slot}.0/30`,
241
+ "via",
242
+ transitGuestIp
243
+ ]);
244
+ sudo([
245
+ "sysctl",
246
+ "-w",
247
+ "net.ipv4.ip_forward=1"
248
+ ]);
249
+ }
250
+ teardownNamespace() {
251
+ const { slot, netnsName } = this.config;
252
+ if (!netnsName) return;
253
+ const tryRun = (args) => {
254
+ try {
255
+ sudo(args);
256
+ } catch {}
257
+ };
258
+ tryRun([
259
+ "ip",
260
+ "route",
261
+ "del",
262
+ `172.16.${slot}.0/30`
263
+ ]);
264
+ tryRun([
265
+ "ip",
266
+ "netns",
267
+ "delete",
268
+ netnsName
269
+ ]);
270
+ }
271
+ setupDevice() {
272
+ const { tapDevice, hostIp, netnsName } = this.config;
273
+ if (!netnsName) {
274
+ if (existsSync(`/sys/class/net/${tapDevice}`)) try {
275
+ sudo([
276
+ "ip",
277
+ "link",
278
+ "delete",
279
+ tapDevice
280
+ ]);
281
+ } catch {}
282
+ sudo([
283
+ "ip",
284
+ "tuntap",
285
+ "add",
286
+ "dev",
287
+ tapDevice,
288
+ "mode",
289
+ "tap"
290
+ ]);
291
+ sudo([
292
+ "ip",
293
+ "addr",
294
+ "add",
295
+ `${hostIp}/30`,
296
+ "dev",
297
+ tapDevice
298
+ ]);
299
+ sudo([
300
+ "ip",
301
+ "link",
302
+ "set",
303
+ tapDevice,
304
+ "up"
305
+ ]);
306
+ sudo([
307
+ "sysctl",
308
+ "-w",
309
+ "net.ipv4.ip_forward=1"
310
+ ]);
311
+ } else {
312
+ sudoNetns(netnsName, [
313
+ "ip",
314
+ "tuntap",
315
+ "add",
316
+ "dev",
317
+ tapDevice,
318
+ "mode",
319
+ "tap"
320
+ ]);
321
+ sudoNetns(netnsName, [
322
+ "ip",
323
+ "addr",
324
+ "add",
325
+ `${hostIp}/30`,
326
+ "dev",
327
+ tapDevice
328
+ ]);
329
+ sudoNetns(netnsName, [
330
+ "ip",
331
+ "link",
332
+ "set",
333
+ tapDevice,
334
+ "up"
335
+ ]);
336
+ }
337
+ }
338
+ setupRules() {
339
+ const { tapDevice, hostIp, guestIp, publishedPorts } = this.config;
340
+ const policy = effectivePolicy(this.config);
341
+ const fwd = this.nsRun.bind(this);
342
+ if (policy === "deny-all") {
343
+ fwd([
344
+ "iptables",
345
+ "-I",
346
+ "FORWARD",
347
+ "-i",
348
+ tapDevice,
349
+ "-j",
350
+ "DROP"
351
+ ]);
352
+ fwd([
353
+ "iptables",
354
+ "-I",
355
+ "FORWARD",
356
+ "-o",
357
+ tapDevice,
358
+ "-j",
359
+ "DROP"
360
+ ]);
361
+ return;
362
+ }
363
+ const defaultIface = getDefaultInterface();
364
+ sudo([
365
+ "iptables",
366
+ "-t",
367
+ "nat",
368
+ "-A",
369
+ "POSTROUTING",
370
+ "-s",
371
+ `${guestIp}/30`,
372
+ "-o",
373
+ defaultIface,
374
+ "-j",
375
+ "MASQUERADE"
376
+ ]);
377
+ if (this.config.netnsName) {
378
+ const vethHost = `veth-h-${this.config.slot}`;
379
+ sudo([
380
+ "iptables",
381
+ "-A",
382
+ "FORWARD",
383
+ "-i",
384
+ vethHost,
385
+ "-o",
386
+ defaultIface,
387
+ "-s",
388
+ `${guestIp}/30`,
389
+ "-j",
390
+ "ACCEPT"
391
+ ]);
392
+ sudo([
393
+ "iptables",
394
+ "-A",
395
+ "FORWARD",
396
+ "-i",
397
+ defaultIface,
398
+ "-o",
399
+ vethHost,
400
+ "-d",
401
+ `${guestIp}/30`,
402
+ "-m",
403
+ "state",
404
+ "--state",
405
+ "RELATED,ESTABLISHED",
406
+ "-j",
407
+ "ACCEPT"
408
+ ]);
409
+ }
410
+ if (policy === "custom") {
411
+ for (const cidr of this.config.deniedCidrs) fwd([
412
+ "iptables",
413
+ "-A",
414
+ "FORWARD",
415
+ "-i",
416
+ tapDevice,
417
+ "-d",
418
+ cidr,
419
+ "-j",
420
+ "DROP"
421
+ ]);
422
+ for (const dnsIp of DNS_RESOLVERS) {
423
+ fwd([
424
+ "iptables",
425
+ "-A",
426
+ "FORWARD",
427
+ "-i",
428
+ tapDevice,
429
+ "-d",
430
+ dnsIp,
431
+ "-p",
432
+ "udp",
433
+ "--dport",
434
+ "53",
435
+ "-j",
436
+ "ACCEPT"
437
+ ]);
438
+ fwd([
439
+ "iptables",
440
+ "-A",
441
+ "FORWARD",
442
+ "-i",
443
+ tapDevice,
444
+ "-d",
445
+ dnsIp,
446
+ "-p",
447
+ "tcp",
448
+ "--dport",
449
+ "53",
450
+ "-j",
451
+ "ACCEPT"
452
+ ]);
453
+ }
454
+ fwd([
455
+ "iptables",
456
+ "-A",
457
+ "FORWARD",
458
+ "-i",
459
+ tapDevice,
460
+ "-p",
461
+ "udp",
462
+ "--dport",
463
+ "53",
464
+ "-j",
465
+ "DROP"
466
+ ]);
467
+ fwd([
468
+ "iptables",
469
+ "-A",
470
+ "FORWARD",
471
+ "-i",
472
+ tapDevice,
473
+ "-p",
474
+ "tcp",
475
+ "--dport",
476
+ "53",
477
+ "-j",
478
+ "DROP"
479
+ ]);
480
+ fwd([
481
+ "iptables",
482
+ "-A",
483
+ "FORWARD",
484
+ "-i",
485
+ tapDevice,
486
+ "-p",
487
+ "udp",
488
+ "--dport",
489
+ "853",
490
+ "-j",
491
+ "DROP"
492
+ ]);
493
+ fwd([
494
+ "iptables",
495
+ "-A",
496
+ "FORWARD",
497
+ "-i",
498
+ tapDevice,
499
+ "-p",
500
+ "tcp",
501
+ "--dport",
502
+ "853",
503
+ "-j",
504
+ "DROP"
505
+ ]);
506
+ for (const ip of DOH_RESOLVER_IPS) {
507
+ fwd([
508
+ "iptables",
509
+ "-A",
510
+ "FORWARD",
511
+ "-i",
512
+ tapDevice,
513
+ "-d",
514
+ ip,
515
+ "-p",
516
+ "tcp",
517
+ "--dport",
518
+ "443",
519
+ "-j",
520
+ "DROP"
521
+ ]);
522
+ fwd([
523
+ "iptables",
524
+ "-A",
525
+ "FORWARD",
526
+ "-i",
527
+ tapDevice,
528
+ "-d",
529
+ ip,
530
+ "-p",
531
+ "udp",
532
+ "--dport",
533
+ "443",
534
+ "-j",
535
+ "DROP"
536
+ ]);
537
+ }
538
+ fwd([
539
+ "iptables",
540
+ "-A",
541
+ "FORWARD",
542
+ "-i",
543
+ tapDevice,
544
+ "-d",
545
+ "172.16.0.0/16",
546
+ "-j",
547
+ "DROP"
548
+ ]);
549
+ for (const cidr of this.config.allowedCidrs) fwd([
550
+ "iptables",
551
+ "-A",
552
+ "FORWARD",
553
+ "-i",
554
+ tapDevice,
555
+ "-d",
556
+ cidr,
557
+ "-j",
558
+ "ACCEPT"
559
+ ]);
560
+ fwd([
561
+ "iptables",
562
+ "-A",
563
+ "FORWARD",
564
+ "-i",
565
+ tapDevice,
566
+ "-j",
567
+ "ACCEPT"
568
+ ]);
569
+ } else {
570
+ for (const dnsIp of DNS_RESOLVERS) {
571
+ fwd([
572
+ "iptables",
573
+ "-A",
574
+ "FORWARD",
575
+ "-i",
576
+ tapDevice,
577
+ "-d",
578
+ dnsIp,
579
+ "-p",
580
+ "udp",
581
+ "--dport",
582
+ "53",
583
+ "-j",
584
+ "ACCEPT"
585
+ ]);
586
+ fwd([
587
+ "iptables",
588
+ "-A",
589
+ "FORWARD",
590
+ "-i",
591
+ tapDevice,
592
+ "-d",
593
+ dnsIp,
594
+ "-p",
595
+ "tcp",
596
+ "--dport",
597
+ "53",
598
+ "-j",
599
+ "ACCEPT"
600
+ ]);
601
+ }
602
+ fwd([
603
+ "iptables",
604
+ "-A",
605
+ "FORWARD",
606
+ "-i",
607
+ tapDevice,
608
+ "-p",
609
+ "udp",
610
+ "--dport",
611
+ "53",
612
+ "-j",
613
+ "DROP"
614
+ ]);
615
+ fwd([
616
+ "iptables",
617
+ "-A",
618
+ "FORWARD",
619
+ "-i",
620
+ tapDevice,
621
+ "-p",
622
+ "tcp",
623
+ "--dport",
624
+ "53",
625
+ "-j",
626
+ "DROP"
627
+ ]);
628
+ fwd([
629
+ "iptables",
630
+ "-A",
631
+ "FORWARD",
632
+ "-i",
633
+ tapDevice,
634
+ "-p",
635
+ "udp",
636
+ "--dport",
637
+ "853",
638
+ "-j",
639
+ "DROP"
640
+ ]);
641
+ fwd([
642
+ "iptables",
643
+ "-A",
644
+ "FORWARD",
645
+ "-i",
646
+ tapDevice,
647
+ "-p",
648
+ "tcp",
649
+ "--dport",
650
+ "853",
651
+ "-j",
652
+ "DROP"
653
+ ]);
654
+ for (const ip of DOH_RESOLVER_IPS) {
655
+ fwd([
656
+ "iptables",
657
+ "-A",
658
+ "FORWARD",
659
+ "-i",
660
+ tapDevice,
661
+ "-d",
662
+ ip,
663
+ "-p",
664
+ "tcp",
665
+ "--dport",
666
+ "443",
667
+ "-j",
668
+ "DROP"
669
+ ]);
670
+ fwd([
671
+ "iptables",
672
+ "-A",
673
+ "FORWARD",
674
+ "-i",
675
+ tapDevice,
676
+ "-d",
677
+ ip,
678
+ "-p",
679
+ "udp",
680
+ "--dport",
681
+ "443",
682
+ "-j",
683
+ "DROP"
684
+ ]);
685
+ }
686
+ fwd([
687
+ "iptables",
688
+ "-A",
689
+ "FORWARD",
690
+ "-i",
691
+ tapDevice,
692
+ "-d",
693
+ "172.16.0.0/16",
694
+ "-j",
695
+ "DROP"
696
+ ]);
697
+ fwd([
698
+ "iptables",
699
+ "-A",
700
+ "FORWARD",
701
+ "-i",
702
+ tapDevice,
703
+ "-j",
704
+ "ACCEPT"
705
+ ]);
706
+ }
707
+ fwd([
708
+ "iptables",
709
+ "-A",
710
+ "FORWARD",
711
+ "-o",
712
+ tapDevice,
713
+ "-m",
714
+ "state",
715
+ "--state",
716
+ "RELATED,ESTABLISHED",
717
+ "-j",
718
+ "ACCEPT"
719
+ ]);
720
+ for (const port of publishedPorts) {
721
+ const portStr = String(port);
722
+ sudo([
723
+ "iptables",
724
+ "-t",
725
+ "nat",
726
+ "-A",
727
+ "PREROUTING",
728
+ "-i",
729
+ defaultIface,
730
+ "-p",
731
+ "tcp",
732
+ "--dport",
733
+ portStr,
734
+ "-j",
735
+ "DNAT",
736
+ "--to-destination",
737
+ `${guestIp}:${portStr}`
738
+ ]);
739
+ sudo([
740
+ "iptables",
741
+ "-A",
742
+ "FORWARD",
743
+ "-p",
744
+ "tcp",
745
+ "-d",
746
+ guestIp,
747
+ "--dport",
748
+ portStr,
749
+ "-j",
750
+ "ACCEPT"
751
+ ]);
752
+ }
753
+ }
754
+ setupThrottle() {
755
+ const { tapDevice, bandwidthMbit } = this.config;
756
+ if (bandwidthMbit === void 0) return;
757
+ const rateKbit = bandwidthMbit * 1e3;
758
+ const burstKb = Math.max(32, Math.floor(rateKbit / 8));
759
+ this.nsRun([
760
+ "tc",
761
+ "qdisc",
762
+ "add",
763
+ "dev",
764
+ tapDevice,
765
+ "root",
766
+ "tbf",
767
+ "rate",
768
+ `${bandwidthMbit}mbit`,
769
+ "burst",
770
+ `${burstKb}kb`,
771
+ "latency",
772
+ "400ms"
773
+ ]);
774
+ }
775
+ teardownThrottle() {
776
+ const { tapDevice } = this.config;
777
+ try {
778
+ this.nsRun([
779
+ "tc",
780
+ "qdisc",
781
+ "del",
782
+ "dev",
783
+ tapDevice,
784
+ "root"
785
+ ]);
786
+ } catch {}
787
+ }
788
+ teardownRules() {
789
+ const { tapDevice, hostIp, guestIp, publishedPorts, netnsName } = this.config;
790
+ let defaultIface;
791
+ try {
792
+ defaultIface = getDefaultInterface();
793
+ } catch {}
794
+ const tryRun = (args) => {
795
+ try {
796
+ sudo(args);
797
+ } catch {}
798
+ };
799
+ const tryFwd = (args) => {
800
+ try {
801
+ this.nsRun(args);
802
+ } catch {}
803
+ };
804
+ for (const port of publishedPorts) {
805
+ const portStr = String(port);
806
+ if (defaultIface) tryRun([
807
+ "iptables",
808
+ "-t",
809
+ "nat",
810
+ "-D",
811
+ "PREROUTING",
812
+ "-i",
813
+ defaultIface,
814
+ "-p",
815
+ "tcp",
816
+ "--dport",
817
+ portStr,
818
+ "-j",
819
+ "DNAT",
820
+ "--to-destination",
821
+ `${guestIp}:${portStr}`
822
+ ]);
823
+ tryRun([
824
+ "iptables",
825
+ "-D",
826
+ "FORWARD",
827
+ "-p",
828
+ "tcp",
829
+ "-d",
830
+ guestIp,
831
+ "--dport",
832
+ portStr,
833
+ "-j",
834
+ "ACCEPT"
835
+ ]);
836
+ }
837
+ if (!netnsName) {
838
+ for (const cidr of this.config.deniedCidrs) tryFwd([
839
+ "iptables",
840
+ "-D",
841
+ "FORWARD",
842
+ "-i",
843
+ tapDevice,
844
+ "-d",
845
+ cidr,
846
+ "-j",
847
+ "DROP"
848
+ ]);
849
+ for (const cidr of this.config.allowedCidrs) tryFwd([
850
+ "iptables",
851
+ "-D",
852
+ "FORWARD",
853
+ "-i",
854
+ tapDevice,
855
+ "-d",
856
+ cidr,
857
+ "-j",
858
+ "ACCEPT"
859
+ ]);
860
+ for (const dnsIp of DNS_RESOLVERS) {
861
+ tryFwd([
862
+ "iptables",
863
+ "-D",
864
+ "FORWARD",
865
+ "-i",
866
+ tapDevice,
867
+ "-d",
868
+ dnsIp,
869
+ "-p",
870
+ "udp",
871
+ "--dport",
872
+ "53",
873
+ "-j",
874
+ "ACCEPT"
875
+ ]);
876
+ tryFwd([
877
+ "iptables",
878
+ "-D",
879
+ "FORWARD",
880
+ "-i",
881
+ tapDevice,
882
+ "-d",
883
+ dnsIp,
884
+ "-p",
885
+ "tcp",
886
+ "--dport",
887
+ "53",
888
+ "-j",
889
+ "ACCEPT"
890
+ ]);
891
+ }
892
+ tryFwd([
893
+ "iptables",
894
+ "-D",
895
+ "FORWARD",
896
+ "-i",
897
+ tapDevice,
898
+ "-p",
899
+ "udp",
900
+ "--dport",
901
+ "53",
902
+ "-j",
903
+ "DROP"
904
+ ]);
905
+ tryFwd([
906
+ "iptables",
907
+ "-D",
908
+ "FORWARD",
909
+ "-i",
910
+ tapDevice,
911
+ "-p",
912
+ "tcp",
913
+ "--dport",
914
+ "53",
915
+ "-j",
916
+ "DROP"
917
+ ]);
918
+ tryFwd([
919
+ "iptables",
920
+ "-D",
921
+ "FORWARD",
922
+ "-i",
923
+ tapDevice,
924
+ "-p",
925
+ "udp",
926
+ "--dport",
927
+ "853",
928
+ "-j",
929
+ "DROP"
930
+ ]);
931
+ tryFwd([
932
+ "iptables",
933
+ "-D",
934
+ "FORWARD",
935
+ "-i",
936
+ tapDevice,
937
+ "-p",
938
+ "tcp",
939
+ "--dport",
940
+ "853",
941
+ "-j",
942
+ "DROP"
943
+ ]);
944
+ for (const ip of DOH_RESOLVER_IPS) {
945
+ tryFwd([
946
+ "iptables",
947
+ "-D",
948
+ "FORWARD",
949
+ "-i",
950
+ tapDevice,
951
+ "-d",
952
+ ip,
953
+ "-p",
954
+ "tcp",
955
+ "--dport",
956
+ "443",
957
+ "-j",
958
+ "DROP"
959
+ ]);
960
+ tryFwd([
961
+ "iptables",
962
+ "-D",
963
+ "FORWARD",
964
+ "-i",
965
+ tapDevice,
966
+ "-d",
967
+ ip,
968
+ "-p",
969
+ "udp",
970
+ "--dport",
971
+ "443",
972
+ "-j",
973
+ "DROP"
974
+ ]);
975
+ }
976
+ tryFwd([
977
+ "iptables",
978
+ "-D",
979
+ "FORWARD",
980
+ "-i",
981
+ tapDevice,
982
+ "-d",
983
+ "172.16.0.0/16",
984
+ "-j",
985
+ "DROP"
986
+ ]);
987
+ tryFwd([
988
+ "iptables",
989
+ "-D",
990
+ "FORWARD",
991
+ "-i",
992
+ tapDevice,
993
+ "-j",
994
+ "ACCEPT"
995
+ ]);
996
+ tryFwd([
997
+ "iptables",
998
+ "-D",
999
+ "FORWARD",
1000
+ "-o",
1001
+ tapDevice,
1002
+ "-m",
1003
+ "state",
1004
+ "--state",
1005
+ "RELATED,ESTABLISHED",
1006
+ "-j",
1007
+ "ACCEPT"
1008
+ ]);
1009
+ tryFwd([
1010
+ "iptables",
1011
+ "-D",
1012
+ "FORWARD",
1013
+ "-i",
1014
+ tapDevice,
1015
+ "-j",
1016
+ "DROP"
1017
+ ]);
1018
+ tryFwd([
1019
+ "iptables",
1020
+ "-D",
1021
+ "FORWARD",
1022
+ "-o",
1023
+ tapDevice,
1024
+ "-j",
1025
+ "DROP"
1026
+ ]);
1027
+ }
1028
+ if (netnsName && defaultIface) {
1029
+ const vethHost = `veth-h-${this.config.slot}`;
1030
+ tryRun([
1031
+ "iptables",
1032
+ "-D",
1033
+ "FORWARD",
1034
+ "-i",
1035
+ vethHost,
1036
+ "-o",
1037
+ defaultIface,
1038
+ "-s",
1039
+ `${guestIp}/30`,
1040
+ "-j",
1041
+ "ACCEPT"
1042
+ ]);
1043
+ tryRun([
1044
+ "iptables",
1045
+ "-D",
1046
+ "FORWARD",
1047
+ "-i",
1048
+ defaultIface,
1049
+ "-o",
1050
+ vethHost,
1051
+ "-d",
1052
+ `${guestIp}/30`,
1053
+ "-m",
1054
+ "state",
1055
+ "--state",
1056
+ "RELATED,ESTABLISHED",
1057
+ "-j",
1058
+ "ACCEPT"
1059
+ ]);
1060
+ }
1061
+ if (defaultIface) tryRun([
1062
+ "iptables",
1063
+ "-t",
1064
+ "nat",
1065
+ "-D",
1066
+ "POSTROUTING",
1067
+ "-s",
1068
+ `${guestIp}/30`,
1069
+ "-o",
1070
+ defaultIface,
1071
+ "-j",
1072
+ "MASQUERADE"
1073
+ ]);
1074
+ }
1075
+ teardownDevice() {
1076
+ const { tapDevice, netnsName } = this.config;
1077
+ if (netnsName) return;
1078
+ try {
1079
+ sudo([
1080
+ "ip",
1081
+ "link",
1082
+ "delete",
1083
+ tapDevice
1084
+ ]);
1085
+ } catch {}
1086
+ }
1087
+ async setup() {
1088
+ this.setupNamespace();
1089
+ this.setupDevice();
1090
+ this.setupRules();
1091
+ this.setupThrottle();
1092
+ }
1093
+ teardown() {
1094
+ if (this.config.netnsName) {
1095
+ this.teardownRules();
1096
+ this.teardownNamespace();
1097
+ } else {
1098
+ this.teardownThrottle();
1099
+ this.teardownRules();
1100
+ this.teardownDevice();
1101
+ }
1102
+ }
1103
+ updatePolicy(newPolicy, newDomains, newAllowedCidrs, newDeniedCidrs) {
1104
+ const oldConfig = { ...this.config };
1105
+ this.teardownRules();
1106
+ this.config.networkPolicy = newPolicy;
1107
+ this.config.allowedDomains = newDomains;
1108
+ this.config.allowedCidrs = newAllowedCidrs;
1109
+ this.config.deniedCidrs = newDeniedCidrs;
1110
+ try {
1111
+ this.setupRules();
1112
+ } catch (err) {
1113
+ this.config = oldConfig;
1114
+ try {
1115
+ this.setupRules();
1116
+ } catch {}
1117
+ throw err;
1118
+ }
1119
+ }
1120
+ };
1121
+ const STALE_MS = 3e5;
1122
+ const RETRY_MS = 50;
1123
+ const MAX_RETRIES = 600;
1124
+ const WAIT_ARRAY = new Int32Array(new SharedArrayBuffer(4));
1125
+ var FileLock = class {
1126
+ stale;
1127
+ retries;
1128
+ realpath;
1129
+ constructor(path, name, options) {
1130
+ this.path = path;
1131
+ this.name = name;
1132
+ this.stale = options?.stale ?? STALE_MS;
1133
+ this.retries = options?.retries ?? MAX_RETRIES;
1134
+ this.realpath = options?.realpath ?? false;
1135
+ }
1136
+ run(fn) {
1137
+ mkdirSync(dirname(this.path), { recursive: true });
1138
+ const syncOpts = {
1139
+ stale: this.stale,
1140
+ realpath: this.realpath
1141
+ };
1142
+ let release;
1143
+ for (let attempt = 0;; attempt++) try {
1144
+ release = lockSync(this.path, syncOpts);
1145
+ break;
1146
+ } catch (error) {
1147
+ if (error.code !== "ELOCKED") throw error;
1148
+ if (attempt >= this.retries) throw lockTimeoutError(this.name);
1149
+ Atomics.wait(WAIT_ARRAY, 0, 0, RETRY_MS);
1150
+ }
1151
+ try {
1152
+ return fn();
1153
+ } finally {
1154
+ release();
1155
+ }
1156
+ }
1157
+ async runAsync(fn) {
1158
+ mkdirSync(dirname(this.path), { recursive: true });
1159
+ const asyncOpts = {
1160
+ stale: this.stale,
1161
+ realpath: this.realpath,
1162
+ retries: {
1163
+ retries: this.retries,
1164
+ minTimeout: RETRY_MS,
1165
+ maxTimeout: RETRY_MS,
1166
+ factor: 1
1167
+ }
1168
+ };
1169
+ let release;
1170
+ try {
1171
+ release = await lock(this.path, asyncOpts);
1172
+ } catch (error) {
1173
+ if (error.code === "ELOCKED") throw lockTimeoutError(this.name);
1174
+ throw error;
1175
+ }
1176
+ try {
1177
+ return await fn();
1178
+ } finally {
1179
+ await release();
1180
+ }
1181
+ }
1182
+ };
1183
+ /**
1184
+ * Template generators for the node22-demo runtime welcome page.
1185
+ * All functions are pure and return string content ready to write to files.
1186
+ */
1187
+ function generateWelcomeHtml(vmId, ports) {
1188
+ ports.map((p) => `<li>${p}</li>`).join("\n ");
1189
+ return `<!DOCTYPE html>
1190
+ <html lang="en">
1191
+ <head>
1192
+ <meta charset="utf-8">
1193
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1194
+ <title>vmsan VM ${vmId}</title>
1195
+ <style>
1196
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
1197
+ body {
1198
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
1199
+ background: #0f172a;
1200
+ color: #e2e8f0;
1201
+ min-height: 100vh;
1202
+ display: flex;
1203
+ align-items: center;
1204
+ justify-content: center;
1205
+ padding: 2rem;
1206
+ }
1207
+ .container { max-width: 640px; width: 100%; }
1208
+ .header { text-align: center; margin-bottom: 2rem; }
1209
+ .logo {
1210
+ font-size: 2.5rem;
1211
+ font-weight: 800;
1212
+ background: linear-gradient(135deg, #f97316, #ef4444);
1213
+ -webkit-background-clip: text;
1214
+ -webkit-text-fill-color: transparent;
1215
+ background-clip: text;
1216
+ }
1217
+ .subtitle { color: #94a3b8; margin-top: 0.5rem; font-size: 1.1rem; }
1218
+ .card {
1219
+ background: #1e293b;
1220
+ border: 1px solid #334155;
1221
+ border-radius: 12px;
1222
+ padding: 1.5rem;
1223
+ margin-bottom: 1.25rem;
1224
+ }
1225
+ .card h2 { font-size: 1rem; color: #f97316; margin-bottom: 0.75rem; }
1226
+ .info-row { display: flex; justify-content: space-between; padding: 0.35rem 0; }
1227
+ .info-label { color: #94a3b8; }
1228
+ .info-value { font-family: monospace; color: #e2e8f0; }
1229
+ ul { list-style: none; }
1230
+ ul li { padding: 0.25rem 0; }
1231
+ code {
1232
+ background: #0f172a;
1233
+ border: 1px solid #334155;
1234
+ border-radius: 6px;
1235
+ padding: 0.2rem 0.5rem;
1236
+ font-size: 0.875rem;
1237
+ color: #f97316;
1238
+ }
1239
+ .steps li { padding: 0.5rem 0; color: #cbd5e1; }
1240
+ .steps li strong { color: #e2e8f0; }
1241
+ .footer { text-align: center; color: #475569; font-size: 0.85rem; margin-top: 1.5rem; }
1242
+ </style>
1243
+ </head>
1244
+ <body>
1245
+ <div class="container">
1246
+ <div class="header">
1247
+ <div class="logo">vmsan</div>
1248
+ <div class="subtitle">Your microVM is running</div>
1249
+ </div>
1250
+ <div class="card">
1251
+ <h2>VM Info</h2>
1252
+ <div class="info-row">
1253
+ <span class="info-label">VM ID</span>
1254
+ <span class="info-value">${vmId}</span>
1255
+ </div>
1256
+ <div class="info-row">
1257
+ <span class="info-label">Runtime</span>
1258
+ <span class="info-value">node22-demo</span>
1259
+ </div>
1260
+ <div class="info-row">
1261
+ <span class="info-label">Published Ports</span>
1262
+ <span class="info-value">${ports.join(", ")}</span>
1263
+ </div>
1264
+ </div>
1265
+ <div class="card">
1266
+ <h2>Next Steps</h2>
1267
+ <ul class="steps">
1268
+ <li><strong>Connect to the VM:</strong> <code>vmsan connect ${vmId}</code></li>
1269
+ <li><strong>Deploy your app:</strong> Replace this page by stopping the welcome service and running your own server on the published port(s).</li>
1270
+ <li><strong>Stop this page:</strong> <code>systemctl stop vmsan-welcome</code></li>
1271
+ </ul>
1272
+ </div>
1273
+ <div class="footer">Powered by vmsan &middot; Firecracker microVMs</div>
1274
+ </div>
1275
+ </body>
1276
+ </html>`;
1277
+ }
1278
+ function generateWelcomeServer(ports) {
1279
+ return `"use strict";
1280
+ const http = require("node:http");
1281
+ const fs = require("node:fs");
1282
+ const path = require("node:path");
1283
+
1284
+ const html = fs.readFileSync(path.join(__dirname, "index.html"), "utf-8");
1285
+
1286
+ const server = http.createServer((req, res) => {
1287
+ res.writeHead(200, {
1288
+ "Content-Type": "text/html; charset=utf-8",
1289
+ "Cache-Control": "no-cache",
1290
+ });
1291
+ res.end(html);
1292
+ });
1293
+
1294
+ ${ports.map((p) => `server.listen(${p}, "0.0.0.0", () => console.log("vmsan-welcome listening on 0.0.0.0:${p}"));`).join("\n")}
1295
+ `;
1296
+ }
1297
+ function generateWelcomeService(ports) {
1298
+ return `[Unit]
1299
+ Description=${`vmsan welcome page on port(s) ${ports.join(", ")}`}
1300
+ After=network.target
1301
+
1302
+ [Service]
1303
+ Type=simple
1304
+ ExecStart=/usr/local/bin/node /opt/vmsan/welcome/server.js
1305
+ Restart=on-failure
1306
+ RestartSec=2
1307
+
1308
+ [Install]
1309
+ WantedBy=multi-user.target
1310
+ `;
1311
+ }
1312
+ /**
1313
+ * Template generators for the vmsan-agent systemd service.
1314
+ * Follows the same pattern as welcome-page.ts.
1315
+ */
1316
+ function generateAgentService() {
1317
+ return `[Unit]
1318
+ Description=Vmsan VM Agent
1319
+ After=network.target
1320
+
1321
+ [Service]
1322
+ Type=simple
1323
+ ExecStart=/usr/local/bin/vmsan-agent
1324
+ EnvironmentFile=/etc/vmsan/agent.env
1325
+ Restart=always
1326
+ RestartSec=2
1327
+
1328
+ [Install]
1329
+ WantedBy=multi-user.target
1330
+ `;
1331
+ }
1332
+ function generateAgentEnv(token, port, vmId) {
1333
+ return `VMSAN_AGENT_TOKEN=${token}
1334
+ VMSAN_AGENT_PORT=${port}
1335
+ VMSAN_VM_ID=${vmId}
1336
+ VMSAN_DEFAULT_USER=ubuntu
1337
+ `;
1338
+ }
1339
+ /**
1340
+ * Extra memory (in MiB) added to the cgroup limit beyond guest memory.
1341
+ * Covers Firecracker VMM process overhead, page tables, and kernel slab.
1342
+ * Without this, the OOM killer can terminate the VM under memory pressure.
1343
+ */
1344
+ const CGROUP_VMM_OVERHEAD_MIB = 64;
1345
+ function detectCgroupVersion() {
1346
+ try {
1347
+ readFileSync("/sys/fs/cgroup/cgroup.controllers", "utf-8");
1348
+ return 2;
1349
+ } catch {
1350
+ return 1;
1351
+ }
1352
+ }
1353
+ var Jailer = class {
1354
+ paths;
1355
+ constructor(vmId, jailerBaseDir) {
1356
+ this.vmId = vmId;
1357
+ const chrootBase = jailerBaseDir;
1358
+ const chrootDir = join(chrootBase, "firecracker", vmId);
1359
+ const rootDir = join(chrootDir, "root");
1360
+ const kernelDir = join(rootDir, "kernel");
1361
+ const rootfsDir = join(rootDir, "rootfs");
1362
+ const socketDir = join(rootDir, "run");
1363
+ const snapshotDir = join(rootDir, "snapshot");
1364
+ this.paths = {
1365
+ chrootBase,
1366
+ chrootDir,
1367
+ rootDir,
1368
+ kernelDir,
1369
+ kernelPath: join(kernelDir, "vmlinux"),
1370
+ rootfsDir,
1371
+ rootfsPath: join(rootfsDir, "rootfs.ext4"),
1372
+ socketDir,
1373
+ socketPath: join(socketDir, "firecracker.socket"),
1374
+ snapshotDir
1375
+ };
1376
+ }
1377
+ prepare(config) {
1378
+ const paths = this.paths;
1379
+ mkdirSync(paths.kernelDir, { recursive: true });
1380
+ mkdirSync(paths.rootfsDir, { recursive: true });
1381
+ mkdirSync(paths.socketDir, { recursive: true });
1382
+ if (!existsSync(paths.kernelPath)) linkSync(config.kernelSrc, paths.kernelPath);
1383
+ copyFileSync(config.rootfsSrc, paths.rootfsPath);
1384
+ if (typeof config.diskSizeGb === "number" && Number.isFinite(config.diskSizeGb)) {
1385
+ const targetBytes = Math.trunc(config.diskSizeGb * 1024 * 1024 * 1024);
1386
+ if (targetBytes > statSync(paths.rootfsPath).size) {
1387
+ execSync(`truncate -s ${targetBytes} "${paths.rootfsPath}"`, { stdio: "pipe" });
1388
+ execSync(`sudo e2fsck -fy "${paths.rootfsPath}"; [ $? -lt 4 ]`, { stdio: "pipe" });
1389
+ execSync(`sudo resize2fs "${paths.rootfsPath}"`, { stdio: "pipe" });
1390
+ execSync(`sudo tune2fs -m 0 "${paths.rootfsPath}"`, { stdio: "pipe" });
1391
+ }
1392
+ }
1393
+ const tmpMount = join(paths.rootDir, "tmp-mount");
1394
+ mkdirSync(tmpMount, { recursive: true });
1395
+ try {
1396
+ execSync(`sudo mount -o loop "${paths.rootfsPath}" "${tmpMount}"`, { stdio: "pipe" });
1397
+ execSync(`rm -f "${tmpMount}/etc/resolv.conf" && ln -s /proc/net/pnp "${tmpMount}/etc/resolv.conf"`, { stdio: "pipe" });
1398
+ if (config.welcomePage) {
1399
+ const { vmId: welcomeVmId, ports: welcomePorts } = config.welcomePage;
1400
+ const welcomeDir = join(tmpMount, "opt", "vmsan", "welcome");
1401
+ mkdirSync(welcomeDir, { recursive: true });
1402
+ writeFileSync(join(welcomeDir, "index.html"), generateWelcomeHtml(welcomeVmId, welcomePorts));
1403
+ writeFileSync(join(welcomeDir, "server.js"), generateWelcomeServer(welcomePorts));
1404
+ const systemdDir = join(tmpMount, "etc", "systemd", "system");
1405
+ mkdirSync(systemdDir, { recursive: true });
1406
+ writeFileSync(join(systemdDir, "vmsan-welcome.service"), generateWelcomeService(welcomePorts));
1407
+ const wantsDir = join(systemdDir, "multi-user.target.wants");
1408
+ mkdirSync(wantsDir, { recursive: true });
1409
+ execSync(`ln -sf /etc/systemd/system/vmsan-welcome.service "${join(wantsDir, "vmsan-welcome.service")}"`, { stdio: "pipe" });
1410
+ }
1411
+ if (config.agent) {
1412
+ const agentDst = join(tmpMount, "usr", "local", "bin", "vmsan-agent");
1413
+ mkdirSync(join(tmpMount, "usr", "local", "bin"), { recursive: true });
1414
+ copyFileSync(config.agent.binaryPath, agentDst);
1415
+ execSync(`chmod 755 "${agentDst}"`, { stdio: "pipe" });
1416
+ const envDir = join(tmpMount, "etc", "vmsan");
1417
+ mkdirSync(envDir, { recursive: true });
1418
+ writeFileSync(join(envDir, "agent.env"), generateAgentEnv(config.agent.token, config.agent.port, config.agent.vmId));
1419
+ const systemdDir = join(tmpMount, "etc", "systemd", "system");
1420
+ mkdirSync(systemdDir, { recursive: true });
1421
+ writeFileSync(join(systemdDir, "vmsan-agent.service"), generateAgentService());
1422
+ const wantsDir = join(systemdDir, "multi-user.target.wants");
1423
+ mkdirSync(wantsDir, { recursive: true });
1424
+ execSync(`ln -sf /etc/systemd/system/vmsan-agent.service "${join(wantsDir, "vmsan-agent.service")}"`, { stdio: "pipe" });
1425
+ }
1426
+ execSync(`sudo umount "${tmpMount}"`, { stdio: "pipe" });
1427
+ } catch {
1428
+ try {
1429
+ execSync(`sudo umount "${tmpMount}" 2>/dev/null`, { stdio: "pipe" });
1430
+ } catch {}
1431
+ }
1432
+ try {
1433
+ execSync(`rm -rf "${tmpMount}"`, { stdio: "pipe" });
1434
+ } catch {}
1435
+ if (config.snapshot) {
1436
+ mkdirSync(paths.snapshotDir, { recursive: true });
1437
+ copyFileSync(config.snapshot.snapshotFile, join(paths.snapshotDir, "snapshot_file"));
1438
+ copyFileSync(config.snapshot.memFile, join(paths.snapshotDir, "mem_file"));
1439
+ }
1440
+ return paths;
1441
+ }
1442
+ spawn(config) {
1443
+ const uid = config.uid ?? 0;
1444
+ const gid = config.gid ?? 0;
1445
+ const args = [
1446
+ config.jailerBin,
1447
+ "--exec-file",
1448
+ config.firecrackerBin,
1449
+ "--id",
1450
+ this.vmId,
1451
+ "--uid",
1452
+ String(uid),
1453
+ "--gid",
1454
+ String(gid),
1455
+ "--chroot-base-dir",
1456
+ config.chrootBase,
1457
+ "--daemonize"
1458
+ ];
1459
+ if (config.newPidNs !== false) args.push("--new-pid-ns");
1460
+ if (config.netns) args.push("--netns", `/var/run/netns/${config.netns}`);
1461
+ if (config.cgroup) if (detectCgroupVersion() === 2) {
1462
+ args.push("--cgroup-version", "2");
1463
+ args.push("--cgroup", `cpu.max=${config.cgroup.cpuQuotaUs} ${config.cgroup.cpuPeriodUs}`);
1464
+ args.push("--cgroup", `memory.max=${config.cgroup.memoryBytes}`);
1465
+ } else {
1466
+ args.push("--cgroup", `cpu.cfs_quota_us=${config.cgroup.cpuQuotaUs}`);
1467
+ args.push("--cgroup", `cpu.cfs_period_us=${config.cgroup.cpuPeriodUs}`);
1468
+ args.push("--cgroup", `memory.limit_in_bytes=${config.cgroup.memoryBytes}`);
1469
+ }
1470
+ args.push("--", "--api-sock", "run/firecracker.socket");
1471
+ if (config.seccompFilter && existsSync(config.seccompFilter)) args.push("--seccomp-filter", config.seccompFilter);
1472
+ else args.push("--no-seccomp");
1473
+ execFileSync("sudo", args, { stdio: "pipe" });
1474
+ }
1475
+ };
1476
+ function validateEnvironment(baseDir) {
1477
+ const firecrackerPath = join(baseDir, "bin", "firecracker");
1478
+ const jailerPath = join(baseDir, "bin", "jailer");
1479
+ if (!existsSync(firecrackerPath)) throw missingBinaryError("Firecracker", firecrackerPath);
1480
+ if (!existsSync(jailerPath)) throw missingBinaryError("Jailer", jailerPath);
1481
+ }
1482
+ function findKernel(baseDir) {
1483
+ const kernelDir = join(baseDir, "kernels");
1484
+ if (!existsSync(kernelDir)) throw noKernelDirError();
1485
+ const files = readdirSync(kernelDir).filter((fileName) => fileName.startsWith("vmlinux"));
1486
+ if (files.length === 0) throw noKernelError();
1487
+ return join(kernelDir, files.sort().at(-1));
1488
+ }
1489
+ function findRootfs(baseDir) {
1490
+ const rootfsDir = join(baseDir, "rootfs");
1491
+ if (!existsSync(rootfsDir)) throw noRootfsDirError();
1492
+ const files = readdirSync(rootfsDir).filter((fileName) => fileName.endsWith(".ext4"));
1493
+ if (files.length === 0) throw noExt4RootfsError();
1494
+ return join(rootfsDir, files.sort().at(-1));
1495
+ }
1496
+ async function waitForSocket(socketPath, timeoutMs = 5e3) {
1497
+ const start = Date.now();
1498
+ while (Date.now() - start < timeoutMs) {
1499
+ if (existsSync(socketPath)) {
1500
+ if (await new Promise((resolve) => {
1501
+ const socket = connect(socketPath);
1502
+ socket.on("connect", () => {
1503
+ socket.destroy();
1504
+ resolve(true);
1505
+ });
1506
+ socket.on("error", () => resolve(false));
1507
+ })) return;
1508
+ }
1509
+ await new Promise((resolve) => setTimeout(resolve, 100));
1510
+ }
1511
+ throw socketTimeoutError(socketPath);
1512
+ }
1513
+ function getVmPid(vmId) {
1514
+ try {
1515
+ const entries = readdirSync("/proc").filter((entry) => /^\d+$/.test(entry));
1516
+ for (const entry of entries) try {
1517
+ const cmdline = readFileSync(`/proc/${entry}/cmdline`, "utf-8");
1518
+ if (cmdline.includes("firecracker") && cmdline.includes(vmId)) return Number(entry);
1519
+ } catch {}
1520
+ } catch {}
1521
+ return null;
1522
+ }
1523
+ function getVmJailerPid(vmId) {
1524
+ try {
1525
+ const entries = readdirSync("/proc").filter((entry) => /^\d+$/.test(entry));
1526
+ for (const entry of entries) try {
1527
+ const cmdline = readFileSync(`/proc/${entry}/cmdline`, "utf-8");
1528
+ if (cmdline.includes("jailer") && cmdline.includes(vmId)) return Number(entry);
1529
+ } catch {}
1530
+ } catch {}
1531
+ return null;
1532
+ }
1533
+ function assertSnapshotExists(snapshotId, paths) {
1534
+ const snapshotDir = join(paths.snapshotsDir, snapshotId);
1535
+ if (!existsSync(join(snapshotDir, "snapshot_file")) || !existsSync(join(snapshotDir, "mem_file"))) throw snapshotNotFoundError(snapshotId);
1536
+ }
1537
+ function killOrphanVmProcess(vmId) {
1538
+ const orphanPid = getVmPid(vmId);
1539
+ const orphanJailerPid = getVmJailerPid(vmId);
1540
+ if (orphanPid) safeKill(orphanPid, "SIGKILL");
1541
+ if (orphanJailerPid) safeKill(orphanJailerPid, "SIGKILL");
1542
+ }
1543
+ function markVmAsError(vmId, error, paths) {
1544
+ try {
1545
+ new FileVmStateStore(paths.vmsDir).update(vmId, {
1546
+ status: "error",
1547
+ error: error instanceof Error ? error.message : String(error)
1548
+ });
1549
+ } catch {}
1550
+ }
1551
+ function cleanupNetwork(networkConfig) {
1552
+ if (!networkConfig) return;
1553
+ try {
1554
+ NetworkManager.fromConfig(networkConfig).teardown();
1555
+ } catch {}
1556
+ }
1557
+ function cleanupChroot(chrootDir) {
1558
+ if (!chrootDir) return;
1559
+ const vmJailerDir = dirname(chrootDir);
1560
+ try {
1561
+ rmSync(chrootDir, {
1562
+ recursive: true,
1563
+ force: true
1564
+ });
1565
+ } catch {}
1566
+ try {
1567
+ rmSync(vmJailerDir, {
1568
+ recursive: true,
1569
+ force: true
1570
+ });
1571
+ } catch {}
1572
+ }
1573
+ function buildInitialVmState(input) {
1574
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1575
+ return {
1576
+ id: input.vmId,
1577
+ project: input.project,
1578
+ runtime: input.runtime,
1579
+ diskSizeGb: input.diskSizeGb,
1580
+ status: "creating",
1581
+ pid: null,
1582
+ apiSocket: "",
1583
+ chrootDir: "",
1584
+ kernel: input.kernelPath,
1585
+ rootfs: input.rootfsPath,
1586
+ vcpuCount: input.vcpus,
1587
+ memSizeMib: input.memMib,
1588
+ network: {
1589
+ tapDevice: input.tapDevice,
1590
+ hostIp: input.hostIp,
1591
+ guestIp: input.guestIp,
1592
+ subnetMask: input.subnetMask,
1593
+ macAddress: input.macAddress,
1594
+ networkPolicy: input.networkPolicy,
1595
+ allowedDomains: input.domains,
1596
+ allowedCidrs: input.allowedCidrs,
1597
+ deniedCidrs: input.deniedCidrs,
1598
+ publishedPorts: input.ports,
1599
+ tunnelHostname: null,
1600
+ tunnelHostnames: [],
1601
+ bandwidthMbit: input.bandwidthMbit,
1602
+ netnsName: input.netnsName
1603
+ },
1604
+ snapshot: input.snapshotId,
1605
+ timeoutMs: input.timeoutMs,
1606
+ timeoutAt: input.timeoutMs ? new Date(Date.now() + input.timeoutMs).toISOString() : null,
1607
+ createdAt: now,
1608
+ error: null,
1609
+ agentToken: input.agentToken,
1610
+ agentPort: input.agentPort
1611
+ };
1612
+ }
1613
+ function dockerUnavailableError() {
1614
+ return /* @__PURE__ */ new Error("Docker is not available. Install Docker and ensure the daemon is running.");
1615
+ }
1616
+ const APT_PACKAGES = [
1617
+ "bind9-utils",
1618
+ "bzip2",
1619
+ "findutils",
1620
+ "git",
1621
+ "gzip",
1622
+ "iputils-ping",
1623
+ "libicu-dev",
1624
+ "libjpeg-dev",
1625
+ "libpng-dev",
1626
+ "ncurses-base",
1627
+ "libssl-dev",
1628
+ "openssh-server",
1629
+ "openssl",
1630
+ "procps",
1631
+ "sudo",
1632
+ "tar",
1633
+ "unzip",
1634
+ "debianutils",
1635
+ "whois",
1636
+ "zstd"
1637
+ ];
1638
+ const DNF_PACKAGES = [
1639
+ "bind-utils",
1640
+ "bzip2",
1641
+ "findutils",
1642
+ "git",
1643
+ "gzip",
1644
+ "iputils",
1645
+ "libicu",
1646
+ "libjpeg",
1647
+ "libpng",
1648
+ "ncurses-libs",
1649
+ "openssh-server",
1650
+ "openssl",
1651
+ "openssl-libs",
1652
+ "procps",
1653
+ "sudo",
1654
+ "tar",
1655
+ "unzip",
1656
+ "which",
1657
+ "whois",
1658
+ "zstd"
1659
+ ];
1660
+ const APK_PACKAGES = [
1661
+ "bash",
1662
+ "bind-tools",
1663
+ "bzip2",
1664
+ "findutils",
1665
+ "git",
1666
+ "gzip",
1667
+ "iputils",
1668
+ "icu-libs",
1669
+ "libjpeg-turbo",
1670
+ "libpng",
1671
+ "ncurses-libs",
1672
+ "openrc",
1673
+ "openssh",
1674
+ "openssl",
1675
+ "procps",
1676
+ "sudo",
1677
+ "tar",
1678
+ "unzip",
1679
+ "whois",
1680
+ "zstd"
1681
+ ];
1682
+ function generateDockerfile(baseImage) {
1683
+ return `FROM ${baseImage}
1684
+ RUN if command -v apt-get >/dev/null 2>&1; then ${`apt-get update && apt-get install -y --no-install-recommends ${APT_PACKAGES.join(" ")} && rm -rf /var/lib/apt/lists/*`}; \\
1685
+ elif command -v dnf >/dev/null 2>&1; then ${`dnf install -y ${DNF_PACKAGES.join(" ")} && dnf clean all`}; \\
1686
+ elif command -v yum >/dev/null 2>&1; then ${`yum install -y ${DNF_PACKAGES.join(" ")} && yum clean all`}; \\
1687
+ elif command -v apk >/dev/null 2>&1; then ${`apk add --no-cache ${APK_PACKAGES.join(" ")}`}; \\
1688
+ fi
1689
+ RUN if command -v apk >/dev/null 2>&1; then \\
1690
+ id -u ubuntu >/dev/null 2>&1 || adduser -D -s /bin/bash ubuntu; \\
1691
+ else \\
1692
+ id -u ubuntu >/dev/null 2>&1 || useradd -m -s /bin/bash ubuntu; \\
1693
+ fi; \\
1694
+ echo 'ubuntu ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/ubuntu; \\
1695
+ chmod 440 /etc/sudoers.d/ubuntu; \\
1696
+ mkdir -p /home/ubuntu/.ssh && chown -R ubuntu:ubuntu /home/ubuntu
1697
+ RUN ssh-keygen -A 2>/dev/null || true; \\
1698
+ mkdir -p /root/.ssh && chmod 700 /root/.ssh; \\
1699
+ if [ -f /etc/ssh/sshd_config ]; then \\
1700
+ sed -i 's/^#*PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config; \\
1701
+ fi; \\
1702
+ if command -v rc-update >/dev/null 2>&1; then \\
1703
+ rc-update add devfs sysinit 2>/dev/null || true; \\
1704
+ rc-update add mdev sysinit 2>/dev/null || true; \\
1705
+ rc-update add hwdrivers sysinit 2>/dev/null || true; \\
1706
+ rc-update add modules boot 2>/dev/null || true; \\
1707
+ rc-update add sysctl boot 2>/dev/null || true; \\
1708
+ rc-update add hostname boot 2>/dev/null || true; \\
1709
+ rc-update add bootmisc boot 2>/dev/null || true; \\
1710
+ rc-update add networking boot 2>/dev/null || true; \\
1711
+ rc-update add sshd default 2>/dev/null || true; \\
1712
+ printf '%s\\n' '::sysinit:/sbin/openrc sysinit' '::sysinit:/sbin/openrc boot' '::wait:/sbin/openrc default' '::shutdown:/sbin/openrc shutdown' 'ttyS0::respawn:/sbin/getty 115200 ttyS0' > /etc/inittab; \\
1713
+ fi; \\
1714
+ if command -v systemctl >/dev/null 2>&1; then systemctl enable sshd 2>/dev/null || systemctl enable ssh 2>/dev/null || true; fi
1715
+ `;
1716
+ }
1717
+ function verifyDocker() {
1718
+ try {
1719
+ execSync("docker info", { stdio: "pipe" });
1720
+ } catch {
1721
+ throw dockerUnavailableError();
1722
+ }
1723
+ }
1724
+ function buildImageRootfs(imageRef, cacheDir) {
1725
+ const ext4Path = join(cacheDir, "rootfs.ext4");
1726
+ verifyDocker();
1727
+ const buildTag = `vmsan-rootfs-${imageRef.name.replace(/[^a-z0-9._-]/gi, "-")}:${imageRef.tag}`;
1728
+ const containerName = `vmsan-export-${Date.now()}`;
1729
+ const tmpTar = join(cacheDir, "rootfs.tar");
1730
+ mkdirSync(cacheDir, { recursive: true });
1731
+ try {
1732
+ consola.start(`Building image from ${imageRef.full}...`);
1733
+ execSync(`docker build -t "${buildTag}" -f - . <<'DOCKERFILE'\n${generateDockerfile(imageRef.full)}\nDOCKERFILE`, {
1734
+ stdio: "pipe",
1735
+ shell: "/bin/bash"
1736
+ });
1737
+ consola.start("Exporting filesystem...");
1738
+ execSync(`docker create --name "${containerName}" "${buildTag}"`, { stdio: "pipe" });
1739
+ execSync(`docker export "${containerName}" -o "${tmpTar}"`, { stdio: "pipe" });
1740
+ consola.start("Converting to ext4...");
1741
+ const tarSizeOutput = execSync(`stat -c %s "${tmpTar}"`, { encoding: "utf-8" }).trim();
1742
+ const tarMb = Number(tarSizeOutput) / 1024 / 1024;
1743
+ const imageSizeMb = Math.max(1024, Math.ceil(tarMb + 512));
1744
+ execSync(`dd if=/dev/zero of="${ext4Path}" bs=1M count=${imageSizeMb} 2>/dev/null`, { stdio: "pipe" });
1745
+ execSync(`mkfs.ext4 -q "${ext4Path}"`, { stdio: "pipe" });
1746
+ execSync(`tune2fs -m 0 "${ext4Path}"`, { stdio: "pipe" });
1747
+ const tmpMount = join(cacheDir, "mnt");
1748
+ mkdirSync(tmpMount, { recursive: true });
1749
+ execSync(`mount -o loop "${ext4Path}" "${tmpMount}"`, { stdio: "pipe" });
1750
+ try {
1751
+ execSync(`tar -xf "${tmpTar}" -C "${tmpMount}"`, { stdio: "pipe" });
1752
+ } finally {
1753
+ execSync(`umount "${tmpMount}"`, { stdio: "pipe" });
1754
+ execSync(`rm -rf "${tmpMount}"`, { stdio: "pipe" });
1755
+ }
1756
+ writeFileSync(join(cacheDir, "metadata.json"), JSON.stringify({
1757
+ image: imageRef.full,
1758
+ builtAt: (/* @__PURE__ */ new Date()).toISOString()
1759
+ }, null, 2));
1760
+ consola.success(`Rootfs built from ${imageRef.full} (${imageSizeMb} MB)`);
1761
+ return ext4Path;
1762
+ } finally {
1763
+ try {
1764
+ execSync(`docker rm -f "${containerName}" 2>/dev/null`, { stdio: "pipe" });
1765
+ } catch {}
1766
+ try {
1767
+ execSync(`rm -f "${tmpTar}"`, { stdio: "pipe" });
1768
+ } catch {}
1769
+ }
1770
+ }
1771
+ function resolveImageRootfs(imageRef, registryDir) {
1772
+ const cacheDir = join(registryDir, imageRef.cacheKey);
1773
+ const ext4Path = join(cacheDir, "rootfs.ext4");
1774
+ if (existsSync(ext4Path)) {
1775
+ consola.info(`Using cached rootfs for ${imageRef.full}`);
1776
+ return ext4Path;
1777
+ }
1778
+ return buildImageRootfs(imageRef, cacheDir);
1779
+ }
1780
+ const _dirname = dirname(fileURLToPath(import.meta.url));
1781
+ const VALID_ARCHES = ["x86_64", "aarch64"];
1782
+ const MAX_FILTER_SIZE = 1048576;
1783
+ /**
1784
+ * Compile a Firecracker seccomp JSON filter to BPF using seccompiler-bin.
1785
+ * Falls back to using the JSON filter directly if seccompiler-bin is not available.
1786
+ */
1787
+ function compileSeccompFilter(jsonPath, outputPath, arch) {
1788
+ const targetArch = arch ?? "x86_64";
1789
+ if (!VALID_ARCHES.includes(targetArch)) throw new Error(`unsupported seccomp arch: ${targetArch} (allowed: ${VALID_ARCHES.join(", ")})`);
1790
+ const stat = statSync(jsonPath);
1791
+ if (stat.size > MAX_FILTER_SIZE) throw new Error(`seccomp filter too large: ${stat.size} bytes (max ${MAX_FILTER_SIZE})`);
1792
+ mkdirSync(dirname(outputPath), { recursive: true });
1793
+ execFileSync("seccompiler-bin", [
1794
+ "--input-file",
1795
+ jsonPath,
1796
+ "--target-arch",
1797
+ targetArch,
1798
+ "--output-file",
1799
+ outputPath
1800
+ ], { stdio: "pipe" });
1801
+ }
1802
+ /**
1803
+ * Ensure a seccomp filter is available for Firecracker.
1804
+ *
1805
+ * 1. If a compiled BPF exists at paths.seccompDir/default.bpf, return it.
1806
+ * 2. If the JSON source exists, try to compile it; return BPF path on success.
1807
+ * 3. If compilation fails (seccompiler-bin not installed), return null
1808
+ * (Firecracker requires compiled BPF, not raw JSON).
1809
+ * 4. If no filter source exists at all, return null.
1810
+ */
1811
+ function ensureSeccompFilter(paths) {
1812
+ const bpfPath = join(paths.seccompDir, "default.bpf");
1813
+ if (existsSync(bpfPath)) {
1814
+ consola.debug(`seccomp: using compiled BPF filter at ${bpfPath}`);
1815
+ return bpfPath;
1816
+ }
1817
+ const bundledJson = join(dirname(dirname(_dirname)), "seccomp", "default.json");
1818
+ const userJson = paths.seccompFilter;
1819
+ let sourceJson = null;
1820
+ try {
1821
+ const mode = statSync(userJson).mode;
1822
+ if (mode & 18) consola.warn(`seccomp: filter at ${userJson} is group/world writable (mode ${(mode & 511).toString(8)}); consider restricting permissions`);
1823
+ consola.debug(`seccomp: using user filter at ${userJson}`);
1824
+ sourceJson = userJson;
1825
+ } catch {}
1826
+ if (!sourceJson && existsSync(bundledJson)) {
1827
+ mkdirSync(paths.seccompDir, { recursive: true });
1828
+ copyFileSync(bundledJson, userJson);
1829
+ consola.debug(`seccomp: copied bundled filter to ${userJson}`);
1830
+ sourceJson = userJson;
1831
+ }
1832
+ if (!sourceJson) return null;
1833
+ try {
1834
+ compileSeccompFilter(sourceJson, bpfPath);
1835
+ consola.debug(`seccomp: compiled BPF filter at ${bpfPath}`);
1836
+ return bpfPath;
1837
+ } catch {
1838
+ consola.warn("seccomp: BPF compilation failed (seccompiler-bin not available?); seccomp filtering disabled. Install seccompiler-bin for seccomp support.");
1839
+ return null;
1840
+ }
1841
+ }
1842
+ var VMService = class {
1843
+ paths;
1844
+ store;
1845
+ hooks;
1846
+ logger;
1847
+ constructor(ctx) {
1848
+ this.paths = ctx.paths;
1849
+ this.store = ctx.store;
1850
+ this.hooks = ctx.hooks;
1851
+ this.logger = ctx.logger;
1852
+ }
1853
+ list() {
1854
+ return this.store.list().sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
1855
+ }
1856
+ get(vmId) {
1857
+ return this.store.load(vmId);
1858
+ }
1859
+ async create(opts) {
1860
+ const { logger, paths, hooks } = this;
1861
+ const vmId = generateVmId();
1862
+ const log = logger.withTag(vmId);
1863
+ let networkConfig;
1864
+ let chrootDir;
1865
+ try {
1866
+ validateEnvironment(paths.baseDir);
1867
+ if (opts.fromImage && opts.rootfsPath) throw mutuallyExclusiveFlagsError("--from-image", "--rootfs");
1868
+ const vcpus = opts.vcpus ?? 1;
1869
+ const memMib = opts.memMib ?? 128;
1870
+ const diskSizeGb = opts.diskSizeGb ?? 10;
1871
+ const runtime = opts.runtime ?? "base";
1872
+ const networkPolicy = opts.networkPolicy ?? "allow-all";
1873
+ const domains = opts.domains ?? [];
1874
+ const allowedCidrs = opts.allowedCidrs ?? [];
1875
+ const deniedCidrs = opts.deniedCidrs ?? [];
1876
+ const ports = opts.ports ?? [];
1877
+ const bandwidthMbit = opts.bandwidthMbit;
1878
+ const snapshotId = opts.snapshotId ?? null;
1879
+ const timeoutMs = opts.timeoutMs ?? null;
1880
+ await hooks.callHook("vm:beforeCreate", {
1881
+ vmId,
1882
+ options: opts
1883
+ });
1884
+ const kernelPath = opts.kernelPath ?? findKernel(paths.baseDir);
1885
+ logger.debug(`Kernel resolved: ${kernelPath}`);
1886
+ let rootfsPath;
1887
+ if (opts.fromImage) rootfsPath = resolveImageRootfs(opts.fromImage, paths.registryDir);
1888
+ else rootfsPath = opts.rootfsPath ?? findRootfs(paths.baseDir);
1889
+ logger.debug(`Rootfs resolved: ${rootfsPath}`);
1890
+ const netnsName = opts.disableNetns ? void 0 : `vmsan-${vmId}`;
1891
+ const agentToken = existsSync(paths.agentBin) ? randomBytes(32).toString("hex") : null;
1892
+ log.start(`Creating VM ${vmId}...`);
1893
+ const { net } = new FileLock(join(paths.vmsDir, ".slot-lock"), "slot-alloc").run(() => {
1894
+ const slot = this.store.allocateNetworkSlot();
1895
+ logger.debug(`Network slot allocated: ${slot}`);
1896
+ const net = new NetworkManager(slot, networkPolicy, domains, allowedCidrs, deniedCidrs, ports, bandwidthMbit, netnsName);
1897
+ networkConfig = net.config;
1898
+ const state = buildInitialVmState({
1899
+ vmId,
1900
+ project: opts.project || "",
1901
+ runtime,
1902
+ diskSizeGb,
1903
+ kernelPath,
1904
+ rootfsPath,
1905
+ vcpus,
1906
+ memMib,
1907
+ networkPolicy,
1908
+ domains,
1909
+ allowedCidrs,
1910
+ deniedCidrs,
1911
+ ports,
1912
+ tapDevice: net.config.tapDevice,
1913
+ hostIp: net.config.hostIp,
1914
+ guestIp: net.config.guestIp,
1915
+ subnetMask: net.config.subnetMask,
1916
+ macAddress: net.config.macAddress,
1917
+ snapshotId,
1918
+ timeoutMs,
1919
+ agentToken,
1920
+ agentPort: paths.agentPort,
1921
+ bandwidthMbit,
1922
+ netnsName
1923
+ });
1924
+ this.store.save(state);
1925
+ return { net };
1926
+ });
1927
+ const netCfg = networkConfig;
1928
+ log.start("Setting up networking...");
1929
+ await net.setup();
1930
+ log.success(`Network: TAP ${netCfg.tapDevice}, Host ${netCfg.hostIp}, Guest ${netCfg.guestIp}`);
1931
+ await hooks.callHook("network:afterSetup", {
1932
+ vmId,
1933
+ slot: netCfg.slot,
1934
+ networkConfig: netCfg,
1935
+ domains,
1936
+ networkPolicy
1937
+ });
1938
+ log.start("Preparing chroot...");
1939
+ const snapshotConfig = snapshotId ? {
1940
+ snapshotFile: join(paths.snapshotsDir, snapshotId, "snapshot_file"),
1941
+ memFile: join(paths.snapshotsDir, snapshotId, "mem_file")
1942
+ } : void 0;
1943
+ const jailer = new Jailer(vmId, paths.jailerBaseDir);
1944
+ const welcomePage = runtime === "node22-demo" && ports.length > 0 ? {
1945
+ vmId,
1946
+ ports
1947
+ } : void 0;
1948
+ const agentConfig = agentToken ? {
1949
+ binaryPath: paths.agentBin,
1950
+ token: agentToken,
1951
+ port: paths.agentPort,
1952
+ vmId
1953
+ } : void 0;
1954
+ const jailerPaths = jailer.prepare({
1955
+ kernelSrc: kernelPath,
1956
+ rootfsSrc: rootfsPath,
1957
+ diskSizeGb,
1958
+ snapshot: snapshotConfig,
1959
+ welcomePage,
1960
+ agent: agentConfig
1961
+ });
1962
+ chrootDir = jailerPaths.chrootDir;
1963
+ this.store.update(vmId, {
1964
+ chrootDir: jailerPaths.chrootDir,
1965
+ apiSocket: jailerPaths.socketPath
1966
+ });
1967
+ logger.debug(`Jailer chroot: ${jailerPaths.chrootDir}`);
1968
+ logger.debug(`API socket path: ${jailerPaths.socketPath}`);
1969
+ log.start("Spawning Firecracker via jailer...");
1970
+ const firecrackerBin = join(paths.binDir, "firecracker");
1971
+ const jailerBin = join(paths.binDir, "jailer");
1972
+ const seccompFilter = opts.disableSeccomp ? void 0 : ensureSeccompFilter(paths);
1973
+ if (seccompFilter) logger.debug(`Seccomp filter: ${seccompFilter}`);
1974
+ const cgroup = opts.disableCgroup ? void 0 : this.buildCgroupConfig(vcpus, memMib);
1975
+ jailer.spawn({
1976
+ firecrackerBin,
1977
+ jailerBin,
1978
+ chrootBase: jailerPaths.chrootBase,
1979
+ seccompFilter: seccompFilter ?? void 0,
1980
+ newPidNs: !opts.disablePidNs,
1981
+ cgroup,
1982
+ netns: netnsName
1983
+ });
1984
+ log.start("Waiting for API socket...");
1985
+ await waitForSocket(jailerPaths.socketPath, 5e3);
1986
+ log.success("API socket ready");
1987
+ if (snapshotId) {
1988
+ log.start("Restoring from snapshot...");
1989
+ const vm = new FirecrackerClient(jailerPaths.socketPath);
1990
+ await vm.loadSnapshot("snapshot/snapshot_file", "snapshot/mem_file");
1991
+ await vm.resume();
1992
+ log.success("Snapshot restored and VM resumed");
1993
+ } else {
1994
+ await this.bootVm(jailerPaths.socketPath, netCfg, vcpus, memMib);
1995
+ log.start("Starting VM...");
1996
+ await new FirecrackerClient(jailerPaths.socketPath).start();
1997
+ }
1998
+ const pid = getVmPid(vmId);
1999
+ logger.debug(`Firecracker PID: ${pid ?? "unknown"}`);
2000
+ this.store.update(vmId, {
2001
+ status: "running",
2002
+ pid
2003
+ });
2004
+ log.success(`VM ${vmId} is running (PID: ${pid || "unknown"})`);
2005
+ if (timeoutMs && pid) spawnTimeoutKiller({
2006
+ vmId,
2007
+ pid,
2008
+ timeoutMs,
2009
+ stateFile: join(paths.vmsDir, `${vmId}.json`)
2010
+ });
2011
+ const finalState = this.store.load(vmId);
2012
+ await hooks.callHook("vm:afterCreate", finalState);
2013
+ return {
2014
+ vmId,
2015
+ pid,
2016
+ state: finalState
2017
+ };
2018
+ } catch (error) {
2019
+ if (vmId) await hooks.callHook("vm:error", {
2020
+ vmId,
2021
+ error: toError(error),
2022
+ phase: "create"
2023
+ });
2024
+ killOrphanVmProcess(vmId);
2025
+ this.markAsError(vmId, error);
2026
+ cleanupNetwork(networkConfig);
2027
+ cleanupChroot(chrootDir);
2028
+ throw error;
2029
+ }
2030
+ }
2031
+ async start(vmId) {
2032
+ const { logger, paths, hooks } = this;
2033
+ const log = logger.withTag(vmId);
2034
+ let networkConfig;
2035
+ try {
2036
+ const state = this.store.load(vmId);
2037
+ if (!state) throw vmNotFoundError(vmId);
2038
+ if (state.status !== "stopped") throw vmNotStoppedError(vmId, state.status);
2039
+ if (!state.chrootDir || !existsSync(state.chrootDir)) throw chrootNotFoundError(vmId);
2040
+ validateEnvironment(paths.baseDir);
2041
+ await hooks.callHook("vm:beforeStart", {
2042
+ vmId,
2043
+ state
2044
+ });
2045
+ log.start(`Starting VM ${vmId}...`);
2046
+ const mgr = NetworkManager.fromVmNetwork(state.network);
2047
+ networkConfig = mgr.config;
2048
+ logger.debug(`Reconstructed network config: slot=${networkConfig.slot}, tap=${networkConfig.tapDevice}, host=${networkConfig.hostIp}, guest=${networkConfig.guestIp}`);
2049
+ log.start("Setting up networking...");
2050
+ await mgr.setup();
2051
+ log.success(`Network: TAP ${networkConfig.tapDevice}, Host ${networkConfig.hostIp}, Guest ${networkConfig.guestIp}`);
2052
+ await hooks.callHook("network:afterSetup", {
2053
+ vmId,
2054
+ slot: networkConfig.slot,
2055
+ networkConfig,
2056
+ domains: state.network.allowedDomains,
2057
+ networkPolicy: state.network.networkPolicy
2058
+ });
2059
+ const vmRootCandidates = Array.from(new Set([
2060
+ join(state.chrootDir, "root"),
2061
+ state.chrootDir,
2062
+ dirname(dirname(state.apiSocket))
2063
+ ]));
2064
+ const removeStaleFirecrackerFiles = () => {
2065
+ for (const rootDir of vmRootCandidates) {
2066
+ rmSync(join(rootDir, "firecracker"), { force: true });
2067
+ rmSync(join(rootDir, "firecracker.pid"), { force: true });
2068
+ }
2069
+ };
2070
+ removeStaleFirecrackerFiles();
2071
+ const socketPath = state.apiSocket;
2072
+ rmSync(socketPath, { force: true });
2073
+ const removeStaleDevTrees = () => {
2074
+ for (const rootDir of vmRootCandidates) rmSync(join(rootDir, "dev"), {
2075
+ recursive: true,
2076
+ force: true
2077
+ });
2078
+ };
2079
+ const removeStaleDeviceNodes = () => {
2080
+ const staleNodes = [
2081
+ "dev/net/tun",
2082
+ "dev/kvm",
2083
+ "dev/userfaultfd",
2084
+ "dev/urandom"
2085
+ ];
2086
+ for (const rootDir of vmRootCandidates) for (const rel of staleNodes) rmSync(join(rootDir, rel), {
2087
+ recursive: true,
2088
+ force: true
2089
+ });
2090
+ };
2091
+ const firecrackerBin = join(paths.binDir, "firecracker");
2092
+ const jailerBin = join(paths.binDir, "jailer");
2093
+ const jailer = new Jailer(vmId, paths.jailerBaseDir);
2094
+ let socketReady = false;
2095
+ const startTag = `[start:${vmId}]`;
2096
+ const logAttemptError = (attempt, error) => {
2097
+ logger.error(`${startTag} ${attempt} failed: ${toError(error).message}`);
2098
+ };
2099
+ logger.debug(`Stale file cleanup: checked ${vmRootCandidates.length} root candidates`);
2100
+ const logDiagnostics = () => {
2101
+ const socketExists = existsSync(socketPath);
2102
+ const devState = vmRootCandidates.map((rootDir) => `${join(rootDir, "dev")}=${existsSync(join(rootDir, "dev"))}`).join(", ");
2103
+ const firecrackerPid = getVmPid(vmId);
2104
+ const jailerPid = getVmJailerPid(vmId);
2105
+ log.error(`${startTag} diagnostics: socketExists=${socketExists}; firecrackerPid=${firecrackerPid ?? "none"}; jailerPid=${jailerPid ?? "none"}; devDirs=[${devState}]`);
2106
+ };
2107
+ const isRecoverableStartError = (message) => {
2108
+ if (message.includes("Timeout waiting for API socket")) return true;
2109
+ if (message.includes("mknod inside the jail") && message.includes("File exists")) return true;
2110
+ if (message.includes("MknodDev(") && message.includes("os error 17")) return true;
2111
+ return false;
2112
+ };
2113
+ const cgroup = this.buildCgroupConfig(state.vcpuCount, state.memSizeMib);
2114
+ const spawnAndWait = async (timeoutMs) => {
2115
+ log.start("Spawning Firecracker via jailer...");
2116
+ logger.debug(`Jailer spawn: firecracker=${firecrackerBin}, jailer=${jailerBin}, chrootBase=${jailer.paths.chrootBase}`);
2117
+ jailer.spawn({
2118
+ firecrackerBin,
2119
+ jailerBin,
2120
+ chrootBase: jailer.paths.chrootBase,
2121
+ newPidNs: true,
2122
+ cgroup,
2123
+ netns: state.network.netnsName
2124
+ });
2125
+ log.start("Waiting for API socket...");
2126
+ await waitForSocket(socketPath, timeoutMs);
2127
+ };
2128
+ try {
2129
+ removeStaleDeviceNodes();
2130
+ await spawnAndWait(1e4);
2131
+ socketReady = true;
2132
+ } catch (firstStartError) {
2133
+ const message = toError(firstStartError).message;
2134
+ if (!isRecoverableStartError(message)) {
2135
+ logAttemptError("initial attempt", firstStartError);
2136
+ logDiagnostics();
2137
+ throw firstStartError;
2138
+ }
2139
+ logAttemptError("initial attempt", firstStartError);
2140
+ killOrphanVmProcess(vmId);
2141
+ rmSync(socketPath, { force: true });
2142
+ removeStaleDeviceNodes();
2143
+ removeStaleDevTrees();
2144
+ removeStaleFirecrackerFiles();
2145
+ try {
2146
+ await spawnAndWait(15e3);
2147
+ socketReady = true;
2148
+ } catch (retryError) {
2149
+ logAttemptError("retry attempt", retryError);
2150
+ logDiagnostics();
2151
+ throw new Error(`${startTag} retry failed after cleanup. First error: ${message}. Retry error: ${toError(retryError).message}`);
2152
+ }
2153
+ }
2154
+ if (!socketReady) throw new Error(`Timeout waiting for API socket at ${socketPath}`);
2155
+ log.success("API socket ready");
2156
+ await this.bootVm(socketPath, networkConfig, state.vcpuCount, state.memSizeMib);
2157
+ log.start("Starting VM...");
2158
+ await new FirecrackerClient(socketPath).start();
2159
+ const pid = getVmPid(vmId);
2160
+ this.store.update(vmId, {
2161
+ status: "running",
2162
+ pid
2163
+ });
2164
+ log.success(`VM ${vmId} is running (PID: ${pid || "unknown"})`);
2165
+ const finalState = this.store.load(vmId);
2166
+ await hooks.callHook("vm:afterStart", finalState);
2167
+ return {
2168
+ vmId,
2169
+ pid,
2170
+ state: finalState,
2171
+ success: true
2172
+ };
2173
+ } catch (error) {
2174
+ await hooks.callHook("vm:error", {
2175
+ vmId,
2176
+ error: toError(error),
2177
+ phase: "start"
2178
+ });
2179
+ killOrphanVmProcess(vmId);
2180
+ this.markAsError(vmId, error);
2181
+ cleanupNetwork(networkConfig);
2182
+ return {
2183
+ vmId,
2184
+ pid: null,
2185
+ state: null,
2186
+ success: false,
2187
+ error: error instanceof VmsanError ? error : void 0
2188
+ };
2189
+ }
2190
+ }
2191
+ async stop(vmId) {
2192
+ const state = this.store.load(vmId);
2193
+ if (!state) return {
2194
+ vmId,
2195
+ success: false,
2196
+ error: vmNotFoundError(vmId)
2197
+ };
2198
+ if (state.status === "stopped") return {
2199
+ vmId,
2200
+ success: true,
2201
+ alreadyStopped: true
2202
+ };
2203
+ try {
2204
+ await this.hooks.callHook("vm:beforeStop", {
2205
+ vmId,
2206
+ state
2207
+ });
2208
+ const previousStatus = state.status;
2209
+ if (state.pid) safeKill(state.pid, "SIGKILL");
2210
+ const orphanPid = getVmPid(vmId);
2211
+ if (orphanPid) safeKill(orphanPid, "SIGKILL");
2212
+ const orphanJailerPid = getVmJailerPid(vmId);
2213
+ if (orphanJailerPid) safeKill(orphanJailerPid, "SIGKILL");
2214
+ if (state.network) {
2215
+ const netCfg = NetworkManager.fromVmNetwork(state.network);
2216
+ try {
2217
+ netCfg.teardown();
2218
+ } catch {}
2219
+ await this.hooks.callHook("network:afterTeardown", {
2220
+ vmId,
2221
+ networkConfig: netCfg.config
2222
+ });
2223
+ }
2224
+ this.store.update(vmId, {
2225
+ status: "stopped",
2226
+ pid: null
2227
+ });
2228
+ await this.hooks.callHook("vm:afterStop", {
2229
+ vmId,
2230
+ previousStatus
2231
+ });
2232
+ return {
2233
+ vmId,
2234
+ success: true
2235
+ };
2236
+ } catch (err) {
2237
+ await this.hooks.callHook("vm:error", {
2238
+ vmId,
2239
+ error: toError(err),
2240
+ phase: "stop"
2241
+ });
2242
+ return {
2243
+ vmId,
2244
+ success: false,
2245
+ error: err instanceof VmsanError ? err : void 0
2246
+ };
2247
+ }
2248
+ }
2249
+ async updateNetworkPolicy(vmId, policy, domains, allowedCidrs, deniedCidrs) {
2250
+ return new FileLock(join(this.paths.vmsDir, `${vmId}.json`), `update-policy-${vmId}`).runAsync(async () => {
2251
+ const state = this.store.load(vmId);
2252
+ if (!state) return {
2253
+ vmId,
2254
+ success: false,
2255
+ previousPolicy: policy,
2256
+ newPolicy: policy,
2257
+ error: vmNotFoundError(vmId)
2258
+ };
2259
+ const previousPolicy = state.network.networkPolicy;
2260
+ if (state.status !== "running") return {
2261
+ vmId,
2262
+ success: false,
2263
+ previousPolicy,
2264
+ newPolicy: policy,
2265
+ error: vmNotRunningError(vmId)
2266
+ };
2267
+ try {
2268
+ NetworkManager.fromVmNetwork(state.network).updatePolicy(policy, domains, allowedCidrs, deniedCidrs);
2269
+ this.store.update(vmId, { network: {
2270
+ ...state.network,
2271
+ networkPolicy: policy,
2272
+ allowedDomains: domains,
2273
+ allowedCidrs,
2274
+ deniedCidrs
2275
+ } });
2276
+ await this.hooks.callHook("network:policyChange", {
2277
+ vmId,
2278
+ previousPolicy,
2279
+ newPolicy: policy
2280
+ });
2281
+ return {
2282
+ vmId,
2283
+ success: true,
2284
+ previousPolicy,
2285
+ newPolicy: policy
2286
+ };
2287
+ } catch (err) {
2288
+ return {
2289
+ vmId,
2290
+ success: false,
2291
+ previousPolicy,
2292
+ newPolicy: policy,
2293
+ error: err instanceof VmsanError ? err : void 0
2294
+ };
2295
+ }
2296
+ });
2297
+ }
2298
+ async remove(vmId, opts) {
2299
+ const state = this.store.load(vmId);
2300
+ if (!state) return {
2301
+ vmId,
2302
+ success: false,
2303
+ error: vmNotFoundError(vmId)
2304
+ };
2305
+ try {
2306
+ const force = opts?.force ?? false;
2307
+ await this.hooks.callHook("vm:beforeRemove", {
2308
+ vmId,
2309
+ state,
2310
+ force
2311
+ });
2312
+ if (state.status !== "stopped") {
2313
+ if (!force) return {
2314
+ vmId,
2315
+ success: false,
2316
+ error: vmNotStoppedError(vmId, state.status)
2317
+ };
2318
+ const stopResult = await this.stop(vmId);
2319
+ if (!stopResult.success) return stopResult;
2320
+ }
2321
+ cleanupChroot(state.chrootDir);
2322
+ this.store.delete(vmId);
2323
+ await this.hooks.callHook("vm:afterRemove", { vmId });
2324
+ return {
2325
+ vmId,
2326
+ success: true
2327
+ };
2328
+ } catch (err) {
2329
+ await this.hooks.callHook("vm:error", {
2330
+ vmId,
2331
+ error: toError(err),
2332
+ phase: "remove"
2333
+ });
2334
+ return {
2335
+ vmId,
2336
+ success: false,
2337
+ error: err instanceof VmsanError ? err : void 0
2338
+ };
2339
+ }
2340
+ }
2341
+ buildCgroupConfig(vcpus, memMib) {
2342
+ return {
2343
+ cpuQuotaUs: vcpus * 1e5,
2344
+ cpuPeriodUs: 1e5,
2345
+ memoryBytes: (memMib + CGROUP_VMM_OVERHEAD_MIB) * 1024 * 1024
2346
+ };
2347
+ }
2348
+ async bootVm(socketPath, netCfg, vcpus, memMib) {
2349
+ const vm = new FirecrackerClient(socketPath);
2350
+ const bootArgs = NetworkManager.bootArgs(netCfg.slot);
2351
+ this.logger.debug(`Boot args: ${bootArgs}`);
2352
+ await vm.boot("kernel/vmlinux", bootArgs);
2353
+ await vm.addDrive("rootfs", "rootfs/rootfs.ext4", true, false);
2354
+ await vm.configure(vcpus, memMib);
2355
+ await vm.addNetwork("eth0", netCfg.tapDevice, netCfg.macAddress);
2356
+ }
2357
+ markAsError(vmId, error) {
2358
+ try {
2359
+ this.store.update(vmId, {
2360
+ status: "error",
2361
+ error: toError(error).message
2362
+ });
2363
+ } catch {}
2364
+ }
2365
+ };
2366
+ async function createVmsan(options) {
2367
+ const paths = options?.paths === void 0 ? vmsanPaths() : typeof options.paths === "string" ? vmsanPaths(options.paths) : options.paths;
2368
+ const store = options?.store ?? new FileVmStateStore(paths.vmsDir);
2369
+ const logger = options?.logger ?? createDefaultLogger();
2370
+ const ctx = {
2371
+ paths,
2372
+ store,
2373
+ hooks: createHooks(),
2374
+ logger
2375
+ };
2376
+ const vmsan = new VMService(ctx);
2377
+ if (options?.plugins) for (const plugin of options.plugins) await plugin.setup(ctx);
2378
+ return vmsan;
2379
+ }
2380
+ export { createSilentLogger as C, createDefaultLogger as S, waitForSocket as _, resolveImageRootfs as a, FileLock as b, cleanupNetwork as c, assertSnapshotExists as d, findKernel as f, validateEnvironment as g, getVmPid as h, ensureSeccompFilter as i, killOrphanVmProcess as l, getVmJailerPid as m, VMService as n, buildInitialVmState as o, findRootfs as p, compileSeccompFilter as r, cleanupChroot as s, createVmsan as t, markVmAsError as u, Jailer as v, NetworkManager as x, detectCgroupVersion as y };