import * as plugins from './plugins.js'; import type { INetworkManagerOptions, ITapDevice } from './interfaces/index.js'; import { SmartVMError } from './interfaces/index.js'; type TShellExecResult = Awaited['execSpawn']>>; interface IParsedSubnet { networkAddress: number; broadcastAddress: number; cidr: number; subnetMask: string; } /** * Manages host networking for Firecracker VMs. * Creates TAP devices, Linux bridges, and configures NAT for VM internet access. */ export class NetworkManager { private bridgeName: string; private subnetBase: string; private subnetCidr: number; private gatewayIp: string; private subnetMask: string; private nextIpAddress: number; private lastUsableIpAddress: number; private activeTaps: Map = new Map(); private bridgeCreated: boolean = false; private defaultRouteInterface: string | null = null; private shell: InstanceType; constructor(options: INetworkManagerOptions = {}) { this.bridgeName = options.bridgeName || 'svbr0'; this.validateInterfaceName(this.bridgeName, 'bridgeName'); const subnet = options.subnet || '172.30.0.0/24'; const parsedSubnet = this.parseSubnet(subnet); this.subnetBase = this.intToIp(parsedSubnet.networkAddress); this.subnetCidr = parsedSubnet.cidr; this.subnetMask = parsedSubnet.subnetMask; this.gatewayIp = this.intToIp(parsedSubnet.networkAddress + 1); this.nextIpAddress = parsedSubnet.networkAddress + 2; this.lastUsableIpAddress = parsedSubnet.broadcastAddress - 1; this.shell = new plugins.smartshell.Smartshell({ executor: 'bash' }); } /** * Parse an IPv4 CIDR subnet and ensure there is room for a gateway and guests. */ private parseSubnet(subnet: string): IParsedSubnet { const [ip, cidrText, extra] = subnet.split('/'); const cidr = Number(cidrText); if (!ip || !cidrText || extra !== undefined || !Number.isInteger(cidr) || cidr < 1 || cidr > 30) { throw new SmartVMError( `Invalid subnet '${subnet}': expected IPv4 CIDR with prefix length 1-30`, 'INVALID_SUBNET', ); } const ipAddress = this.ipToInt(ip); const mask = this.cidrToMask(cidr); const networkAddress = (ipAddress & mask) >>> 0; const hostCount = 2 ** (32 - cidr); const broadcastAddress = networkAddress + hostCount - 1; if (hostCount < 4) { throw new SmartVMError( `Invalid subnet '${subnet}': at least two usable host addresses are required`, 'INVALID_SUBNET', ); } return { networkAddress, broadcastAddress, cidr, subnetMask: this.intToIp(mask), }; } private ipToInt(ip: string): number { const octets = ip.split('.'); if (octets.length !== 4) { throw new SmartVMError(`Invalid IPv4 address '${ip}'`, 'INVALID_SUBNET'); } if (octets.some((octet) => !/^[0-9]+$/.test(octet))) { throw new SmartVMError(`Invalid IPv4 address '${ip}'`, 'INVALID_SUBNET'); } const numbers = octets.map((octet) => Number(octet)); if (numbers.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) { throw new SmartVMError(`Invalid IPv4 address '${ip}'`, 'INVALID_SUBNET'); } return ( numbers[0] * 256 ** 3 + numbers[1] * 256 ** 2 + numbers[2] * 256 + numbers[3] ) >>> 0; } private intToIp(address: number): string { return [ Math.floor(address / 256 ** 3) % 256, Math.floor(address / 256 ** 2) % 256, Math.floor(address / 256) % 256, address % 256, ].join('.'); } private cidrToMask(cidr: number): number { return (0xffffffff << (32 - cidr)) >>> 0; } private validateInterfaceName(name: string, fieldName: string): void { if (!/^[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', ); } } /** * Allocate the next available IP address in the subnet. */ public allocateIp(): string { if (this.nextIpAddress > this.lastUsableIpAddress) { throw new SmartVMError( `Subnet ${this.subnetBase}/${this.subnetCidr} has no available guest IP addresses`, 'IP_EXHAUSTED', ); } const ip = this.intToIp(this.nextIpAddress); this.nextIpAddress++; return ip; } /** * Generate a deterministic locally-administered MAC address. */ public generateMac(vmId: string, ifaceId: string): string { // Create a simple hash from vmId + ifaceId for deterministic MAC generation const input = `${vmId}:${ifaceId}`; let hash = 0; for (let i = 0; i < input.length; i++) { const char = input.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32-bit integer } // Ensure hash is positive const h = Math.abs(hash); // Generate MAC octets from hash, using locally-administered prefix (02:xx:xx:xx:xx:xx) const mac = [ 0x02, (h >> 0) & 0xff, (h >> 8) & 0xff, (h >> 16) & 0xff, (h >> 24) & 0xff, ((h >> 4) ^ (h >> 12)) & 0xff, ]; return mac.map((b) => b.toString(16).padStart(2, '0')).join(':'); } /** * Generate a TAP device name that fits within IFNAMSIZ (15 chars). * Format: sv<4charVmId> */ public generateTapName(vmId: string, ifaceId: string): string { const vmPart = vmId.replace(/-/g, '').substring(0, 4); const ifacePart = ifaceId.substring(0, 6); const tapName = `sv${vmPart}${ifacePart}`; // Ensure max 15 chars (Linux IFNAMSIZ) return tapName.substring(0, 15); } private async run(command: string, args: string[]): Promise { return this.shell.execSpawn(command, args, { silent: true }); } private async runChecked(command: string, args: string[]): Promise { const result = await this.run(command, args); if (result.exitCode !== 0) { const output = (result.stderr || result.stdout || '').trim(); throw new Error(`${command} ${args.join(' ')} exited with code ${result.exitCode}${output ? `: ${output}` : ''}`); } return result; } private async getDefaultRouteInterface(): Promise { if (this.defaultRouteInterface) { return this.defaultRouteInterface; } const result = await this.runChecked('ip', ['route', 'show', 'default']); const match = result.stdout.match(/\bdev\s+([^\s]+)/); if (!match) { throw new Error('Could not determine default route interface'); } const iface = match[1]; this.validateInterfaceName(iface, 'default route interface'); this.defaultRouteInterface = iface; return iface; } /** * Ensure the Linux bridge is created and configured. */ public async ensureBridge(): Promise { if (this.bridgeCreated) return; 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']); await this.runChecked('ip', ['addr', 'add', `${this.gatewayIp}/${this.subnetCidr}`, 'dev', this.bridgeName]); await this.runChecked('ip', ['link', 'set', this.bridgeName, 'up']); } // 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', ]); } this.bridgeCreated = true; } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new SmartVMError( `Failed to set up network bridge: ${message}`, 'BRIDGE_SETUP_FAILED', ); } } /** * Create a TAP device for a VM and attach it to the bridge. */ public async createTapDevice(vmId: string, ifaceId: string): Promise { await this.ensureBridge(); const tapName = this.generateTapName(vmId, ifaceId); this.validateInterfaceName(tapName, 'tapName'); const guestIp = this.allocateIp(); const mac = this.generateMac(vmId, ifaceId); let tapCreated = false; try { // Create TAP device await this.runChecked('ip', ['tuntap', 'add', 'dev', tapName, 'mode', 'tap']); tapCreated = true; // Attach to bridge await this.runChecked('ip', ['link', 'set', tapName, 'master', this.bridgeName]); // Bring TAP device up await this.runChecked('ip', ['link', 'set', tapName, 'up']); const tap: ITapDevice = { tapName, guestIp, gatewayIp: this.gatewayIp, subnetMask: this.subnetMask, mac, }; this.activeTaps.set(tapName, tap); return tap; } catch (err) { if (tapCreated) { await this.removeTapDevice(tapName); } const message = err instanceof Error ? err.message : String(err); throw new SmartVMError( `Failed to create TAP device ${tapName}: ${message}`, 'TAP_CREATE_FAILED', ); } } /** * Remove a TAP device and free its resources. */ public async removeTapDevice(tapName: string): Promise { this.validateInterfaceName(tapName, 'tapName'); try { await this.run('ip', ['link', 'del', tapName]); this.activeTaps.delete(tapName); } catch { // Device may already be gone } } /** * Generate kernel boot args for guest networking. * Returns the `ip=` parameter for the kernel command line. */ public getGuestNetworkBootArgs(tap: ITapDevice): string { // Format: ip=::::::off return `ip=${tap.guestIp}::${tap.gatewayIp}:${tap.subnetMask}::eth0:off`; } /** * Get all active TAP devices. */ public getActiveTaps(): ITapDevice[] { return Array.from(this.activeTaps.values()); } /** * Clean up all TAP devices and the bridge. */ public async cleanup(): Promise { // Remove all TAP devices for (const tapName of Array.from(this.activeTaps.keys())) { await this.removeTapDevice(tapName); } // Remove bridge if we created it if (this.bridgeCreated) { try { await this.run('ip', ['link', 'set', this.bridgeName, 'down']); await this.run('ip', ['link', 'del', this.bridgeName]); } catch { // 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; } } }