networking VMs (WIP)

This commit is contained in:
Maxime Van Hees
2025-08-21 18:57:20 +02:00
parent 768e3e176d
commit af89ef0149
4 changed files with 940 additions and 0 deletions

View File

@@ -5,6 +5,8 @@ use std::fs;
use std::path::{Path, PathBuf};
use std::thread;
use std::time::Duration;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use sal_os;
use sal_process;
@@ -299,6 +301,32 @@ pub fn vm_start(id: &str) -> Result<(), CloudHvError> {
parts.push("--console".into());
parts.push("off".into());
// Networking prerequisites (bridge + NAT via nftables + dnsmasq DHCP)
// Defaults can be overridden via env:
// HERO_VIRT_BRIDGE_NAME, HERO_VIRT_BRIDGE_ADDR_CIDR, HERO_VIRT_SUBNET_CIDR, HERO_VIRT_DHCP_START, HERO_VIRT_DHCP_END
let bridge_name = std::env::var("HERO_VIRT_BRIDGE_NAME").unwrap_or_else(|_| "br-hero".into());
let bridge_addr_cidr = std::env::var("HERO_VIRT_BRIDGE_ADDR_CIDR").unwrap_or_else(|_| "172.30.0.1/24".into());
let subnet_cidr = std::env::var("HERO_VIRT_SUBNET_CIDR").unwrap_or_else(|_| "172.30.0.0/24".into());
let dhcp_start = std::env::var("HERO_VIRT_DHCP_START").unwrap_or_else(|_| "172.30.0.50".into());
let dhcp_end = std::env::var("HERO_VIRT_DHCP_END").unwrap_or_else(|_| "172.30.0.250".into());
// Ensure host-side networking (requires root privileges / CAP_NET_ADMIN)
ensure_host_net_prereq_dnsmasq_nftables(
&bridge_name,
&bridge_addr_cidr,
&subnet_cidr,
&dhcp_start,
&dhcp_end,
)?;
// Ensure a TAP device for this VM and attach to the bridge
let tap_name = ensure_tap_for_vm(&bridge_name, id)?;
// Stable locally-administered MAC derived from VM id
let mac = stable_mac_from_id(id);
parts.push("--net".into());
parts.push(format!("tap={},mac={}", tap_name, mac));
if let Some(extra) = rec.spec.extra_args.clone() {
for e in extra {
parts.push(e);
@@ -480,6 +508,150 @@ pub fn vm_list() -> Result<Vec<VmRecord>, CloudHvError> {
Ok(out)
}
fn tap_name_for_id(id: &str) -> String {
// Linux IFNAMSIZ is typically 15; keep "tap-" + 10 hex = 14 chars
let mut h = DefaultHasher::new();
id.hash(&mut h);
let v = h.finish();
let hex = format!("{:016x}", v);
format!("tap-{}", &hex[..10])
}
fn ensure_tap_for_vm(bridge_name: &str, id: &str) -> Result<String, CloudHvError> {
let tap = tap_name_for_id(id);
let script = format!(
"#!/bin/bash -e
BR={br}
TAP={tap}
UIDX=$(id -u)
GIDX=$(id -g)
# Create TAP if missing and assign to current user/group
ip link show \"$TAP\" >/dev/null 2>&1 || ip tuntap add dev \"$TAP\" mode tap user \"$UIDX\" group \"$GIDX\"
# Enslave to bridge and bring up (idempotent)
ip link set \"$TAP\" master \"$BR\" 2>/dev/null || true
ip link set \"$TAP\" up
",
br = shell_escape(bridge_name),
tap = shell_escape(&tap),
);
match sal_process::run(&script).silent(true).execute() {
Ok(res) if res.success => Ok(tap),
Ok(res) => Err(CloudHvError::CommandFailed(format!(
"Failed to ensure TAP '{}': {}",
tap, res.stderr
))),
Err(e) => Err(CloudHvError::CommandFailed(format!(
"Failed to ensure TAP '{}': {}",
tap, e
))),
}
}
fn stable_mac_from_id(id: &str) -> String {
let mut h = DefaultHasher::new();
id.hash(&mut h);
let v = h.finish();
let b0 = (((v >> 40) & 0xff) as u8 & 0xfe) | 0x02; // locally administered, unicast
let b1 = ((v >> 32) & 0xff) as u8;
let b2 = ((v >> 24) & 0xff) as u8;
let b3 = ((v >> 16) & 0xff) as u8;
let b4 = ((v >> 8) & 0xff) as u8;
let b5 = (v & 0xff) as u8;
format!("{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", b0, b1, b2, b3, b4, b5)
}
fn ensure_host_net_prereq_dnsmasq_nftables(
bridge_name: &str,
bridge_addr_cidr: &str,
subnet_cidr: &str,
dhcp_start: &str,
dhcp_end: &str,
) -> Result<(), CloudHvError> {
// Dependencies
for bin in ["ip", "nft", "dnsmasq", "systemctl"] {
if sal_process::which(bin).is_none() {
return Err(CloudHvError::DependencyMissing(format!(
"{} not found on PATH; required for VM networking",
bin
)));
}
}
// Build idempotent setup script
let script = format!(
"#!/bin/bash -e
set -e
BR={br}
BR_ADDR={br_addr}
SUBNET={subnet}
DHCP_START={dstart}
DHCP_END={dend}
# Determine default WAN interface
WAN_IF=$(ip -o route show default | awk '{{print $5}}' | head -n1)
# Bridge creation (idempotent)
ip link show \"$BR\" >/dev/null 2>&1 || ip link add name \"$BR\" type bridge
ip addr replace \"$BR_ADDR\" dev \"$BR\"
ip link set \"$BR\" up
# IPv4 forwarding
sysctl -w net.ipv4.ip_forward=1 >/dev/null
# nftables NAT (idempotent)
nft list table ip hero >/dev/null 2>&1 || nft add table ip hero
nft list chain ip hero postrouting >/dev/null 2>&1 || nft add chain ip hero postrouting {{ type nat hook postrouting priority 100 \\; }}
nft list chain ip hero postrouting | grep -q \"ip saddr $SUBNET oifname \\\"$WAN_IF\\\" masquerade\" \
|| nft add rule ip hero postrouting ip saddr $SUBNET oifname \"$WAN_IF\" masquerade
# dnsmasq DHCP config (idempotent)
mkdir -p /etc/dnsmasq.d
CFG=/etc/dnsmasq.d/hero-$BR.conf
TMP=/etc/dnsmasq.d/.hero-$BR.conf.new
cat >\"$TMP\" <<EOF
interface=$BR
bind-interfaces
dhcp-range=$DHCP_START,$DHCP_END,12h
dhcp-option=option:dns-server,1.1.1.1,8.8.8.8
EOF
if [ ! -f \"$CFG\" ] || ! cmp -s \"$CFG\" \"$TMP\"; then
mv \"$TMP\" \"$CFG\"
if systemctl is-active --quiet dnsmasq; then
systemctl reload dnsmasq || systemctl restart dnsmasq || true
else
systemctl enable --now dnsmasq || true
fi
else
rm -f \"$TMP\"
systemctl enable --now dnsmasq || true
fi
",
br = shell_escape(bridge_name),
br_addr = shell_escape(bridge_addr_cidr),
subnet = shell_escape(subnet_cidr),
dstart = shell_escape(dhcp_start),
dend = shell_escape(dhcp_end),
);
match sal_process::run(&script).silent(true).execute() {
Ok(res) if res.success => Ok(()),
Ok(res) => Err(CloudHvError::CommandFailed(format!(
"Host networking setup failed: {}",
res.stderr
))),
Err(e) => Err(CloudHvError::CommandFailed(format!(
"Host networking setup failed: {}",
e
))),
}
}
/// Render a shell-safe command string from vector of tokens
fn shell_join(parts: &Vec<String>) -> String {
let mut s = String::new();