Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4768faeec2 | |||
| f0fc9e88b7 | |||
| 4dbded8dab | |||
| 8c8b692fc1 | |||
| 69e66cba00 | |||
| 0e6384b3ee | |||
| a61694bd01 | |||
| c868d07d29 |
@@ -1,34 +0,0 @@
|
|||||||
# SmartVM Base Image Bundles
|
|
||||||
|
|
||||||
This directory documents the project-owned base image manifest format. The actual kernel and rootfs binaries should be hosted as release assets or in object storage, not committed to git.
|
|
||||||
|
|
||||||
## Bundle Layout
|
|
||||||
|
|
||||||
A hosted bundle should expose three files:
|
|
||||||
|
|
||||||
```text
|
|
||||||
smartvm-minimal-v1-x86_64.manifest.json
|
|
||||||
vmlinux
|
|
||||||
rootfs.ext4
|
|
||||||
```
|
|
||||||
|
|
||||||
The manifest is the only file shape `smartvm` needs to know. It points at the hosted kernel and rootfs artifacts and records checksums.
|
|
||||||
|
|
||||||
## Manifest Fields
|
|
||||||
|
|
||||||
- `schemaVersion`: currently `1`
|
|
||||||
- `bundleId`: stable cache key, using letters, numbers, dot, underscore, and dash only
|
|
||||||
- `arch`: `x86_64` or `aarch64`
|
|
||||||
- `firecrackerVersion`: Firecracker version validated with this bundle
|
|
||||||
- `rootfsType`: `ext4` or `squashfs`
|
|
||||||
- `rootfsIsReadOnly`: use `true` for squashfs or immutable rootfs images
|
|
||||||
- `bootArgs`: kernel boot args to use with the bundle
|
|
||||||
- `kernel`: hosted kernel artifact URL/path plus `sha256` for URL artifacts and optional `sizeBytes`
|
|
||||||
- `rootfs`: hosted rootfs artifact URL/path plus `sha256` for URL artifacts and optional `sizeBytes`
|
|
||||||
- `fileName`: optional plain output filename; path separators are rejected
|
|
||||||
|
|
||||||
`sha256` is required for hosted URL artifacts. `sizeBytes` is optional but helps catch incomplete downloads.
|
|
||||||
|
|
||||||
## Cache Behavior
|
|
||||||
|
|
||||||
Downloaded bundles are cached under `/tmp/.smartvm/base-images` by default. The cache keeps two bundles unless `maxStoredBaseImages` is configured. Eviction is announced with `console.warn`.
|
|
||||||
@@ -1,5 +1,34 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-05-01 - 1.4.1 - fix(readme)
|
||||||
|
improve documentation for Firecracker runtime, networking, and base image usage
|
||||||
|
|
||||||
|
- expand the project overview and feature summary with clearer runtime, networking, firewall, and WireGuard behavior
|
||||||
|
- clarify host requirements, root privilege expectations, and optional tooling for blank rootfs creation
|
||||||
|
- add quick-start outcome details and stronger guidance for using hosted base image manifests in production
|
||||||
|
|
||||||
|
## 2026-05-01 - 1.4.0 - feat(network)
|
||||||
|
add configurable VM egress firewall policies and WireGuard-based host routing
|
||||||
|
|
||||||
|
- adds firewall and wireguard options to SmartVM and NetworkManager configuration interfaces
|
||||||
|
- validates firewall rules, WireGuard managed configs, and unsupported hook or IPv6 settings with dedicated error codes
|
||||||
|
- sets up iptables egress chains, NAT, policy routing, and cleanup for managed VM subnet traffic
|
||||||
|
- documents the new networking capabilities and adds tests for config validation and option forwarding
|
||||||
|
|
||||||
|
## 2026-05-01 - 1.3.1 - fix(docs)
|
||||||
|
remove outdated base image bundle readme and consolidate hosted manifest documentation
|
||||||
|
|
||||||
|
- Deletes the dedicated assets/base-images/readme.md documentation file
|
||||||
|
- Keeps hosted base image manifest guidance and example usage in the main project README
|
||||||
|
|
||||||
|
## 2026-05-01 - 1.3.0 - feat(runtime)
|
||||||
|
stage VM runtime artifacts and writable drives in per-VM ephemeral storage by default
|
||||||
|
|
||||||
|
- default runtime files to /dev/shm/.smartvm/runtime when available, with per-VM socket and drive staging paths
|
||||||
|
- copy writable drives into per-VM runtime storage before boot and remove them during cleanup, with per-drive and global opt-out controls
|
||||||
|
- prefer squashfs rootfs images over ext4 when resolving Firecracker CI base images
|
||||||
|
- add tests and documentation for ephemeral drive staging and runtime directory defaults
|
||||||
|
|
||||||
## 2026-05-01 - 1.2.0 - feat(base-images)
|
## 2026-05-01 - 1.2.0 - feat(base-images)
|
||||||
add managed base image bundles with cache retention, hosted manifests, and opt-in integration boot testing
|
add managed base image bundles with cache retention, hosted manifests, and opt-in integration boot testing
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartvm",
|
"name": "@push.rocks/smartvm",
|
||||||
"version": "1.2.0",
|
"version": "1.4.1",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "A TypeScript module wrapping Amazon Firecracker VMM for managing lightweight microVMs",
|
"description": "A TypeScript module wrapping Amazon Firecracker VMM for managing lightweight microVMs",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
- `BaseImageManager` downloads Firecracker CI demo artifacts or hosted project manifests into `/tmp/.smartvm/base-images` by default
|
- `BaseImageManager` downloads Firecracker CI demo artifacts or hosted project manifests into `/tmp/.smartvm/base-images` by default
|
||||||
- Base image cache keeps 2 bundles by default and warns before evicting older bundles
|
- Base image cache keeps 2 bundles by default and warns before evicting older bundles
|
||||||
- Hosted manifest examples live in `assets/base-images/`
|
- Hosted manifest examples live in `assets/base-images/`
|
||||||
|
- VM runtime files default to `/dev/shm/.smartvm/runtime` when available
|
||||||
|
- Writable drives are staged into per-VM runtime storage by default and removed during cleanup; use `ephemeral: false` only for explicit persistence
|
||||||
|
|
||||||
## Key API Patterns
|
## Key API Patterns
|
||||||
- SmartRequest: `SmartRequest.create().url(...).json(body).put()` — response has `.status`, `.ok`, `.json()`
|
- SmartRequest: `SmartRequest.create().url(...).json(body).put()` — response has `.status`, `.ok`, `.json()`
|
||||||
|
|||||||
+228
@@ -23,6 +23,15 @@ async function getRejectedError(promise: Promise<unknown>): Promise<unknown> {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getThrownError(fn: () => unknown): unknown {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} catch (err) {
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function sha256Buffer(buffer: Buffer): string {
|
function sha256Buffer(buffer: Buffer): string {
|
||||||
return crypto.createHash('sha256').update(buffer).digest('hex');
|
return crypto.createHash('sha256').update(buffer).digest('hex');
|
||||||
}
|
}
|
||||||
@@ -656,6 +665,118 @@ tap.test('NetworkManager - getGuestNetworkBootArgs() should format correctly', a
|
|||||||
expect(bootArgs).toEqual('ip=172.30.0.2::172.30.0.1:255.255.255.0::eth0:off');
|
expect(bootArgs).toEqual('ip=172.30.0.2::172.30.0.1:255.255.255.0::eth0:off');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('NetworkManager - constructor should accept valid egress firewall config', async () => {
|
||||||
|
const nm = new NetworkManager({
|
||||||
|
firewall: {
|
||||||
|
egress: {
|
||||||
|
defaultAction: 'deny',
|
||||||
|
rules: [
|
||||||
|
{ action: 'allow', to: '1.1.1.1', protocol: 'tcp', ports: [80, 443] },
|
||||||
|
{ action: 'deny', to: '10.0.0.0/8' },
|
||||||
|
{ action: 'allow', protocol: 'icmp' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(nm).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('NetworkManager - constructor should reject invalid firewall CIDRs and ports', async () => {
|
||||||
|
const invalidCidrError = getThrownError(() => new NetworkManager({
|
||||||
|
firewall: {
|
||||||
|
egress: {
|
||||||
|
rules: [{ action: 'allow', to: '300.1.1.1/32' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
expect(invalidCidrError).toBeInstanceOf(SmartVMError);
|
||||||
|
expect((invalidCidrError as SmartVMError).code).toEqual('INVALID_FIREWALL_CONFIG');
|
||||||
|
|
||||||
|
const invalidPortError = getThrownError(() => new NetworkManager({
|
||||||
|
firewall: {
|
||||||
|
egress: {
|
||||||
|
rules: [{ action: 'allow', protocol: 'icmp', ports: 53 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
expect(invalidPortError).toBeInstanceOf(SmartVMError);
|
||||||
|
expect((invalidPortError as SmartVMError).code).toEqual('INVALID_FIREWALL_CONFIG');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('NetworkManager - constructor should accept valid WireGuard managed config', async () => {
|
||||||
|
const nm = new NetworkManager({
|
||||||
|
wireguard: {
|
||||||
|
interfaceName: 'svwgtest0',
|
||||||
|
routeTable: 51821,
|
||||||
|
config: `
|
||||||
|
# comments are ignored
|
||||||
|
[Interface]
|
||||||
|
PrivateKey = test-private-key
|
||||||
|
Address = 10.70.0.2/32
|
||||||
|
DNS = 1.1.1.1
|
||||||
|
MTU = 1420
|
||||||
|
Table = off
|
||||||
|
|
||||||
|
[Peer]
|
||||||
|
PublicKey = test-public-key
|
||||||
|
AllowedIPs = 0.0.0.0/0
|
||||||
|
Endpoint = 203.0.113.10:51820
|
||||||
|
PersistentKeepalive = 25
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(nm).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('NetworkManager - constructor should reject unsafe WireGuard hook fields', async () => {
|
||||||
|
const error = getThrownError(() => new NetworkManager({
|
||||||
|
wireguard: {
|
||||||
|
config: `
|
||||||
|
[Interface]
|
||||||
|
PrivateKey = test-private-key
|
||||||
|
Address = 10.70.0.2/32
|
||||||
|
PostUp = iptables -A OUTPUT -j ACCEPT
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(SmartVMError);
|
||||||
|
expect((error as SmartVMError).code).toEqual('INVALID_WIREGUARD_CONFIG');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('NetworkManager - constructor should reject IPv6 WireGuard AllowedIPs', async () => {
|
||||||
|
const error = getThrownError(() => new NetworkManager({
|
||||||
|
wireguard: {
|
||||||
|
config: `
|
||||||
|
[Interface]
|
||||||
|
PrivateKey = test-private-key
|
||||||
|
Address = 10.70.0.2/32
|
||||||
|
|
||||||
|
[Peer]
|
||||||
|
PublicKey = test-public-key
|
||||||
|
AllowedIPs = ::/0
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(SmartVMError);
|
||||||
|
expect((error as SmartVMError).code).toEqual('INVALID_WIREGUARD_CONFIG');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('NetworkManager - constructor should reject mixed WireGuard modes', async () => {
|
||||||
|
const error = getThrownError(() => new NetworkManager({
|
||||||
|
wireguard: {
|
||||||
|
existingInterface: 'wg0',
|
||||||
|
config: '[Interface]\nPrivateKey = test-private-key\nAddress = 10.70.0.2/32\n',
|
||||||
|
} as any,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(SmartVMError);
|
||||||
|
expect((error as SmartVMError).code).toEqual('INVALID_WIREGUARD_CONFIG');
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// MicroVM Tests
|
// MicroVM Tests
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -678,6 +799,84 @@ tap.test('MicroVM - invalid lifecycle calls should throw SmartVMError', async ()
|
|||||||
expect((infoError as SmartVMError).code).toEqual('NO_CLIENT');
|
expect((infoError as SmartVMError).code).toEqual('NO_CLIENT');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('MicroVM - start() should stage writable drives ephemerally and clean them up on failure', async () => {
|
||||||
|
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-ephemeral-drive-test-'));
|
||||||
|
const runtimeDir = path.join(workDir, 'runtime');
|
||||||
|
const sourceRootfs = path.join(workDir, 'rootfs.ext4');
|
||||||
|
await fs.promises.writeFile(sourceRootfs, 'persistent-rootfs');
|
||||||
|
|
||||||
|
const config: IMicroVMConfig = {
|
||||||
|
...sampleConfig,
|
||||||
|
id: 'ephemeral-vm',
|
||||||
|
drives: [
|
||||||
|
{
|
||||||
|
driveId: 'rootfs',
|
||||||
|
pathOnHost: sourceRootfs,
|
||||||
|
isRootDevice: true,
|
||||||
|
isReadOnly: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
networkInterfaces: [],
|
||||||
|
};
|
||||||
|
const vm = new MicroVM(
|
||||||
|
'ephemeral-vm',
|
||||||
|
config,
|
||||||
|
'/bin/false',
|
||||||
|
path.join(runtimeDir, 'ephemeral-vm', 'firecracker.sock'),
|
||||||
|
new NetworkManager(),
|
||||||
|
{ runtimeDir },
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const error = await getRejectedError(vm.start());
|
||||||
|
expect(error).toBeInstanceOf(SmartVMError);
|
||||||
|
expect(fs.existsSync(path.join(runtimeDir, 'ephemeral-vm'))).toBeFalse();
|
||||||
|
expect(await fs.promises.readFile(sourceRootfs, 'utf8')).toEqual('persistent-rootfs');
|
||||||
|
expect(vm.getVMConfig().config.drives![0].pathOnHost).not.toEqual(sourceRootfs);
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(workDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('MicroVM - start() should honor per-drive ephemeral opt-out', async () => {
|
||||||
|
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-persistent-drive-test-'));
|
||||||
|
const runtimeDir = path.join(workDir, 'runtime');
|
||||||
|
const sourceRootfs = path.join(workDir, 'rootfs.ext4');
|
||||||
|
await fs.promises.writeFile(sourceRootfs, 'persistent-rootfs');
|
||||||
|
|
||||||
|
const config: IMicroVMConfig = {
|
||||||
|
...sampleConfig,
|
||||||
|
id: 'persistent-vm',
|
||||||
|
drives: [
|
||||||
|
{
|
||||||
|
driveId: 'rootfs',
|
||||||
|
pathOnHost: sourceRootfs,
|
||||||
|
isRootDevice: true,
|
||||||
|
isReadOnly: false,
|
||||||
|
ephemeral: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
networkInterfaces: [],
|
||||||
|
};
|
||||||
|
const vm = new MicroVM(
|
||||||
|
'persistent-vm',
|
||||||
|
config,
|
||||||
|
'/bin/false',
|
||||||
|
path.join(runtimeDir, 'persistent-vm', 'firecracker.sock'),
|
||||||
|
new NetworkManager(),
|
||||||
|
{ runtimeDir },
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const error = await getRejectedError(vm.start());
|
||||||
|
expect(error).toBeInstanceOf(SmartVMError);
|
||||||
|
expect(vm.getVMConfig().config.drives![0].pathOnHost).toEqual(sourceRootfs);
|
||||||
|
expect(fs.existsSync(path.join(runtimeDir, 'persistent-vm'))).toBeFalse();
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(workDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// SmartVM Tests
|
// SmartVM Tests
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -688,6 +887,9 @@ tap.test('SmartVM - instantiation with defaults', async () => {
|
|||||||
expect(smartvm.imageManager).toBeTruthy();
|
expect(smartvm.imageManager).toBeTruthy();
|
||||||
expect(smartvm.baseImageManager).toBeTruthy();
|
expect(smartvm.baseImageManager).toBeTruthy();
|
||||||
expect(smartvm.networkManager).toBeTruthy();
|
expect(smartvm.networkManager).toBeTruthy();
|
||||||
|
if (fs.existsSync('/dev/shm')) {
|
||||||
|
expect(smartvm.getRuntimeDir()).toEqual('/dev/shm/.smartvm/runtime');
|
||||||
|
}
|
||||||
expect(smartvm.vmCount).toEqual(0);
|
expect(smartvm.vmCount).toEqual(0);
|
||||||
expect(smartvm.listVMs()).toHaveLength(0);
|
expect(smartvm.listVMs()).toHaveLength(0);
|
||||||
});
|
});
|
||||||
@@ -702,6 +904,32 @@ tap.test('SmartVM - instantiation with custom options', async () => {
|
|||||||
expect(smartvm).toBeTruthy();
|
expect(smartvm).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('SmartVM - should forward firewall and WireGuard options to NetworkManager', async () => {
|
||||||
|
const firewall = {
|
||||||
|
egress: {
|
||||||
|
defaultAction: 'deny' as const,
|
||||||
|
rules: [{ action: 'allow' as const, to: '1.1.1.1', protocol: 'tcp' as const, ports: 443 }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const wireguard = {
|
||||||
|
existingInterface: 'wgsmartvm0',
|
||||||
|
routeAllVmTraffic: false,
|
||||||
|
};
|
||||||
|
const smartvm = new SmartVM({
|
||||||
|
dataDir: '/tmp/smartvm-test',
|
||||||
|
firecrackerBinaryPath: '/bin/false',
|
||||||
|
firewall,
|
||||||
|
wireguard,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect((smartvm.networkManager as any).firewall).toEqual(firewall);
|
||||||
|
expect((smartvm.networkManager as any).wireguard).toEqual(wireguard);
|
||||||
|
} finally {
|
||||||
|
await smartvm.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
tap.test('SmartVM - createVM() should track created VMs', async () => {
|
tap.test('SmartVM - createVM() should track created VMs', async () => {
|
||||||
const smartvm = new SmartVM({
|
const smartvm = new SmartVM({
|
||||||
dataDir: '/tmp/smartvm-test',
|
dataDir: '/tmp/smartvm-test',
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartvm',
|
name: '@push.rocks/smartvm',
|
||||||
version: '1.2.0',
|
version: '1.4.1',
|
||||||
description: 'A TypeScript module wrapping Amazon Firecracker VMM for managing lightweight microVMs'
|
description: 'A TypeScript module wrapping Amazon Firecracker VMM for managing lightweight microVMs'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -485,14 +485,14 @@ export class BaseImageManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private selectRootfsKey(keys: string[]): string {
|
private selectRootfsKey(keys: string[]): string {
|
||||||
const ext4Keys = keys.filter((key) => /\/ubuntu-[^/]+\.ext4$/.test(key));
|
|
||||||
if (ext4Keys.length > 0) {
|
|
||||||
return ext4Keys.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).at(-1)!;
|
|
||||||
}
|
|
||||||
const squashfsKeys = keys.filter((key) => /\/ubuntu-[^/]+\.squashfs$/.test(key));
|
const squashfsKeys = keys.filter((key) => /\/ubuntu-[^/]+\.squashfs$/.test(key));
|
||||||
if (squashfsKeys.length > 0) {
|
if (squashfsKeys.length > 0) {
|
||||||
return squashfsKeys.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).at(-1)!;
|
return squashfsKeys.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).at(-1)!;
|
||||||
}
|
}
|
||||||
|
const ext4Keys = keys.filter((key) => /\/ubuntu-[^/]+\.ext4$/.test(key));
|
||||||
|
if (ext4Keys.length > 0) {
|
||||||
|
return ext4Keys.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).at(-1)!;
|
||||||
|
}
|
||||||
throw new SmartVMError('No suitable Firecracker CI rootfs image found', 'BASE_IMAGE_RESOLVE_FAILED');
|
throw new SmartVMError('No suitable Firecracker CI rootfs image found', 'BASE_IMAGE_RESOLVE_FAILED');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import * as plugins from './plugins.js';
|
|||||||
import type {
|
import type {
|
||||||
TVMState,
|
TVMState,
|
||||||
IMicroVMConfig,
|
IMicroVMConfig,
|
||||||
|
IMicroVMRuntimeOptions,
|
||||||
ISnapshotCreateParams,
|
ISnapshotCreateParams,
|
||||||
ISnapshotLoadParams,
|
ISnapshotLoadParams,
|
||||||
|
IDriveConfig,
|
||||||
ITapDevice,
|
ITapDevice,
|
||||||
} from './interfaces/index.js';
|
} from './interfaces/index.js';
|
||||||
import { SmartVMError } from './interfaces/index.js';
|
import { SmartVMError } from './interfaces/index.js';
|
||||||
@@ -26,6 +28,9 @@ export class MicroVM {
|
|||||||
private networkManager: NetworkManager;
|
private networkManager: NetworkManager;
|
||||||
private binaryPath: string;
|
private binaryPath: string;
|
||||||
private socketPath: string;
|
private socketPath: string;
|
||||||
|
private runtimeDir: string;
|
||||||
|
private ephemeralWritableDrives: boolean;
|
||||||
|
private vmRuntimeDir: string | null = null;
|
||||||
private tapDevices: ITapDevice[] = [];
|
private tapDevices: ITapDevice[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -34,12 +39,15 @@ export class MicroVM {
|
|||||||
binaryPath: string,
|
binaryPath: string,
|
||||||
socketPath: string,
|
socketPath: string,
|
||||||
networkManager: NetworkManager,
|
networkManager: NetworkManager,
|
||||||
|
runtimeOptions: IMicroVMRuntimeOptions = {},
|
||||||
) {
|
) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.vmConfig = new VMConfig(config);
|
this.vmConfig = new VMConfig(config);
|
||||||
this.binaryPath = binaryPath;
|
this.binaryPath = binaryPath;
|
||||||
this.socketPath = socketPath;
|
this.socketPath = socketPath;
|
||||||
this.networkManager = networkManager;
|
this.networkManager = networkManager;
|
||||||
|
this.runtimeDir = runtimeOptions.runtimeDir || plugins.path.join(plugins.os.tmpdir(), '.smartvm', 'runtime');
|
||||||
|
this.ephemeralWritableDrives = runtimeOptions.ephemeralWritableDrives ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,6 +91,9 @@ export class MicroVM {
|
|||||||
this.state = 'configuring';
|
this.state = 'configuring';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await this.ensureVMRuntimeDir();
|
||||||
|
await this.prepareEphemeralDrives();
|
||||||
|
|
||||||
// Start the Firecracker process
|
// Start the Firecracker process
|
||||||
this.process = new FirecrackerProcess({
|
this.process = new FirecrackerProcess({
|
||||||
binaryPath: this.binaryPath,
|
binaryPath: this.binaryPath,
|
||||||
@@ -318,6 +329,13 @@ export class MicroVM {
|
|||||||
return this.vmConfig;
|
return this.vmConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the per-VM runtime directory if it has been created.
|
||||||
|
*/
|
||||||
|
public getRuntimeDir(): string | null {
|
||||||
|
return this.vmRuntimeDir;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full cleanup: stop process, remove socket, remove TAP devices.
|
* Full cleanup: stop process, remove socket, remove TAP devices.
|
||||||
*/
|
*/
|
||||||
@@ -334,12 +352,69 @@ export class MicroVM {
|
|||||||
}
|
}
|
||||||
this.tapDevices = [];
|
this.tapDevices = [];
|
||||||
|
|
||||||
|
if (this.vmRuntimeDir) {
|
||||||
|
await plugins.fs.promises.rm(this.vmRuntimeDir, { recursive: true, force: true });
|
||||||
|
this.vmRuntimeDir = null;
|
||||||
|
}
|
||||||
|
|
||||||
this.socketClient = null;
|
this.socketClient = null;
|
||||||
if (this.state !== 'error') {
|
if (this.state !== 'error') {
|
||||||
this.state = 'stopped';
|
this.state = 'stopped';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private shouldStageDrive(drive: IDriveConfig): boolean {
|
||||||
|
if (!this.ephemeralWritableDrives) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (drive.ephemeral === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (drive.isReadOnly === true && drive.ephemeral !== true) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureVMRuntimeDir(): Promise<string> {
|
||||||
|
if (!this.vmRuntimeDir) {
|
||||||
|
this.vmRuntimeDir = plugins.path.join(this.runtimeDir, this.sanitizePathPart(this.id));
|
||||||
|
}
|
||||||
|
await plugins.fs.promises.mkdir(this.vmRuntimeDir, { recursive: true });
|
||||||
|
return this.vmRuntimeDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async prepareEphemeralDrives(): Promise<void> {
|
||||||
|
const drives = this.vmConfig.config.drives || [];
|
||||||
|
for (const drive of drives) {
|
||||||
|
if (!this.shouldStageDrive(drive)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vmRuntimeDir = await this.ensureVMRuntimeDir();
|
||||||
|
const drivesDir = plugins.path.join(vmRuntimeDir, 'drives');
|
||||||
|
await plugins.fs.promises.mkdir(drivesDir, { recursive: true });
|
||||||
|
|
||||||
|
const sourcePath = drive.pathOnHost;
|
||||||
|
const sourceFileName = plugins.path.basename(sourcePath) || 'drive.img';
|
||||||
|
const stagedPath = plugins.path.join(
|
||||||
|
drivesDir,
|
||||||
|
`${this.sanitizePathPart(drive.driveId)}-${this.sanitizePathPart(sourceFileName)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await plugins.fs.promises.copyFile(sourcePath, stagedPath);
|
||||||
|
drive.pathOnHost = stagedPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizePathPart(value: string): string {
|
||||||
|
const sanitized = value.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
|
if (!sanitized || sanitized === '.' || sanitized === '..') {
|
||||||
|
return 'item';
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper: PUT request with error handling.
|
* Helper: PUT request with error handling.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+667
-50
@@ -1,5 +1,13 @@
|
|||||||
import * as plugins from './plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
import type { INetworkManagerOptions, ITapDevice } from './interfaces/index.js';
|
import type {
|
||||||
|
IFirewallConfig,
|
||||||
|
IFirewallRule,
|
||||||
|
INetworkManagerOptions,
|
||||||
|
ITapDevice,
|
||||||
|
TFirewallAction,
|
||||||
|
TFirewallProtocol,
|
||||||
|
TWireGuardConfig,
|
||||||
|
} from './interfaces/index.js';
|
||||||
import { SmartVMError } from './interfaces/index.js';
|
import { SmartVMError } from './interfaces/index.js';
|
||||||
|
|
||||||
type TShellExecResult = Awaited<ReturnType<InstanceType<typeof plugins.smartshell.Smartshell>['execSpawn']>>;
|
type TShellExecResult = Awaited<ReturnType<InstanceType<typeof plugins.smartshell.Smartshell>['execSpawn']>>;
|
||||||
@@ -11,6 +19,17 @@ interface IParsedSubnet {
|
|||||||
subnetMask: string;
|
subnetMask: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface IParsedWireGuardConfig {
|
||||||
|
setConfig: string;
|
||||||
|
addresses: string[];
|
||||||
|
mtu?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIREWALL_ACTION_TO_IPTABLES_TARGET: Record<TFirewallAction, 'ACCEPT' | 'DROP'> = {
|
||||||
|
allow: 'ACCEPT',
|
||||||
|
deny: 'DROP',
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages host networking for Firecracker VMs.
|
* Manages host networking for Firecracker VMs.
|
||||||
* Creates TAP devices, Linux bridges, and configures NAT for VM internet access.
|
* Creates TAP devices, Linux bridges, and configures NAT for VM internet access.
|
||||||
@@ -26,6 +45,19 @@ export class NetworkManager {
|
|||||||
private activeTaps: Map<string, ITapDevice> = new Map();
|
private activeTaps: Map<string, ITapDevice> = new Map();
|
||||||
private bridgeCreated: boolean = false;
|
private bridgeCreated: boolean = false;
|
||||||
private defaultRouteInterface: string | null = null;
|
private defaultRouteInterface: string | null = null;
|
||||||
|
private firewall?: IFirewallConfig;
|
||||||
|
private firewallChainName: string;
|
||||||
|
private firewallConfigured: boolean = false;
|
||||||
|
private wireguard?: TWireGuardConfig;
|
||||||
|
private wireGuardInterface: string | null = null;
|
||||||
|
private wireGuardManaged: boolean = false;
|
||||||
|
private wireGuardRouteConfigured: boolean = false;
|
||||||
|
private wireGuardRouteAdded: boolean = false;
|
||||||
|
private wireGuardIpRuleAdded: boolean = false;
|
||||||
|
private wireGuardRouteTable: number | null = null;
|
||||||
|
private natInterface: string | null = null;
|
||||||
|
private natConfigured: boolean = false;
|
||||||
|
private natRuleAdded: boolean = false;
|
||||||
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
|
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
|
||||||
|
|
||||||
constructor(options: INetworkManagerOptions = {}) {
|
constructor(options: INetworkManagerOptions = {}) {
|
||||||
@@ -40,6 +72,11 @@ export class NetworkManager {
|
|||||||
this.gatewayIp = this.intToIp(parsedSubnet.networkAddress + 1);
|
this.gatewayIp = this.intToIp(parsedSubnet.networkAddress + 1);
|
||||||
this.nextIpAddress = parsedSubnet.networkAddress + 2;
|
this.nextIpAddress = parsedSubnet.networkAddress + 2;
|
||||||
this.lastUsableIpAddress = parsedSubnet.broadcastAddress - 1;
|
this.lastUsableIpAddress = parsedSubnet.broadcastAddress - 1;
|
||||||
|
this.firewall = options.firewall;
|
||||||
|
this.wireguard = options.wireguard;
|
||||||
|
this.firewallChainName = this.buildFirewallChainName(this.bridgeName, this.subnetBase, this.subnetCidr);
|
||||||
|
this.validateFirewallConfig(this.firewall);
|
||||||
|
this.validateWireGuardConfig(this.wireguard);
|
||||||
|
|
||||||
this.shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
this.shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
||||||
}
|
}
|
||||||
@@ -114,7 +151,7 @@ export class NetworkManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private validateInterfaceName(name: string, fieldName: string): void {
|
private validateInterfaceName(name: string, fieldName: string): void {
|
||||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,14}$/.test(name)) {
|
if (typeof name !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,14}$/.test(name)) {
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
`${fieldName} '${name}' is not a valid Linux interface name`,
|
`${fieldName} '${name}' is not a valid Linux interface name`,
|
||||||
'INVALID_INTERFACE_NAME',
|
'INVALID_INTERFACE_NAME',
|
||||||
@@ -122,6 +159,297 @@ export class NetworkManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildFirewallChainName(bridgeName: string, subnetBase: string, subnetCidr: number): string {
|
||||||
|
const hash = plugins.crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(`${bridgeName}:${subnetBase}/${subnetCidr}`)
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 10);
|
||||||
|
return `SVMEG-${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSubnetCidr(): string {
|
||||||
|
return `${this.subnetBase}/${this.subnetCidr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateIpv4Cidr(value: string, fieldName: string, errorCode = 'INVALID_FIREWALL_CONFIG'): void {
|
||||||
|
const [ip, cidrText, extra] = value.split('/');
|
||||||
|
const cidr = cidrText === undefined || cidrText === '' ? 32 : Number(cidrText);
|
||||||
|
if (!ip || extra !== undefined || !Number.isInteger(cidr) || cidr < 0 || cidr > 32) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`${fieldName} '${value}' must be an IPv4 address or CIDR with prefix length 0-32`,
|
||||||
|
errorCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.ipToInt(ip);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SmartVMError) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`${fieldName} '${value}' must be an IPv4 address or CIDR with prefix length 0-32`,
|
||||||
|
errorCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeCidr(value: string): string {
|
||||||
|
return value.includes('/') ? value : `${value}/32`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateFirewallConfig(firewall?: IFirewallConfig): void {
|
||||||
|
if (!firewall || firewall.egress === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const egress = firewall.egress;
|
||||||
|
|
||||||
|
if (!egress || typeof egress !== 'object') {
|
||||||
|
throw new SmartVMError('Firewall egress config must be an object', 'INVALID_FIREWALL_CONFIG');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
egress.defaultAction !== undefined &&
|
||||||
|
egress.defaultAction !== 'allow' &&
|
||||||
|
egress.defaultAction !== 'deny'
|
||||||
|
) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Invalid firewall egress defaultAction '${egress.defaultAction}'`,
|
||||||
|
'INVALID_FIREWALL_CONFIG',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (egress.rules !== undefined && !Array.isArray(egress.rules)) {
|
||||||
|
throw new SmartVMError('Firewall egress rules must be an array', 'INVALID_FIREWALL_CONFIG');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rule of egress.rules || []) {
|
||||||
|
this.validateFirewallRule(rule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateFirewallRule(rule: IFirewallRule): void {
|
||||||
|
if (!rule || typeof rule !== 'object') {
|
||||||
|
throw new SmartVMError('Firewall rule must be an object', 'INVALID_FIREWALL_CONFIG');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.action !== 'allow' && rule.action !== 'deny') {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Invalid firewall rule action '${rule.action}'`,
|
||||||
|
'INVALID_FIREWALL_CONFIG',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = rule.protocol || 'all';
|
||||||
|
if (!['all', 'tcp', 'udp', 'icmp'].includes(protocol)) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Invalid firewall rule protocol '${protocol}'`,
|
||||||
|
'INVALID_FIREWALL_CONFIG',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.to !== undefined) {
|
||||||
|
if (typeof rule.to !== 'string') {
|
||||||
|
throw new SmartVMError('Firewall rule destination must be a string', 'INVALID_FIREWALL_CONFIG');
|
||||||
|
}
|
||||||
|
this.validateIpv4Cidr(rule.to, 'firewall rule destination');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.comment !== undefined && typeof rule.comment !== 'string') {
|
||||||
|
throw new SmartVMError('Firewall rule comment must be a string', 'INVALID_FIREWALL_CONFIG');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ports = this.normalizePorts(rule.ports);
|
||||||
|
if (ports.length > 0 && protocol !== 'tcp' && protocol !== 'udp') {
|
||||||
|
throw new SmartVMError(
|
||||||
|
'Firewall rule ports require protocol tcp or udp',
|
||||||
|
'INVALID_FIREWALL_CONFIG',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizePorts(ports?: number | number[]): number[] {
|
||||||
|
if (ports === undefined) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const portList = Array.isArray(ports) ? ports : [ports];
|
||||||
|
if (portList.length === 0) {
|
||||||
|
throw new SmartVMError('Firewall rule ports must not be empty', 'INVALID_FIREWALL_CONFIG');
|
||||||
|
}
|
||||||
|
for (const port of portList) {
|
||||||
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Firewall rule port '${port}' must be an integer between 1 and 65535`,
|
||||||
|
'INVALID_FIREWALL_CONFIG',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return portList;
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateWireGuardConfig(wireguard?: TWireGuardConfig): void {
|
||||||
|
if (!wireguard) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeTable = wireguard.routeTable === undefined ? 51820 : wireguard.routeTable;
|
||||||
|
if (!Number.isInteger(routeTable) || routeTable < 1 || routeTable > 4294967295) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`WireGuard routeTable '${routeTable}' must be an integer between 1 and 4294967295`,
|
||||||
|
'INVALID_WIREGUARD_CONFIG',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wireguardOptions = wireguard as unknown as Record<string, unknown>;
|
||||||
|
const hasExistingInterface = Object.prototype.hasOwnProperty.call(wireguardOptions, 'existingInterface');
|
||||||
|
const hasManagedConfig = Object.prototype.hasOwnProperty.call(wireguardOptions, 'config');
|
||||||
|
if (hasExistingInterface && hasManagedConfig) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
'WireGuard config must use either existingInterface or managed config, not both',
|
||||||
|
'INVALID_WIREGUARD_CONFIG',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasExistingInterface) {
|
||||||
|
this.validateInterfaceName(wireguardOptions.existingInterface as string, 'wireguard.existingInterface');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof wireguardOptions.config !== 'string') {
|
||||||
|
throw new SmartVMError('WireGuard managed config requires config text', 'INVALID_WIREGUARD_CONFIG');
|
||||||
|
}
|
||||||
|
|
||||||
|
const interfaceName = wireguardOptions.interfaceName === undefined
|
||||||
|
? 'svwg0'
|
||||||
|
: wireguardOptions.interfaceName;
|
||||||
|
this.validateInterfaceName(interfaceName as string, 'wireguard.interfaceName');
|
||||||
|
this.parseWireGuardConfig(wireguardOptions.config);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseWireGuardConfig(config: string): IParsedWireGuardConfig {
|
||||||
|
if (!config || !config.trim()) {
|
||||||
|
throw new SmartVMError('WireGuard config must not be empty', 'INVALID_WIREGUARD_CONFIG');
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsafeFields = new Set(['preup', 'postup', 'predown', 'postdown', 'saveconfig']);
|
||||||
|
const ignoredFields = new Set(['address', 'dns', 'mtu', 'table']);
|
||||||
|
const wireGuardInterfaceFields = new Set(['privatekey', 'listenport', 'fwmark']);
|
||||||
|
const wireGuardPeerFields = new Set([
|
||||||
|
'publickey',
|
||||||
|
'presharedkey',
|
||||||
|
'allowedips',
|
||||||
|
'endpoint',
|
||||||
|
'persistentkeepalive',
|
||||||
|
]);
|
||||||
|
const setConfigLines: string[] = [];
|
||||||
|
const addresses: string[] = [];
|
||||||
|
let mtu: number | undefined;
|
||||||
|
let currentSection: 'Interface' | 'Peer' | null = null;
|
||||||
|
let sawPrivateKey = false;
|
||||||
|
|
||||||
|
for (const rawLine of config.split(/\r?\n/)) {
|
||||||
|
const trimmedLine = rawLine.trim();
|
||||||
|
if (!trimmedLine || trimmedLine.startsWith('#') || trimmedLine.startsWith(';')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineWithoutComment = trimmedLine.replace(/\s+[;#].*$/, '').trim();
|
||||||
|
if (!lineWithoutComment) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionMatch = lineWithoutComment.match(/^\[(Interface|Peer)\]$/i);
|
||||||
|
if (sectionMatch) {
|
||||||
|
currentSection = sectionMatch[1].toLowerCase() === 'interface' ? 'Interface' : 'Peer';
|
||||||
|
setConfigLines.push(`[${currentSection}]`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyValueMatch = lineWithoutComment.match(/^([^=]+?)\s*=\s*(.+)$/);
|
||||||
|
if (!keyValueMatch || !currentSection) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Invalid WireGuard config line '${rawLine.trim()}'`,
|
||||||
|
'INVALID_WIREGUARD_CONFIG',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = keyValueMatch[1].trim();
|
||||||
|
const normalizedKey = key.toLowerCase();
|
||||||
|
const value = keyValueMatch[2].trim();
|
||||||
|
if (unsafeFields.has(normalizedKey)) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`WireGuard config field '${key}' is not allowed because it can execute commands or mutate host state`,
|
||||||
|
'INVALID_WIREGUARD_CONFIG',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSection === 'Interface' && normalizedKey === 'address') {
|
||||||
|
for (const address of value.split(',').map((item) => item.trim()).filter(Boolean)) {
|
||||||
|
this.validateIpv4Cidr(address, 'WireGuard Address', 'INVALID_WIREGUARD_CONFIG');
|
||||||
|
addresses.push(this.normalizeCidr(address));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSection === 'Interface' && normalizedKey === 'mtu') {
|
||||||
|
const parsedMtu = Number(value);
|
||||||
|
if (!Number.isInteger(parsedMtu) || parsedMtu < 576 || parsedMtu > 9000) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`WireGuard MTU '${value}' must be an integer between 576 and 9000`,
|
||||||
|
'INVALID_WIREGUARD_CONFIG',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
mtu = parsedMtu;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSection === 'Peer' && normalizedKey === 'allowedips') {
|
||||||
|
const allowedIps = value.split(',').map((item) => item.trim()).filter(Boolean);
|
||||||
|
if (allowedIps.length === 0) {
|
||||||
|
throw new SmartVMError('WireGuard Peer.AllowedIPs must not be empty', 'INVALID_WIREGUARD_CONFIG');
|
||||||
|
}
|
||||||
|
for (const allowedIp of allowedIps) {
|
||||||
|
this.validateIpv4Cidr(allowedIp, 'WireGuard AllowedIPs', 'INVALID_WIREGUARD_CONFIG');
|
||||||
|
}
|
||||||
|
setConfigLines.push(`${key} = ${allowedIps.join(', ')}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ignoredFields.has(normalizedKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedFields = currentSection === 'Interface'
|
||||||
|
? wireGuardInterfaceFields
|
||||||
|
: wireGuardPeerFields;
|
||||||
|
if (!allowedFields.has(normalizedKey)) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Unsupported WireGuard ${currentSection} field '${key}'`,
|
||||||
|
'INVALID_WIREGUARD_CONFIG',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (currentSection === 'Interface' && normalizedKey === 'privatekey') {
|
||||||
|
sawPrivateKey = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
setConfigLines.push(`${key} = ${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sawPrivateKey) {
|
||||||
|
throw new SmartVMError('WireGuard config requires Interface.PrivateKey', 'INVALID_WIREGUARD_CONFIG');
|
||||||
|
}
|
||||||
|
if (addresses.length === 0) {
|
||||||
|
throw new SmartVMError('WireGuard config requires at least one IPv4 Interface.Address', 'INVALID_WIREGUARD_CONFIG');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
setConfig: `${setConfigLines.join('\n')}\n`,
|
||||||
|
addresses,
|
||||||
|
mtu,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allocate the next available IP address in the subnet.
|
* Allocate the next available IP address in the subnet.
|
||||||
*/
|
*/
|
||||||
@@ -209,18 +537,263 @@ export class NetworkManager {
|
|||||||
return iface;
|
return iface;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getSharedMemoryTempDir(): string {
|
||||||
|
try {
|
||||||
|
if (plugins.fs.existsSync('/dev/shm') && plugins.fs.statSync('/dev/shm').isDirectory()) {
|
||||||
|
return '/dev/shm';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to os.tmpdir().
|
||||||
|
}
|
||||||
|
return plugins.os.tmpdir();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async configureWireGuardEgress(): Promise<string> {
|
||||||
|
if (!this.wireguard || this.wireguard.routeAllVmTraffic === false) {
|
||||||
|
return this.getDefaultRouteInterface();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const iface = await this.ensureWireGuardInterface();
|
||||||
|
const routeTable = this.wireguard.routeTable === undefined ? 51820 : this.wireguard.routeTable;
|
||||||
|
this.wireGuardInterface = iface;
|
||||||
|
this.wireGuardRouteTable = routeTable;
|
||||||
|
this.wireGuardRouteAdded = false;
|
||||||
|
this.wireGuardIpRuleAdded = false;
|
||||||
|
const routeResult = await this.run('ip', ['route', 'show', 'table', String(routeTable), 'default']);
|
||||||
|
const existingDefaultRoutes = routeResult.stdout
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (existingDefaultRoutes.some((line) => !line.includes(` dev ${iface} `) && !line.endsWith(` dev ${iface}`))) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`WireGuard route table ${routeTable} already has a default route not using ${iface}`,
|
||||||
|
'WIREGUARD_SETUP_FAILED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (existingDefaultRoutes.length === 0) {
|
||||||
|
await this.runChecked('ip', [
|
||||||
|
'route',
|
||||||
|
'add',
|
||||||
|
'default',
|
||||||
|
'dev',
|
||||||
|
iface,
|
||||||
|
'table',
|
||||||
|
String(routeTable),
|
||||||
|
]);
|
||||||
|
this.wireGuardRouteAdded = true;
|
||||||
|
}
|
||||||
|
this.wireGuardRouteConfigured = true;
|
||||||
|
if (!await this.hasWireGuardIpRule(routeTable)) {
|
||||||
|
await this.runChecked('ip', [
|
||||||
|
'rule',
|
||||||
|
'add',
|
||||||
|
'from',
|
||||||
|
this.getSubnetCidr(),
|
||||||
|
'table',
|
||||||
|
String(routeTable),
|
||||||
|
]);
|
||||||
|
this.wireGuardIpRuleAdded = true;
|
||||||
|
}
|
||||||
|
return iface;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SmartVMError) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
throw new SmartVMError(`Failed to configure WireGuard egress: ${message}`, 'WIREGUARD_SETUP_FAILED');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async hasWireGuardIpRule(routeTable: number): Promise<boolean> {
|
||||||
|
const result = await this.run('ip', ['rule', 'show']);
|
||||||
|
return result.stdout
|
||||||
|
.split('\n')
|
||||||
|
.some((line) => line.includes(`from ${this.getSubnetCidr()}`) && line.includes(`lookup ${routeTable}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureWireGuardInterface(): Promise<string> {
|
||||||
|
if (!this.wireguard) {
|
||||||
|
throw new Error('WireGuard is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('existingInterface' in this.wireguard) {
|
||||||
|
await this.runChecked('ip', ['link', 'show', this.wireguard.existingInterface]);
|
||||||
|
this.wireGuardManaged = false;
|
||||||
|
return this.wireguard.existingInterface;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iface = this.wireguard.interfaceName || 'svwg0';
|
||||||
|
const existingInterface = await this.run('ip', ['link', 'show', iface]);
|
||||||
|
if (existingInterface.exitCode === 0) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Managed WireGuard interface '${iface}' already exists; use existingInterface to route through it`,
|
||||||
|
'WIREGUARD_SETUP_FAILED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedConfig = this.parseWireGuardConfig(this.wireguard.config);
|
||||||
|
await this.runChecked('ip', ['link', 'add', 'dev', iface, 'type', 'wireguard']);
|
||||||
|
this.wireGuardManaged = true;
|
||||||
|
this.wireGuardInterface = iface;
|
||||||
|
|
||||||
|
const tempDir = await plugins.fs.promises.mkdtemp(
|
||||||
|
plugins.path.join(this.getSharedMemoryTempDir(), `smartvm-wg-${iface}-`),
|
||||||
|
);
|
||||||
|
const tempConfigPath = plugins.path.join(tempDir, 'wg.conf');
|
||||||
|
try {
|
||||||
|
await plugins.fs.promises.writeFile(tempConfigPath, parsedConfig.setConfig, { mode: 0o600 });
|
||||||
|
await this.runChecked('wg', ['setconf', iface, tempConfigPath]);
|
||||||
|
} finally {
|
||||||
|
await plugins.fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const address of parsedConfig.addresses) {
|
||||||
|
await this.runChecked('ip', ['addr', 'add', address, 'dev', iface]);
|
||||||
|
}
|
||||||
|
if (parsedConfig.mtu !== undefined) {
|
||||||
|
await this.runChecked('ip', ['link', 'set', 'mtu', String(parsedConfig.mtu), 'dev', iface]);
|
||||||
|
}
|
||||||
|
await this.runChecked('ip', ['link', 'set', iface, 'up']);
|
||||||
|
return iface;
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldApplyFailClosed(): boolean {
|
||||||
|
return Boolean(
|
||||||
|
this.wireguard &&
|
||||||
|
this.wireguard.routeAllVmTraffic !== false &&
|
||||||
|
this.wireguard.failClosed !== false &&
|
||||||
|
this.wireGuardInterface,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setupNat(egressInterface: string): Promise<void> {
|
||||||
|
this.natRuleAdded = false;
|
||||||
|
const ruleArgs = [
|
||||||
|
'-s',
|
||||||
|
this.getSubnetCidr(),
|
||||||
|
'-o',
|
||||||
|
egressInterface,
|
||||||
|
'-j',
|
||||||
|
'MASQUERADE',
|
||||||
|
];
|
||||||
|
const checkResult = await this.run('iptables', ['-t', 'nat', '-C', 'POSTROUTING', ...ruleArgs]);
|
||||||
|
if (checkResult.exitCode !== 0) {
|
||||||
|
await this.runChecked('iptables', ['-t', 'nat', '-A', 'POSTROUTING', ...ruleArgs]);
|
||||||
|
this.natRuleAdded = true;
|
||||||
|
}
|
||||||
|
this.natInterface = egressInterface;
|
||||||
|
this.natConfigured = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setupEgressFirewall(egressInterface: string): Promise<void> {
|
||||||
|
const egress = this.firewall?.egress;
|
||||||
|
const shouldSetupFirewall = Boolean(egress || this.shouldApplyFailClosed());
|
||||||
|
if (!shouldSetupFirewall) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.ensureIptablesChain('filter', this.firewallChainName);
|
||||||
|
this.firewallConfigured = true;
|
||||||
|
await this.runChecked('iptables', ['-t', 'filter', '-F', this.firewallChainName]);
|
||||||
|
await this.ensureIptablesRule('filter', 'FORWARD', ['-s', this.getSubnetCidr(), '-j', this.firewallChainName]);
|
||||||
|
await this.runChecked('iptables', [
|
||||||
|
'-t',
|
||||||
|
'filter',
|
||||||
|
'-A',
|
||||||
|
this.firewallChainName,
|
||||||
|
'-m',
|
||||||
|
'conntrack',
|
||||||
|
'--ctstate',
|
||||||
|
'ESTABLISHED,RELATED',
|
||||||
|
'-j',
|
||||||
|
'ACCEPT',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (this.shouldApplyFailClosed()) {
|
||||||
|
await this.runChecked('iptables', [
|
||||||
|
'-t',
|
||||||
|
'filter',
|
||||||
|
'-A',
|
||||||
|
this.firewallChainName,
|
||||||
|
'!',
|
||||||
|
'-o',
|
||||||
|
egressInterface,
|
||||||
|
'-j',
|
||||||
|
'DROP',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rule of egress?.rules || []) {
|
||||||
|
for (const ruleArgs of this.buildFirewallRuleArgs(rule)) {
|
||||||
|
await this.runChecked('iptables', ['-t', 'filter', '-A', this.firewallChainName, ...ruleArgs]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultAction = egress?.defaultAction || 'allow';
|
||||||
|
await this.runChecked('iptables', [
|
||||||
|
'-t',
|
||||||
|
'filter',
|
||||||
|
'-A',
|
||||||
|
this.firewallChainName,
|
||||||
|
'-j',
|
||||||
|
FIREWALL_ACTION_TO_IPTABLES_TARGET[defaultAction],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildFirewallRuleArgs(rule: IFirewallRule): string[][] {
|
||||||
|
const baseArgs: string[] = [];
|
||||||
|
const protocol = rule.protocol || 'all';
|
||||||
|
if (rule.to !== undefined) {
|
||||||
|
baseArgs.push('-d', this.normalizeCidr(rule.to));
|
||||||
|
}
|
||||||
|
if (protocol !== 'all') {
|
||||||
|
baseArgs.push('-p', protocol);
|
||||||
|
}
|
||||||
|
if (rule.comment) {
|
||||||
|
baseArgs.push('-m', 'comment', '--comment', rule.comment.slice(0, 240));
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = FIREWALL_ACTION_TO_IPTABLES_TARGET[rule.action];
|
||||||
|
const ports = this.normalizePorts(rule.ports);
|
||||||
|
if (ports.length === 0) {
|
||||||
|
return [[...baseArgs, '-j', target]];
|
||||||
|
}
|
||||||
|
return ports.map((port) => [...baseArgs, '--dport', String(port), '-j', target]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureIptablesChain(table: string, chain: string): Promise<void> {
|
||||||
|
const checkResult = await this.run('iptables', ['-t', table, '-S', chain]);
|
||||||
|
if (checkResult.exitCode !== 0) {
|
||||||
|
await this.runChecked('iptables', ['-t', table, '-N', chain]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureIptablesRule(table: string, chain: string, ruleArgs: string[]): Promise<void> {
|
||||||
|
const checkResult = await this.run('iptables', ['-t', table, '-C', chain, ...ruleArgs]);
|
||||||
|
if (checkResult.exitCode !== 0) {
|
||||||
|
await this.runChecked('iptables', ['-t', table, '-A', chain, ...ruleArgs]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteIptablesRule(table: string, chain: string, ruleArgs: string[]): Promise<void> {
|
||||||
|
await this.run('iptables', ['-t', table, '-D', chain, ...ruleArgs]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure the Linux bridge is created and configured.
|
* Ensure the Linux bridge is created and configured.
|
||||||
*/
|
*/
|
||||||
public async ensureBridge(): Promise<void> {
|
public async ensureBridge(): Promise<void> {
|
||||||
if (this.bridgeCreated) return;
|
if (this.bridgeCreated) return;
|
||||||
|
|
||||||
|
let createdBridge = false;
|
||||||
try {
|
try {
|
||||||
// Check if bridge already exists
|
// Check if bridge already exists
|
||||||
const result = await this.run('ip', ['link', 'show', this.bridgeName]);
|
const result = await this.run('ip', ['link', 'show', this.bridgeName]);
|
||||||
if (result.exitCode !== 0) {
|
if (result.exitCode !== 0) {
|
||||||
// Create bridge
|
// Create bridge
|
||||||
await this.runChecked('ip', ['link', 'add', this.bridgeName, 'type', 'bridge']);
|
await this.runChecked('ip', ['link', 'add', this.bridgeName, 'type', 'bridge']);
|
||||||
|
createdBridge = true;
|
||||||
await this.runChecked('ip', ['addr', 'add', `${this.gatewayIp}/${this.subnetCidr}`, 'dev', this.bridgeName]);
|
await this.runChecked('ip', ['addr', 'add', `${this.gatewayIp}/${this.subnetCidr}`, 'dev', this.bridgeName]);
|
||||||
await this.runChecked('ip', ['link', 'set', this.bridgeName, 'up']);
|
await this.runChecked('ip', ['link', 'set', this.bridgeName, 'up']);
|
||||||
}
|
}
|
||||||
@@ -228,38 +801,26 @@ export class NetworkManager {
|
|||||||
// Enable IP forwarding
|
// Enable IP forwarding
|
||||||
await this.runChecked('sysctl', ['-w', 'net.ipv4.ip_forward=1']);
|
await this.runChecked('sysctl', ['-w', 'net.ipv4.ip_forward=1']);
|
||||||
|
|
||||||
// Set up NAT masquerade (idempotent with -C check)
|
const egressInterface = await this.configureWireGuardEgress();
|
||||||
const defaultIface = await this.getDefaultRouteInterface();
|
await this.setupEgressFirewall(egressInterface);
|
||||||
const natArgs = [
|
await this.setupNat(egressInterface);
|
||||||
'-t',
|
|
||||||
'nat',
|
|
||||||
'-C',
|
|
||||||
'POSTROUTING',
|
|
||||||
'-s',
|
|
||||||
`${this.subnetBase}/${this.subnetCidr}`,
|
|
||||||
'-o',
|
|
||||||
defaultIface,
|
|
||||||
'-j',
|
|
||||||
'MASQUERADE',
|
|
||||||
];
|
|
||||||
const checkResult = await this.run('iptables', natArgs);
|
|
||||||
if (checkResult.exitCode !== 0) {
|
|
||||||
await this.runChecked('iptables', [
|
|
||||||
'-t',
|
|
||||||
'nat',
|
|
||||||
'-A',
|
|
||||||
'POSTROUTING',
|
|
||||||
'-s',
|
|
||||||
`${this.subnetBase}/${this.subnetCidr}`,
|
|
||||||
'-o',
|
|
||||||
defaultIface,
|
|
||||||
'-j',
|
|
||||||
'MASQUERADE',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.bridgeCreated = true;
|
this.bridgeCreated = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
try {
|
||||||
|
await this.cleanupEgressFirewall();
|
||||||
|
await this.cleanupNat();
|
||||||
|
await this.cleanupWireGuardEgress();
|
||||||
|
if (createdBridge) {
|
||||||
|
await this.run('ip', ['link', 'set', this.bridgeName, 'down']);
|
||||||
|
await this.run('ip', ['link', 'del', this.bridgeName]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Preserve the original setup error.
|
||||||
|
}
|
||||||
|
if (err instanceof SmartVMError) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
`Failed to set up network bridge: ${message}`,
|
`Failed to set up network bridge: ${message}`,
|
||||||
@@ -349,6 +910,10 @@ export class NetworkManager {
|
|||||||
await this.removeTapDevice(tapName);
|
await this.removeTapDevice(tapName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.cleanupEgressFirewall();
|
||||||
|
await this.cleanupNat();
|
||||||
|
await this.cleanupWireGuardEgress();
|
||||||
|
|
||||||
// Remove bridge if we created it
|
// Remove bridge if we created it
|
||||||
if (this.bridgeCreated) {
|
if (this.bridgeCreated) {
|
||||||
try {
|
try {
|
||||||
@@ -358,26 +923,78 @@ export class NetworkManager {
|
|||||||
// Bridge may already be gone
|
// Bridge may already be gone
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove NAT rule
|
|
||||||
try {
|
|
||||||
const defaultIface = this.defaultRouteInterface || await this.getDefaultRouteInterface();
|
|
||||||
await this.run('iptables', [
|
|
||||||
'-t',
|
|
||||||
'nat',
|
|
||||||
'-D',
|
|
||||||
'POSTROUTING',
|
|
||||||
'-s',
|
|
||||||
`${this.subnetBase}/${this.subnetCidr}`,
|
|
||||||
'-o',
|
|
||||||
defaultIface,
|
|
||||||
'-j',
|
|
||||||
'MASQUERADE',
|
|
||||||
]);
|
|
||||||
} catch {
|
|
||||||
// Rule may not exist
|
|
||||||
}
|
|
||||||
|
|
||||||
this.bridgeCreated = false;
|
this.bridgeCreated = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async cleanupEgressFirewall(): Promise<void> {
|
||||||
|
if (!this.firewallConfigured) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.deleteIptablesRule('filter', 'FORWARD', [
|
||||||
|
'-s',
|
||||||
|
this.getSubnetCidr(),
|
||||||
|
'-j',
|
||||||
|
this.firewallChainName,
|
||||||
|
]);
|
||||||
|
await this.run('iptables', ['-t', 'filter', '-F', this.firewallChainName]);
|
||||||
|
await this.run('iptables', ['-t', 'filter', '-X', this.firewallChainName]);
|
||||||
|
this.firewallConfigured = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cleanupNat(): Promise<void> {
|
||||||
|
if (!this.natConfigured || !this.natInterface) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.natRuleAdded) {
|
||||||
|
await this.deleteIptablesRule('nat', 'POSTROUTING', [
|
||||||
|
'-s',
|
||||||
|
this.getSubnetCidr(),
|
||||||
|
'-o',
|
||||||
|
this.natInterface,
|
||||||
|
'-j',
|
||||||
|
'MASQUERADE',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
this.natInterface = null;
|
||||||
|
this.natConfigured = false;
|
||||||
|
this.natRuleAdded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cleanupWireGuardEgress(): Promise<void> {
|
||||||
|
if (this.wireGuardRouteConfigured && this.wireGuardRouteTable !== null) {
|
||||||
|
if (this.wireGuardIpRuleAdded) {
|
||||||
|
await this.run('ip', [
|
||||||
|
'rule',
|
||||||
|
'del',
|
||||||
|
'from',
|
||||||
|
this.getSubnetCidr(),
|
||||||
|
'table',
|
||||||
|
String(this.wireGuardRouteTable),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (this.wireGuardRouteAdded && this.wireGuardInterface) {
|
||||||
|
await this.run('ip', [
|
||||||
|
'route',
|
||||||
|
'del',
|
||||||
|
'default',
|
||||||
|
'dev',
|
||||||
|
this.wireGuardInterface,
|
||||||
|
'table',
|
||||||
|
String(this.wireGuardRouteTable),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.wireGuardManaged && this.wireGuardInterface) {
|
||||||
|
await this.run('ip', ['link', 'del', this.wireGuardInterface]);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.wireGuardRouteConfigured = false;
|
||||||
|
this.wireGuardRouteAdded = false;
|
||||||
|
this.wireGuardIpRuleAdded = false;
|
||||||
|
this.wireGuardRouteTable = null;
|
||||||
|
this.wireGuardManaged = false;
|
||||||
|
this.wireGuardInterface = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-2
@@ -26,6 +26,8 @@ export class SmartVM {
|
|||||||
constructor(options: ISmartVMOptions = {}) {
|
constructor(options: ISmartVMOptions = {}) {
|
||||||
this.options = {
|
this.options = {
|
||||||
dataDir: options.dataDir || '/tmp/.smartvm',
|
dataDir: options.dataDir || '/tmp/.smartvm',
|
||||||
|
runtimeDir: options.runtimeDir || this.getDefaultRuntimeDir(),
|
||||||
|
ephemeralWritableDrives: options.ephemeralWritableDrives ?? true,
|
||||||
arch: options.arch || 'x86_64',
|
arch: options.arch || 'x86_64',
|
||||||
bridgeName: options.bridgeName || 'svbr0',
|
bridgeName: options.bridgeName || 'svbr0',
|
||||||
subnet: options.subnet || '172.30.0.0/24',
|
subnet: options.subnet || '172.30.0.0/24',
|
||||||
@@ -43,6 +45,8 @@ export class SmartVM {
|
|||||||
this.networkManager = new NetworkManager({
|
this.networkManager = new NetworkManager({
|
||||||
bridgeName: this.options.bridgeName,
|
bridgeName: this.options.bridgeName,
|
||||||
subnet: this.options.subnet,
|
subnet: this.options.subnet,
|
||||||
|
firewall: this.options.firewall,
|
||||||
|
wireguard: this.options.wireguard,
|
||||||
});
|
});
|
||||||
|
|
||||||
// If a custom binary path is provided, use it directly
|
// If a custom binary path is provided, use it directly
|
||||||
@@ -57,6 +61,30 @@ export class SmartVM {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getDefaultRuntimeDir(): string {
|
||||||
|
const tmpfsDir = '/dev/shm';
|
||||||
|
try {
|
||||||
|
if (plugins.fs.existsSync(tmpfsDir) && plugins.fs.statSync(tmpfsDir).isDirectory()) {
|
||||||
|
return plugins.path.join(tmpfsDir, '.smartvm', 'runtime');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to os.tmpdir() below.
|
||||||
|
}
|
||||||
|
return plugins.path.join(plugins.os.tmpdir(), '.smartvm', 'runtime');
|
||||||
|
}
|
||||||
|
|
||||||
|
public getRuntimeDir(): string {
|
||||||
|
return this.options.runtimeDir!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizePathPart(value: string): string {
|
||||||
|
const sanitized = value.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
|
if (!sanitized || sanitized === '.' || sanitized === '..') {
|
||||||
|
return 'item';
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure the Firecracker binary is available.
|
* Ensure the Firecracker binary is available.
|
||||||
* Downloads it if not present.
|
* Downloads it if not present.
|
||||||
@@ -110,8 +138,9 @@ export class SmartVM {
|
|||||||
// Generate VM ID if not provided
|
// Generate VM ID if not provided
|
||||||
const vmId = config.id || plugins.smartunique.uuid4();
|
const vmId = config.id || plugins.smartunique.uuid4();
|
||||||
|
|
||||||
// Generate socket path
|
// Keep per-VM runtime artifacts in tmpfs by default.
|
||||||
const socketPath = this.imageManager.getSocketPath(vmId);
|
const vmRuntimeDir = plugins.path.join(this.options.runtimeDir!, this.sanitizePathPart(vmId));
|
||||||
|
const socketPath = plugins.path.join(vmRuntimeDir, 'firecracker.sock');
|
||||||
|
|
||||||
// Create MicroVM instance
|
// Create MicroVM instance
|
||||||
const vm = new MicroVM(
|
const vm = new MicroVM(
|
||||||
@@ -120,6 +149,10 @@ export class SmartVM {
|
|||||||
this.firecrackerBinaryPath!,
|
this.firecrackerBinaryPath!,
|
||||||
socketPath,
|
socketPath,
|
||||||
this.networkManager,
|
this.networkManager,
|
||||||
|
{
|
||||||
|
runtimeDir: this.options.runtimeDir,
|
||||||
|
ephemeralWritableDrives: this.options.ephemeralWritableDrives,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Register in active VMs
|
// Register in active VMs
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export class VMConfig {
|
|||||||
drives: config.drives?.map((drive) => ({
|
drives: config.drives?.map((drive) => ({
|
||||||
...drive,
|
...drive,
|
||||||
rateLimiter: drive.rateLimiter ? this.cloneRateLimiter(drive.rateLimiter) : undefined,
|
rateLimiter: drive.rateLimiter ? this.cloneRateLimiter(drive.rateLimiter) : undefined,
|
||||||
|
ephemeral: drive.ephemeral,
|
||||||
})),
|
})),
|
||||||
networkInterfaces: config.networkInterfaces?.map((iface) => ({
|
networkInterfaces: config.networkInterfaces?.map((iface) => ({
|
||||||
...iface,
|
...iface,
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import type { TFirecrackerArch, TCacheType, TSnapshotType, TLogLevel } from './c
|
|||||||
export interface ISmartVMOptions {
|
export interface ISmartVMOptions {
|
||||||
/** Directory for storing binaries, kernels, rootfs images, and sockets. Defaults to /tmp/.smartvm */
|
/** Directory for storing binaries, kernels, rootfs images, and sockets. Defaults to /tmp/.smartvm */
|
||||||
dataDir?: string;
|
dataDir?: string;
|
||||||
|
/** Directory for VM sockets and ephemeral per-VM files. Defaults to /dev/shm/.smartvm/runtime on Linux when available. */
|
||||||
|
runtimeDir?: string;
|
||||||
|
/** Copy writable drives into the VM runtime directory before boot and delete them on cleanup. Defaults to true. */
|
||||||
|
ephemeralWritableDrives?: boolean;
|
||||||
/** Firecracker version to use. Defaults to latest. */
|
/** Firecracker version to use. Defaults to latest. */
|
||||||
firecrackerVersion?: string;
|
firecrackerVersion?: string;
|
||||||
/** Target architecture. Defaults to x86_64. */
|
/** Target architecture. Defaults to x86_64. */
|
||||||
@@ -16,6 +20,10 @@ export interface ISmartVMOptions {
|
|||||||
bridgeName?: string;
|
bridgeName?: string;
|
||||||
/** Network subnet in CIDR notation. Defaults to '172.30.0.0/24'. */
|
/** Network subnet in CIDR notation. Defaults to '172.30.0.0/24'. */
|
||||||
subnet?: string;
|
subnet?: string;
|
||||||
|
/** VM egress firewall configuration. */
|
||||||
|
firewall?: IFirewallConfig;
|
||||||
|
/** Host-side WireGuard egress routing configuration for VM traffic. */
|
||||||
|
wireguard?: TWireGuardConfig;
|
||||||
/** Directory for cached base images. Defaults to /tmp/.smartvm/base-images. */
|
/** Directory for cached base images. Defaults to /tmp/.smartvm/base-images. */
|
||||||
baseImageCacheDir?: string;
|
baseImageCacheDir?: string;
|
||||||
/** Maximum number of cached base image bundles. Defaults to 2. */
|
/** Maximum number of cached base image bundles. Defaults to 2. */
|
||||||
@@ -139,6 +147,77 @@ export interface IBaseImageBundle {
|
|||||||
lastAccessedAt: string;
|
lastAccessedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime behavior for a MicroVM instance.
|
||||||
|
*/
|
||||||
|
export interface IMicroVMRuntimeOptions {
|
||||||
|
/** Directory for VM sockets and ephemeral per-VM files. */
|
||||||
|
runtimeDir?: string;
|
||||||
|
/** Copy writable drives into runtimeDir before boot and delete them on cleanup. Defaults to true. */
|
||||||
|
ephemeralWritableDrives?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Firewall action for VM egress traffic. */
|
||||||
|
export type TFirewallAction = 'allow' | 'deny';
|
||||||
|
|
||||||
|
/** Firewall protocol selector for VM egress traffic. */
|
||||||
|
export type TFirewallProtocol = 'all' | 'tcp' | 'udp' | 'icmp';
|
||||||
|
|
||||||
|
/** One ordered VM egress firewall rule. */
|
||||||
|
export interface IFirewallRule {
|
||||||
|
/** Rule action. */
|
||||||
|
action: TFirewallAction;
|
||||||
|
/** Destination IPv4 address or CIDR. Omit to match all destinations. */
|
||||||
|
to?: string;
|
||||||
|
/** Protocol to match. Defaults to all. */
|
||||||
|
protocol?: TFirewallProtocol;
|
||||||
|
/** Destination port or ports for tcp/udp rules. */
|
||||||
|
ports?: number | number[];
|
||||||
|
/** Optional human-readable rule label. */
|
||||||
|
comment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** VM egress firewall policy. */
|
||||||
|
export interface IFirewallEgressConfig {
|
||||||
|
/** Final action when no rule matches. Defaults to allow. */
|
||||||
|
defaultAction?: TFirewallAction;
|
||||||
|
/** Ordered rules; first match wins. */
|
||||||
|
rules?: IFirewallRule[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Firewall configuration. */
|
||||||
|
export interface IFirewallConfig {
|
||||||
|
/** Egress firewall for traffic leaving the VM subnet. */
|
||||||
|
egress?: IFirewallEgressConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Common WireGuard routing options. */
|
||||||
|
export interface IWireGuardBaseConfig {
|
||||||
|
/** Route all VM subnet traffic through this WireGuard interface. Defaults to true. */
|
||||||
|
routeAllVmTraffic?: boolean;
|
||||||
|
/** Drop VM traffic that would leave through a non-WireGuard interface. Defaults to true. */
|
||||||
|
failClosed?: boolean;
|
||||||
|
/** Linux routing table number for VM WireGuard egress. Defaults to 51820. */
|
||||||
|
routeTable?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Managed WireGuard interface created and removed by smartvm. */
|
||||||
|
export interface IWireGuardManagedConfig extends IWireGuardBaseConfig {
|
||||||
|
/** wg-quick-style WireGuard config text. Hook fields are rejected. */
|
||||||
|
config: string;
|
||||||
|
/** Interface name to create. Defaults to svwg0. */
|
||||||
|
interfaceName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Existing WireGuard interface owned outside smartvm. */
|
||||||
|
export interface IWireGuardExistingInterfaceConfig extends IWireGuardBaseConfig {
|
||||||
|
/** Existing WireGuard interface to route VM traffic through. */
|
||||||
|
existingInterface: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WireGuard egress configuration. */
|
||||||
|
export type TWireGuardConfig = IWireGuardManagedConfig | IWireGuardExistingInterfaceConfig;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Firecracker boot source configuration.
|
* Firecracker boot source configuration.
|
||||||
*/
|
*/
|
||||||
@@ -205,6 +284,8 @@ export interface IDriveConfig {
|
|||||||
rateLimiter?: IRateLimiter;
|
rateLimiter?: IRateLimiter;
|
||||||
/** Path to a file that backs the device for I/O. */
|
/** Path to a file that backs the device for I/O. */
|
||||||
ioEngine?: string;
|
ioEngine?: string;
|
||||||
|
/** Whether this drive should be staged into per-VM ephemeral storage. Defaults to true for writable drives. */
|
||||||
|
ephemeral?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -337,6 +418,10 @@ export interface INetworkManagerOptions {
|
|||||||
bridgeName?: string;
|
bridgeName?: string;
|
||||||
/** Subnet in CIDR notation. Defaults to '172.30.0.0/24'. */
|
/** Subnet in CIDR notation. Defaults to '172.30.0.0/24'. */
|
||||||
subnet?: string;
|
subnet?: string;
|
||||||
|
/** VM egress firewall configuration. */
|
||||||
|
firewall?: IFirewallConfig;
|
||||||
|
/** Host-side WireGuard egress routing configuration for VM traffic. */
|
||||||
|
wireguard?: TWireGuardConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user