This commit is contained in:
Maxime Van Hees
2025-09-09 10:31:07 +02:00
parent f5670f20be
commit 182b0edeb7
10 changed files with 115 additions and 294 deletions

View File

@@ -1,6 +1,5 @@
use crate::cloudhv::{vm_create, vm_start, CloudHvError, VmSpec};
use crate::image_prep::{image_prepare, Flavor as ImgFlavor, ImagePrepOptions, NetPlanOpts};
use sal_process;
use crate::cloudhv::net::{NetworkingProfileSpec, DefaultNatOptions, BridgeOptions};
/// Cloud Hypervisor VM Builder focused on Rhai ergonomics.

View File

@@ -5,8 +5,6 @@ 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;
@@ -416,7 +414,7 @@ pub fn vm_start(id: &str) -> Result<(), CloudHvError> {
if let Some(profile) = profile_effective {
match profile {
NetworkingProfileSpec::DefaultNat(mut nat) => {
NetworkingProfileSpec::DefaultNat(nat) => {
// IPv6 handling (auto via Mycelium unless disabled)
let mut ipv6_bridge_cidr: Option<String> = None;
if nat.ipv6_enable {
@@ -579,8 +577,8 @@ pub fn vm_start(id: &str) -> Result<(), CloudHvError> {
std::env::var("HERO_VIRT_DHCP_LEASE_FILE")
.unwrap_or_else(|_| format!("/var/lib/misc/dnsmasq-hero-{}.leases", bridge_name))
});
let ipv4 = net::discover_ipv4_from_leases(&lease_path, &mac_lower, 12);
let ipv6 = net::discover_ipv6_on_bridge(&bridge_name, &mac_lower);
let _ipv4 = net::discover_ipv4_from_leases(&lease_path, &mac_lower, 12);
let _ipv6 = net::discover_ipv6_on_bridge(&bridge_name, &mac_lower);
}
Ok(())
@@ -713,253 +711,14 @@ 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 body = format!(
"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),
);
let heredoc_tap = format!("bash -e -s <<'EOF'\n{}\nEOF\n", body);
match sal_process::run(&heredoc_tap).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)
}
/// Discover the mycelium IPv6 address by inspecting the interface itself (no CLI dependency).
/// Returns (interface_name, first global IPv6 address found on the interface).
fn get_mycelium_ipv6_addr(iface_hint: &str) -> Result<(String, String), CloudHvError> {
let iface = std::env::var("HERO_VIRT_MYCELIUM_IF").unwrap_or_else(|_| iface_hint.to_string());
// Query IPv6 addresses on the interface
let cmd = format!("ip -6 addr show dev {}", shell_escape(&iface));
let res = sal_process::run(&cmd).silent(true).die(false).execute();
let out = match res {
Ok(r) if r.success => r.stdout,
_ => {
return Err(CloudHvError::DependencyMissing(format!(
"mycelium interface '{}' not found or no IPv6 configured",
iface
)))
}
};
// Extract the first global IPv6 address present on the interface.
for line in out.lines() {
let lt = line.trim();
// Example line: "inet6 578:9fcf:.../7 scope global"
if lt.starts_with("inet6 ") && lt.contains("scope global") {
let parts: Vec<&str> = lt.split_whitespace().collect();
if let Some(addr_cidr) = parts.get(1) {
let addr_only = addr_cidr.split('/').next().unwrap_or("").trim();
if !addr_only.is_empty() && addr_only.parse::<std::net::Ipv6Addr>().is_ok() {
return Ok((iface, addr_only.to_string()));
}
}
}
}
Err(CloudHvError::DependencyMissing(format!(
"no global IPv6 found on interface '{}'",
iface
)))
}
/// Derive a /64 prefix P from the mycelium IPv6 and return (P/64, P::2/64).
fn derive_ipv6_prefix_from_mycelium(m: &str) -> Result<(String, String), CloudHvError> {
let ip = m
.parse::<std::net::Ipv6Addr>()
.map_err(|e| CloudHvError::InvalidSpec(format!("invalid mycelium IPv6 address '{}': {}", m, e)))?;
let seg = ip.segments(); // [u16; 8]
// Take the top /64 from the mycelium address; zero the host half
let pfx = std::net::Ipv6Addr::new(seg[0], seg[1], seg[2], seg[3], 0, 0, 0, 0);
// Router address for the bridge = P::2
let router = std::net::Ipv6Addr::new(seg[0], seg[1], seg[2], seg[3], 0, 0, 0, 2);
let pfx_str = format!("{}/64", pfx);
let router_cidr = format!("{}/64", router);
Ok((pfx_str, router_cidr))
}
fn ensure_host_net_prereq_dnsmasq_nftables(
bridge_name: &str,
bridge_addr_cidr: &str,
subnet_cidr: &str,
dhcp_start: &str,
dhcp_end: &str,
ipv6_bridge_cidr: Option<&str>,
mycelium_if: Option<&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
)));
}
}
// Prepare optional IPv6 value (empty string when disabled)
let ipv6_cidr = ipv6_bridge_cidr.unwrap_or("");
// Build idempotent setup script
let body = format!(
"set -e
BR={br}
BR_ADDR={br_addr}
SUBNET={subnet}
DHCP_START={dstart}
DHCP_END={dend}
IPV6_CIDR={v6cidr}
LEASE_FILE=/var/lib/misc/dnsmasq-hero-$BR.leases
# Determine default WAN interface
WAN_IF=$(ip -o route show default | awk '{{print $5}}' | head -n1)
if [ -z \"$WAN_IF\" ]; then
echo \"No default WAN interface detected (required for IPv4 NAT)\" >&2
exit 2
fi
# 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
# IPv6 bridge address and forwarding (optional)
if [ -n \"$IPV6_CIDR\" ]; then
ip -6 addr replace \"$IPV6_CIDR\" dev \"$BR\"
sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null || true
fi
# IPv4 forwarding
sysctl -w net.ipv4.ip_forward=1 >/dev/null
# nftables NAT (idempotent) for IPv4
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 DHCPv4 + RA/DHCPv6 config (idempotent)
mkdir -p /etc/dnsmasq.d
mkdir -p /var/lib/misc
CFG=/etc/dnsmasq.d/hero-$BR.conf
TMP=/etc/dnsmasq.d/.hero-$BR.conf.new
RELOAD=0
CONF=/etc/dnsmasq.conf
# Ensure conf-dir includes /etc/dnsmasq.d (simple fixed-string check to avoid regex escapes in Rust)
if ! grep -qF \"conf-dir=/etc/dnsmasq.d\" \"$CONF\"; then
printf '%s\n' 'conf-dir=/etc/dnsmasq.d,*.conf' >> \"$CONF\"
RELOAD=1
fi
# Ensure lease file exists and is writable by dnsmasq user
touch \"$LEASE_FILE\" || true
chown dnsmasq:dnsmasq \"$LEASE_FILE\" 2>/dev/null || true
# Always include IPv4 section
printf '%s\n' \
\"interface=$BR\" \
\"bind-interfaces\" \
\"dhcp-authoritative\" \
\"dhcp-range=$DHCP_START,$DHCP_END,12h\" \
\"dhcp-option=option:dns-server,1.1.1.1,8.8.8.8\" \
\"dhcp-leasefile=$LEASE_FILE\" >\"$TMP\"
# Optionally append IPv6 RA/DHCPv6
if [ -n \"$IPV6_CIDR\" ]; then
printf '%s\n' \
\"enable-ra\" \
\"dhcp-range=::,constructor:BR_PLACEHOLDER,ra-names,64,12h\" \
\"dhcp-option=option6:dns-server,[2001:4860:4860::8888],[2606:4700:4700::1111]\" >>\"$TMP\"
sed -i \"s/BR_PLACEHOLDER/$BR/g\" \"$TMP\"
fi
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
# Reload if main conf was updated to include conf-dir
if [ \"$RELOAD\" = \"1\" ]; then
systemctl reload dnsmasq || systemctl restart 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),
v6cidr = shell_escape(ipv6_cidr),
);
// Use a unique heredoc delimiter to avoid clashing with inner <<EOF blocks
let heredoc_net = format!("bash -e -s <<'HERONET'\n{}\nHERONET\n", body);
match sal_process::run(&heredoc_net).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 {

View File

@@ -1,5 +1,3 @@
use serde::{Deserialize, Serialize};
use sal_process;
use crate::cloudhv::CloudHvError;

View File

@@ -42,7 +42,7 @@ fn bin_missing(name: &str) -> bool {
/// Returns a structured report that Rhai can consume easily.
pub fn host_check_deps() -> Result<HostCheckReport, HostCheckError> {
let mut critical: Vec<String> = Vec::new();
let mut optional: Vec<String> = Vec::new();
let optional: Vec<String> = Vec::new();
let mut notes: Vec<String> = Vec::new();
// Must run as root

View File

@@ -1,5 +1,4 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use sal_os;
@@ -443,7 +442,7 @@ if [ -f "$MNT_ROOT/etc/ssh/sshd_config" ]; then
sed -i -E 's/^[[:space:]]*AuthenticationMethods[[:space:]].*$/# hero: removed AuthenticationMethods/' "$MNT_ROOT/etc/ssh/sshd_config" 2>/dev/null || true
fi
if [ -d "$MNT_ROOT/etc/ssh/sshd_config.d" ]; then
find "$MNT_ROOT/etc/ssh/sshd_config.d" -type f -name '*.conf' -exec sed -i -E 's/^[[:space:]]*AuthenticationMethods[[:space:]].*$/# hero: removed AuthenticationMethods/' {} + 2>/dev/null \; || true
find "$MNT_ROOT/etc/ssh/sshd_config.d" -type f -name '*.conf' -exec sed -i -E 's/^[[:space:]]*AuthenticationMethods[[:space:]].*$/# hero: removed AuthenticationMethods/' {{}} + 2>/dev/null \; || true
fi
# Set password for default user 'ubuntu'
@@ -653,7 +652,7 @@ if [ -f /etc/ssh/sshd_config ]; then
sed -i -E 's/^[[:space:]]*AuthenticationMethods[[:space:]].*$/# hero: removed AuthenticationMethods/' /etc/ssh/sshd_config 2>/dev/null || true
fi
if [ -d /etc/ssh/sshd_config.d ]; then
find /etc/ssh/sshd_config.d -type f -name '*.conf' -exec sed -i -E 's/^[[:space:]]*AuthenticationMethods[[:space:]].*$/# hero: removed AuthenticationMethods/' {} + 2>/dev/null \; || true
find /etc/ssh/sshd_config.d -type f -name '*.conf' -exec sed -i -E 's/^[[:space:]]*AuthenticationMethods[[:space:]].*$/# hero: removed AuthenticationMethods/' {{}} + 2>/dev/null \; || true
fi
# Ensure Include covers drop-ins

View File

@@ -171,6 +171,13 @@ pub fn cloudhv_vm_info(id: &str) -> Result<Map, Box<EvalAltResult>> {
}
pub fn cloudhv_discover_ipv4_from_leases(lease_path: &str, mac_lower: &str, timeout_secs: i64) -> Dynamic {
// Check verbosity from environment variable, default to verbose
let verbose = std::env::var("VIRT_VERBOSE").unwrap_or_else(|_| "1".to_string()) == "1";
if verbose {
println!("🔍 Discovering VM network addresses...");
}
match crate::cloudhv::net::discover_ipv4_from_leases(lease_path, mac_lower, timeout_secs as u64) {
Some(ip) => ip.into(),
None => Dynamic::UNIT,
@@ -184,6 +191,45 @@ pub fn cloudhv_discover_ipv6_on_bridge(bridge_name: &str, mac_lower: &str) -> Dy
}
}
pub fn cloudhv_display_network_info(vm_id: &str, ipv4: Dynamic, ipv6: Dynamic) {
// Check verbosity from environment variable, default to verbose
let verbose = std::env::var("VIRT_VERBOSE").unwrap_or_else(|_| "1".to_string()) == "1";
if !verbose {
return;
}
println!("✅ VM {} is ready!", vm_id);
println!("");
println!("🌐 Network Information:");
if ipv4.is_string() && !ipv4.clone().cast::<String>().is_empty() {
println!(" IPv4: {}", ipv4.clone().cast::<String>());
} else {
println!(" IPv4: Not assigned yet (VM may still be configuring)");
}
if ipv6.is_string() && !ipv6.clone().cast::<String>().is_empty() {
println!(" IPv6: {}", ipv6.clone().cast::<String>());
} else {
println!(" IPv6: Not available");
}
println!("");
println!("💡 VM is running in the background. To connect:");
let ssh_addr = if ipv4.is_string() && !ipv4.clone().cast::<String>().is_empty() {
ipv4.cast::<String>()
} else {
"<IPv4>".to_string()
};
println!(" SSH: ssh ubuntu@{}", ssh_addr);
println!("");
println!("🛑 To stop the VM later:");
println!(" cloudhv_vm_stop(\"{}\", false);", vm_id);
println!(" cloudhv_vm_delete(\"{}\", true);", vm_id);
}
// Module registration
pub fn register_cloudhv_module(engine: &mut Engine) -> Result<(), Box<EvalAltResult>> {
@@ -195,5 +241,6 @@ pub fn register_cloudhv_module(engine: &mut Engine) -> Result<(), Box<EvalAltRes
engine.register_fn("cloudhv_vm_info", cloudhv_vm_info);
engine.register_fn("cloudhv_discover_ipv4_from_leases", cloudhv_discover_ipv4_from_leases);
engine.register_fn("cloudhv_discover_ipv6_on_bridge", cloudhv_discover_ipv6_on_bridge);
engine.register_fn("cloudhv_display_network_info", cloudhv_display_network_info);
Ok(())
}

View File

@@ -1,7 +1,7 @@
use crate::cloudhv::builder::CloudHvBuilder;
use crate::hostcheck::host_check_deps;
use crate::image_prep::{image_prepare, Flavor as ImgFlavor, ImagePrepOptions, NetPlanOpts};
use rhai::{Engine, EvalAltResult, Map, Array};
use rhai::{Engine, EvalAltResult, Array};
// Improved functional-style builder with better method names for fluent feel
fn cloudhv_builder(id: &str) -> CloudHvBuilder {
@@ -85,14 +85,37 @@ fn network_custom(b: CloudHvBuilder, args: Array) -> CloudHvBuilder {
}
fn launch(mut b: CloudHvBuilder) -> Result<String, Box<EvalAltResult>> {
// Check verbosity from environment variable, default to verbose
let verbose = std::env::var("VIRT_VERBOSE").unwrap_or_else(|_| "1".to_string()) == "1";
if verbose {
println!("Preparing Ubuntu image and configuring VM...");
}
b.launch().map_err(|e| {
Box::new(EvalAltResult::ErrorRuntime(
format!("cloudhv builder launch failed: {}", e).into(),
rhai::Position::NONE,
))
}).map(|vm_id| {
if verbose {
println!("✅ VM launched successfully");
}
vm_id
})
}
fn wait_for_vm_boot(seconds: i64) {
// Check verbosity from environment variable, default to verbose
let verbose = std::env::var("VIRT_VERBOSE").unwrap_or_else(|_| "1".to_string()) == "1";
if verbose {
println!("⏳ Waiting {} seconds for VM to boot and configure network...", seconds);
}
std::thread::sleep(std::time::Duration::from_secs(seconds as u64));
}
// Noob-friendly one-shot wrapper
fn vm_easy_launch(flavor: &str, id: &str, memory_mb: i64, vcpus: i64) -> Result<String, Box<EvalAltResult>> {
// Preflight
@@ -172,6 +195,7 @@ pub fn register_cloudhv_builder_module(engine: &mut Engine) -> Result<(), Box<Ev
// Action
engine.register_fn("launch", launch);
engine.register_fn("wait_for_vm_boot", wait_for_vm_boot);
// One-shot wrapper
engine.register_fn("vm_easy_launch", vm_easy_launch);

View File

@@ -27,9 +27,42 @@ fn report_to_map(r: &HostCheckReport) -> Map {
}
fn host_check() -> Result<Map, Box<EvalAltResult>> {
// Check verbosity from environment variable, default to verbose
let verbose = std::env::var("VIRT_VERBOSE").unwrap_or_else(|_| "1".to_string()) == "1";
if verbose {
println!("Checking system requirements...");
}
match host_check_deps() {
Ok(rep) => Ok(report_to_map(&rep)),
Ok(rep) => {
if verbose {
if rep.ok {
println!("✅ System requirements met");
} else {
println!("❌ System check failed - missing dependencies:");
if !rep.critical.is_empty() {
println!("Critical:");
for dep in &rep.critical {
println!(" - {}", dep);
}
}
if !rep.optional.is_empty() {
println!("Optional:");
for dep in &rep.optional {
println!(" - {}", dep);
}
}
}
}
Ok(report_to_map(&rep))
},
Err(e) => {
if verbose {
println!("❌ System check failed - missing dependencies:");
println!("Critical:");
println!(" - host_check failed: {}", e);
}
let mut m = Map::new();
m.insert("ok".into(), Dynamic::FALSE);
let mut crit = Array::new();

View File

@@ -1,5 +1,5 @@
use crate::image_prep::{image_prepare, Flavor, ImagePrepOptions, NetPlanOpts};
use rhai::{Array, Dynamic, Engine, EvalAltResult, Map};
use rhai::{Engine, EvalAltResult, Map};
fn parse_flavor(s: &str) -> Result<Flavor, Box<EvalAltResult>> {
match s {

View File

@@ -4,28 +4,12 @@
let vm_id = "vm-clean-test";
// Phase 1: Host check
print("Checking system requirements...");
let hc = host_check();
if !(hc.ok == true) {
print("❌ System check failed - missing dependencies:");
if hc.critical != () && hc.critical.len() > 0 {
print("Critical:");
for dep in hc.critical {
print(" - " + dep);
}
}
if hc.optional != () && hc.optional.len() > 0 {
print("Optional:");
for dep in hc.optional {
print(" - " + dep);
}
}
throw "Host check failed: missing dependencies";
}
print("✅ System requirements met");
// Phase 2: Create VM using fluent builder pattern
print("Preparing Ubuntu image and configuring VM...");
let vm_id_actual = "";
try {
vm_id_actual = cloudhv_builder(vm_id)
@@ -39,37 +23,15 @@ try {
}
// Phase 3: Wait for VM to boot and get network configuration
print("✅ VM launched successfully");
print("⏳ Waiting 10 seconds for VM to boot and configure network...");
sleep(10);
wait_for_vm_boot(10);
// Phase 4: Discover VM IP addresses
print("🔍 Discovering VM network addresses...");
let mac_addr = "a2:26:1e:ac:96:3a"; // This should be derived from vm_id_actual
let ipv4 = cloudhv_discover_ipv4_from_leases("/var/lib/misc/dnsmasq-hero-br-hero.leases", mac_addr, 30);
let ipv6 = cloudhv_discover_ipv6_on_bridge("br-hero", mac_addr);
// Phase 5: Display connection info
print("✅ VM " + vm_id_actual + " is ready!");
print("");
print("🌐 Network Information:");
if ipv4 != () && ipv4 != "" {
print(" IPv4: " + ipv4);
} else {
print(" IPv4: Not assigned yet (VM may still be configuring)");
}
if ipv6 != () && ipv6 != "" {
print(" IPv6: " + ipv6);
} else {
print(" IPv6: Not available");
}
print("");
print("💡 VM is running in the background. To connect:");
print(" SSH: ssh ubuntu@" + (if ipv4 != () && ipv4 != "" { ipv4 } else { "<IPv4>" }));
print("");
print("🛑 To stop the VM later:");
print(" cloudhv_vm_stop(\"" + vm_id_actual + "\", false);");
print(" cloudhv_vm_delete(\"" + vm_id_actual + "\", true);");
cloudhv_display_network_info(vm_id_actual, ipv4, ipv6);
/*
try {