238 lines
7.2 KiB
TypeScript
238 lines
7.2 KiB
TypeScript
import * as plugins from './plugins.js';
|
|
import type { INetworkManagerOptions, ITapDevice } from './interfaces/index.js';
|
|
import { SmartVMError } from './interfaces/index.js';
|
|
|
|
/**
|
|
* 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 nextIpOctet: number;
|
|
private activeTaps: Map<string, ITapDevice> = new Map();
|
|
private bridgeCreated: boolean = false;
|
|
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
|
|
|
|
constructor(options: INetworkManagerOptions = {}) {
|
|
this.bridgeName = options.bridgeName || 'svbr0';
|
|
const subnet = options.subnet || '172.30.0.0/24';
|
|
|
|
// Parse the subnet
|
|
const [baseIp, cidrStr] = subnet.split('/');
|
|
this.subnetBase = baseIp;
|
|
this.subnetCidr = parseInt(cidrStr, 10);
|
|
this.subnetMask = this.cidrToSubnetMask(this.subnetCidr);
|
|
|
|
// Gateway is .1 in the subnet
|
|
const parts = this.subnetBase.split('.').map(Number);
|
|
parts[3] = 1;
|
|
this.gatewayIp = parts.join('.');
|
|
|
|
// VMs start at .2
|
|
this.nextIpOctet = 2;
|
|
|
|
this.shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
|
}
|
|
|
|
/**
|
|
* Convert a CIDR prefix length to a dotted-decimal subnet mask.
|
|
*/
|
|
private cidrToSubnetMask(cidr: number): string {
|
|
const mask = (0xffffffff << (32 - cidr)) >>> 0;
|
|
return [
|
|
(mask >>> 24) & 0xff,
|
|
(mask >>> 16) & 0xff,
|
|
(mask >>> 8) & 0xff,
|
|
mask & 0xff,
|
|
].join('.');
|
|
}
|
|
|
|
/**
|
|
* Allocate the next available IP address in the subnet.
|
|
*/
|
|
public allocateIp(): string {
|
|
const parts = this.subnetBase.split('.').map(Number);
|
|
parts[3] = this.nextIpOctet;
|
|
this.nextIpOctet++;
|
|
return parts.join('.');
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
/**
|
|
* 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.shell.exec(`ip link show ${this.bridgeName} 2>/dev/null`);
|
|
if (result.exitCode !== 0) {
|
|
// Create bridge
|
|
await this.shell.exec(`ip link add ${this.bridgeName} type bridge`);
|
|
await this.shell.exec(`ip addr add ${this.gatewayIp}/${this.subnetCidr} dev ${this.bridgeName}`);
|
|
await this.shell.exec(`ip link set ${this.bridgeName} up`);
|
|
}
|
|
|
|
// Enable IP forwarding
|
|
await this.shell.exec('sysctl -w net.ipv4.ip_forward=1');
|
|
|
|
// Set up NAT masquerade (idempotent with -C check)
|
|
const checkResult = await this.shell.exec(
|
|
`iptables -t nat -C POSTROUTING -s ${this.subnetBase}/${this.subnetCidr} -o $(ip route | grep default | awk '{print $5}' | head -1) -j MASQUERADE 2>/dev/null`,
|
|
);
|
|
if (checkResult.exitCode !== 0) {
|
|
await this.shell.exec(
|
|
`iptables -t nat -A POSTROUTING -s ${this.subnetBase}/${this.subnetCidr} -o $(ip route | grep default | awk '{print $5}' | head -1) -j MASQUERADE`,
|
|
);
|
|
}
|
|
|
|
this.bridgeCreated = true;
|
|
} catch (err) {
|
|
throw new SmartVMError(
|
|
`Failed to set up network bridge: ${err.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<ITapDevice> {
|
|
await this.ensureBridge();
|
|
|
|
const tapName = this.generateTapName(vmId, ifaceId);
|
|
const guestIp = this.allocateIp();
|
|
const mac = this.generateMac(vmId, ifaceId);
|
|
|
|
try {
|
|
// Create TAP device
|
|
await this.shell.exec(`ip tuntap add dev ${tapName} mode tap`);
|
|
// Attach to bridge
|
|
await this.shell.exec(`ip link set ${tapName} master ${this.bridgeName}`);
|
|
// Bring TAP device up
|
|
await this.shell.exec(`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) {
|
|
throw new SmartVMError(
|
|
`Failed to create TAP device ${tapName}: ${err.message}`,
|
|
'TAP_CREATE_FAILED',
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove a TAP device and free its resources.
|
|
*/
|
|
public async removeTapDevice(tapName: string): Promise<void> {
|
|
try {
|
|
await this.shell.exec(`ip link del ${tapName} 2>/dev/null`);
|
|
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 this.activeTaps.keys()) {
|
|
await this.removeTapDevice(tapName);
|
|
}
|
|
|
|
// Remove bridge if we created it
|
|
if (this.bridgeCreated) {
|
|
try {
|
|
await this.shell.exec(`ip link set ${this.bridgeName} down 2>/dev/null`);
|
|
await this.shell.exec(`ip link del ${this.bridgeName} 2>/dev/null`);
|
|
} catch {
|
|
// Bridge may already be gone
|
|
}
|
|
|
|
// Remove NAT rule
|
|
try {
|
|
await this.shell.exec(
|
|
`iptables -t nat -D POSTROUTING -s ${this.subnetBase}/${this.subnetCidr} -o $(ip route | grep default | awk '{print $5}' | head -1) -j MASQUERADE 2>/dev/null`,
|
|
);
|
|
} catch {
|
|
// Rule may not exist
|
|
}
|
|
|
|
this.bridgeCreated = false;
|
|
}
|
|
}
|
|
}
|