import { execFileSync } from "node:child_process"; import { openSync, closeSync, readFileSync, writeFileSync } from "node:fs"; import { CONFIG } from "./config.js"; function run(cmd: string, args: string[]) { execFileSync(cmd, args, { stdio: "pipe" }); } function sudo(args: string[]) { run("sudo", args); } export function ensureBridge() { try { execFileSync("ip", ["link", "show", CONFIG.bridge.name], { stdio: "pipe", }); } catch { sudo(["ip", "link", "add", CONFIG.bridge.name, "type", "bridge"]); sudo([ "ip", "addr", "add", `${CONFIG.bridge.ip}/24`, "dev", CONFIG.bridge.name, ]); sudo(["ip", "link", "set", CONFIG.bridge.name, "up"]); sudo(["sysctl", "-w", "net.ipv4.ip_forward=1"]); } } export function ensureNat() { // Check if rule already exists try { execFileSync( "sudo", [ "iptables", "-t", "nat", "-C", "POSTROUTING", "-s", CONFIG.bridge.subnet, "-j", "MASQUERADE", ], { stdio: "pipe" } ); } catch { // Find the default route interface const routeOut = execFileSync("ip", ["route", "show", "default"], { encoding: "utf-8", }); const extIface = routeOut.match(/dev\s+(\S+)/)?.[1] ?? "eno2"; sudo([ "iptables", "-t", "nat", "-A", "POSTROUTING", "-s", CONFIG.bridge.subnet, "-o", extIface, "-j", "MASQUERADE", ]); sudo([ "iptables", "-A", "FORWARD", "-i", CONFIG.bridge.name, "-o", extIface, "-j", "ACCEPT", ]); sudo([ "iptables", "-A", "FORWARD", "-i", extIface, "-o", CONFIG.bridge.name, "-m", "state", "--state", "RELATED,ESTABLISHED", "-j", "ACCEPT", ]); } } export function createTap(tapName: string) { sudo(["ip", "tuntap", "add", tapName, "mode", "tap"]); sudo(["ip", "link", "set", tapName, "master", CONFIG.bridge.name]); sudo(["ip", "link", "set", tapName, "up"]); } export function deleteTap(tapName: string) { try { sudo(["ip", "tuntap", "del", tapName, "mode", "tap"]); } catch { // Already gone } } export type NetworkPolicy = "full" | "local" | "none"; export function applyNetworkPolicy(ip: string, policy: NetworkPolicy) { if (policy === "full") return; // Default, no restrictions // Block outbound internet — only allow LAN, bridge, Ollama, IRC const allowedDests = [ CONFIG.bridge.subnet, // bridge network (other VMs, IRC, Ollama) "192.168.0.0/16", // LAN ]; if (policy === "none") { // Only allow bridge subnet (IRC + Ollama) allowedDests.length = 1; // keep only bridge subnet } // Allow established connections back sudo([ "iptables", "-I", "FORWARD", "-s", ip, "-m", "state", "--state", "ESTABLISHED,RELATED", "-j", "ACCEPT", ]); // Allow specific destinations for (const dest of allowedDests) { sudo([ "iptables", "-I", "FORWARD", "-s", ip, "-d", dest, "-j", "ACCEPT", ]); } // Drop everything else from this IP sudo([ "iptables", "-A", "FORWARD", "-s", ip, "-j", "DROP", ]); } export function removeNetworkPolicy(ip: string) { // Remove all FORWARD rules mentioning this IP // Run in a loop since there may be multiple rules for (let i = 0; i < 10; i++) { try { sudo([ "iptables", "-D", "FORWARD", "-s", ip, "-j", "DROP", ]); } catch { break; } } for (let i = 0; i < 10; i++) { try { sudo([ "iptables", "-D", "FORWARD", "-s", ip, "-j", "ACCEPT", ]); } catch { break; } } } export function macFromOctet(octet: number): string { return `AA:FC:00:00:00:${octet.toString(16).padStart(2, "0").toUpperCase()}`; } interface IpPool { allocated: number[]; } function readPool(): IpPool { try { return JSON.parse(readFileSync(CONFIG.ipPoolFile, "utf-8")); } catch { return { allocated: [] }; } } function writePool(pool: IpPool) { writeFileSync(CONFIG.ipPoolFile, JSON.stringify(pool)); } export function allocateIp(): { ip: string; octet: number } { const fd = openSync(CONFIG.ipPoolLock, "w"); try { // Simple flock via child process const pool = readPool(); for ( let octet = CONFIG.bridge.minHost; octet <= CONFIG.bridge.maxHost; octet++ ) { if (!pool.allocated.includes(octet)) { pool.allocated.push(octet); writePool(pool); return { ip: `${CONFIG.bridge.prefix}.${octet}`, octet }; } } throw new Error("No free IPs in pool"); } finally { closeSync(fd); } } export function releaseIp(octet: number) { const fd = openSync(CONFIG.ipPoolLock, "w"); try { const pool = readPool(); pool.allocated = pool.allocated.filter((o) => o !== octet); writePool(pool); } finally { closeSync(fd); } }