4 Commits

8 changed files with 1035 additions and 63 deletions
+15
View File
@@ -1,5 +1,20 @@
# 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) ## 2026-05-01 - 1.3.1 - fix(docs)
remove outdated base image bundle readme and consolidate hosted manifest documentation remove outdated base image bundle readme and consolidate hosted manifest documentation
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartvm", "name": "@push.rocks/smartvm",
"version": "1.3.1", "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",
+133 -11
View File
@@ -1,11 +1,23 @@
# @push.rocks/smartvm # @push.rocks/smartvm
Run Firecracker microVMs from TypeScript without hand-rolling process management, Unix-socket HTTP calls, TAP devices, bridge setup, image caching, and cleanup. `@push.rocks/smartvm` gives you a typed orchestration layer for building tiny, fast, disk-light VM workflows on Linux/KVM. Boot and control Firecracker microVMs from TypeScript without rebuilding the same host plumbing every time. `@push.rocks/smartvm` handles Firecracker binaries, Unix-socket API calls, per-VM runtime directories, base-image bundles, TAP/bridge networking, optional egress firewalling, host-side WireGuard routing, and cleanup so you can focus on the VM workload.
## Issue Reporting and Security ## Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly. For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## Why It Rocks
`smartvm` is for programmers who want real microVM isolation with a TypeScript control plane and sane defaults:
- 🚀 Boot Firecracker VMs with a few typed calls.
- 🧊 Keep runtime state ephemeral by default with tmpfs-backed sockets and staged writable drives.
- 🧰 Use known-good Firecracker CI images for quick starts, or ship your own hosted image manifest with SHA256 verification.
- 🌐 Create Linux TAP devices, bridges, deterministic guest MACs, static guest IP data, and NAT without custom scripts.
- 🔒 Apply VM-subnet egress policy with ordered IPv4 firewall rules.
- 🕳️ Route all VM traffic through a host WireGuard interface without installing WireGuard in the guest.
- 🧹 Tear down processes, sockets, TAP devices, bridge/NAT/firewall/WireGuard state, and staged drive copies.
## What It Does ## What It Does
`smartvm` wraps the operational parts of Amazon Firecracker: `smartvm` wraps the operational parts of Amazon Firecracker:
@@ -15,12 +27,14 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
- Supports project-owned hosted base-image manifests with SHA256 verification. - Supports project-owned hosted base-image manifests with SHA256 verification.
- Creates and controls microVMs through Firecracker's HTTP-over-Unix-socket API. - Creates and controls microVMs through Firecracker's HTTP-over-Unix-socket API.
- Converts TypeScript camelCase config into Firecracker's snake_case payloads. - Converts TypeScript camelCase config into Firecracker's snake_case payloads.
- Creates TAP devices, a Linux bridge, static guest IP assignments, and NAT rules. - Creates TAP devices, a Linux bridge, static guest network assignments, and NAT rules.
- Applies optional global VM egress firewall rules for the managed subnet.
- Routes VM egress through host-side WireGuard when configured.
- Defaults VM runtime artifacts to tmpfs via `/dev/shm/.smartvm/runtime` when available. - Defaults VM runtime artifacts to tmpfs via `/dev/shm/.smartvm/runtime` when available.
- Stages writable drives into per-VM ephemeral storage by default so guest writes do not touch cached rootfs files. - Stages writable drives into per-VM ephemeral storage by default so guest writes do not touch cached rootfs files.
- Cleans up Firecracker processes, sockets, TAPs, bridges, NAT rules, and staged drive copies. - Cleans up Firecracker processes, sockets, TAPs, bridges, NAT/firewall/WireGuard rules, and staged drive copies.
The design goal is close to a Cloudflare Workers/Deno-style filesystem model, adapted to Firecracker: immutable root image, explicit writable scratch, no accidental persistent state, and persistence only when you opt in. The default mental model is: immutable base image, explicit writable scratch, no accidental persistent state, and persistence only when you opt in.
## Install ## Install
@@ -36,13 +50,15 @@ Firecracker is a Linux/KVM technology. The package is TypeScript, but the runtim
|---|---| |---|---|
| Linux with `/dev/kvm` | Firecracker needs KVM acceleration. | | Linux with `/dev/kvm` | Firecracker needs KVM acceleration. |
| Firecracker binary | Downloaded by `ensureBinary()` or supplied through `firecrackerBinaryPath`. | | Firecracker binary | Downloaded by `ensureBinary()` or supplied through `firecrackerBinaryPath`. |
| Root privileges for networking | TAP devices, bridges, IP forwarding, and iptables NAT require elevated privileges. | | Root privileges for networking | TAP devices, bridges, IP forwarding, iptables NAT, firewalling, and WireGuard policy routing require elevated privileges. |
| Host tools: `curl`, `tar`, `ip`, `sysctl`, `iptables` | Used for binary/image downloads and network setup. | | Host tools: `curl`, `tar`, `ip`, `sysctl`, `iptables`, `wg` when WireGuard is used | Used for binary/image downloads and network setup. `dd` and `mkfs.ext4` are only needed for `ImageManager.createBlankRootfs()`. |
| Enough tmpfs memory | Writable VM drives are copied into `/dev/shm` by default when available. | | Enough tmpfs memory | Writable VM drives are copied into `/dev/shm` by default when available. |
If you only use `VMConfig`, `SocketClient`, or custom low-level flows without creating host networking, those pieces do not need root. Actual Firecracker boot still needs a Linux/KVM-capable host.
## Quick Start ## Quick Start
This is the happy path: let `smartvm` download Firecracker, resolve a known-good base image, boot it, and clean it up. This is the happy path: let `smartvm` download Firecracker, resolve a known-good base image, boot it, and clean everything up. Run it on a Linux host with KVM access.
```typescript ```typescript
import { SmartVM } from '@push.rocks/smartvm'; import { SmartVM } from '@push.rocks/smartvm';
@@ -89,6 +105,14 @@ try {
} }
``` ```
What happened:
- Firecracker was downloaded or reused from `/tmp/.smartvm/bin`.
- A base image bundle was resolved and cached under `/tmp/.smartvm/base-images`.
- A per-VM socket directory was created under `/dev/shm/.smartvm/runtime/<vmId>` when `/dev/shm` exists.
- Writable drives would be staged into that runtime directory before boot; read-only drives stay in place.
- `cleanup()` removed VM runtime files and networking resources owned by this `SmartVM` instance.
## Disk-Light Runtime Model ## Disk-Light Runtime Model
By default, `smartvm` treats VMs as ephemeral execution units. By default, `smartvm` treats VMs as ephemeral execution units.
@@ -160,7 +184,7 @@ Best practice for high-volume VM starts:
SmartVM SmartVM
ImageManager downloads/caches Firecracker binaries and manual images ImageManager downloads/caches Firecracker binaries and manual images
BaseImageManager resolves known-good base-image bundles BaseImageManager resolves known-good base-image bundles
NetworkManager creates TAP devices, bridge, NAT, and static guest network data NetworkManager creates TAP devices, bridge, NAT, firewall/WireGuard egress, and static guest network data
MicroVM MicroVM
FirecrackerProcess starts/stops the VMM process FirecrackerProcess starts/stops the VMM process
SocketClient talks HTTP over the Firecracker Unix socket SocketClient talks HTTP over the Firecracker Unix socket
@@ -186,6 +210,16 @@ const options: ISmartVMOptions = {
firecrackerBinaryPath: '/usr/bin/firecracker', firecrackerBinaryPath: '/usr/bin/firecracker',
bridgeName: 'svbr0', bridgeName: 'svbr0',
subnet: '172.30.0.0/24', subnet: '172.30.0.0/24',
firewall: {
egress: {
defaultAction: 'allow',
rules: [],
},
},
wireguard: {
existingInterface: 'wg0',
failClosed: true,
},
baseImageCacheDir: '/tmp/.smartvm/base-images', baseImageCacheDir: '/tmp/.smartvm/base-images',
maxStoredBaseImages: 2, maxStoredBaseImages: 2,
baseImageManifestUrl: 'https://assets.example.com/smartvm/manifest.json', baseImageManifestUrl: 'https://assets.example.com/smartvm/manifest.json',
@@ -210,7 +244,7 @@ const smartvm = new SmartVM(options);
## Base Images ## Base Images
`BaseImageManager` gives you fast bootable image discovery without committing giant rootfs files to git. `BaseImageManager` gives you fast bootable image discovery without committing giant rootfs files to git. The `latest` and `lts` presets use Firecracker CI artifacts, which are excellent for tests, demos, and bring-up. For product workloads, prefer the `hosted` manifest path so you control the kernel/rootfs pair and verify artifacts with SHA256.
```typescript ```typescript
const baseImage = await smartvm.ensureBaseImage(); // preset: "latest" const baseImage = await smartvm.ensureBaseImage(); // preset: "latest"
@@ -266,6 +300,8 @@ Cache behavior:
- Hosted URL artifacts require SHA256 hashes - Hosted URL artifacts require SHA256 hashes
- Hosted local-path artifacts may omit SHA256, but hashes are still recorded in the cached manifest - Hosted local-path artifacts may omit SHA256, but hashes are still recorded in the cached manifest
Hosted manifests are the clean way to ship a project-owned minimal image. Keep big binaries in object storage or release assets, keep only the manifest in git, and let `smartvm` verify the exact bytes before boot.
Hosted manifest example: Hosted manifest example:
The repository ships an example at `assets/base-images/smartvm-minimal.manifest.example.json`. The repository ships an example at `assets/base-images/smartvm-minimal.manifest.example.json`.
@@ -426,9 +462,84 @@ Networking behavior:
- Allocation is sequential and not reused within the same `NetworkManager` instance - Allocation is sequential and not reused within the same `NetworkManager` instance
- MAC addresses are deterministic and locally administered (`02:xx:xx:xx:xx:xx`) - MAC addresses are deterministic and locally administered (`02:xx:xx:xx:xx:xx`)
- TAP names are capped to Linux's 15-character IFNAMSIZ limit - TAP names are capped to Linux's 15-character IFNAMSIZ limit
- NAT masquerade uses the host default route interface - NAT masquerade uses the host default route interface unless WireGuard egress is configured
- Use a dedicated bridge name; `cleanup()` tears down the bridge configured by this manager - Use a dedicated bridge name; `cleanup()` tears down the bridge configured by this manager
### Egress Firewall
Configure `firewall.egress` on `SmartVM` to apply one ordered policy to all VMs using that manager's subnet. The default action is `allow`, so existing behavior is preserved unless you opt into a stricter policy.
```typescript
const smartvm = new SmartVM({
firewall: {
egress: {
defaultAction: 'deny',
rules: [
{ action: 'allow', to: '1.1.1.1', protocol: 'udp', ports: 53, comment: 'DNS' },
{ action: 'allow', to: '203.0.113.0/24', protocol: 'tcp', ports: [443] },
],
},
},
});
```
Firewall behavior:
- Rules are evaluated in order before the final `defaultAction`.
- `to` accepts IPv4 addresses or CIDR ranges only.
- `protocol` can be `all`, `tcp`, `udp`, or `icmp`.
- `ports` are destination ports and require `protocol: 'tcp'` or `protocol: 'udp'`.
- The implementation uses an owned `iptables` chain jumped from `FORWARD` for traffic from the VM subnet.
### WireGuard Egress
WireGuard routing is host-side. The guest does not need WireGuard installed; `smartvm` policy-routes packets from the VM subnet through a WireGuard interface and leaves normal host traffic on the host default route.
Managed interface mode creates and removes the WireGuard interface:
```typescript
const smartvm = new SmartVM({
wireguard: {
interfaceName: 'svwg0',
routeTable: 51820,
failClosed: true,
config: `
[Interface]
PrivateKey = <private-key>
Address = 10.70.0.2/32
MTU = 1420
[Peer]
PublicKey = <public-key>
AllowedIPs = 0.0.0.0/0
Endpoint = vpn.example.com:51820
PersistentKeepalive = 25
`,
},
});
```
Existing interface mode uses an interface you manage outside `smartvm`:
```typescript
const smartvm = new SmartVM({
wireguard: {
existingInterface: 'wg0',
routeTable: 51820,
failClosed: true,
},
});
```
WireGuard behavior:
- `routeAllVmTraffic` defaults to `true`; set it to `false` to keep normal default-route NAT.
- `failClosed` defaults to `true`; VM forwarding to non-WireGuard egress interfaces is dropped when WireGuard routing is active.
- Managed configs accept wg-quick-style `Address` and `MTU`, but reject `PreUp`, `PostUp`, `PreDown`, `PostDown`, and `SaveConfig`.
- `DNS` and `Table` in managed configs are ignored; use host DNS and the `routeTable` option instead.
- IPv4 addresses and IPv4 `AllowedIPs` are supported in this release.
- `cleanup()` removes owned policy routes, iptables rules, NAT rules, and managed WireGuard interfaces. Existing WireGuard interfaces are not deleted.
## ImageManager ## ImageManager
`ImageManager` is the lower-level helper for Firecracker binaries and manually managed kernel/rootfs files. `ImageManager` is the lower-level helper for Firecracker binaries and manually managed kernel/rootfs files.
@@ -578,8 +689,11 @@ try {
| `INVALID_BASE_IMAGE_CACHE_LIMIT` | Base-image retention limit is invalid. | | `INVALID_BASE_IMAGE_CACHE_LIMIT` | Base-image retention limit is invalid. |
| `INVALID_SUBNET` | Subnet is not a supported IPv4 CIDR. | | `INVALID_SUBNET` | Subnet is not a supported IPv4 CIDR. |
| `INVALID_INTERFACE_NAME` | Bridge or TAP name is invalid. | | `INVALID_INTERFACE_NAME` | Bridge or TAP name is invalid. |
| `INVALID_FIREWALL_CONFIG` | VM egress firewall config is invalid. |
| `INVALID_WIREGUARD_CONFIG` | WireGuard egress config is invalid. |
| `IP_EXHAUSTED` | No guest IPs remain in the configured subnet. | | `IP_EXHAUSTED` | No guest IPs remain in the configured subnet. |
| `BRIDGE_SETUP_FAILED` | Bridge/NAT setup failed. | | `BRIDGE_SETUP_FAILED` | Bridge/NAT setup failed. |
| `WIREGUARD_SETUP_FAILED` | WireGuard interface or policy-route setup failed. |
| `TAP_CREATE_FAILED` | TAP creation failed. | | `TAP_CREATE_FAILED` | TAP creation failed. |
| `ROOTFS_CREATE_FAILED` | Blank rootfs creation failed. | | `ROOTFS_CREATE_FAILED` | Blank rootfs creation failed. |
| `ROOTFS_CLONE_FAILED` | Rootfs clone failed. | | `ROOTFS_CLONE_FAILED` | Rootfs clone failed. |
@@ -595,7 +709,7 @@ pnpm test
pnpm run build pnpm run build
``` ```
The default suite covers config validation, payload generation, lifecycle guards, base-image cache behavior, hosted manifest validation, VM tracking, ephemeral drive staging, and subnet/IP behavior. The default suite covers config validation, payload generation, lifecycle guards, base-image cache behavior, hosted manifest validation, VM tracking, ephemeral drive staging, subnet/IP behavior, and firewall/WireGuard option validation.
Opt into real Firecracker boot tests on a Linux/KVM host: Opt into real Firecracker boot tests on a Linux/KVM host:
@@ -661,6 +775,14 @@ import type {
IRateLimiter, IRateLimiter,
INetworkManagerOptions, INetworkManagerOptions,
ITapDevice, ITapDevice,
IFirewallConfig,
IFirewallEgressConfig,
IFirewallRule,
TFirewallAction,
TFirewallProtocol,
TWireGuardConfig,
IWireGuardManagedConfig,
IWireGuardExistingInterfaceConfig,
ISocketClientOptions, ISocketClientOptions,
IApiResponse, IApiResponse,
TVMState, TVMState,
+147
View File
@@ -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
// ============================================================ // ============================================================
@@ -783,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',
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartvm', name: '@push.rocks/smartvm',
version: '1.3.1', 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'
} }
+667 -50
View File
@@ -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;
}
} }
+2
View File
@@ -45,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
+69
View File
@@ -20,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. */
@@ -153,6 +157,67 @@ export interface IMicroVMRuntimeOptions {
ephemeralWritableDrives?: boolean; 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.
*/ */
@@ -353,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;
} }
/** /**