vmsan 0.1.0-alpha.13 → 0.1.0-alpha.14

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