230 lines
5.0 KiB
TypeScript
230 lines
5.0 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import { readFileSync, writeFileSync, renameSync } 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 atomicWritePool(pool: IpPool) {
|
|
const tmp = CONFIG.ipPoolFile + ".tmp";
|
|
writeFileSync(tmp, JSON.stringify(pool));
|
|
renameSync(tmp, CONFIG.ipPoolFile);
|
|
}
|
|
|
|
export function allocateIp(): { ip: string; octet: number } {
|
|
// Use flock for proper mutual exclusion
|
|
const result = execFileSync("bash", ["-c",
|
|
`flock "${CONFIG.ipPoolLock}" cat "${CONFIG.ipPoolFile}" 2>/dev/null || echo '{"allocated":[]}'`
|
|
], { encoding: "utf-8" });
|
|
const pool: IpPool = JSON.parse(result.trim());
|
|
|
|
for (
|
|
let octet = CONFIG.bridge.minHost;
|
|
octet <= CONFIG.bridge.maxHost;
|
|
octet++
|
|
) {
|
|
if (!pool.allocated.includes(octet)) {
|
|
pool.allocated.push(octet);
|
|
atomicWritePool(pool);
|
|
return { ip: `${CONFIG.bridge.prefix}.${octet}`, octet };
|
|
}
|
|
}
|
|
throw new Error("No free IPs in pool");
|
|
}
|
|
|
|
export function releaseIp(octet: number) {
|
|
const pool = readPool();
|
|
pool.allocated = pool.allocated.filter((o) => o !== octet);
|
|
atomicWritePool(pool);
|
|
}
|