Files
smartvm/ts/classes.networkmanager.ts
T

384 lines
12 KiB
TypeScript
Raw Permalink Normal View History

2026-02-08 21:47:33 +00:00
import * as plugins from './plugins.js';
import type { INetworkManagerOptions, ITapDevice } from './interfaces/index.js';
import { SmartVMError } from './interfaces/index.js';
type TShellExecResult = Awaited<ReturnType<InstanceType<typeof plugins.smartshell.Smartshell>['execSpawn']>>;
interface IParsedSubnet {
networkAddress: number;
broadcastAddress: number;
cidr: number;
subnetMask: string;
}
2026-02-08 21:47:33 +00:00
/**
* 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;
2026-02-08 21:47:33 +00:00
private activeTaps: Map<string, ITapDevice> = new Map();
private bridgeCreated: boolean = false;
private defaultRouteInterface: string | null = null;
2026-02-08 21:47:33 +00:00
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
constructor(options: INetworkManagerOptions = {}) {
this.bridgeName = options.bridgeName || 'svbr0';
this.validateInterfaceName(this.bridgeName, 'bridgeName');
2026-02-08 21:47:33 +00:00
const subnet = options.subnet || '172.30.0.0/24';
const parsedSubnet = this.parseSubnet(subnet);
2026-02-08 21:47:33 +00:00
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;
2026-02-08 21:47:33 +00:00
this.shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
}
/**
* Parse an IPv4 CIDR subnet and ensure there is room for a gateway and guests.
2026-02-08 21:47:33 +00:00
*/
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 {
2026-02-08 21:47:33 +00:00
return [
Math.floor(address / 256 ** 3) % 256,
Math.floor(address / 256 ** 2) % 256,
Math.floor(address / 256) % 256,
address % 256,
2026-02-08 21:47:33 +00:00
].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',
);
}
}
2026-02-08 21:47:33 +00:00
/**
* 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;
2026-02-08 21:47:33 +00:00
}
/**
* 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><ifaceId truncated>
*/
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<TShellExecResult> {
return this.shell.execSpawn(command, args, { silent: true });
}
private async runChecked(command: string, args: string[]): Promise<TShellExecResult> {
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<string> {
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;
}
2026-02-08 21:47:33 +00:00
/**
* Ensure the Linux bridge is created and configured.
*/
public async ensureBridge(): Promise<void> {
if (this.bridgeCreated) return;
try {
// Check if bridge already exists
const result = await this.run('ip', ['link', 'show', this.bridgeName]);
2026-02-08 21:47:33 +00:00
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']);
2026-02-08 21:47:33 +00:00
}
// Enable IP forwarding
await this.runChecked('sysctl', ['-w', 'net.ipv4.ip_forward=1']);
2026-02-08 21:47:33 +00:00
// 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);
2026-02-08 21:47:33 +00:00
if (checkResult.exitCode !== 0) {
await this.runChecked('iptables', [
'-t',
'nat',
'-A',
'POSTROUTING',
'-s',
`${this.subnetBase}/${this.subnetCidr}`,
'-o',
defaultIface,
'-j',
'MASQUERADE',
]);
2026-02-08 21:47:33 +00:00
}
this.bridgeCreated = true;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
2026-02-08 21:47:33 +00:00
throw new SmartVMError(
`Failed to set up network bridge: ${message}`,
2026-02-08 21:47:33 +00:00
'BRIDGE_SETUP_FAILED',
);
}
}
/**
* Create a TAP device for a VM and attach it to the bridge.
*/
public async createTapDevice(vmId: string, ifaceId: string): Promise<ITapDevice> {
await this.ensureBridge();
const tapName = this.generateTapName(vmId, ifaceId);
this.validateInterfaceName(tapName, 'tapName');
2026-02-08 21:47:33 +00:00
const guestIp = this.allocateIp();
const mac = this.generateMac(vmId, ifaceId);
let tapCreated = false;
2026-02-08 21:47:33 +00:00
try {
// Create TAP device
await this.runChecked('ip', ['tuntap', 'add', 'dev', tapName, 'mode', 'tap']);
tapCreated = true;
2026-02-08 21:47:33 +00:00
// Attach to bridge
await this.runChecked('ip', ['link', 'set', tapName, 'master', this.bridgeName]);
2026-02-08 21:47:33 +00:00
// Bring TAP device up
await this.runChecked('ip', ['link', 'set', tapName, 'up']);
2026-02-08 21:47:33 +00:00
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);
2026-02-08 21:47:33 +00:00
throw new SmartVMError(
`Failed to create TAP device ${tapName}: ${message}`,
2026-02-08 21:47:33 +00:00
'TAP_CREATE_FAILED',
);
}
}
/**
* Remove a TAP device and free its resources.
*/
public async removeTapDevice(tapName: string): Promise<void> {
this.validateInterfaceName(tapName, 'tapName');
2026-02-08 21:47:33 +00:00
try {
await this.run('ip', ['link', 'del', tapName]);
2026-02-08 21:47:33 +00:00
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=<client-ip>:<server-ip>:<gw-ip>:<netmask>::<device>: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<void> {
// Remove all TAP devices
for (const tapName of Array.from(this.activeTaps.keys())) {
2026-02-08 21:47:33 +00:00
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]);
2026-02-08 21:47:33 +00:00
} 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',
]);
2026-02-08 21:47:33 +00:00
} catch {
// Rule may not exist
}
this.bridgeCreated = false;
}
}
}