vmsan 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +219 -0
- package/dist/_chunks/agent.mjs +121 -0
- package/dist/_chunks/cleanup.mjs +328 -0
- package/dist/_chunks/connect.mjs +13 -0
- package/dist/_chunks/connect2.mjs +72 -0
- package/dist/_chunks/create.mjs +352 -0
- package/dist/_chunks/download.mjs +84 -0
- package/dist/_chunks/environment.mjs +1064 -0
- package/dist/_chunks/errors.mjs +232 -0
- package/dist/_chunks/firecracker.mjs +110 -0
- package/dist/_chunks/image-rootfs.mjs +329 -0
- package/dist/_chunks/list.mjs +83 -0
- package/dist/_chunks/logger.mjs +75 -0
- package/dist/_chunks/network.mjs +79 -0
- package/dist/_chunks/paths.mjs +36 -0
- package/dist/_chunks/remove.mjs +88 -0
- package/dist/_chunks/rolldown-runtime.mjs +11 -0
- package/dist/_chunks/shell.mjs +152 -0
- package/dist/_chunks/start.mjs +192 -0
- package/dist/_chunks/stop.mjs +76 -0
- package/dist/_chunks/upload.mjs +81 -0
- package/dist/_chunks/validation.mjs +125 -0
- package/dist/_chunks/vm-state.mjs +206 -0
- package/dist/_chunks/vm.mjs +208 -0
- package/dist/bin/cli.d.mts +1 -0
- package/dist/bin/cli.mjs +52 -0
- package/dist/index.d.mts +2892 -0
- package/dist/index.mjs +42 -0
- package/package.json +69 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-present Angelo Recca
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# vmsan
|
|
2
|
+
|
|
3
|
+
<!-- automd:badges color="yellow" license licenseSrc bundlephobia packagephobia -->
|
|
4
|
+
|
|
5
|
+
[](https://npmjs.com/package/vmsan)
|
|
6
|
+
[](https://npm.chart.dev/vmsan)
|
|
7
|
+
[](https://bundlephobia.com/package/vmsan)
|
|
8
|
+
[](https://packagephobia.com/result?p=vmsan)
|
|
9
|
+
[](https://github.com/angelorc/vmsan/blob/main/LICENSE)
|
|
10
|
+
|
|
11
|
+
<!-- /automd -->
|
|
12
|
+
|
|
13
|
+
Firecracker microVM sandbox toolkit. Create, manage, and connect to isolated Firecracker microVMs from the command line.
|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
|
|
17
|
+
- Full VM lifecycle management (create, start, stop, remove)
|
|
18
|
+
- Network isolation with policy-based controls (allow-all, deny-all, custom domain/CIDR allowlists)
|
|
19
|
+
- Interactive shell access via WebSocket PTY
|
|
20
|
+
- File upload/download to running VMs
|
|
21
|
+
- Command execution with streaming output
|
|
22
|
+
- Multiple runtimes: `base`, `node22`, `python3.13`
|
|
23
|
+
- Docker image support via `--from-image`
|
|
24
|
+
- VM snapshots
|
|
25
|
+
- Structured JSON output for scripting
|
|
26
|
+
|
|
27
|
+
## Prerequisites
|
|
28
|
+
|
|
29
|
+
- Linux (x86_64 or aarch64) with KVM support
|
|
30
|
+
- [Bun](https://bun.sh) >= 1.2
|
|
31
|
+
- [Go](https://go.dev) >= 1.22 (to build the in-VM agent)
|
|
32
|
+
- Root/sudo access (required for TAP device networking and jailer)
|
|
33
|
+
- `squashfs-tools` (for rootfs conversion during install)
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
### 1. Install Firecracker, kernel, and rootfs
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
curl -fsSL https://raw.githubusercontent.com/angelorc/vmsan/main/install.sh | bash
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
This downloads and installs into `~/.vmsan/`:
|
|
44
|
+
|
|
45
|
+
- Firecracker + Jailer (latest release)
|
|
46
|
+
- Linux kernel (vmlinux 6.1)
|
|
47
|
+
- Ubuntu 24.04 rootfs (converted from squashfs to ext4)
|
|
48
|
+
|
|
49
|
+
### 2. Install vmsan CLI
|
|
50
|
+
|
|
51
|
+
<!-- automd:pm-install -->
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
# ✨ Auto-detect
|
|
55
|
+
npx nypm install vmsan
|
|
56
|
+
|
|
57
|
+
# npm
|
|
58
|
+
npm install vmsan
|
|
59
|
+
|
|
60
|
+
# yarn
|
|
61
|
+
yarn add vmsan
|
|
62
|
+
|
|
63
|
+
# pnpm
|
|
64
|
+
pnpm add vmsan
|
|
65
|
+
|
|
66
|
+
# bun
|
|
67
|
+
bun install vmsan
|
|
68
|
+
|
|
69
|
+
# deno
|
|
70
|
+
deno install npm:vmsan
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
<!-- /automd -->
|
|
74
|
+
|
|
75
|
+
### 3. Build the in-VM agent
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
cd agent
|
|
79
|
+
make install
|
|
80
|
+
cd ..
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Link globally (optional)
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
bun link
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
This makes the `vmsan` command available system-wide.
|
|
90
|
+
|
|
91
|
+
## Usage
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
# Create and start a VM
|
|
95
|
+
vmsan create --runtime node22 --memory 512 --cpus 2
|
|
96
|
+
|
|
97
|
+
# Create a VM from a Docker image
|
|
98
|
+
vmsan create --from-image node:22-alpine
|
|
99
|
+
|
|
100
|
+
# List all VMs
|
|
101
|
+
vmsan list
|
|
102
|
+
|
|
103
|
+
# Connect to a running VM shell
|
|
104
|
+
vmsan connect <vm-id>
|
|
105
|
+
|
|
106
|
+
# Upload a file to a VM
|
|
107
|
+
vmsan upload <vm-id> ./local-file.txt /remote/path/file.txt
|
|
108
|
+
|
|
109
|
+
# Download a file from a VM
|
|
110
|
+
vmsan download <vm-id> /remote/path/file.txt ./local-file.txt
|
|
111
|
+
|
|
112
|
+
# Stop a VM
|
|
113
|
+
vmsan stop <vm-id>
|
|
114
|
+
|
|
115
|
+
# Remove a VM
|
|
116
|
+
vmsan remove <vm-id>
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Global flags
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
--json Output structured JSON
|
|
123
|
+
--verbose Show detailed debug output
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Commands
|
|
127
|
+
|
|
128
|
+
| Command | Alias | Description |
|
|
129
|
+
| ---------- | ----- | --------------------------------- |
|
|
130
|
+
| `create` | | Create and start a new microVM |
|
|
131
|
+
| `list` | `ls` | List all VMs |
|
|
132
|
+
| `start` | | Start a stopped VM |
|
|
133
|
+
| `stop` | | Stop a running VM |
|
|
134
|
+
| `remove` | `rm` | Remove a VM |
|
|
135
|
+
| `connect` | | Open an interactive shell to a VM |
|
|
136
|
+
| `upload` | | Upload files to a VM |
|
|
137
|
+
| `download` | | Download files from a VM |
|
|
138
|
+
|
|
139
|
+
## Development
|
|
140
|
+
|
|
141
|
+
To use your local build instead of the installed one, link it:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
bun run build
|
|
145
|
+
ln -sf "$(pwd)/dist/bin/cli.mjs" ~/.vmsan/bin/vmsan
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
# Dev mode (watch)
|
|
150
|
+
bun run dev
|
|
151
|
+
|
|
152
|
+
# Run tests
|
|
153
|
+
bun run test
|
|
154
|
+
|
|
155
|
+
# Type check
|
|
156
|
+
bun run typecheck
|
|
157
|
+
|
|
158
|
+
# Lint
|
|
159
|
+
bun run lint
|
|
160
|
+
|
|
161
|
+
# Format
|
|
162
|
+
bun run fmt
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Project structure
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
bin/ CLI entry point
|
|
169
|
+
src/
|
|
170
|
+
commands/ CLI subcommands
|
|
171
|
+
services/ Firecracker client, agent client, VM service
|
|
172
|
+
lib/ Utilities (jailer, networking, shell, logging)
|
|
173
|
+
errors/ Typed error system
|
|
174
|
+
generated/ Firecracker API type definitions
|
|
175
|
+
agent/ Go agent that runs inside the VM
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## How it works
|
|
179
|
+
|
|
180
|
+
1. **vmsan** uses [Firecracker](https://github.com/firecracker-microvm/firecracker) to create lightweight microVMs with a jailer for security isolation
|
|
181
|
+
2. Each VM gets a TAP network device with its own `/30` subnet (`172.16.{slot}.0/30`)
|
|
182
|
+
3. A Go-based **agent** runs inside the VM, exposing an HTTP API on port 9119 for command execution, file operations, and shell access
|
|
183
|
+
4. The CLI communicates with the agent over the host-guest network to manage the VM
|
|
184
|
+
|
|
185
|
+
State is persisted in `~/.vmsan/`:
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
~/.vmsan/
|
|
189
|
+
vms/ VM state files (JSON)
|
|
190
|
+
jailer/ Chroot directories
|
|
191
|
+
bin/ Agent binary
|
|
192
|
+
kernels/ VM kernel images
|
|
193
|
+
rootfs/ Base root filesystems
|
|
194
|
+
registry/ Docker image rootfs cache
|
|
195
|
+
snapshots/ VM snapshots
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## License
|
|
199
|
+
|
|
200
|
+
[MIT](./LICENSE)
|
|
201
|
+
|
|
202
|
+
<!-- automd:contributors author="angelorc" license="MIT" -->
|
|
203
|
+
|
|
204
|
+
Published under the [MIT](https://github.com/angelorc/vmsan/blob/main/LICENSE) license.
|
|
205
|
+
Made by [@angelorc](https://github.com/angelorc) and [community](https://github.com/angelorc/vmsan/graphs/contributors) 💛
|
|
206
|
+
<br><br>
|
|
207
|
+
<a href="https://github.com/angelorc/vmsan/graphs/contributors">
|
|
208
|
+
<img src="https://contrib.rocks/image?repo=angelorc/vmsan" />
|
|
209
|
+
</a>
|
|
210
|
+
|
|
211
|
+
<!-- /automd -->
|
|
212
|
+
|
|
213
|
+
<!-- automd:with-automd -->
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
_🤖 auto updated with [automd](https://automd.unjs.io)_
|
|
218
|
+
|
|
219
|
+
<!-- /automd -->
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { createGzip } from "node:zlib";
|
|
2
|
+
import { Readable } from "node:stream";
|
|
3
|
+
import { pack } from "tar-stream";
|
|
4
|
+
var AgentClient = class {
|
|
5
|
+
constructor(baseUrl, token) {
|
|
6
|
+
this.baseUrl = baseUrl;
|
|
7
|
+
this.token = token;
|
|
8
|
+
}
|
|
9
|
+
async health() {
|
|
10
|
+
const res = await fetch(`${this.baseUrl}/health`);
|
|
11
|
+
if (!res.ok) throw new Error(`Agent health check failed: ${res.status}`);
|
|
12
|
+
return res.json();
|
|
13
|
+
}
|
|
14
|
+
async *run(params) {
|
|
15
|
+
const res = await fetch(`${this.baseUrl}/exec`, {
|
|
16
|
+
method: "POST",
|
|
17
|
+
headers: {
|
|
18
|
+
"Content-Type": "application/json",
|
|
19
|
+
Authorization: `Bearer ${this.token}`
|
|
20
|
+
},
|
|
21
|
+
body: JSON.stringify(params)
|
|
22
|
+
});
|
|
23
|
+
if (!res.ok) {
|
|
24
|
+
const text = await res.text();
|
|
25
|
+
throw new Error(`Agent exec failed (${res.status}): ${text}`);
|
|
26
|
+
}
|
|
27
|
+
if (!res.body) throw new Error("Agent exec returned no body");
|
|
28
|
+
const reader = res.body.getReader();
|
|
29
|
+
const decoder = new TextDecoder();
|
|
30
|
+
let buffer = "";
|
|
31
|
+
while (true) {
|
|
32
|
+
const { done, value } = await reader.read();
|
|
33
|
+
if (done) break;
|
|
34
|
+
buffer += decoder.decode(value, { stream: true });
|
|
35
|
+
const lines = buffer.split("\n");
|
|
36
|
+
buffer = lines.pop() || "";
|
|
37
|
+
for (const line of lines) {
|
|
38
|
+
const trimmed = line.trim();
|
|
39
|
+
if (trimmed) yield JSON.parse(trimmed);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (buffer.trim()) yield JSON.parse(buffer.trim());
|
|
43
|
+
}
|
|
44
|
+
async killCommand(cmdId, signal) {
|
|
45
|
+
const url = `${this.baseUrl}/exec/${cmdId}/kill`;
|
|
46
|
+
const res = await fetch(url, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: {
|
|
49
|
+
"Content-Type": "application/json",
|
|
50
|
+
Authorization: `Bearer ${this.token}`
|
|
51
|
+
},
|
|
52
|
+
body: signal ? JSON.stringify({ signal }) : void 0
|
|
53
|
+
});
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
const text = await res.text();
|
|
56
|
+
throw new Error(`Agent kill failed (${res.status}): ${text}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async writeFiles(files, extractDir) {
|
|
60
|
+
const tarPack = pack();
|
|
61
|
+
for (const file of files) tarPack.entry({ name: file.path }, file.content);
|
|
62
|
+
tarPack.finalize();
|
|
63
|
+
const gzipped = await tarToGzipBuffer(tarPack);
|
|
64
|
+
const headers = {
|
|
65
|
+
"Content-Type": "application/gzip",
|
|
66
|
+
Authorization: `Bearer ${this.token}`
|
|
67
|
+
};
|
|
68
|
+
if (extractDir) headers["X-Extract-Dir"] = extractDir;
|
|
69
|
+
const res = await fetch(`${this.baseUrl}/files/write`, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers,
|
|
72
|
+
body: new Uint8Array(gzipped)
|
|
73
|
+
});
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
const text = await res.text();
|
|
76
|
+
throw new Error(`Agent writeFiles failed (${res.status}): ${text}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async listShellSessions() {
|
|
80
|
+
const res = await fetch(`${this.baseUrl}/shell/sessions`, { headers: { Authorization: `Bearer ${this.token}` } });
|
|
81
|
+
if (!res.ok) {
|
|
82
|
+
const text = await res.text();
|
|
83
|
+
throw new Error(`Agent listShellSessions failed (${res.status}): ${text}`);
|
|
84
|
+
}
|
|
85
|
+
return res.json();
|
|
86
|
+
}
|
|
87
|
+
async killShellSession(sessionId) {
|
|
88
|
+
const res = await fetch(`${this.baseUrl}/shell/sessions/${sessionId}/kill`, {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: { Authorization: `Bearer ${this.token}` }
|
|
91
|
+
});
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
const text = await res.text();
|
|
94
|
+
throw new Error(`Agent killShellSession failed (${res.status}): ${text}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async readFile(path) {
|
|
98
|
+
const res = await fetch(`${this.baseUrl}/files/read`, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: {
|
|
101
|
+
"Content-Type": "application/json",
|
|
102
|
+
Authorization: `Bearer ${this.token}`
|
|
103
|
+
},
|
|
104
|
+
body: JSON.stringify({ path })
|
|
105
|
+
});
|
|
106
|
+
if (res.status === 404) return null;
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
const text = await res.text();
|
|
109
|
+
throw new Error(`Agent readFile failed (${res.status}): ${text}`);
|
|
110
|
+
}
|
|
111
|
+
return Buffer.from(await res.arrayBuffer());
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
async function tarToGzipBuffer(tarPack) {
|
|
115
|
+
const gzip = createGzip();
|
|
116
|
+
Readable.from(tarPack).pipe(gzip);
|
|
117
|
+
const chunks = [];
|
|
118
|
+
for await (const chunk of gzip) chunks.push(Buffer.from(chunk));
|
|
119
|
+
return Buffer.concat(chunks);
|
|
120
|
+
}
|
|
121
|
+
export { AgentClient as t };
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { o as safeKill, t as FileVmStateStore } from "./vm-state.mjs";
|
|
2
|
+
import { a as getVmPid, c as NetworkManager, i as getVmJailerPid } from "./environment.mjs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { execFileSync, execSync } from "node:child_process";
|
|
5
|
+
import { copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
6
|
+
/**
|
|
7
|
+
* Template generators for the node22-demo runtime welcome page.
|
|
8
|
+
* All functions are pure and return string content ready to write to files.
|
|
9
|
+
*/
|
|
10
|
+
function generateWelcomeHtml(vmId, ports) {
|
|
11
|
+
ports.map((p) => `<li>${p}</li>`).join("\n ");
|
|
12
|
+
return `<!DOCTYPE html>
|
|
13
|
+
<html lang="en">
|
|
14
|
+
<head>
|
|
15
|
+
<meta charset="utf-8">
|
|
16
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
17
|
+
<title>vmsan VM ${vmId}</title>
|
|
18
|
+
<style>
|
|
19
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
20
|
+
body {
|
|
21
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
22
|
+
background: #0f172a;
|
|
23
|
+
color: #e2e8f0;
|
|
24
|
+
min-height: 100vh;
|
|
25
|
+
display: flex;
|
|
26
|
+
align-items: center;
|
|
27
|
+
justify-content: center;
|
|
28
|
+
padding: 2rem;
|
|
29
|
+
}
|
|
30
|
+
.container { max-width: 640px; width: 100%; }
|
|
31
|
+
.header { text-align: center; margin-bottom: 2rem; }
|
|
32
|
+
.logo {
|
|
33
|
+
font-size: 2.5rem;
|
|
34
|
+
font-weight: 800;
|
|
35
|
+
background: linear-gradient(135deg, #f97316, #ef4444);
|
|
36
|
+
-webkit-background-clip: text;
|
|
37
|
+
-webkit-text-fill-color: transparent;
|
|
38
|
+
background-clip: text;
|
|
39
|
+
}
|
|
40
|
+
.subtitle { color: #94a3b8; margin-top: 0.5rem; font-size: 1.1rem; }
|
|
41
|
+
.card {
|
|
42
|
+
background: #1e293b;
|
|
43
|
+
border: 1px solid #334155;
|
|
44
|
+
border-radius: 12px;
|
|
45
|
+
padding: 1.5rem;
|
|
46
|
+
margin-bottom: 1.25rem;
|
|
47
|
+
}
|
|
48
|
+
.card h2 { font-size: 1rem; color: #f97316; margin-bottom: 0.75rem; }
|
|
49
|
+
.info-row { display: flex; justify-content: space-between; padding: 0.35rem 0; }
|
|
50
|
+
.info-label { color: #94a3b8; }
|
|
51
|
+
.info-value { font-family: monospace; color: #e2e8f0; }
|
|
52
|
+
ul { list-style: none; }
|
|
53
|
+
ul li { padding: 0.25rem 0; }
|
|
54
|
+
code {
|
|
55
|
+
background: #0f172a;
|
|
56
|
+
border: 1px solid #334155;
|
|
57
|
+
border-radius: 6px;
|
|
58
|
+
padding: 0.2rem 0.5rem;
|
|
59
|
+
font-size: 0.875rem;
|
|
60
|
+
color: #f97316;
|
|
61
|
+
}
|
|
62
|
+
.steps li { padding: 0.5rem 0; color: #cbd5e1; }
|
|
63
|
+
.steps li strong { color: #e2e8f0; }
|
|
64
|
+
.footer { text-align: center; color: #475569; font-size: 0.85rem; margin-top: 1.5rem; }
|
|
65
|
+
</style>
|
|
66
|
+
</head>
|
|
67
|
+
<body>
|
|
68
|
+
<div class="container">
|
|
69
|
+
<div class="header">
|
|
70
|
+
<div class="logo">vmsan</div>
|
|
71
|
+
<div class="subtitle">Your microVM is running</div>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="card">
|
|
74
|
+
<h2>VM Info</h2>
|
|
75
|
+
<div class="info-row">
|
|
76
|
+
<span class="info-label">VM ID</span>
|
|
77
|
+
<span class="info-value">${vmId}</span>
|
|
78
|
+
</div>
|
|
79
|
+
<div class="info-row">
|
|
80
|
+
<span class="info-label">Runtime</span>
|
|
81
|
+
<span class="info-value">node22-demo</span>
|
|
82
|
+
</div>
|
|
83
|
+
<div class="info-row">
|
|
84
|
+
<span class="info-label">Published Ports</span>
|
|
85
|
+
<span class="info-value">${ports.join(", ")}</span>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
<div class="card">
|
|
89
|
+
<h2>Next Steps</h2>
|
|
90
|
+
<ul class="steps">
|
|
91
|
+
<li><strong>Connect to the VM:</strong> <code>vmsan connect ${vmId}</code></li>
|
|
92
|
+
<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>
|
|
93
|
+
<li><strong>Stop this page:</strong> <code>systemctl stop vmsan-welcome</code></li>
|
|
94
|
+
</ul>
|
|
95
|
+
</div>
|
|
96
|
+
<div class="footer">Powered by vmsan · Firecracker microVMs</div>
|
|
97
|
+
</div>
|
|
98
|
+
</body>
|
|
99
|
+
</html>`;
|
|
100
|
+
}
|
|
101
|
+
function generateWelcomeServer(ports) {
|
|
102
|
+
return `"use strict";
|
|
103
|
+
const http = require("node:http");
|
|
104
|
+
const fs = require("node:fs");
|
|
105
|
+
const path = require("node:path");
|
|
106
|
+
|
|
107
|
+
const html = fs.readFileSync(path.join(__dirname, "index.html"), "utf-8");
|
|
108
|
+
|
|
109
|
+
const server = http.createServer((req, res) => {
|
|
110
|
+
res.writeHead(200, {
|
|
111
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
112
|
+
"Cache-Control": "no-cache",
|
|
113
|
+
});
|
|
114
|
+
res.end(html);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
${ports.map((p) => `server.listen(${p}, "0.0.0.0", () => console.log("vmsan-welcome listening on 0.0.0.0:${p}"));`).join("\n")}
|
|
118
|
+
`;
|
|
119
|
+
}
|
|
120
|
+
function generateWelcomeService(ports) {
|
|
121
|
+
return `[Unit]
|
|
122
|
+
Description=${`vmsan welcome page on port(s) ${ports.join(", ")}`}
|
|
123
|
+
After=network.target
|
|
124
|
+
|
|
125
|
+
[Service]
|
|
126
|
+
Type=simple
|
|
127
|
+
ExecStart=/usr/local/bin/node /opt/vmsan/welcome/server.js
|
|
128
|
+
Restart=on-failure
|
|
129
|
+
RestartSec=2
|
|
130
|
+
|
|
131
|
+
[Install]
|
|
132
|
+
WantedBy=multi-user.target
|
|
133
|
+
`;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Template generators for the vmsan-agent systemd service.
|
|
137
|
+
* Follows the same pattern as welcome-page.ts.
|
|
138
|
+
*/
|
|
139
|
+
function generateAgentService() {
|
|
140
|
+
return `[Unit]
|
|
141
|
+
Description=Vmsan VM Agent
|
|
142
|
+
After=network.target
|
|
143
|
+
|
|
144
|
+
[Service]
|
|
145
|
+
Type=simple
|
|
146
|
+
ExecStart=/usr/local/bin/vmsan-agent
|
|
147
|
+
EnvironmentFile=/etc/vmsan/agent.env
|
|
148
|
+
Restart=always
|
|
149
|
+
RestartSec=2
|
|
150
|
+
|
|
151
|
+
[Install]
|
|
152
|
+
WantedBy=multi-user.target
|
|
153
|
+
`;
|
|
154
|
+
}
|
|
155
|
+
function generateAgentEnv(token, port, vmId) {
|
|
156
|
+
return `VMSAN_AGENT_TOKEN=${token}
|
|
157
|
+
VMSAN_AGENT_PORT=${port}
|
|
158
|
+
VMSAN_VM_ID=${vmId}
|
|
159
|
+
`;
|
|
160
|
+
}
|
|
161
|
+
function detectCgroupVersion() {
|
|
162
|
+
try {
|
|
163
|
+
readFileSync("/sys/fs/cgroup/cgroup.controllers", "utf-8");
|
|
164
|
+
return 2;
|
|
165
|
+
} catch {
|
|
166
|
+
return 1;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
var Jailer = class {
|
|
170
|
+
paths;
|
|
171
|
+
constructor(vmId, jailerBaseDir) {
|
|
172
|
+
this.vmId = vmId;
|
|
173
|
+
const chrootBase = jailerBaseDir;
|
|
174
|
+
const chrootDir = join(chrootBase, "firecracker", vmId);
|
|
175
|
+
const rootDir = join(chrootDir, "root");
|
|
176
|
+
const kernelDir = join(rootDir, "kernel");
|
|
177
|
+
const rootfsDir = join(rootDir, "rootfs");
|
|
178
|
+
const socketDir = join(rootDir, "run");
|
|
179
|
+
const snapshotDir = join(rootDir, "snapshot");
|
|
180
|
+
this.paths = {
|
|
181
|
+
chrootBase,
|
|
182
|
+
chrootDir,
|
|
183
|
+
rootDir,
|
|
184
|
+
kernelDir,
|
|
185
|
+
kernelPath: join(kernelDir, "vmlinux"),
|
|
186
|
+
rootfsDir,
|
|
187
|
+
rootfsPath: join(rootfsDir, "rootfs.ext4"),
|
|
188
|
+
socketDir,
|
|
189
|
+
socketPath: join(socketDir, "firecracker.socket"),
|
|
190
|
+
snapshotDir
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
prepare(config) {
|
|
194
|
+
const paths = this.paths;
|
|
195
|
+
mkdirSync(paths.kernelDir, { recursive: true });
|
|
196
|
+
mkdirSync(paths.rootfsDir, { recursive: true });
|
|
197
|
+
mkdirSync(paths.socketDir, { recursive: true });
|
|
198
|
+
if (!existsSync(paths.kernelPath)) linkSync(config.kernelSrc, paths.kernelPath);
|
|
199
|
+
copyFileSync(config.rootfsSrc, paths.rootfsPath);
|
|
200
|
+
if (typeof config.diskSizeGb === "number" && Number.isFinite(config.diskSizeGb)) {
|
|
201
|
+
const targetBytes = Math.trunc(config.diskSizeGb * 1024 * 1024 * 1024);
|
|
202
|
+
if (targetBytes > statSync(paths.rootfsPath).size) {
|
|
203
|
+
execSync(`truncate -s ${targetBytes} "${paths.rootfsPath}"`, { stdio: "pipe" });
|
|
204
|
+
execSync(`sudo e2fsck -fy "${paths.rootfsPath}"; [ $? -lt 4 ]`, { stdio: "pipe" });
|
|
205
|
+
execSync(`sudo resize2fs "${paths.rootfsPath}"`, { stdio: "pipe" });
|
|
206
|
+
execSync(`sudo tune2fs -m 0 "${paths.rootfsPath}"`, { stdio: "pipe" });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const tmpMount = join(paths.rootDir, "tmp-mount");
|
|
210
|
+
mkdirSync(tmpMount, { recursive: true });
|
|
211
|
+
try {
|
|
212
|
+
execSync(`sudo mount -o loop "${paths.rootfsPath}" "${tmpMount}"`, { stdio: "pipe" });
|
|
213
|
+
execSync(`rm -f "${tmpMount}/etc/resolv.conf" && ln -s /proc/net/pnp "${tmpMount}/etc/resolv.conf"`, { stdio: "pipe" });
|
|
214
|
+
if (config.welcomePage) {
|
|
215
|
+
const { vmId: welcomeVmId, ports: welcomePorts } = config.welcomePage;
|
|
216
|
+
const welcomeDir = join(tmpMount, "opt", "vmsan", "welcome");
|
|
217
|
+
mkdirSync(welcomeDir, { recursive: true });
|
|
218
|
+
writeFileSync(join(welcomeDir, "index.html"), generateWelcomeHtml(welcomeVmId, welcomePorts));
|
|
219
|
+
writeFileSync(join(welcomeDir, "server.js"), generateWelcomeServer(welcomePorts));
|
|
220
|
+
const systemdDir = join(tmpMount, "etc", "systemd", "system");
|
|
221
|
+
mkdirSync(systemdDir, { recursive: true });
|
|
222
|
+
writeFileSync(join(systemdDir, "vmsan-welcome.service"), generateWelcomeService(welcomePorts));
|
|
223
|
+
const wantsDir = join(systemdDir, "multi-user.target.wants");
|
|
224
|
+
mkdirSync(wantsDir, { recursive: true });
|
|
225
|
+
execSync(`ln -sf /etc/systemd/system/vmsan-welcome.service "${join(wantsDir, "vmsan-welcome.service")}"`, { stdio: "pipe" });
|
|
226
|
+
}
|
|
227
|
+
if (config.agent) {
|
|
228
|
+
const agentDst = join(tmpMount, "usr", "local", "bin", "vmsan-agent");
|
|
229
|
+
mkdirSync(join(tmpMount, "usr", "local", "bin"), { recursive: true });
|
|
230
|
+
copyFileSync(config.agent.binaryPath, agentDst);
|
|
231
|
+
execSync(`chmod 755 "${agentDst}"`, { stdio: "pipe" });
|
|
232
|
+
const envDir = join(tmpMount, "etc", "vmsan");
|
|
233
|
+
mkdirSync(envDir, { recursive: true });
|
|
234
|
+
writeFileSync(join(envDir, "agent.env"), generateAgentEnv(config.agent.token, config.agent.port, config.agent.vmId));
|
|
235
|
+
const systemdDir = join(tmpMount, "etc", "systemd", "system");
|
|
236
|
+
mkdirSync(systemdDir, { recursive: true });
|
|
237
|
+
writeFileSync(join(systemdDir, "vmsan-agent.service"), generateAgentService());
|
|
238
|
+
const wantsDir = join(systemdDir, "multi-user.target.wants");
|
|
239
|
+
mkdirSync(wantsDir, { recursive: true });
|
|
240
|
+
execSync(`ln -sf /etc/systemd/system/vmsan-agent.service "${join(wantsDir, "vmsan-agent.service")}"`, { stdio: "pipe" });
|
|
241
|
+
}
|
|
242
|
+
execSync(`sudo umount "${tmpMount}"`, { stdio: "pipe" });
|
|
243
|
+
} catch {
|
|
244
|
+
try {
|
|
245
|
+
execSync(`sudo umount "${tmpMount}" 2>/dev/null`, { stdio: "pipe" });
|
|
246
|
+
} catch {}
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
execSync(`rm -rf "${tmpMount}"`, { stdio: "pipe" });
|
|
250
|
+
} catch {}
|
|
251
|
+
if (config.snapshot) {
|
|
252
|
+
mkdirSync(paths.snapshotDir, { recursive: true });
|
|
253
|
+
copyFileSync(config.snapshot.snapshotFile, join(paths.snapshotDir, "snapshot_file"));
|
|
254
|
+
copyFileSync(config.snapshot.memFile, join(paths.snapshotDir, "mem_file"));
|
|
255
|
+
}
|
|
256
|
+
return paths;
|
|
257
|
+
}
|
|
258
|
+
spawn(config) {
|
|
259
|
+
const uid = config.uid ?? 0;
|
|
260
|
+
const gid = config.gid ?? 0;
|
|
261
|
+
const args = [
|
|
262
|
+
config.jailerBin,
|
|
263
|
+
"--exec-file",
|
|
264
|
+
config.firecrackerBin,
|
|
265
|
+
"--id",
|
|
266
|
+
this.vmId,
|
|
267
|
+
"--uid",
|
|
268
|
+
String(uid),
|
|
269
|
+
"--gid",
|
|
270
|
+
String(gid),
|
|
271
|
+
"--chroot-base-dir",
|
|
272
|
+
config.chrootBase,
|
|
273
|
+
"--daemonize"
|
|
274
|
+
];
|
|
275
|
+
if (config.newPidNs !== false) args.push("--new-pid-ns");
|
|
276
|
+
if (config.netns) args.push("--netns", `/var/run/netns/${config.netns}`);
|
|
277
|
+
if (config.cgroup) if (detectCgroupVersion() === 2) {
|
|
278
|
+
args.push("--cgroup-version", "2");
|
|
279
|
+
args.push("--cgroup", `cpu.max=${config.cgroup.cpuQuotaUs} ${config.cgroup.cpuPeriodUs}`);
|
|
280
|
+
args.push("--cgroup", `memory.max=${config.cgroup.memoryBytes}`);
|
|
281
|
+
} else {
|
|
282
|
+
args.push("--cgroup", `cpu.cfs_quota_us=${config.cgroup.cpuQuotaUs}`);
|
|
283
|
+
args.push("--cgroup", `cpu.cfs_period_us=${config.cgroup.cpuPeriodUs}`);
|
|
284
|
+
args.push("--cgroup", `memory.limit_in_bytes=${config.cgroup.memoryBytes}`);
|
|
285
|
+
}
|
|
286
|
+
args.push("--", "--api-sock", "run/firecracker.socket");
|
|
287
|
+
if (config.seccompFilter && existsSync(config.seccompFilter)) args.push("--seccomp-filter", config.seccompFilter);
|
|
288
|
+
else args.push("--no-seccomp");
|
|
289
|
+
execFileSync("sudo", args, { stdio: "pipe" });
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
function killOrphanVmProcess(vmId) {
|
|
293
|
+
const orphanPid = getVmPid(vmId);
|
|
294
|
+
const orphanJailerPid = getVmJailerPid(vmId);
|
|
295
|
+
if (orphanPid) safeKill(orphanPid, "SIGKILL");
|
|
296
|
+
if (orphanJailerPid) safeKill(orphanJailerPid, "SIGKILL");
|
|
297
|
+
}
|
|
298
|
+
function markVmAsError(vmId, error, paths) {
|
|
299
|
+
try {
|
|
300
|
+
new FileVmStateStore(paths.vmsDir).update(vmId, {
|
|
301
|
+
status: "error",
|
|
302
|
+
error: error instanceof Error ? error.message : String(error)
|
|
303
|
+
});
|
|
304
|
+
} catch {}
|
|
305
|
+
}
|
|
306
|
+
function cleanupNetwork(networkConfig) {
|
|
307
|
+
if (!networkConfig) return;
|
|
308
|
+
try {
|
|
309
|
+
NetworkManager.fromConfig(networkConfig).teardown();
|
|
310
|
+
} catch {}
|
|
311
|
+
}
|
|
312
|
+
function cleanupChroot(chrootDir) {
|
|
313
|
+
if (!chrootDir) return;
|
|
314
|
+
const vmJailerDir = dirname(chrootDir);
|
|
315
|
+
try {
|
|
316
|
+
rmSync(chrootDir, {
|
|
317
|
+
recursive: true,
|
|
318
|
+
force: true
|
|
319
|
+
});
|
|
320
|
+
} catch {}
|
|
321
|
+
try {
|
|
322
|
+
rmSync(vmJailerDir, {
|
|
323
|
+
recursive: true,
|
|
324
|
+
force: true
|
|
325
|
+
});
|
|
326
|
+
} catch {}
|
|
327
|
+
}
|
|
328
|
+
export { Jailer as a, markVmAsError as i, cleanupNetwork as n, detectCgroupVersion as o, killOrphanVmProcess as r, cleanupChroot as t };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { l as agentTimeoutError } from "./errors.mjs";
|
|
2
|
+
async function waitForAgent(guestIp, port, timeoutMs = 6e4) {
|
|
3
|
+
const start = Date.now();
|
|
4
|
+
const url = `http://${guestIp}:${port}/health`;
|
|
5
|
+
while (Date.now() - start < timeoutMs) {
|
|
6
|
+
try {
|
|
7
|
+
if ((await fetch(url, { signal: AbortSignal.timeout(2e3) })).ok) return;
|
|
8
|
+
} catch {}
|
|
9
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
10
|
+
}
|
|
11
|
+
throw agentTimeoutError(guestIp, timeoutMs);
|
|
12
|
+
}
|
|
13
|
+
export { waitForAgent as t };
|