diff --git a/changelog.md b/changelog.md index 4d63835..c7414bf 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 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 diff --git a/readme.md b/readme.md index 9ffefbd..5b75b8e 100644 --- a/readme.md +++ b/readme.md @@ -16,9 +16,11 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community - Creates and controls microVMs through Firecracker's HTTP-over-Unix-socket API. - Converts TypeScript camelCase config into Firecracker's snake_case payloads. - Creates TAP devices, a Linux bridge, static guest IP 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. - 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. @@ -37,7 +39,7 @@ Firecracker is a Linux/KVM technology. The package is TypeScript, but the runtim | Linux with `/dev/kvm` | Firecracker needs KVM acceleration. | | Firecracker binary | Downloaded by `ensureBinary()` or supplied through `firecrackerBinaryPath`. | | Root privileges for networking | TAP devices, bridges, IP forwarding, and iptables NAT 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. | | Enough tmpfs memory | Writable VM drives are copied into `/dev/shm` by default when available. | ## Quick Start @@ -160,7 +162,7 @@ Best practice for high-volume VM starts: SmartVM ImageManager downloads/caches Firecracker binaries and manual images 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 FirecrackerProcess starts/stops the VMM process SocketClient talks HTTP over the Firecracker Unix socket @@ -186,6 +188,16 @@ const options: ISmartVMOptions = { firecrackerBinaryPath: '/usr/bin/firecracker', bridgeName: 'svbr0', subnet: '172.30.0.0/24', + firewall: { + egress: { + defaultAction: 'allow', + rules: [], + }, + }, + wireguard: { + existingInterface: 'wg0', + failClosed: true, + }, baseImageCacheDir: '/tmp/.smartvm/base-images', maxStoredBaseImages: 2, baseImageManifestUrl: 'https://assets.example.com/smartvm/manifest.json', @@ -426,9 +438,84 @@ Networking behavior: - Allocation is sequential and not reused within the same `NetworkManager` instance - MAC addresses are deterministic and locally administered (`02:xx:xx:xx:xx:xx`) - 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 +### 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 = +Address = 10.70.0.2/32 +MTU = 1420 + +[Peer] +PublicKey = +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` is the lower-level helper for Firecracker binaries and manually managed kernel/rootfs files. @@ -578,8 +665,11 @@ try { | `INVALID_BASE_IMAGE_CACHE_LIMIT` | Base-image retention limit is invalid. | | `INVALID_SUBNET` | Subnet is not a supported IPv4 CIDR. | | `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. | | `BRIDGE_SETUP_FAILED` | Bridge/NAT setup failed. | +| `WIREGUARD_SETUP_FAILED` | WireGuard interface or policy-route setup failed. | | `TAP_CREATE_FAILED` | TAP creation failed. | | `ROOTFS_CREATE_FAILED` | Blank rootfs creation failed. | | `ROOTFS_CLONE_FAILED` | Rootfs clone failed. | @@ -595,7 +685,7 @@ pnpm test 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: @@ -661,6 +751,14 @@ import type { IRateLimiter, INetworkManagerOptions, ITapDevice, + IFirewallConfig, + IFirewallEgressConfig, + IFirewallRule, + TFirewallAction, + TFirewallProtocol, + TWireGuardConfig, + IWireGuardManagedConfig, + IWireGuardExistingInterfaceConfig, ISocketClientOptions, IApiResponse, TVMState, @@ -675,7 +773,7 @@ import type { ## License and Legal Information -This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file. +This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file. **Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file. diff --git a/test/test.ts b/test/test.ts index f4deb2a..990a3f7 100644 --- a/test/test.ts +++ b/test/test.ts @@ -23,6 +23,15 @@ async function getRejectedError(promise: Promise): Promise { return undefined; } +function getThrownError(fn: () => unknown): unknown { + try { + fn(); + } catch (err) { + return err; + } + return undefined; +} + function sha256Buffer(buffer: Buffer): string { 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'); }); +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 // ============================================================ @@ -783,6 +904,32 @@ tap.test('SmartVM - instantiation with custom options', async () => { 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 () => { const smartvm = new SmartVM({ dataDir: '/tmp/smartvm-test', diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 953d0e9..71f8da1 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartvm', - version: '1.3.1', + version: '1.4.0', description: 'A TypeScript module wrapping Amazon Firecracker VMM for managing lightweight microVMs' } diff --git a/ts/classes.networkmanager.ts b/ts/classes.networkmanager.ts index 36a8708..ff5e21f 100644 --- a/ts/classes.networkmanager.ts +++ b/ts/classes.networkmanager.ts @@ -1,5 +1,13 @@ 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'; type TShellExecResult = Awaited['execSpawn']>>; @@ -11,6 +19,17 @@ interface IParsedSubnet { subnetMask: string; } +interface IParsedWireGuardConfig { + setConfig: string; + addresses: string[]; + mtu?: number; +} + +const FIREWALL_ACTION_TO_IPTABLES_TARGET: Record = { + allow: 'ACCEPT', + deny: 'DROP', +}; + /** * Manages host networking for Firecracker VMs. * Creates TAP devices, Linux bridges, and configures NAT for VM internet access. @@ -26,6 +45,19 @@ export class NetworkManager { private activeTaps: Map = new Map(); private bridgeCreated: boolean = false; 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; constructor(options: INetworkManagerOptions = {}) { @@ -40,6 +72,11 @@ export class NetworkManager { this.gatewayIp = this.intToIp(parsedSubnet.networkAddress + 1); this.nextIpAddress = parsedSubnet.networkAddress + 2; 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' }); } @@ -114,7 +151,7 @@ export class NetworkManager { } 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( `${fieldName} '${name}' is not a valid Linux 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; + 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. */ @@ -209,18 +537,263 @@ export class NetworkManager { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.run('iptables', ['-t', table, '-D', chain, ...ruleArgs]); + } + /** * Ensure the Linux bridge is created and configured. */ public async ensureBridge(): Promise { if (this.bridgeCreated) return; + let createdBridge = false; try { // Check if bridge already exists const result = await this.run('ip', ['link', 'show', this.bridgeName]); if (result.exitCode !== 0) { // Create 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', ['link', 'set', this.bridgeName, 'up']); } @@ -228,38 +801,26 @@ export class NetworkManager { // Enable IP forwarding await this.runChecked('sysctl', ['-w', 'net.ipv4.ip_forward=1']); - // Set up NAT masquerade (idempotent with -C check) - const defaultIface = await this.getDefaultRouteInterface(); - const natArgs = [ - '-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', - ]); - } + const egressInterface = await this.configureWireGuardEgress(); + await this.setupEgressFirewall(egressInterface); + await this.setupNat(egressInterface); this.bridgeCreated = true; } 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); throw new SmartVMError( `Failed to set up network bridge: ${message}`, @@ -349,6 +910,10 @@ export class NetworkManager { await this.removeTapDevice(tapName); } + await this.cleanupEgressFirewall(); + await this.cleanupNat(); + await this.cleanupWireGuardEgress(); + // Remove bridge if we created it if (this.bridgeCreated) { try { @@ -358,26 +923,78 @@ export class NetworkManager { // 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; } } + + private async cleanupEgressFirewall(): Promise { + 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 { + 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 { + 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; + } } diff --git a/ts/classes.smartvm.ts b/ts/classes.smartvm.ts index fb1997e..661157a 100644 --- a/ts/classes.smartvm.ts +++ b/ts/classes.smartvm.ts @@ -45,6 +45,8 @@ export class SmartVM { this.networkManager = new NetworkManager({ bridgeName: this.options.bridgeName, subnet: this.options.subnet, + firewall: this.options.firewall, + wireguard: this.options.wireguard, }); // If a custom binary path is provided, use it directly diff --git a/ts/interfaces/config.ts b/ts/interfaces/config.ts index 5e18fac..0e38c8e 100644 --- a/ts/interfaces/config.ts +++ b/ts/interfaces/config.ts @@ -20,6 +20,10 @@ export interface ISmartVMOptions { bridgeName?: string; /** Network subnet in CIDR notation. Defaults to '172.30.0.0/24'. */ 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. */ baseImageCacheDir?: string; /** Maximum number of cached base image bundles. Defaults to 2. */ @@ -153,6 +157,67 @@ export interface IMicroVMRuntimeOptions { 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. */ @@ -353,6 +418,10 @@ export interface INetworkManagerOptions { bridgeName?: string; /** Subnet in CIDR notation. Defaults to '172.30.0.0/24'. */ subnet?: string; + /** VM egress firewall configuration. */ + firewall?: IFirewallConfig; + /** Host-side WireGuard egress routing configuration for VM traffic. */ + wireguard?: TWireGuardConfig; } /**