1001 lines
32 KiB
TypeScript
1001 lines
32 KiB
TypeScript
import * as plugins from './plugins.js';
|
|
import type {
|
|
IFirewallConfig,
|
|
IFirewallRule,
|
|
INetworkManagerOptions,
|
|
ITapDevice,
|
|
TFirewallAction,
|
|
TFirewallProtocol,
|
|
TWireGuardConfig,
|
|
} 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;
|
|
}
|
|
|
|
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.
|
|
* 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<string, ITapDevice> = 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<typeof plugins.smartshell.Smartshell>;
|
|
|
|
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.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' });
|
|
}
|
|
|
|
/**
|
|
* 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 (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',
|
|
);
|
|
}
|
|
}
|
|
|
|
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.
|
|
*/
|
|
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><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;
|
|
}
|
|
|
|
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.
|
|
*/
|
|
public async ensureBridge(): Promise<void> {
|
|
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']);
|
|
}
|
|
|
|
// Enable IP forwarding
|
|
await this.runChecked('sysctl', ['-w', 'net.ipv4.ip_forward=1']);
|
|
|
|
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}`,
|
|
'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');
|
|
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<void> {
|
|
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=<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())) {
|
|
await this.removeTapDevice(tapName);
|
|
}
|
|
|
|
await this.cleanupEgressFirewall();
|
|
await this.cleanupNat();
|
|
await this.cleanupWireGuardEgress();
|
|
|
|
// 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
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|