Compare commits
	
		
			5 Commits
		
	
	
		
			network_se
			...
			network_se
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					f8436a726e | ||
| 
						 | 
					182b0edeb7 | ||
| 
						 | 
					f5670f20be | ||
| 
						 | 
					0f4ed1d64d | ||
| 
						 | 
					f4512b66cf | 
@@ -1,6 +1,6 @@
 | 
			
		||||
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.
 | 
			
		||||
///
 | 
			
		||||
@@ -23,6 +23,8 @@ pub struct CloudHvBuilder {
 | 
			
		||||
    cmdline: Option<String>,
 | 
			
		||||
    extra_args: Vec<String>,
 | 
			
		||||
    no_default_net: bool,
 | 
			
		||||
    /// Optional networking profile driving host provisioning and NIC injection
 | 
			
		||||
    net_profile: Option<NetworkingProfileSpec>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl CloudHvBuilder {
 | 
			
		||||
@@ -37,6 +39,7 @@ impl CloudHvBuilder {
 | 
			
		||||
            // Enforce --seccomp false by default using extra args
 | 
			
		||||
            extra_args: vec!["--seccomp".into(), "false".into()],
 | 
			
		||||
            no_default_net: false,
 | 
			
		||||
            net_profile: None,
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -98,6 +101,40 @@ impl CloudHvBuilder {
 | 
			
		||||
        self
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Explicitly select the Default NAT networking profile (bridge + NAT + dnsmasq; IPv6 via Mycelium if enabled).
 | 
			
		||||
    pub fn network_default_nat(&mut self) -> &mut Self {
 | 
			
		||||
        self.net_profile = Some(NetworkingProfileSpec::DefaultNat(DefaultNatOptions::default()));
 | 
			
		||||
        self
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Explicitly select a no-network profile (no NIC injection and no host provisioning).
 | 
			
		||||
    pub fn network_none(&mut self) -> &mut Self {
 | 
			
		||||
        self.net_profile = Some(NetworkingProfileSpec::NoNet);
 | 
			
		||||
        // Keep backward compatibility: also set sentinel to suppress any legacy default path
 | 
			
		||||
        if !self
 | 
			
		||||
            .extra_args
 | 
			
		||||
            .iter()
 | 
			
		||||
            .any(|e| e.as_str() == "--no-default-net")
 | 
			
		||||
        {
 | 
			
		||||
            self.extra_args.push("--no-default-net".into());
 | 
			
		||||
        }
 | 
			
		||||
        self
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Ensure only bridge + tap, without NAT or DHCP (L2-only setups). Uses defaults if not overridden later.
 | 
			
		||||
    pub fn network_bridge_only(&mut self) -> &mut Self {
 | 
			
		||||
        self.net_profile = Some(NetworkingProfileSpec::BridgeOnly(BridgeOptions::default()));
 | 
			
		||||
        self
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Provide a custom CH --net configuration and disable host provisioning.
 | 
			
		||||
    pub fn network_custom_cli<S: Into<String>>(&mut self, args: Vec<S>) -> &mut Self {
 | 
			
		||||
        self.net_profile = Some(NetworkingProfileSpec::CustomCli(
 | 
			
		||||
            args.into_iter().map(|s| s.into()).collect(),
 | 
			
		||||
        ));
 | 
			
		||||
        self
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Resolve absolute path to hypervisor-fw from /images
 | 
			
		||||
    fn resolve_hypervisor_fw() -> Result<String, CloudHvError> {
 | 
			
		||||
        let p = "/images/hypervisor-fw";
 | 
			
		||||
@@ -161,6 +198,7 @@ impl CloudHvBuilder {
 | 
			
		||||
            } else {
 | 
			
		||||
                Some(self.extra_args.clone())
 | 
			
		||||
            },
 | 
			
		||||
            net_profile: self.net_profile.clone(),
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        let id = vm_create(&spec)?;
 | 
			
		||||
 
 | 
			
		||||
@@ -5,14 +5,14 @@ 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;
 | 
			
		||||
use crate::qcow2;
 | 
			
		||||
use crate::cloudhv::net::{NetworkingProfileSpec, DefaultNatOptions};
 | 
			
		||||
 | 
			
		||||
pub mod builder;
 | 
			
		||||
pub mod net;
 | 
			
		||||
 | 
			
		||||
/// Error type for Cloud Hypervisor operations
 | 
			
		||||
#[derive(Debug)]
 | 
			
		||||
@@ -61,6 +61,9 @@ pub struct VmSpec {
 | 
			
		||||
    pub cmdline: Option<String>,
 | 
			
		||||
    /// Extra args (raw) if you need to extend; keep minimal for Phase 2
 | 
			
		||||
    pub extra_args: Option<Vec<String>>,
 | 
			
		||||
    /// Optional networking profile; when None, behavior follows explicit --net/--no-default-net or defaults
 | 
			
		||||
    #[serde(default)]
 | 
			
		||||
    pub net_profile: Option<NetworkingProfileSpec>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(Debug, Clone, Serialize, Deserialize)]
 | 
			
		||||
@@ -71,6 +74,15 @@ pub struct VmRuntime {
 | 
			
		||||
    pub status: String,
 | 
			
		||||
    /// Console log file path
 | 
			
		||||
    pub log_file: String,
 | 
			
		||||
    /// Bridge name used for networking discovery (if applicable)
 | 
			
		||||
    #[serde(default)]
 | 
			
		||||
    pub bridge_name: Option<String>,
 | 
			
		||||
    /// dnsmasq lease file used (if applicable)
 | 
			
		||||
    #[serde(default)]
 | 
			
		||||
    pub lease_file: Option<String>,
 | 
			
		||||
    /// Stable MAC used for NIC injection (derived from VM id)
 | 
			
		||||
    #[serde(default)]
 | 
			
		||||
    pub mac: Option<String>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(Debug, Clone, Serialize, Deserialize)]
 | 
			
		||||
@@ -105,11 +117,88 @@ fn vm_dir(id: &str) -> PathBuf {
 | 
			
		||||
fn vm_json_path(id: &str) -> PathBuf {
 | 
			
		||||
    vm_dir(id).join("vm.json")
 | 
			
		||||
}
 | 
			
		||||
// Attempt to resolve a VM record across both the current user's HOME and root HOME.
 | 
			
		||||
// This handles cases where the VM was created/launched under sudo (HOME=/root).
 | 
			
		||||
fn resolve_vm_json_path(id: &str) -> Option<PathBuf> {
 | 
			
		||||
    let candidates = vec![
 | 
			
		||||
        hero_vm_root(), // $HOME/hero/virt/vms
 | 
			
		||||
        Path::new("/root/hero/virt/vms").to_path_buf(),
 | 
			
		||||
    ];
 | 
			
		||||
    for base in candidates {
 | 
			
		||||
        let p = base.join(id).join("vm.json");
 | 
			
		||||
        if p.exists() {
 | 
			
		||||
            return Some(p);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    None
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn vm_log_path(id: &str) -> PathBuf {
 | 
			
		||||
    vm_dir(id).join("logs/console.log")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Attempt to resolve an API socket across both the current user's HOME and root HOME.
 | 
			
		||||
/// This handles cases where the VM was launched under sudo (HOME=/root).
 | 
			
		||||
fn resolve_vm_api_socket_path(id: &str) -> Option<PathBuf> {
 | 
			
		||||
    let candidates = vec![
 | 
			
		||||
        hero_vm_root(), // $HOME/hero/virt/vms
 | 
			
		||||
        Path::new("/root/hero/virt/vms").to_path_buf(),
 | 
			
		||||
    ];
 | 
			
		||||
    for base in candidates {
 | 
			
		||||
        let p = base.join(id).join("api.sock");
 | 
			
		||||
        if p.exists() {
 | 
			
		||||
            return Some(p);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    None
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Query cloud-hypervisor for the first NIC's tap and mac via ch-remote-static.
 | 
			
		||||
/// Returns (tap_name, mac_lower) if successful.
 | 
			
		||||
fn ch_query_tap_mac(api_sock: &Path) -> Option<(String, String)> {
 | 
			
		||||
    let cmd = format!(
 | 
			
		||||
        "ch-remote-static --api-socket {} info",
 | 
			
		||||
        shell_escape(&api_sock.to_string_lossy())
 | 
			
		||||
    );
 | 
			
		||||
    if let Ok(res) = sal_process::run(&cmd).silent(true).die(false).execute() {
 | 
			
		||||
        if res.success {
 | 
			
		||||
            if let Ok(v) = serde_json::from_str::<serde_json::Value>(&res.stdout) {
 | 
			
		||||
                if let Some(net0) = v
 | 
			
		||||
                    .get("config")
 | 
			
		||||
                    .and_then(|c| c.get("net"))
 | 
			
		||||
                    .and_then(|n| n.get(0))
 | 
			
		||||
                {
 | 
			
		||||
                    let tap = net0.get("tap").and_then(|t| t.as_str()).unwrap_or("").to_string();
 | 
			
		||||
                    let mac = net0.get("mac").and_then(|m| m.as_str()).unwrap_or("").to_string();
 | 
			
		||||
                    if !tap.is_empty() && !mac.is_empty() {
 | 
			
		||||
                        return Some((tap, mac.to_lowercase()));
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    None
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Infer the bridge name a tap device is attached to by parsing `ip -o link show <tap>` output.
 | 
			
		||||
fn bridge_name_for_tap(tap: &str) -> Option<String> {
 | 
			
		||||
    let cmd = format!("ip -o link show {}", shell_escape(tap));
 | 
			
		||||
    if let Ok(res) = sal_process::run(&cmd).silent(true).die(false).execute() {
 | 
			
		||||
        if res.success {
 | 
			
		||||
            for line in res.stdout.lines() {
 | 
			
		||||
                if let Some(idx) = line.find(" master ") {
 | 
			
		||||
                    let rest = &line[idx + " master ".len()..];
 | 
			
		||||
                    let name = rest.split_whitespace().next().unwrap_or("");
 | 
			
		||||
                    if !name.is_empty() {
 | 
			
		||||
                        return Some(name.to_string());
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    None
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn vm_pid_path(id: &str) -> PathBuf {
 | 
			
		||||
    vm_dir(id).join("pid")
 | 
			
		||||
}
 | 
			
		||||
@@ -177,6 +266,23 @@ pub fn vm_create(spec: &VmSpec) -> Result<String, CloudHvError> {
 | 
			
		||||
        return Err(CloudHvError::InvalidSpec("memory_mb must be >= 128".into()));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // If a VM with this id already exists, ensure it's not running to avoid clobber + resource conflicts
 | 
			
		||||
    let json_path = vm_json_path(&spec.id);
 | 
			
		||||
    if json_path.exists() {
 | 
			
		||||
        if let Ok(value) = read_json(&json_path) {
 | 
			
		||||
            if let Ok(existing) = serde_json::from_value::<VmRecord>(value.clone()) {
 | 
			
		||||
                if let Some(pid) = existing.runtime.pid {
 | 
			
		||||
                    if proc_exists(pid) {
 | 
			
		||||
                        return Err(CloudHvError::CommandFailed(format!(
 | 
			
		||||
                            "VM '{}' already exists and is running with pid {}. Stop or delete it first, or choose a different id.",
 | 
			
		||||
                            spec.id, pid
 | 
			
		||||
                        )));
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Prepare directory layout
 | 
			
		||||
    let dir = vm_dir(&spec.id);
 | 
			
		||||
    sal_os::mkdir(
 | 
			
		||||
@@ -187,17 +293,35 @@ pub fn vm_create(spec: &VmSpec) -> Result<String, CloudHvError> {
 | 
			
		||||
    let log_dir = dir.join("logs");
 | 
			
		||||
    sal_os::mkdir(log_dir.to_str().unwrap()).map_err(|e| CloudHvError::IoError(e.to_string()))?;
 | 
			
		||||
 | 
			
		||||
    // Persist initial record
 | 
			
		||||
    // Build runtime (preserve prior metadata if present; will be refreshed on start)
 | 
			
		||||
    let mut runtime = VmRuntime {
 | 
			
		||||
        pid: None,
 | 
			
		||||
        status: "stopped".into(),
 | 
			
		||||
        log_file: vm_log_path(&spec.id).to_string_lossy().into_owned(),
 | 
			
		||||
        bridge_name: None,
 | 
			
		||||
        lease_file: None,
 | 
			
		||||
        mac: None,
 | 
			
		||||
    };
 | 
			
		||||
    if json_path.exists() {
 | 
			
		||||
        if let Ok(value) = read_json(&json_path) {
 | 
			
		||||
            if let Ok(existing) = serde_json::from_value::<VmRecord>(value) {
 | 
			
		||||
                if !existing.runtime.log_file.is_empty() {
 | 
			
		||||
                    runtime.log_file = existing.runtime.log_file;
 | 
			
		||||
                }
 | 
			
		||||
                runtime.bridge_name = existing.runtime.bridge_name;
 | 
			
		||||
                runtime.lease_file = existing.runtime.lease_file;
 | 
			
		||||
                runtime.mac = existing.runtime.mac;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Persist record (spec updated, runtime preserved/reset to stopped)
 | 
			
		||||
    let rec = VmRecord {
 | 
			
		||||
        spec: spec.clone(),
 | 
			
		||||
        runtime: VmRuntime {
 | 
			
		||||
            pid: None,
 | 
			
		||||
            status: "stopped".into(),
 | 
			
		||||
            log_file: vm_log_path(&spec.id).to_string_lossy().into_owned(),
 | 
			
		||||
        },
 | 
			
		||||
        runtime,
 | 
			
		||||
    };
 | 
			
		||||
    let value = serde_json::to_value(&rec).map_err(|e| CloudHvError::JsonError(e.to_string()))?;
 | 
			
		||||
    write_json(&vm_json_path(&spec.id), &value)?;
 | 
			
		||||
    write_json(&json_path, &value)?;
 | 
			
		||||
 | 
			
		||||
    Ok(spec.id.clone())
 | 
			
		||||
}
 | 
			
		||||
@@ -394,72 +518,102 @@ pub fn vm_start(id: &str) -> Result<(), CloudHvError> {
 | 
			
		||||
        .map(|v| v.iter().any(|tok| tok == "--net" || tok == "--no-default-net"))
 | 
			
		||||
        .unwrap_or(false);
 | 
			
		||||
 | 
			
		||||
    if !has_user_net {
 | 
			
		||||
        // 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());
 | 
			
		||||
 
 | 
			
		||||
        // IPv6 over Mycelium: enabled by default.
 | 
			
		||||
        // If explicitly disabled via HERO_VIRT_IPV6_ENABLE=false|0, we skip.
 | 
			
		||||
        // If enabled but Mycelium is not detected, return an error.
 | 
			
		||||
        let ipv6_env = std::env::var("HERO_VIRT_IPV6_ENABLE").unwrap_or_else(|_| "".into());
 | 
			
		||||
        let ipv6_requested = match ipv6_env.to_lowercase().as_str() {
 | 
			
		||||
            "" | "1" | "true" | "yes" => true,
 | 
			
		||||
            "0" | "false" | "no" => false,
 | 
			
		||||
            _ => true,
 | 
			
		||||
        };
 | 
			
		||||
        let mycelium_if_cfg = std::env::var("HERO_VIRT_MYCELIUM_IF").unwrap_or_else(|_| "mycelium".into());
 | 
			
		||||
        let mut ipv6_bridge_cidr: Option<String> = None;
 | 
			
		||||
        let mut mycelium_if_opt: Option<String> = None;
 | 
			
		||||
 
 | 
			
		||||
        if ipv6_requested {
 | 
			
		||||
            if let Ok(cidr) = std::env::var("HERO_VIRT_IPV6_BRIDGE_CIDR") {
 | 
			
		||||
                // Explicit override for bridge IPv6 (e.g., "400:...::2/64") but still require mycelium iface presence.
 | 
			
		||||
                // Validate mycelium interface and that it has IPv6 configured.
 | 
			
		||||
                let _ = get_mycelium_ipv6_addr(&mycelium_if_cfg)?; // returns DependencyMissing on failure
 | 
			
		||||
                ipv6_bridge_cidr = Some(cidr);
 | 
			
		||||
                mycelium_if_opt = Some(mycelium_if_cfg.clone());
 | 
			
		||||
            } else {
 | 
			
		||||
                // Auto-derive from mycelium node address; error out if not detected.
 | 
			
		||||
                println!("auto-deriving mycelium address...");
 | 
			
		||||
                let (ifname, myc_addr) = get_mycelium_ipv6_addr(&mycelium_if_cfg)?;
 | 
			
		||||
                println!("on if {ifname}, got myc addr: {myc_addr}");
 | 
			
		||||
                let (_pfx, router_cidr) = derive_ipv6_prefix_from_mycelium(&myc_addr)?;
 | 
			
		||||
                println!("derived pfx: {_pfx} and router cidr: {router_cidr}");
 | 
			
		||||
                ipv6_bridge_cidr = Some(router_cidr);
 | 
			
		||||
                mycelium_if_opt = Some(ifname);
 | 
			
		||||
    // Track chosen bridge/lease for later discovery
 | 
			
		||||
    let mut bridge_for_disc: Option<String> = None;
 | 
			
		||||
    let mut lease_for_disc: Option<String> = None;
 | 
			
		||||
 | 
			
		||||
    // Determine effective networking profile
 | 
			
		||||
    let profile_effective = if let Some(p) = rec.spec.net_profile.clone() {
 | 
			
		||||
        Some(p)
 | 
			
		||||
    } else if has_user_net {
 | 
			
		||||
        // User provided explicit --net or --no-default-net; do not provision
 | 
			
		||||
        None
 | 
			
		||||
    } else {
 | 
			
		||||
        // Default behavior: NAT profile
 | 
			
		||||
        Some(NetworkingProfileSpec::DefaultNat(DefaultNatOptions::default()))
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    if let Some(profile) = profile_effective {
 | 
			
		||||
        match profile {
 | 
			
		||||
            NetworkingProfileSpec::DefaultNat(nat) => {
 | 
			
		||||
                // IPv6 handling (auto via Mycelium unless disabled)
 | 
			
		||||
                let mut ipv6_bridge_cidr: Option<String> = None;
 | 
			
		||||
                if nat.ipv6_enable {
 | 
			
		||||
                    if let Ok(cidr) = std::env::var("HERO_VIRT_IPV6_BRIDGE_CIDR") {
 | 
			
		||||
                        // Validate mycelium iface presence if specified or default
 | 
			
		||||
                        let if_hint = nat.mycelium_if.clone().unwrap_or_else(|| "mycelium".into());
 | 
			
		||||
                        let _ = net::mycelium_ipv6_addr(&if_hint)?;
 | 
			
		||||
                        ipv6_bridge_cidr = Some(cidr);
 | 
			
		||||
                    } else {
 | 
			
		||||
                        let if_hint = nat.mycelium_if.clone().unwrap_or_else(|| "mycelium".into());
 | 
			
		||||
                        let (_ifname, myc_addr) = net::mycelium_ipv6_addr(&if_hint)?;
 | 
			
		||||
                        let (_pfx, router_cidr) = net::derive_ipv6_prefix_from_mycelium(&myc_addr)?;
 | 
			
		||||
                        ipv6_bridge_cidr = Some(router_cidr);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                // Ensure bridge, NAT, and DHCP
 | 
			
		||||
                net::ensure_bridge(&nat.bridge_name, &nat.bridge_addr_cidr, ipv6_bridge_cidr.as_deref())?;
 | 
			
		||||
                // Derive IPv6 subnet for NAT
 | 
			
		||||
                let ipv6_subnet = ipv6_bridge_cidr.as_ref().map(|cidr| {
 | 
			
		||||
                    let parts: Vec<&str> = cidr.split('/').collect();
 | 
			
		||||
                    if parts.len() == 2 {
 | 
			
		||||
                        let addr = parts[0];
 | 
			
		||||
                        if let Ok(ip) = addr.parse::<std::net::Ipv6Addr>() {
 | 
			
		||||
                            let seg = ip.segments();
 | 
			
		||||
                            let pfx = std::net::Ipv6Addr::new(seg[0], seg[1], seg[2], seg[3], 0, 0, 0, 0);
 | 
			
		||||
                            format!("{}/64", pfx)
 | 
			
		||||
                        } else {
 | 
			
		||||
                            "".to_string()
 | 
			
		||||
                        }
 | 
			
		||||
                    } else {
 | 
			
		||||
                        "".to_string()
 | 
			
		||||
                    }
 | 
			
		||||
                });
 | 
			
		||||
                net::ensure_nat(&nat.subnet_cidr, ipv6_subnet.as_deref())?;
 | 
			
		||||
                let lease_used = net::ensure_dnsmasq(
 | 
			
		||||
                    &nat.bridge_name,
 | 
			
		||||
                    &nat.dhcp_start,
 | 
			
		||||
                    &nat.dhcp_end,
 | 
			
		||||
                    ipv6_bridge_cidr.as_deref(),
 | 
			
		||||
                    nat.lease_file.as_deref(),
 | 
			
		||||
                )?;
 | 
			
		||||
 | 
			
		||||
                bridge_for_disc = Some(nat.bridge_name.clone());
 | 
			
		||||
                lease_for_disc = Some(lease_used.clone());
 | 
			
		||||
 | 
			
		||||
                // TAP + NIC args
 | 
			
		||||
                let tap_name = net::ensure_tap_for_vm(&nat.bridge_name, id)?;
 | 
			
		||||
                let mac = net::stable_mac_from_id(id);
 | 
			
		||||
                parts.push("--net".into());
 | 
			
		||||
                parts.push(format!("tap={},mac={}", tap_name, mac));
 | 
			
		||||
            }
 | 
			
		||||
            NetworkingProfileSpec::BridgeOnly(opts) => {
 | 
			
		||||
                let bridge_name = opts.bridge_name.clone();
 | 
			
		||||
                // Use provided IPv4 if any, else env default
 | 
			
		||||
                let bridge_addr_cidr = opts
 | 
			
		||||
                    .bridge_addr_cidr
 | 
			
		||||
                    .clone()
 | 
			
		||||
                    .unwrap_or_else(|| std::env::var("HERO_VIRT_BRIDGE_ADDR_CIDR").unwrap_or_else(|_| "172.30.0.1/24".into()));
 | 
			
		||||
                // Ensure bridge (optional IPv6 from opts)
 | 
			
		||||
                net::ensure_bridge(&bridge_name, &bridge_addr_cidr, opts.bridge_ipv6_cidr.as_deref())?;
 | 
			
		||||
                // TAP + NIC only, no NAT/DHCP
 | 
			
		||||
                let tap_name = net::ensure_tap_for_vm(&bridge_name, id)?;
 | 
			
		||||
                let mac = net::stable_mac_from_id(id);
 | 
			
		||||
                parts.push("--net".into());
 | 
			
		||||
                parts.push(format!("tap={},mac={}", tap_name, mac));
 | 
			
		||||
 | 
			
		||||
                // For discovery: we can attempt IPv6 neighbor; IPv4 lease not present
 | 
			
		||||
                bridge_for_disc = Some(bridge_name);
 | 
			
		||||
                lease_for_disc = None;
 | 
			
		||||
            }
 | 
			
		||||
            NetworkingProfileSpec::NoNet => {
 | 
			
		||||
                // Do nothing
 | 
			
		||||
            }
 | 
			
		||||
            NetworkingProfileSpec::CustomCli(_args) => {
 | 
			
		||||
                // Do not provision; user must add --net via extra_args
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 
 | 
			
		||||
        // 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,
 | 
			
		||||
            ipv6_bridge_cidr.as_deref(),
 | 
			
		||||
            mycelium_if_opt.as_deref(),
 | 
			
		||||
        )?;
 | 
			
		||||
 | 
			
		||||
        // Ensure a TAP device for this VM and attach to the bridge
 | 
			
		||||
        let tap_name = ensure_tap_for_vm(&bridge_name, id)?;
 | 
			
		||||
        println!("TAP device for vm called: {tap_name}");
 | 
			
		||||
        // Stable locally-administered MAC derived from VM id
 | 
			
		||||
        let mac = stable_mac_from_id(id);
 | 
			
		||||
        println!("MAC for vm: {mac}");
 | 
			
		||||
 | 
			
		||||
        parts.push("--net".into());
 | 
			
		||||
        parts.push(format!("tap={},mac={}", tap_name, mac));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Append any user-provided extra args, sans any '--disk' we already consolidated
 | 
			
		||||
@@ -475,9 +629,8 @@ pub fn vm_start(id: &str) -> Result<(), CloudHvError> {
 | 
			
		||||
        log_file,
 | 
			
		||||
        vm_pid_path(id).to_string_lossy()
 | 
			
		||||
    );
 | 
			
		||||
    println!("executing command:\n{heredoc}");
 | 
			
		||||
    // Execute command; this will background cloud-hypervisor and return
 | 
			
		||||
    let result = sal_process::run(&heredoc).execute();
 | 
			
		||||
    let result = sal_process::run(&heredoc).silent(true).execute();
 | 
			
		||||
    match result {
 | 
			
		||||
        Ok(res) => {
 | 
			
		||||
            if !res.success {
 | 
			
		||||
@@ -500,7 +653,6 @@ pub fn vm_start(id: &str) -> Result<(), CloudHvError> {
 | 
			
		||||
        Ok(s) => s.trim().parse::<i64>().ok(),
 | 
			
		||||
        Err(_) => None,
 | 
			
		||||
    };
 | 
			
		||||
    println!("reading PID back: {} - (if 0 == not found)", pid.unwrap_or(0));
 | 
			
		||||
 | 
			
		||||
    // Quick health check: ensure process did not exit immediately due to CLI errors (e.g., duplicate flags)
 | 
			
		||||
    if let Some(pid_num) = pid {
 | 
			
		||||
@@ -508,7 +660,6 @@ pub fn vm_start(id: &str) -> Result<(), CloudHvError> {
 | 
			
		||||
        if !proc_exists(pid_num) {
 | 
			
		||||
            // Tail log to surface the error cause
 | 
			
		||||
            let tail_cmd = format!("tail -n 200 {}", shell_escape(&log_file));
 | 
			
		||||
            println!("executing tail_cmd command:\n{tail_cmd}");
 | 
			
		||||
            let tail = sal_process::run(&tail_cmd).die(false).silent(true).execute();
 | 
			
		||||
            let mut log_snip = String::new();
 | 
			
		||||
            if let Ok(res) = tail {
 | 
			
		||||
@@ -533,95 +684,113 @@ pub fn vm_start(id: &str) -> Result<(), CloudHvError> {
 | 
			
		||||
    rec.runtime.pid = pid;
 | 
			
		||||
    rec.runtime.status = if pid.is_some() { "running".into() } else { "stopped".into() };
 | 
			
		||||
    rec.runtime.log_file = log_file;
 | 
			
		||||
    rec.runtime.bridge_name = bridge_for_disc.clone();
 | 
			
		||||
    rec.runtime.lease_file = lease_for_disc.clone();
 | 
			
		||||
    rec.runtime.mac = Some(net::stable_mac_from_id(id));
 | 
			
		||||
    rec.spec.api_socket = api_socket.clone();
 | 
			
		||||
 | 
			
		||||
    let value = serde_json::to_value(&rec).map_err(|e| CloudHvError::JsonError(e.to_string()))?;
 | 
			
		||||
    write_json(&vm_json_path(id), &value)?;
 | 
			
		||||
    println!("wrote JSON for VM");
 | 
			
		||||
 | 
			
		||||
    // Best-effort: discover and print guest IPv4/IPv6 addresses (default-net path)
 | 
			
		||||
    // Give DHCP/ND a moment
 | 
			
		||||
    println!("waiting 5 secs for DHCP/ND");
 | 
			
		||||
    // Best-effort: discover guest IPv4/IPv6 addresses (default-net path)
 | 
			
		||||
    thread::sleep(Duration::from_millis(5000));
 | 
			
		||||
    let bridge_name = std::env::var("HERO_VIRT_BRIDGE_NAME").unwrap_or_else(|_| "br-hero".into());
 | 
			
		||||
    let mac_lower = stable_mac_from_id(id).to_lowercase();
 | 
			
		||||
    let mac_lower = net::stable_mac_from_id(id).to_lowercase();
 | 
			
		||||
 | 
			
		||||
    // IPv4 from dnsmasq leases (pinned per-bridge leasefile)
 | 
			
		||||
    // Path set in ensure_host_net_prereq_dnsmasq_nftables: /var/lib/misc/dnsmasq-hero-$BR.leases
 | 
			
		||||
    let lease_path = std::env::var("HERO_VIRT_DHCP_LEASE_FILE")
 | 
			
		||||
        .unwrap_or_else(|_| format!("/var/lib/misc/dnsmasq-hero-{}.leases", bridge_name));
 | 
			
		||||
    // Parse dnsmasq leases directly to avoid shell quoting/pipelines
 | 
			
		||||
    let ipv4 = (|| {
 | 
			
		||||
        let deadline = std::time::Instant::now() + Duration::from_secs(12);
 | 
			
		||||
        loop {
 | 
			
		||||
            if let Ok(content) = fs::read_to_string(&lease_path) {
 | 
			
		||||
                let mut last_ip: Option<String> = None;
 | 
			
		||||
                for line in content.lines() {
 | 
			
		||||
                    let cols: Vec<&str> = line.split_whitespace().collect();
 | 
			
		||||
                    if cols.len() >= 3 && cols[1].eq_ignore_ascii_case(&mac_lower) {
 | 
			
		||||
                        last_ip = Some(cols[2].to_string());
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                if last_ip.is_some() {
 | 
			
		||||
                    return last_ip;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            if std::time::Instant::now() >= deadline {
 | 
			
		||||
                return None;
 | 
			
		||||
            }
 | 
			
		||||
            thread::sleep(Duration::from_millis(800));
 | 
			
		||||
        }
 | 
			
		||||
    })();
 | 
			
		||||
    println!(
 | 
			
		||||
        "Got IPv4 from dnsmasq lease ({}): {}",
 | 
			
		||||
        lease_path,
 | 
			
		||||
        ipv4.clone().unwrap_or("not found".to_string())
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    // IPv6 from neighbor table on the bridge (exclude link-local), parsed in Rust
 | 
			
		||||
    let ipv6 = (|| {
 | 
			
		||||
        let cmd = format!("ip -6 neigh show dev {}", bridge_name);
 | 
			
		||||
        if let Ok(res) = sal_process::run(&cmd).silent(true).die(false).execute() {
 | 
			
		||||
            if res.success {
 | 
			
		||||
                let mac_pat = format!("lladdr {}", mac_lower);
 | 
			
		||||
                for line in res.stdout.lines() {
 | 
			
		||||
                    let lt = line.trim();
 | 
			
		||||
                    if lt.to_lowercase().contains(&mac_pat) {
 | 
			
		||||
                        if let Some(addr) = lt.split_whitespace().next() {
 | 
			
		||||
                            if !addr.starts_with("fe80") && !addr.is_empty() {
 | 
			
		||||
                                return Some(addr.to_string());
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        None
 | 
			
		||||
    })();
 | 
			
		||||
 | 
			
		||||
    println!("Got IPv6 from neighbor table on bridge: {}", ipv6.clone().unwrap_or("not found".to_string()));
 | 
			
		||||
 | 
			
		||||
    println!(
 | 
			
		||||
        "[cloudhv] VM '{}' guest addresses: IPv4={}, IPv6={}",
 | 
			
		||||
        id,
 | 
			
		||||
        ipv4.as_deref().unwrap_or(""),
 | 
			
		||||
        ipv6.as_deref().unwrap_or("")
 | 
			
		||||
    );
 | 
			
		||||
    if let Some(bridge_name) = bridge_for_disc.clone() {
 | 
			
		||||
        let lease_path = lease_for_disc.unwrap_or_else(|| {
 | 
			
		||||
            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);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Ok(())
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Return VM record info (spec + runtime) by id
 | 
			
		||||
//// Return VM record info (spec + runtime) by id
 | 
			
		||||
pub fn vm_info(id: &str) -> Result<VmRecord, CloudHvError> {
 | 
			
		||||
    let p = vm_json_path(id);
 | 
			
		||||
    if !p.exists() {
 | 
			
		||||
    // Try current user's VM root first, then fall back to /root (common when VM was launched under sudo)
 | 
			
		||||
    let p_user = vm_json_path(id);
 | 
			
		||||
    let p = if p_user.exists() {
 | 
			
		||||
        p_user
 | 
			
		||||
    } else if let Some(p2) = resolve_vm_json_path(id) {
 | 
			
		||||
        p2
 | 
			
		||||
    } else {
 | 
			
		||||
        return Err(CloudHvError::NotFound(format!("VM '{}' not found", id)));
 | 
			
		||||
    }
 | 
			
		||||
    };
 | 
			
		||||
    let value = read_json(&p)?;
 | 
			
		||||
    let rec: VmRecord = serde_json::from_value(value).map_err(|e| CloudHvError::JsonError(e.to_string()))?;
 | 
			
		||||
    Ok(rec)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
//// Discover VM network info using persisted metadata (bridge/lease/mac) with sensible fallbacks.
 | 
			
		||||
/// Returns (IPv4, IPv6, MAC, BridgeName, LeaseFile), each optional.
 | 
			
		||||
pub fn vm_network_info(
 | 
			
		||||
    id: &str,
 | 
			
		||||
    timeout_secs: u64,
 | 
			
		||||
) -> Result<(Option<String>, Option<String>, Option<String>, Option<String>, Option<String>), CloudHvError> {
 | 
			
		||||
    let rec = vm_info(id)?;
 | 
			
		||||
 | 
			
		||||
    // Start with persisted/env/default values
 | 
			
		||||
    let mut bridge_name = rec
 | 
			
		||||
        .runtime
 | 
			
		||||
        .bridge_name
 | 
			
		||||
        .clone()
 | 
			
		||||
        .or_else(|| std::env::var("HERO_VIRT_BRIDGE_NAME").ok())
 | 
			
		||||
        .unwrap_or_else(|| "br-hero".into());
 | 
			
		||||
 | 
			
		||||
    // MAC: persisted or deterministically derived (lowercased for matching)
 | 
			
		||||
    let mut mac_lower = rec
 | 
			
		||||
        .runtime
 | 
			
		||||
        .mac
 | 
			
		||||
        .clone()
 | 
			
		||||
        .unwrap_or_else(|| net::stable_mac_from_id(id))
 | 
			
		||||
        .to_lowercase();
 | 
			
		||||
 | 
			
		||||
    // Attempt to query CH for ground-truth (tap, mac) if API socket is available
 | 
			
		||||
    if let Some(api_sock) = resolve_vm_api_socket_path(id) {
 | 
			
		||||
        if let Some((tap, mac_from_ch)) = ch_query_tap_mac(&api_sock) {
 | 
			
		||||
            mac_lower = mac_from_ch;
 | 
			
		||||
            if let Some(br) = bridge_name_for_tap(&tap) {
 | 
			
		||||
                bridge_name = br;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Lease file: persisted -> env -> derived from (possibly overridden) bridge
 | 
			
		||||
    let lease_path = rec
 | 
			
		||||
        .runtime
 | 
			
		||||
        .lease_file
 | 
			
		||||
        .clone()
 | 
			
		||||
        .or_else(|| std::env::var("HERO_VIRT_DHCP_LEASE_FILE").ok())
 | 
			
		||||
        .unwrap_or_else(|| format!("/var/lib/misc/dnsmasq-hero-{}.leases", bridge_name));
 | 
			
		||||
 | 
			
		||||
    // Discover addresses
 | 
			
		||||
    let ipv4 = net::discover_ipv4_from_leases(&lease_path, &mac_lower, timeout_secs);
 | 
			
		||||
    let ipv6 = {
 | 
			
		||||
        use std::time::{Duration, Instant};
 | 
			
		||||
        let deadline = Instant::now() + Duration::from_secs(timeout_secs);
 | 
			
		||||
        let mut v6: Option<String> = None;
 | 
			
		||||
        while Instant::now() < deadline {
 | 
			
		||||
            if let Some(ip) = net::discover_ipv6_on_bridge(&bridge_name, &mac_lower) {
 | 
			
		||||
                v6 = Some(ip);
 | 
			
		||||
                break;
 | 
			
		||||
            }
 | 
			
		||||
            std::thread::sleep(Duration::from_millis(800));
 | 
			
		||||
        }
 | 
			
		||||
        v6
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    Ok((
 | 
			
		||||
        ipv4,
 | 
			
		||||
        ipv6,
 | 
			
		||||
        Some(mac_lower),
 | 
			
		||||
        Some(bridge_name),
 | 
			
		||||
        Some(lease_path),
 | 
			
		||||
    ))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Stop a VM via ch-remote (graceful), optionally force kill
 | 
			
		||||
pub fn vm_stop(id: &str, force: bool) -> Result<(), CloudHvError> {
 | 
			
		||||
    ensure_deps().ok(); // best-effort; we might still force-kill
 | 
			
		||||
@@ -738,255 +907,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();
 | 
			
		||||
                println!("got addr from host: {addr_only}");
 | 
			
		||||
                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);
 | 
			
		||||
    println!("executing command:\n{heredoc_net}");
 | 
			
		||||
 
 | 
			
		||||
    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 {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										386
									
								
								packages/system/virt/src/cloudhv/net/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										386
									
								
								packages/system/virt/src/cloudhv/net/mod.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,386 @@
 | 
			
		||||
use sal_process;
 | 
			
		||||
 | 
			
		||||
use crate::cloudhv::CloudHvError;
 | 
			
		||||
 | 
			
		||||
pub mod profile;
 | 
			
		||||
pub use profile::{BridgeOptions, DefaultNatOptions, NetworkingProfileSpec};
 | 
			
		||||
 | 
			
		||||
// Local shell escaping (keep independent from parent module)
 | 
			
		||||
fn shell_escape(s: &str) -> String {
 | 
			
		||||
    if s.is_empty() {
 | 
			
		||||
        return "''".into();
 | 
			
		||||
    }
 | 
			
		||||
    if s.chars()
 | 
			
		||||
        .all(|c| c.is_ascii_alphanumeric() || "-_./=:".contains(c))
 | 
			
		||||
    {
 | 
			
		||||
        return s.into();
 | 
			
		||||
    }
 | 
			
		||||
    let mut out = String::from("'");
 | 
			
		||||
    for ch in s.chars() {
 | 
			
		||||
        if ch == '\'' {
 | 
			
		||||
            out.push_str("'\"'\"'");
 | 
			
		||||
        } else {
 | 
			
		||||
            out.push(ch);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    out.push('\'');
 | 
			
		||||
    out
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn run_heredoc(label: &str, body: &str) -> Result<(), CloudHvError> {
 | 
			
		||||
    let script = format!("bash -e -s <<'{label}'\n{body}\n{label}\n", label = label, body = body);
 | 
			
		||||
    match sal_process::run(&script).silent(true).die(false).execute() {
 | 
			
		||||
        Ok(res) if res.success => Ok(()),
 | 
			
		||||
        Ok(res) => Err(CloudHvError::CommandFailed(format!(
 | 
			
		||||
            "{} failed: {}{}",
 | 
			
		||||
            label, res.stdout, res.stderr
 | 
			
		||||
        ))),
 | 
			
		||||
        Err(e) => Err(CloudHvError::CommandFailed(format!(
 | 
			
		||||
            "{} failed: {}",
 | 
			
		||||
            label, e
 | 
			
		||||
        ))),
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Ensure the Linux bridge exists and has IPv4 (and optional IPv6) configured.
 | 
			
		||||
/// Also enables IPv4 forwarding (and IPv6 forwarding when v6 provided).
 | 
			
		||||
pub fn ensure_bridge(
 | 
			
		||||
    bridge_name: &str,
 | 
			
		||||
    bridge_addr_cidr: &str,
 | 
			
		||||
    ipv6_bridge_cidr: Option<&str>,
 | 
			
		||||
) -> Result<(), CloudHvError> {
 | 
			
		||||
    // deps: ip
 | 
			
		||||
    if sal_process::which("ip").is_none() {
 | 
			
		||||
        return Err(CloudHvError::DependencyMissing(
 | 
			
		||||
            "ip not found on PATH".into(),
 | 
			
		||||
        ));
 | 
			
		||||
    }
 | 
			
		||||
    let v6 = ipv6_bridge_cidr.unwrap_or("");
 | 
			
		||||
    let body = format!(
 | 
			
		||||
        "set -e
 | 
			
		||||
BR={br}
 | 
			
		||||
BR_ADDR={br_addr}
 | 
			
		||||
IPV6_CIDR={v6cidr}
 | 
			
		||||
 | 
			
		||||
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 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 (idempotent)
 | 
			
		||||
sysctl -w net.ipv4.ip_forward=1 >/dev/null || true
 | 
			
		||||
",
 | 
			
		||||
        br = shell_escape(bridge_name),
 | 
			
		||||
        br_addr = shell_escape(bridge_addr_cidr),
 | 
			
		||||
        v6cidr = shell_escape(v6),
 | 
			
		||||
    );
 | 
			
		||||
    run_heredoc("HEROBRIDGE", &body)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Ensure nftables NAT masquerading for the given subnet toward the default WAN interface.
 | 
			
		||||
/// Creates table/chain if missing and adds/keeps a single masquerade rule.
 | 
			
		||||
/// If ipv6_subnet is provided, also sets up IPv6 NAT.
 | 
			
		||||
pub fn ensure_nat(subnet_cidr: &str, ipv6_subnet: Option<&str>) -> Result<(), CloudHvError> {
 | 
			
		||||
    for bin in ["ip", "nft"] {
 | 
			
		||||
        if sal_process::which(bin).is_none() {
 | 
			
		||||
            return Err(CloudHvError::DependencyMissing(format!(
 | 
			
		||||
                "{} not found on PATH",
 | 
			
		||||
                bin
 | 
			
		||||
            )));
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    let v6_subnet = ipv6_subnet.unwrap_or("");
 | 
			
		||||
    let body = format!(
 | 
			
		||||
        "set -e
 | 
			
		||||
SUBNET={subnet}
 | 
			
		||||
IPV6_SUBNET={v6subnet}
 | 
			
		||||
 | 
			
		||||
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 NAT)\" >&2
 | 
			
		||||
  exit 2
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# IPv4 NAT
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
# IPv6 NAT (if subnet provided)
 | 
			
		||||
if [ -n \"$IPV6_SUBNET\" ]; then
 | 
			
		||||
  nft list table ip6 hero >/dev/null 2>&1 || nft add table ip6 hero
 | 
			
		||||
  nft list chain ip6 hero postrouting >/dev/null 2>&1 || nft add chain ip6 hero postrouting {{ type nat hook postrouting priority 100 \\; }}
 | 
			
		||||
  nft list chain ip6 hero postrouting | grep -q \"ip6 saddr $IPV6_SUBNET oifname \\\"$WAN_IF\\\" masquerade\" \
 | 
			
		||||
    || nft add rule ip6 hero postrouting ip6 saddr $IPV6_SUBNET oifname \"$WAN_IF\" masquerade
 | 
			
		||||
fi
 | 
			
		||||
",
 | 
			
		||||
        subnet = shell_escape(subnet_cidr),
 | 
			
		||||
        v6subnet = shell_escape(v6_subnet),
 | 
			
		||||
    );
 | 
			
		||||
    run_heredoc("HERONAT", &body)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Ensure dnsmasq DHCP is configured for the bridge. Returns the lease file path used.
 | 
			
		||||
/// This function is idempotent; it writes a deterministic conf and reloads/enables dnsmasq.
 | 
			
		||||
pub fn ensure_dnsmasq(
 | 
			
		||||
    bridge_name: &str,
 | 
			
		||||
    dhcp_start: &str,
 | 
			
		||||
    dhcp_end: &str,
 | 
			
		||||
    ipv6_bridge_cidr: Option<&str>,
 | 
			
		||||
    lease_file_override: Option<&str>,
 | 
			
		||||
) -> Result<String, CloudHvError> {
 | 
			
		||||
    for bin in ["dnsmasq", "systemctl"] {
 | 
			
		||||
        if sal_process::which(bin).is_none() {
 | 
			
		||||
            return Err(CloudHvError::DependencyMissing(format!(
 | 
			
		||||
                "{} not found on PATH",
 | 
			
		||||
                bin
 | 
			
		||||
            )));
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    let lease_file = lease_file_override
 | 
			
		||||
        .map(|s| s.to_string())
 | 
			
		||||
        .unwrap_or_else(|| format!("/var/lib/misc/dnsmasq-hero-{}.leases", bridge_name));
 | 
			
		||||
    let v6 = ipv6_bridge_cidr.unwrap_or("");
 | 
			
		||||
    let body = format!(
 | 
			
		||||
        "set -e
 | 
			
		||||
BR={br}
 | 
			
		||||
DHCP_START={dstart}
 | 
			
		||||
DHCP_END={dend}
 | 
			
		||||
LEASE_FILE={lease}
 | 
			
		||||
IPV6_CIDR={v6cidr}
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
# Ensure main conf includes our conf-dir
 | 
			
		||||
CONF=/etc/dnsmasq.conf
 | 
			
		||||
RELOAD=0
 | 
			
		||||
if ! grep -qF \"conf-dir=/etc/dnsmasq.d\" \"$CONF\" 2>/dev/null; then
 | 
			
		||||
  printf '%s\\n' 'conf-dir=/etc/dnsmasq.d,*.conf' >> \"$CONF\"
 | 
			
		||||
  RELOAD=1
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Ensure lease file and ownership (best effort)
 | 
			
		||||
touch \"$LEASE_FILE\" || true
 | 
			
		||||
chown dnsmasq:dnsmasq \"$LEASE_FILE\" 2>/dev/null || true
 | 
			
		||||
 | 
			
		||||
# 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\"
 | 
			
		||||
 | 
			
		||||
# Optional IPv6 RA/DHCPv6
 | 
			
		||||
if [ -n \"$IPV6_CIDR\" ]; then
 | 
			
		||||
  BRIDGE_ADDR=\"${{IPV6_CIDR%/*}}\"
 | 
			
		||||
  BRIDGE_PREFIX=$(echo \"$IPV6_CIDR\" | cut -d: -f1-4)::
 | 
			
		||||
  printf '%s\\n' \
 | 
			
		||||
    \"enable-ra\" \
 | 
			
		||||
    \"dhcp-range=$BRIDGE_PREFIX,ra-names,12h\" \
 | 
			
		||||
    \"dhcp-option=option6:dns-server,[2001:4860:4860::8888]\" >>\"$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
 | 
			
		||||
 | 
			
		||||
if [ \"$RELOAD\" = \"1\" ]; then
 | 
			
		||||
  systemctl reload dnsmasq || systemctl restart dnsmasq || true
 | 
			
		||||
fi
 | 
			
		||||
",
 | 
			
		||||
        br = shell_escape(bridge_name),
 | 
			
		||||
        dstart = shell_escape(dhcp_start),
 | 
			
		||||
        dend = shell_escape(dhcp_end),
 | 
			
		||||
        lease = shell_escape(&lease_file),
 | 
			
		||||
        v6cidr = shell_escape(v6),
 | 
			
		||||
    );
 | 
			
		||||
    run_heredoc("HERODNSMASQ", &body)?;
 | 
			
		||||
    Ok(lease_file)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Deterministic TAP name from VM id (Linux IFNAMSIZ safe)
 | 
			
		||||
pub fn tap_name_for_id(id: &str) -> String {
 | 
			
		||||
    use std::collections::hash_map::DefaultHasher;
 | 
			
		||||
    use std::hash::{Hash, Hasher};
 | 
			
		||||
    let mut h = DefaultHasher::new();
 | 
			
		||||
    id.hash(&mut h);
 | 
			
		||||
    let v = h.finish();
 | 
			
		||||
    let hex = format!("{:016x}", v);
 | 
			
		||||
    format!("tap-{}", &hex[..10])
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Ensure a per-VM TAP exists, enslaved to the bridge, and up.
 | 
			
		||||
/// Assign ownership to current user/group so CH can open the fd unprivileged.
 | 
			
		||||
pub fn ensure_tap_for_vm(bridge_name: &str, id: &str) -> Result<String, CloudHvError> {
 | 
			
		||||
    if sal_process::which("ip").is_none() {
 | 
			
		||||
        return Err(CloudHvError::DependencyMissing(
 | 
			
		||||
            "ip not found on PATH".into(),
 | 
			
		||||
        ));
 | 
			
		||||
    }
 | 
			
		||||
    let tap = tap_name_for_id(id);
 | 
			
		||||
    let body = format!(
 | 
			
		||||
        "set -e
 | 
			
		||||
BR={br}
 | 
			
		||||
TAP={tap}
 | 
			
		||||
UIDX=$(id -u)
 | 
			
		||||
GIDX=$(id -g)
 | 
			
		||||
 | 
			
		||||
# Ensure a clean TAP state to avoid Resource busy if a previous VM run left it lingering
 | 
			
		||||
if ip link show \"$TAP\" >/dev/null 2>&1; then
 | 
			
		||||
  ip link set \"$TAP\" down || true
 | 
			
		||||
  ip link set \"$TAP\" nomaster 2>/dev/null || true
 | 
			
		||||
  ip tuntap del dev \"$TAP\" mode tap 2>/dev/null || true
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Recreate with correct ownership and attach to bridge
 | 
			
		||||
ip tuntap add dev \"$TAP\" mode tap user \"$UIDX\" group \"$GIDX\"
 | 
			
		||||
ip link set \"$TAP\" master \"$BR\" 2>/dev/null || true
 | 
			
		||||
ip link set \"$TAP\" up
 | 
			
		||||
",
 | 
			
		||||
        br = shell_escape(bridge_name),
 | 
			
		||||
        tap = shell_escape(&tap),
 | 
			
		||||
    );
 | 
			
		||||
    run_heredoc("HEROTAP", &body)?;
 | 
			
		||||
    Ok(tap)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Stable locally-administered unicast MAC derived from VM id.
 | 
			
		||||
/// IMPORTANT: Use a deterministic hash (FNV-1a) rather than DefaultHasher (which is randomized).
 | 
			
		||||
pub fn stable_mac_from_id(id: &str) -> String {
 | 
			
		||||
    // 64-bit FNV-1a
 | 
			
		||||
    const FNV_OFFSET: u64 = 0xcbf29ce484222325;
 | 
			
		||||
    const FNV_PRIME: u64 = 0x00000100000001B3;
 | 
			
		||||
    let mut v: u64 = FNV_OFFSET;
 | 
			
		||||
    for b in id.as_bytes() {
 | 
			
		||||
        v ^= *b as u64;
 | 
			
		||||
        v = v.wrapping_mul(FNV_PRIME);
 | 
			
		||||
    }
 | 
			
		||||
    // Locally administered, unicast
 | 
			
		||||
    let b0 = (((v >> 40) & 0xff) as u8 & 0xfe) | 0x02;
 | 
			
		||||
    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 global address on iface (or env override).
 | 
			
		||||
/// Returns (iface_name, address).
 | 
			
		||||
pub fn 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());
 | 
			
		||||
    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
 | 
			
		||||
            )))
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
    for line in out.lines() {
 | 
			
		||||
        let lt = line.trim();
 | 
			
		||||
        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 (prefix /64, router /64 string) from a mycelium IPv6 address string.
 | 
			
		||||
pub 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();
 | 
			
		||||
    let pfx = std::net::Ipv6Addr::new(seg[0], seg[1], seg[2], seg[3], 0, 0, 0, 0);
 | 
			
		||||
    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))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Parse a dnsmasq lease file to find last IPv4 by MAC (lowercased).
 | 
			
		||||
/// Polls up to timeout_secs with 800ms sleep, returns None on timeout.
 | 
			
		||||
pub fn discover_ipv4_from_leases(
 | 
			
		||||
    lease_path: &str,
 | 
			
		||||
    mac_lower: &str,
 | 
			
		||||
    timeout_secs: u64,
 | 
			
		||||
) -> Option<String> {
 | 
			
		||||
    use std::fs;
 | 
			
		||||
    use std::time::{Duration, Instant};
 | 
			
		||||
    let deadline = Instant::now() + Duration::from_secs(timeout_secs);
 | 
			
		||||
    loop {
 | 
			
		||||
        if let Ok(content) = fs::read_to_string(lease_path) {
 | 
			
		||||
            let mut last_ip: Option<String> = None;
 | 
			
		||||
            for line in content.lines() {
 | 
			
		||||
                let cols: Vec<&str> = line.split_whitespace().collect();
 | 
			
		||||
                if cols.len() >= 3 && cols[1].eq_ignore_ascii_case(mac_lower) {
 | 
			
		||||
                    last_ip = Some(cols[2].to_string());
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            if last_ip.is_some() {
 | 
			
		||||
                return last_ip;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        if Instant::now() >= deadline {
 | 
			
		||||
            return None;
 | 
			
		||||
        }
 | 
			
		||||
        std::thread::sleep(Duration::from_millis(800));
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Search IPv6 neighbor table on bridge for an entry matching MAC (lladdr), excluding link-local.
 | 
			
		||||
pub fn discover_ipv6_on_bridge(bridge_name: &str, mac_lower: &str) -> Option<String> {
 | 
			
		||||
    let cmd = format!("ip -6 neigh show dev {}", shell_escape(bridge_name));
 | 
			
		||||
    if let Ok(res) = sal_process::run(&cmd).silent(true).die(false).execute() {
 | 
			
		||||
        if res.success {
 | 
			
		||||
            let mac_pat = format!("lladdr {}", mac_lower);
 | 
			
		||||
            for line in res.stdout.lines() {
 | 
			
		||||
                let lt = line.trim();
 | 
			
		||||
                if lt.to_lowercase().contains(&mac_pat) {
 | 
			
		||||
                    if let Some(addr) = lt.split_whitespace().next() {
 | 
			
		||||
                        if !addr.starts_with("fe80") && !addr.is_empty() {
 | 
			
		||||
                            return Some(addr.to_string());
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    None
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										95
									
								
								packages/system/virt/src/cloudhv/net/profile.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								packages/system/virt/src/cloudhv/net/profile.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,95 @@
 | 
			
		||||
use serde::{Deserialize, Serialize};
 | 
			
		||||
 | 
			
		||||
#[derive(Debug, Clone, Serialize, Deserialize)]
 | 
			
		||||
pub struct DefaultNatOptions {
 | 
			
		||||
    #[serde(default = "DefaultNatOptions::default_bridge_name")]
 | 
			
		||||
    pub bridge_name: String,
 | 
			
		||||
    #[serde(default = "DefaultNatOptions::default_bridge_addr")]
 | 
			
		||||
    pub bridge_addr_cidr: String,
 | 
			
		||||
    #[serde(default = "DefaultNatOptions::default_subnet")]
 | 
			
		||||
    pub subnet_cidr: String,
 | 
			
		||||
    #[serde(default = "DefaultNatOptions::default_dhcp_start")]
 | 
			
		||||
    pub dhcp_start: String,
 | 
			
		||||
    #[serde(default = "DefaultNatOptions::default_dhcp_end")]
 | 
			
		||||
    pub dhcp_end: String,
 | 
			
		||||
    #[serde(default = "DefaultNatOptions::default_ipv6_enable")]
 | 
			
		||||
    pub ipv6_enable: bool,
 | 
			
		||||
    /// Optional: if set, use this IPv6 on bridge (e.g. "400:...::2/64"), else derive via mycelium
 | 
			
		||||
    #[serde(default)]
 | 
			
		||||
    pub bridge_ipv6_cidr: Option<String>,
 | 
			
		||||
    /// Optional explicit mycelium interface name
 | 
			
		||||
    #[serde(default)]
 | 
			
		||||
    pub mycelium_if: Option<String>,
 | 
			
		||||
    /// Optional override for dnsmasq lease file
 | 
			
		||||
    #[serde(default)]
 | 
			
		||||
    pub lease_file: Option<String>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl DefaultNatOptions {
 | 
			
		||||
    fn default_bridge_name() -> String {
 | 
			
		||||
        std::env::var("HERO_VIRT_BRIDGE_NAME").unwrap_or_else(|_| "br-hero".into())
 | 
			
		||||
    }
 | 
			
		||||
    fn default_bridge_addr() -> String {
 | 
			
		||||
        std::env::var("HERO_VIRT_BRIDGE_ADDR_CIDR").unwrap_or_else(|_| "172.30.0.1/24".into())
 | 
			
		||||
    }
 | 
			
		||||
    fn default_subnet() -> String {
 | 
			
		||||
        std::env::var("HERO_VIRT_SUBNET_CIDR").unwrap_or_else(|_| "172.30.0.0/24".into())
 | 
			
		||||
    }
 | 
			
		||||
    fn default_dhcp_start() -> String {
 | 
			
		||||
        std::env::var("HERO_VIRT_DHCP_START").unwrap_or_else(|_| "172.30.0.50".into())
 | 
			
		||||
    }
 | 
			
		||||
    fn default_dhcp_end() -> String {
 | 
			
		||||
        std::env::var("HERO_VIRT_DHCP_END").unwrap_or_else(|_| "172.30.0.250".into())
 | 
			
		||||
    }
 | 
			
		||||
    fn default_ipv6_enable() -> bool {
 | 
			
		||||
        match std::env::var("HERO_VIRT_IPV6_ENABLE").map(|v| v.to_lowercase()) {
 | 
			
		||||
            Ok(s) if s == "0" || s == "false" || s == "no" => false,
 | 
			
		||||
            _ => true,
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl Default for DefaultNatOptions {
 | 
			
		||||
    fn default() -> Self {
 | 
			
		||||
        Self {
 | 
			
		||||
            bridge_name: Self::default_bridge_name(),
 | 
			
		||||
            bridge_addr_cidr: Self::default_bridge_addr(),
 | 
			
		||||
            subnet_cidr: Self::default_subnet(),
 | 
			
		||||
            dhcp_start: Self::default_dhcp_start(),
 | 
			
		||||
            dhcp_end: Self::default_dhcp_end(),
 | 
			
		||||
            ipv6_enable: Self::default_ipv6_enable(),
 | 
			
		||||
            bridge_ipv6_cidr: None,
 | 
			
		||||
            mycelium_if: None,
 | 
			
		||||
            lease_file: None,
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
 | 
			
		||||
pub struct BridgeOptions {
 | 
			
		||||
    #[serde(default = "DefaultNatOptions::default_bridge_name")]
 | 
			
		||||
    pub bridge_name: String,
 | 
			
		||||
    /// Optional: if provided, configure IPv4 on the bridge
 | 
			
		||||
    #[serde(default)]
 | 
			
		||||
    pub bridge_addr_cidr: Option<String>,
 | 
			
		||||
    /// Optional: if provided, configure IPv6 on the bridge
 | 
			
		||||
    #[serde(default)]
 | 
			
		||||
    pub bridge_ipv6_cidr: Option<String>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(Debug, Clone, Serialize, Deserialize)]
 | 
			
		||||
#[serde(tag = "type", content = "opts")]
 | 
			
		||||
pub enum NetworkingProfileSpec {
 | 
			
		||||
    DefaultNat(DefaultNatOptions),
 | 
			
		||||
    NoNet,
 | 
			
		||||
    /// Pass-through user args to CH; currently informational in VmSpec
 | 
			
		||||
    CustomCli(Vec<String>),
 | 
			
		||||
    /// Ensure bridge and tap only; no NAT/DHCP
 | 
			
		||||
    BridgeOnly(BridgeOptions),
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl Default for NetworkingProfileSpec {
 | 
			
		||||
    fn default() -> Self {
 | 
			
		||||
        NetworkingProfileSpec::DefaultNat(DefaultNatOptions::default())
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,4 @@
 | 
			
		||||
use serde::{Deserialize, Serialize};
 | 
			
		||||
use std::fs;
 | 
			
		||||
use std::path::Path;
 | 
			
		||||
 | 
			
		||||
use sal_os;
 | 
			
		||||
@@ -44,7 +43,7 @@ pub struct NetPlanOpts {
 | 
			
		||||
    pub dhcp6: bool,
 | 
			
		||||
    /// Static IPv6 address to assign in guest (temporary behavior)
 | 
			
		||||
    pub ipv6_addr: Option<String>, // e.g., "400::10/64"
 | 
			
		||||
    pub gw6: Option<String>,       // e.g., "400::1"
 | 
			
		||||
    pub gw6: Option<String>, // e.g., "400::1"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn default_dhcp4() -> bool {
 | 
			
		||||
@@ -84,9 +83,14 @@ fn default_disable_cloud_init_net() -> bool {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn stable_mac_from_id(id: &str) -> String {
 | 
			
		||||
    let mut h = DefaultHasher::new();
 | 
			
		||||
    id.hash(&mut h);
 | 
			
		||||
    let v = h.finish();
 | 
			
		||||
    // Use deterministic FNV-1a (matches host-side MAC derivation used by CH builder)
 | 
			
		||||
    const FNV_OFFSET: u64 = 0xcbf29ce484222325;
 | 
			
		||||
    const FNV_PRIME: u64 = 0x00000100000001B3;
 | 
			
		||||
    let mut v: u64 = FNV_OFFSET;
 | 
			
		||||
    for b in id.as_bytes() {
 | 
			
		||||
        v ^= *b as u64;
 | 
			
		||||
        v = v.wrapping_mul(FNV_PRIME);
 | 
			
		||||
    }
 | 
			
		||||
    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;
 | 
			
		||||
@@ -172,12 +176,13 @@ pub fn image_prepare(opts: &ImagePrepOptions) -> Result<ImagePrepResult, ImagePr
 | 
			
		||||
            // Build bash script that performs all steps and echos "RAW|ROOT_UUID|BOOT_UUID" at end
 | 
			
		||||
            let disable_ci_net = opts.disable_cloud_init_net;
 | 
			
		||||
 | 
			
		||||
            // IPv6 static guest assignment (derive from mycelium interface) - enabled by default
 | 
			
		||||
            // If HERO_VIRT_IPV6_STATIC_GUEST=false, keep dynamic behavior (SLAAC/DHCPv6).
 | 
			
		||||
            // IPv6 static guest assignment (derive from mycelium interface) - disabled by default to use RA
 | 
			
		||||
            // If HERO_VIRT_IPV6_STATIC_GUEST=true, use static IPv6; else use RA/SLAAC.
 | 
			
		||||
            let static_v6 = std::env::var("HERO_VIRT_IPV6_STATIC_GUEST")
 | 
			
		||||
                .map(|v| matches!(v.to_lowercase().as_str(), "" | "1" | "true" | "yes"))
 | 
			
		||||
                .unwrap_or(true);
 | 
			
		||||
            let myc_if = std::env::var("HERO_VIRT_MYCELIUM_IF").unwrap_or_else(|_| "mycelium".into());
 | 
			
		||||
                .unwrap_or(false);
 | 
			
		||||
            let myc_if =
 | 
			
		||||
                std::env::var("HERO_VIRT_MYCELIUM_IF").unwrap_or_else(|_| "mycelium".into());
 | 
			
		||||
 | 
			
		||||
            // Discover host mycelium global IPv6 in 400::/7 from the interface
 | 
			
		||||
            let mut host_v6: Option<Ipv6Addr> = None;
 | 
			
		||||
@@ -189,7 +194,8 @@ pub fn image_prepare(opts: &ImagePrepOptions) -> Result<ImagePrepResult, ImagePr
 | 
			
		||||
                            let lt = l.trim();
 | 
			
		||||
                            if lt.starts_with("inet6 ") && lt.contains("scope global") {
 | 
			
		||||
                                if let Some(addr_cidr) = lt.split_whitespace().nth(1) {
 | 
			
		||||
                                    let addr_only = addr_cidr.split('/').next().unwrap_or("").trim();
 | 
			
		||||
                                    let addr_only =
 | 
			
		||||
                                        addr_cidr.split('/').next().unwrap_or("").trim();
 | 
			
		||||
                                    if let Ok(ip) = addr_only.parse::<Ipv6Addr>() {
 | 
			
		||||
                                        let seg0 = ip.segments()[0];
 | 
			
		||||
                                        if (seg0 & 0xFE00) == 0x0400 {
 | 
			
		||||
@@ -206,6 +212,7 @@ pub fn image_prepare(opts: &ImagePrepOptions) -> Result<ImagePrepResult, ImagePr
 | 
			
		||||
 | 
			
		||||
            // Derive per-host /64 from mycelium and deterministic per-VM guest address
 | 
			
		||||
            let mut np_v6_block = String::new();
 | 
			
		||||
            let mut accept_ra = String::new();
 | 
			
		||||
            let mut dhcp6_effective = opts.net.dhcp6;
 | 
			
		||||
            if static_v6 {
 | 
			
		||||
                if let Some(h) = host_v6 {
 | 
			
		||||
@@ -217,24 +224,30 @@ pub fn image_prepare(opts: &ImagePrepOptions) -> Result<ImagePrepResult, ImagePr
 | 
			
		||||
                    if suffix == 0 || suffix == 2 {
 | 
			
		||||
                        suffix = 0x100;
 | 
			
		||||
                    }
 | 
			
		||||
                    let guest_ip = Ipv6Addr::new(seg[0], seg[1], seg[2], seg[3], 0, 0, 0, suffix).to_string();
 | 
			
		||||
                    let gw_ip = Ipv6Addr::new(seg[0], seg[1], seg[2], seg[3], 0, 0, 0, 2).to_string();
 | 
			
		||||
                    let guest_ip =
 | 
			
		||||
                        Ipv6Addr::new(seg[0], seg[1], seg[2], seg[3], 0, 0, 0, suffix).to_string();
 | 
			
		||||
                    let gw_ip =
 | 
			
		||||
                        Ipv6Addr::new(seg[0], seg[1], seg[2], seg[3], 0, 0, 0, 2).to_string();
 | 
			
		||||
 | 
			
		||||
                    // Inject a YAML block for static v6
 | 
			
		||||
                    np_v6_block = format!(
 | 
			
		||||
                        "      addresses:\n        - {}/64\n      routes:\n        - to: \"::/0\"\n          via: {}\n",
 | 
			
		||||
                        guest_ip, gw_ip
 | 
			
		||||
                        "      addresses:\n        - {}/64\n      routes:\n        - to: \"::/0\"\n          via: {}\n        - to: \"400::/7\"\n          via: {}\n",
 | 
			
		||||
                        guest_ip, gw_ip, gw_ip
 | 
			
		||||
                    );
 | 
			
		||||
                    // Disable dhcp6 when we provide a static address
 | 
			
		||||
                    dhcp6_effective = false;
 | 
			
		||||
                }
 | 
			
		||||
            } else {
 | 
			
		||||
                // Use RA for IPv6
 | 
			
		||||
                accept_ra = "\n      accept-ra: true".to_string();
 | 
			
		||||
                dhcp6_effective = false;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Keep script small and robust; avoid brace-heavy awk to simplify escaping.
 | 
			
		||||
            // Compute stable MAC (must match what vm_start() uses) and use it to match NIC in netplan.
 | 
			
		||||
            let vm_mac = stable_mac_from_id(&opts.id);
 | 
			
		||||
            let script = format!(
 | 
			
		||||
                "#!/bin/bash -e
 | 
			
		||||
                r#"#!/bin/bash -e
 | 
			
		||||
set -euo pipefail
 | 
			
		||||
 | 
			
		||||
SRC={src}
 | 
			
		||||
@@ -244,146 +257,146 @@ MNT_ROOT={mnt_root}
 | 
			
		||||
MNT_BOOT={mnt_boot}
 | 
			
		||||
RAW={raw}
 | 
			
		||||
 | 
			
		||||
mkdir -p \"$VM_DIR\"
 | 
			
		||||
mkdir -p \"$(dirname \"$MNT_ROOT\")\"
 | 
			
		||||
mkdir -p \"$MNT_ROOT\" \"$MNT_BOOT\"
 | 
			
		||||
mkdir -p "$VM_DIR"
 | 
			
		||||
mkdir -p "$(dirname "$MNT_ROOT")"
 | 
			
		||||
mkdir -p "$MNT_ROOT" "$MNT_BOOT"
 | 
			
		||||
 | 
			
		||||
# Make per-VM working copy (reflink if supported)
 | 
			
		||||
cp --reflink=auto -f \"$SRC\" \"$WORK\"
 | 
			
		||||
cp --reflink=auto -f "$SRC" "$WORK"
 | 
			
		||||
 | 
			
		||||
# Load NBD with sufficient partitions
 | 
			
		||||
modprobe nbd max_part=63
 | 
			
		||||
 | 
			
		||||
# Pick a free /dev/nbdX and connect the qcow2
 | 
			
		||||
NBD=\"\"
 | 
			
		||||
NBD=""
 | 
			
		||||
for i in $(seq 0 15); do
 | 
			
		||||
  DEV=\"/dev/nbd$i\"
 | 
			
		||||
  DEV="/dev/nbd$i"
 | 
			
		||||
  # Skip devices that have any mounted partitions (avoid reusing in-use NBDs)
 | 
			
		||||
  if findmnt -rn -S \"$DEV\" >/dev/null 2>&1 || \
 | 
			
		||||
     findmnt -rn -S \"${{DEV}}p1\" >/dev/null 2>&1 || \
 | 
			
		||||
     findmnt -rn -S \"${{DEV}}p14\" >/dev/null 2>&1 || \
 | 
			
		||||
     findmnt -rn -S \"${{DEV}}p15\" >/dev/null 2>&1 || \
 | 
			
		||||
     findmnt -rn -S \"${{DEV}}p16\" >/dev/null 2>&1; then
 | 
			
		||||
  if findmnt -rn -S "$DEV" >/dev/null 2>&1 || \
 | 
			
		||||
     findmnt -rn -S "${{DEV}}p1" >/dev/null 2>&1 || \
 | 
			
		||||
      findmnt -rn -S "${{DEV}}p14" >/dev/null 2>&1 || \
 | 
			
		||||
      findmnt -rn -S "${{DEV}}p15" >/dev/null 2>&1 || \
 | 
			
		||||
      findmnt -rn -S "${{DEV}}p16" >/dev/null 2>&1; then
 | 
			
		||||
    continue
 | 
			
		||||
  fi
 | 
			
		||||
  # Ensure it's not connected (ignore errors if already disconnected)
 | 
			
		||||
  qemu-nbd --disconnect \"$DEV\" >/dev/null 2>&1 || true
 | 
			
		||||
  if qemu-nbd --format=qcow2 --connect=\"$DEV\" \"$WORK\"; then
 | 
			
		||||
    NBD=\"$DEV\"
 | 
			
		||||
  qemu-nbd --disconnect "$DEV" >/dev/null 2>&1 || true
 | 
			
		||||
  if qemu-nbd --format=qcow2 --connect="$DEV" "$WORK"; then
 | 
			
		||||
    NBD="$DEV"
 | 
			
		||||
    break
 | 
			
		||||
  fi
 | 
			
		||||
done
 | 
			
		||||
if [ -z \"$NBD\" ]; then
 | 
			
		||||
  echo \"No free /dev/nbdX device available\" >&2
 | 
			
		||||
if [ -z "$NBD" ]; then
 | 
			
		||||
  echo "No free /dev/nbdX device available" >&2
 | 
			
		||||
  exit 1
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
echo \"Selected NBD: $NBD\" >&2
 | 
			
		||||
echo "Selected NBD: $NBD" >&2
 | 
			
		||||
 | 
			
		||||
# Settle and probe partitions
 | 
			
		||||
udevadm settle >/dev/null 2>&1 || true
 | 
			
		||||
blockdev --rereadpt \"$NBD\" >/dev/null 2>&1 || true
 | 
			
		||||
partprobe \"$NBD\" >/dev/null 2>&1 || true
 | 
			
		||||
blockdev --rereadpt "$NBD" >/dev/null 2>&1 || true
 | 
			
		||||
partprobe "$NBD" >/dev/null 2>&1 || true
 | 
			
		||||
for t in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
 | 
			
		||||
  if [ -b \"${{NBD}}p1\" ]; then
 | 
			
		||||
    sz=$(blockdev --getsize64 \"${{NBD}}p1\" 2>/dev/null || echo 0)
 | 
			
		||||
    if [ \"$sz\" -gt 0 ]; then
 | 
			
		||||
  if [ -b "${{NBD}}p1" ]; then
 | 
			
		||||
    sz=$(blockdev --getsize64 "${{NBD}}p1" 2>/dev/null || echo 0)
 | 
			
		||||
    if [ "$sz" -gt 0 ]; then
 | 
			
		||||
      break
 | 
			
		||||
    fi
 | 
			
		||||
  fi
 | 
			
		||||
  sleep 0.4
 | 
			
		||||
  udevadm settle >/dev/null 2>&1 || true
 | 
			
		||||
  blockdev --rereadpt \"$NBD\" >/dev/null 2>&1 || true
 | 
			
		||||
  partprobe \"$NBD\" >/dev/null 2>&1 || true
 | 
			
		||||
  blockdev --rereadpt "$NBD" >/dev/null 2>&1 || true
 | 
			
		||||
  partprobe "$NBD" >/dev/null 2>&1 || true
 | 
			
		||||
done
 | 
			
		||||
 | 
			
		||||
ROOT_DEV=\"${{NBD}}p1\"
 | 
			
		||||
ROOT_DEV="${{NBD}}p1"
 | 
			
		||||
# Prefer p16, else p15
 | 
			
		||||
if [ -b \"${{NBD}}p16\" ]; then
 | 
			
		||||
  BOOT_DEV=\"${{NBD}}p16\"
 | 
			
		||||
elif [ -b \"${{NBD}}p15\" ]; then
 | 
			
		||||
  BOOT_DEV=\"${{NBD}}p15\"
 | 
			
		||||
if [ -b "${{NBD}}p16" ]; then
 | 
			
		||||
  BOOT_DEV="${{NBD}}p16"
 | 
			
		||||
elif [ -b "${{NBD}}p15" ]; then
 | 
			
		||||
  BOOT_DEV="${{NBD}}p15"
 | 
			
		||||
else
 | 
			
		||||
  echo \"Boot partition not found on $NBD (tried p16 and p15)\" >&2
 | 
			
		||||
  echo "Boot partition not found on $NBD (tried p16 and p15)" >&2
 | 
			
		||||
  exit 33
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
echo \"ROOT_DEV=$ROOT_DEV BOOT_DEV=$BOOT_DEV\" >&2
 | 
			
		||||
echo "ROOT_DEV=$ROOT_DEV BOOT_DEV=$BOOT_DEV" >&2
 | 
			
		||||
 | 
			
		||||
if [ ! -b \"$ROOT_DEV\" ]; then
 | 
			
		||||
  echo \"Root partition not found: $ROOT_DEV\" >&2
 | 
			
		||||
if [ ! -b "$ROOT_DEV" ]; then
 | 
			
		||||
  echo "Root partition not found: $ROOT_DEV" >&2
 | 
			
		||||
  exit 32
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
cleanup() {{
 | 
			
		||||
  set +e
 | 
			
		||||
  umount \"$MNT_BOOT\" 2>/dev/null || true
 | 
			
		||||
  umount \"$MNT_ROOT\" 2>/dev/null || true
 | 
			
		||||
  [ -n \"$NBD\" ] && qemu-nbd --disconnect \"$NBD\" 2>/dev/null || true
 | 
			
		||||
  umount "$MNT_BOOT" 2>/dev/null || true
 | 
			
		||||
  umount "$MNT_ROOT" 2>/dev/null || true
 | 
			
		||||
  [ -n "$NBD" ] && qemu-nbd --disconnect "$NBD" 2>/dev/null || true
 | 
			
		||||
  rmmod nbd 2>/dev/null || true
 | 
			
		||||
}}
 | 
			
		||||
trap cleanup EXIT
 | 
			
		||||
 | 
			
		||||
# Ensure partitions are readable before mounting
 | 
			
		||||
for t in 1 2 3 4 5 6 7 8; do
 | 
			
		||||
  szr=$(blockdev --getsize64 \"$ROOT_DEV\" 2>/dev/null || echo 0)
 | 
			
		||||
  szb=$(blockdev --getsize64 \"$BOOT_DEV\" 2>/dev/null || echo 0)
 | 
			
		||||
  if [ \"$szr\" -gt 0 ] && [ \"$szb\" -gt 0 ] && blkid \"$ROOT_DEV\" >/dev/null 2>&1; then
 | 
			
		||||
  szr=$(blockdev --getsize64 "$ROOT_DEV" 2>/dev/null || echo 0)
 | 
			
		||||
  szb=$(blockdev --getsize64 "$BOOT_DEV" 2>/dev/null || echo 0)
 | 
			
		||||
  if [ "$szr" -gt 0 ] && [ "$szb" -gt 0 ] && blkid "$ROOT_DEV" >/dev/null 2>&1; then
 | 
			
		||||
    break
 | 
			
		||||
  fi
 | 
			
		||||
  sleep 0.4
 | 
			
		||||
  udevadm settle >/dev/null 2>&1 || true
 | 
			
		||||
  blockdev --rereadpt \"$NBD\" >/dev/null 2>&1 || true
 | 
			
		||||
  partprobe \"$NBD\" >/dev/null 2>&1 || true
 | 
			
		||||
  blockdev --rereadpt "$NBD" >/dev/null 2>&1 || true
 | 
			
		||||
  partprobe "$NBD" >/dev/null 2>&1 || true
 | 
			
		||||
done
 | 
			
		||||
 | 
			
		||||
# Mount and mutate (with retries to avoid races)
 | 
			
		||||
mounted_root=0
 | 
			
		||||
for t in 1 2 3 4 5 6 7 8 9 10; do
 | 
			
		||||
  if mount \"$ROOT_DEV\" \"$MNT_ROOT\"; then
 | 
			
		||||
  if mount "$ROOT_DEV" "$MNT_ROOT"; then
 | 
			
		||||
    mounted_root=1
 | 
			
		||||
    break
 | 
			
		||||
  fi
 | 
			
		||||
  sleep 0.5
 | 
			
		||||
  udevadm settle >/dev/null 2>&1 || true
 | 
			
		||||
  partprobe \"$NBD\" >/dev/null 2>&1 || true
 | 
			
		||||
  partprobe "$NBD" >/dev/null 2>&1 || true
 | 
			
		||||
done
 | 
			
		||||
if [ \"$mounted_root\" -ne 1 ]; then
 | 
			
		||||
  echo \"Failed to mount root $ROOT_DEV\" >&2
 | 
			
		||||
if [ "$mounted_root" -ne 1 ]; then
 | 
			
		||||
  echo "Failed to mount root $ROOT_DEV" >&2
 | 
			
		||||
  exit 32
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
mounted_boot=0
 | 
			
		||||
for t in 1 2 3 4 5; do
 | 
			
		||||
  if mount \"$BOOT_DEV\" \"$MNT_BOOT\"; then
 | 
			
		||||
  if mount "$BOOT_DEV" "$MNT_BOOT"; then
 | 
			
		||||
    mounted_boot=1
 | 
			
		||||
    break
 | 
			
		||||
  fi
 | 
			
		||||
  sleep 0.5
 | 
			
		||||
  udevadm settle >/dev/null 2>&1 || true
 | 
			
		||||
  partprobe \"$NBD\" >/dev/null 2>&1 || true
 | 
			
		||||
  partprobe "$NBD" >/dev/null 2>&1 || true
 | 
			
		||||
done
 | 
			
		||||
if [ \"$mounted_boot\" -ne 1 ]; then
 | 
			
		||||
  echo \"Failed to mount boot $BOOT_DEV\" >&2
 | 
			
		||||
if [ "$mounted_boot" -ne 1 ]; then
 | 
			
		||||
  echo "Failed to mount boot "$BOOT_DEV"" >&2
 | 
			
		||||
  exit 33
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Change UUIDs (best-effort)
 | 
			
		||||
tune2fs -U random \"$ROOT_DEV\" || true
 | 
			
		||||
tune2fs -U random \"$BOOT_DEV\" || true
 | 
			
		||||
tune2fs -U random "$ROOT_DEV" || true
 | 
			
		||||
tune2fs -U random "$BOOT_DEV" || true
 | 
			
		||||
 | 
			
		||||
ROOT_UUID=$(blkid -o value -s UUID \"$ROOT_DEV\")
 | 
			
		||||
BOOT_UUID=$(blkid -o value -s UUID \"$BOOT_DEV\")
 | 
			
		||||
ROOT_UUID=$(blkid -o value -s UUID "$ROOT_DEV")
 | 
			
		||||
BOOT_UUID=$(blkid -o value -s UUID "$BOOT_DEV")
 | 
			
		||||
 | 
			
		||||
# Update fstab
 | 
			
		||||
sed -i \"s/UUID=[a-f0-9-]* \\/ /UUID=$ROOT_UUID \\/ /\" \"$MNT_ROOT/etc/fstab\"
 | 
			
		||||
sed -i \"s/UUID=[a-f0-9-]* \\/boot /UUID=$BOOT_UUID \\/boot /\" \"$MNT_ROOT/etc/fstab\"
 | 
			
		||||
sed -i "s/UUID=[a-f0-9-]* \\/ /UUID=$ROOT_UUID \\/ /" "$MNT_ROOT/etc/fstab"
 | 
			
		||||
sed -i "s/UUID=[a-f0-9-]* \\/boot /UUID=$BOOT_UUID \\/boot /" "$MNT_ROOT/etc/fstab"
 | 
			
		||||
 | 
			
		||||
# Minimal grub.cfg (note: braces escaped for Rust format!)
 | 
			
		||||
mkdir -p \"$MNT_BOOT/grub\"
 | 
			
		||||
KERNEL=$(ls -1 \"$MNT_BOOT\"/vmlinuz-* | sort -V | tail -n1 | xargs -n1 basename)
 | 
			
		||||
INITRD=$(ls -1 \"$MNT_BOOT\"/initrd.img-* | sort -V | tail -n1 | xargs -n1 basename)
 | 
			
		||||
cat > \"$MNT_BOOT/grub/grub.cfg\" << EOF
 | 
			
		||||
mkdir -p "$MNT_BOOT/grub"
 | 
			
		||||
KERNEL=$(ls -1 "$MNT_BOOT"/vmlinuz-* | sort -V | tail -n1 | xargs -n1 basename)
 | 
			
		||||
INITRD=$(ls -1 "$MNT_BOOT"/initrd.img-* | sort -V | tail -n1 | xargs -n1 basename)
 | 
			
		||||
cat > "$MNT_BOOT/grub/grub.cfg" << EOF
 | 
			
		||||
set default=0
 | 
			
		||||
set timeout=3
 | 
			
		||||
menuentry 'Ubuntu Cloud' {{
 | 
			
		||||
@@ -397,98 +410,314 @@ menuentry 'Ubuntu Cloud' {{
 | 
			
		||||
EOF
 | 
			
		||||
 | 
			
		||||
# Netplan config
 | 
			
		||||
rm -f \"$MNT_ROOT\"/etc/netplan/*.yaml
 | 
			
		||||
mkdir -p \"$MNT_ROOT\"/etc/netplan
 | 
			
		||||
cat > \"$MNT_ROOT/etc/netplan/01-netconfig.yaml\" << EOF
 | 
			
		||||
rm -f "$MNT_ROOT"/etc/netplan/*.yaml
 | 
			
		||||
mkdir -p "$MNT_ROOT"/etc/netplan
 | 
			
		||||
cat > "$MNT_ROOT/etc/netplan/01-netconfig.yaml" << EOF
 | 
			
		||||
network:
 | 
			
		||||
  version: 2
 | 
			
		||||
  renderer: networkd
 | 
			
		||||
  ethernets:
 | 
			
		||||
    eth0:
 | 
			
		||||
      match:
 | 
			
		||||
        macaddress: {vm_mac}
 | 
			
		||||
      set-name: eth0
 | 
			
		||||
      dhcp4: {dhcp4}
 | 
			
		||||
      dhcp6: {dhcp6}
 | 
			
		||||
{np_v6_block}      nameservers:
 | 
			
		||||
      dhcp6: {dhcp6}{accept_ra}{np_v6_block}
 | 
			
		||||
      nameservers:
 | 
			
		||||
        addresses: [8.8.8.8, 1.1.1.1, 2001:4860:4860::8888]
 | 
			
		||||
EOF
 | 
			
		||||
# Enable SSH password authentication and set a default password for 'ubuntu'
 | 
			
		||||
mkdir -p \"$MNT_ROOT/etc/cloud/cloud.cfg.d\"
 | 
			
		||||
printf '%s\n' 'ssh_pwauth: true' > \"$MNT_ROOT/etc/cloud/cloud.cfg.d/99-ssh-password-auth.cfg\"
 | 
			
		||||
mkdir -p "$MNT_ROOT/etc/cloud/cloud.cfg.d"
 | 
			
		||||
printf '%s\n' 'ssh_pwauth: true' > "$MNT_ROOT/etc/cloud/cloud.cfg.d/99-ssh-password-auth.cfg"
 | 
			
		||||
 | 
			
		||||
mkdir -p \"$MNT_ROOT/etc/ssh/sshd_config.d\"
 | 
			
		||||
cat > \"$MNT_ROOT/etc/ssh/sshd_config.d/99-hero-password-auth.conf\" << EOF
 | 
			
		||||
mkdir -p "$MNT_ROOT/etc/ssh/sshd_config.d"
 | 
			
		||||
cat > "$MNT_ROOT/etc/ssh/sshd_config.d/99-hero-password-auth.conf" << EOF
 | 
			
		||||
# Hero test: force password auth, explicitly disable pubkey to avoid client auto-trying keys
 | 
			
		||||
PasswordAuthentication yes
 | 
			
		||||
KbdInteractiveAuthentication yes
 | 
			
		||||
UsePAM yes
 | 
			
		||||
PubkeyAuthentication no
 | 
			
		||||
EOF
 | 
			
		||||
 | 
			
		||||
# Remove any AuthenticationMethods directives that might force publickey-only
 | 
			
		||||
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
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Set password for default user 'ubuntu'
 | 
			
		||||
if chroot \"$MNT_ROOT\" getent passwd ubuntu >/dev/null 2>&1; then
 | 
			
		||||
  chroot \"$MNT_ROOT\" bash -c \"echo 'ubuntu:ubuntu' | chpasswd\" || true
 | 
			
		||||
if chroot "$MNT_ROOT" getent passwd ubuntu >/dev/null 2>&1; then
 | 
			
		||||
  echo 'ubuntu:ubuntu' | chroot "$MNT_ROOT" /usr/sbin/chpasswd || true
 | 
			
		||||
fi
 | 
			
		||||
# Ensure openssh-server is present (some cloud images may omit it)
 | 
			
		||||
# Ensure SSH service enabled and keys generated on boot
 | 
			
		||||
chroot \"$MNT_ROOT\" systemctl unmask ssh 2>/dev/null || true
 | 
			
		||||
chroot \"$MNT_ROOT\" systemctl enable ssh 2>/dev/null || true
 | 
			
		||||
chroot \"$MNT_ROOT\" systemctl enable ssh-keygen.service 2>/dev/null || true
 | 
			
		||||
chroot "$MNT_ROOT" systemctl unmask ssh 2>/dev/null || true
 | 
			
		||||
chroot "$MNT_ROOT" systemctl enable ssh 2>/dev/null || true
 | 
			
		||||
chroot "$MNT_ROOT" systemctl enable ssh-keygen.service 2>/dev/null || true
 | 
			
		||||
 | 
			
		||||
# Ensure sshd listens on both IPv4 and IPv6 explicitly
 | 
			
		||||
cat > \"$MNT_ROOT/etc/ssh/sshd_config.d/99-hero-address-family.conf\" << EOF
 | 
			
		||||
cat > "$MNT_ROOT/etc/ssh/sshd_config.d/99-hero-address-family.conf" << EOF
 | 
			
		||||
AddressFamily any
 | 
			
		||||
ListenAddress ::
 | 
			
		||||
ListenAddress 0.0.0.0
 | 
			
		||||
EOF
 | 
			
		||||
 | 
			
		||||
# If UFW is present, allow SSH and disable firewall (for tests)
 | 
			
		||||
if chroot \"$MNT_ROOT\" command -v ufw >/dev/null 2>&1; then
 | 
			
		||||
  chroot \"$MNT_ROOT\" ufw allow OpenSSH || true
 | 
			
		||||
  chroot \"$MNT_ROOT\" ufw disable || true
 | 
			
		||||
# Ensure sshd waits for network to be online (helps IPv6 readiness)
 | 
			
		||||
mkdir -p "$MNT_ROOT/etc/systemd/system/ssh.service.d"
 | 
			
		||||
cat > "$MNT_ROOT/etc/systemd/system/ssh.service.d/override.conf" << 'EOF'
 | 
			
		||||
[Unit]
 | 
			
		||||
After=network-online.target
 | 
			
		||||
Wants=network-online.target
 | 
			
		||||
EOF
 | 
			
		||||
 | 
			
		||||
# Ensure sshd_config includes conf.d include so our drop-ins are loaded
 | 
			
		||||
if ! grep -qE '^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config\.d/\*\.conf' "$MNT_ROOT/etc/ssh/sshd_config"; then
 | 
			
		||||
  echo 'Include /etc/ssh/sshd_config.d/*.conf' >> "$MNT_ROOT/etc/ssh/sshd_config"
 | 
			
		||||
fi
 | 
			
		||||
if ! chroot \"$MNT_ROOT\" test -x /usr/sbin/sshd; then
 | 
			
		||||
  cp -f /etc/resolv.conf \"$MNT_ROOT/etc/resolv.conf\" 2>/dev/null || true
 | 
			
		||||
  chroot \"$MNT_ROOT\" bash -c \"apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends openssh-server\" || true
 | 
			
		||||
 | 
			
		||||
# Ensure required packages present before user/password changes
 | 
			
		||||
cp -f /etc/resolv.conf "$MNT_ROOT/etc/resolv.conf" 2>/dev/null || true
 | 
			
		||||
chroot "$MNT_ROOT" bash -c "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends passwd openssh-server" || true
 | 
			
		||||
 | 
			
		||||
# Remove previously forced AuthenticationMethods drop-in (old)
 | 
			
		||||
rm -f "$MNT_ROOT/etc/ssh/sshd_config.d/99-hero-authmethods.conf"
 | 
			
		||||
 | 
			
		||||
# Force explicit password-only auth to avoid publickey-only negotiation from server
 | 
			
		||||
# Removed AuthenticationMethods to avoid config issues
 | 
			
		||||
 | 
			
		||||
# Ensure our overrides are last-wins even if main sshd_config sets different values after Include
 | 
			
		||||
cat >> "$MNT_ROOT/etc/ssh/sshd_config" << 'EOF'
 | 
			
		||||
# hero override (appended last)
 | 
			
		||||
PasswordAuthentication yes
 | 
			
		||||
KbdInteractiveAuthentication yes
 | 
			
		||||
UsePAM yes
 | 
			
		||||
PubkeyAuthentication no
 | 
			
		||||
EOF
 | 
			
		||||
 | 
			
		||||
# If UFW is present, allow SSH and disable firewall (for tests)
 | 
			
		||||
if chroot "$MNT_ROOT" command -v ufw >/dev/null 2>&1; then
 | 
			
		||||
  chroot "$MNT_ROOT" ufw allow OpenSSH || true
 | 
			
		||||
  chroot "$MNT_ROOT" ufw disable || true
 | 
			
		||||
fi
 | 
			
		||||
if ! chroot "$MNT_ROOT" test -x /usr/sbin/sshd; then
 | 
			
		||||
  cp -f /etc/resolv.conf "$MNT_ROOT/etc/resolv.conf" 2>/dev/null || true
 | 
			
		||||
  chroot "$MNT_ROOT" bash -c "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends openssh-server" || true
 | 
			
		||||
fi
 | 
			
		||||
# Ensure user management utilities are present (useradd, chpasswd)
 | 
			
		||||
if ! chroot "$MNT_ROOT" command -v /usr/sbin/useradd >/dev/null 2>&1 || \
 | 
			
		||||
   ! chroot "$MNT_ROOT" command -v /usr/sbin/chpasswd >/dev/null 2>&1; then
 | 
			
		||||
  cp -f /etc/resolv.conf "$MNT_ROOT/etc/resolv.conf" 2>/dev/null || true
 | 
			
		||||
  chroot "$MNT_ROOT" bash -c "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y passwd adduser" || true
 | 
			
		||||
fi
 | 
			
		||||
# Ensure user management utilities are present (useradd, chpasswd)
 | 
			
		||||
if ! chroot "$MNT_ROOT" command -v /usr/sbin/useradd >/dev/null 2>&1 || \
 | 
			
		||||
   ! chroot "$MNT_ROOT" command -v /usr/sbin/chpasswd >/dev/null 2>&1; then
 | 
			
		||||
  cp -f /etc/resolv.conf "$MNT_ROOT/etc/resolv.conf" 2>/dev/null || true
 | 
			
		||||
  chroot "$MNT_ROOT" bash -c "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y passwd adduser" || true
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Ensure user management utilities are present (useradd, chpasswd)
 | 
			
		||||
if ! chroot "$MNT_ROOT" command -v /usr/sbin/useradd >/dev/null 2>&1 || \
 | 
			
		||||
   ! chroot "$MNT_ROOT" command -v /usr/sbin/chpasswd >/dev/null 2>&1; then
 | 
			
		||||
  cp -f /etc/resolv.conf "$MNT_ROOT/etc/resolv.conf" 2>/dev/null || true
 | 
			
		||||
  chroot "$MNT_ROOT" bash -c "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y passwd adduser" || true
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Ensure shadow utilities present (useradd/chpasswd)
 | 
			
		||||
if ! chroot "$MNT_ROOT" command -v /usr/sbin/useradd >/dev/null 2>&1 || \
 | 
			
		||||
   ! chroot "$MNT_ROOT" command -v /usr/sbin/chpasswd >/dev/null 2>&1; then
 | 
			
		||||
  cp -f /etc/resolv.conf "$MNT_ROOT/etc/resolv.conf" 2>/dev/null || true
 | 
			
		||||
  chroot "$MNT_ROOT" bash -c "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y passwd" || true
 | 
			
		||||
fi
 | 
			
		||||
# Ensure default user 'ubuntu' exists (fallback for minimal images)
 | 
			
		||||
if ! chroot \"$MNT_ROOT\" id -u ubuntu >/dev/null 2>&1; then
 | 
			
		||||
  chroot \"$MNT_ROOT\" useradd -m -s /bin/bash ubuntu || true
 | 
			
		||||
  echo \"ubuntu ALL=(ALL) NOPASSWD:ALL\" > \"$MNT_ROOT/etc/sudoers.d/90-ubuntu\" || true
 | 
			
		||||
  chmod 0440 \"$MNT_ROOT/etc/sudoers.d/90-ubuntu\" || true
 | 
			
		||||
if ! chroot "$MNT_ROOT" id -u ubuntu >/dev/null 2>&1; then
 | 
			
		||||
  chroot "$MNT_ROOT" /usr/sbin/useradd -m -s /bin/bash ubuntu || true
 | 
			
		||||
  echo "ubuntu ALL=(ALL) NOPASSWD:ALL" > "$MNT_ROOT/etc/sudoers.d/90-ubuntu" || true
 | 
			
		||||
  chmod 0440 "$MNT_ROOT/etc/sudoers.d/90-ubuntu" || true
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Re-assert password (covers both existing and newly created users)
 | 
			
		||||
if chroot \"$MNT_ROOT\" getent passwd ubuntu >/dev/null 2>&1; then
 | 
			
		||||
  chroot \"$MNT_ROOT\" bash -c \"echo 'ubuntu:ubuntu' | chpasswd\" || true
 | 
			
		||||
if chroot "$MNT_ROOT" getent passwd ubuntu >/dev/null 2>&1; then
 | 
			
		||||
  echo 'ubuntu:ubuntu' | chroot "$MNT_ROOT" /usr/sbin/chpasswd || true
 | 
			
		||||
fi
 | 
			
		||||
# Ensure account is unlocked (some cloud images ship locked local users)
 | 
			
		||||
chroot "$MNT_ROOT" /usr/bin/passwd -u ubuntu 2>/dev/null || true
 | 
			
		||||
chroot "$MNT_ROOT" /usr/sbin/usermod -U ubuntu 2>/dev/null || true
 | 
			
		||||
 | 
			
		||||
# Robustly set ubuntu password offline; generate hash on host and set inside chroot
 | 
			
		||||
UBUNTU_HASH="$(openssl passwd -6 'ubuntu' 2>/dev/null || python3 - <<'PY'
 | 
			
		||||
import crypt
 | 
			
		||||
print(crypt.crypt('ubuntu', crypt.mksalt(crypt.METHOD_SHA512)))
 | 
			
		||||
PY
 | 
			
		||||
)"
 | 
			
		||||
if [ -n "$UBUNTU_HASH" ] && chroot "$MNT_ROOT" getent passwd ubuntu >/dev/null 2>&1; then
 | 
			
		||||
  printf 'ubuntu:%s\n' "$UBUNTU_HASH" | chroot "$MNT_ROOT" /usr/sbin/chpasswd -e || true
 | 
			
		||||
  # Ensure account is not expired/locked and has sane aging
 | 
			
		||||
  chroot "$MNT_ROOT" /usr/bin/chage -I -1 -m 0 -M 99999 -E -1 ubuntu 2>/dev/null || true
 | 
			
		||||
  chroot "$MNT_ROOT" /usr/bin/passwd -u ubuntu 2>/dev/null || true
 | 
			
		||||
  chroot "$MNT_ROOT" /usr/sbin/usermod -U ubuntu 2>/dev/null || true
 | 
			
		||||
  # Debug: show status and shadow entry (for test logs)
 | 
			
		||||
  chroot "$MNT_ROOT" /usr/bin/passwd -S ubuntu 2>/dev/null || true
 | 
			
		||||
  chroot "$MNT_ROOT" bash -c "grep '^ubuntu:' /etc/shadow || true" 2>/dev/null || true
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Also set root password and allow root login for test debugging
 | 
			
		||||
if chroot "$MNT_ROOT" getent passwd root >/dev/null 2>&1; then
 | 
			
		||||
  echo 'root:root' | chroot "$MNT_ROOT" /usr/sbin/chpasswd || true
 | 
			
		||||
  chroot "$MNT_ROOT" /usr/bin/passwd -u root 2>/dev/null || true
 | 
			
		||||
  chroot "$MNT_ROOT" /usr/bin/chage -I -1 -m 0 -M 99999 -E -1 root 2>/dev/null || true
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Pre-generate host SSH keys so sshd can start immediately
 | 
			
		||||
chroot \"$MNT_ROOT\" ssh-keygen -A 2>/dev/null || true
 | 
			
		||||
mkdir -p \"$MNT_ROOT/var/run/sshd\"
 | 
			
		||||
chroot "$MNT_ROOT" ssh-keygen -A 2>/dev/null || true
 | 
			
		||||
mkdir -p "$MNT_ROOT/var/run/sshd"
 | 
			
		||||
 | 
			
		||||
# Also enable socket activation as a fallback
 | 
			
		||||
chroot \"$MNT_ROOT\" systemctl enable ssh.socket 2>/dev/null || true
 | 
			
		||||
# Ensure sshd runs as a regular service and not via socket (binds IPv4+IPv6)
 | 
			
		||||
chroot "$MNT_ROOT" systemctl disable --now ssh.socket 2>/dev/null || true
 | 
			
		||||
chroot "$MNT_ROOT" systemctl mask ssh.socket 2>/dev/null || true
 | 
			
		||||
chroot "$MNT_ROOT" systemctl enable ssh.service 2>/dev/null || true
 | 
			
		||||
chroot "$MNT_ROOT" systemctl restart ssh.service 2>/dev/null || true
 | 
			
		||||
 | 
			
		||||
# Disable cloud-init networking (optional but default)
 | 
			
		||||
if [ \"{disable_ci_net}\" = \"true\" ]; then
 | 
			
		||||
  mkdir -p \"$MNT_ROOT/etc/cloud/cloud.cfg.d\"
 | 
			
		||||
  echo \"network: {{config: disabled}}\" > \"$MNT_ROOT/etc/cloud/cloud.cfg.d/99-disable-network-config.cfg\"
 | 
			
		||||
if [ "{disable_ci_net}" = "true" ]; then
 | 
			
		||||
  mkdir -p "$MNT_ROOT/etc/cloud/cloud.cfg.d"
 | 
			
		||||
  echo "network: {{{{config: disabled}}}}" > "$MNT_ROOT/etc/cloud/cloud.cfg.d/99-disable-network-config.cfg"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Fully disable cloud-init on first boot for deterministic tests
 | 
			
		||||
mkdir -p "$MNT_ROOT/etc/cloud"
 | 
			
		||||
: > "$MNT_ROOT/etc/cloud/cloud-init.disabled"
 | 
			
		||||
 | 
			
		||||
# Belt-and-braces: mask cloud-init services offline (no systemd required)
 | 
			
		||||
mkdir -p "$MNT_ROOT/etc/systemd/system"
 | 
			
		||||
for s in cloud-init.service cloud-config.service cloud-final.service cloud-init-local.service; do
 | 
			
		||||
  ln -sf /dev/null "$MNT_ROOT/etc/systemd/system/$s" || true
 | 
			
		||||
done
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# First-boot fallback: ensure ubuntu:ubuntu credentials and SSH password auth
 | 
			
		||||
mkdir -p "$MNT_ROOT/usr/local/sbin"
 | 
			
		||||
cat > "$MNT_ROOT/usr/local/sbin/hero-ensure-ubuntu-cred.sh" << 'EOS'
 | 
			
		||||
#!/bin/bash
 | 
			
		||||
set -euo pipefail
 | 
			
		||||
 | 
			
		||||
# Guarantee ubuntu user exists
 | 
			
		||||
if ! id -u ubuntu >/dev/null 2>&1; then
 | 
			
		||||
  useradd -m -s /bin/bash ubuntu || true
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Ensure sudo without password
 | 
			
		||||
mkdir -p /etc/sudoers.d
 | 
			
		||||
echo "ubuntu ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/90-ubuntu
 | 
			
		||||
chmod 0440 /etc/sudoers.d/90-ubuntu
 | 
			
		||||
 | 
			
		||||
# Set password 'ubuntu' (hashed)
 | 
			
		||||
UBUNTU_HASH="$(openssl passwd -6 'ubuntu' 2>/dev/null || python3 - <<'PY'
 | 
			
		||||
import crypt
 | 
			
		||||
print(crypt.crypt('ubuntu', crypt.mksalt(crypt.METHOD_SHA512)))
 | 
			
		||||
PY
 | 
			
		||||
)"
 | 
			
		||||
if [ -n "$UBUNTU_HASH" ]; then
 | 
			
		||||
  printf 'ubuntu:%s\n' "$UBUNTU_HASH" | chpasswd -e || true
 | 
			
		||||
  chage -I -1 -m 0 -M 99999 -E -1 ubuntu 2>/dev/null || true
 | 
			
		||||
  passwd -u ubuntu 2>/dev/null || true
 | 
			
		||||
  usermod -U ubuntu 2>/dev/null || true
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# SSHD password-auth settings
 | 
			
		||||
mkdir -p /etc/ssh/sshd_config.d
 | 
			
		||||
cat > /etc/ssh/sshd_config.d/99-hero-password-auth.conf << EOF
 | 
			
		||||
PasswordAuthentication yes
 | 
			
		||||
KbdInteractiveAuthentication yes
 | 
			
		||||
UsePAM yes
 | 
			
		||||
PubkeyAuthentication no
 | 
			
		||||
EOF
 | 
			
		||||
 | 
			
		||||
cat > /etc/ssh/sshd_config.d/99-hero-address-family.conf << EOF
 | 
			
		||||
AddressFamily any
 | 
			
		||||
ListenAddress ::
 | 
			
		||||
ListenAddress 0.0.0.0
 | 
			
		||||
EOF
 | 
			
		||||
 | 
			
		||||
# Ensure sshd waits for network-online at first boot as well
 | 
			
		||||
mkdir -p /etc/systemd/system/ssh.service.d
 | 
			
		||||
cat > /etc/systemd/system/ssh.service.d/override.conf << 'EOF'
 | 
			
		||||
[Unit]
 | 
			
		||||
After=network-online.target
 | 
			
		||||
Wants=network-online.target
 | 
			
		||||
EOF
 | 
			
		||||
 | 
			
		||||
# Remove any AuthenticationMethods directives from drop-ins that could conflict
 | 
			
		||||
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
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Ensure Include covers drop-ins
 | 
			
		||||
grep -qE '^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config\.d/\*\.conf' /etc/ssh/sshd_config || \
 | 
			
		||||
  echo 'Include /etc/ssh/sshd_config.d/*.conf' >> /etc/ssh/sshd_config
 | 
			
		||||
 | 
			
		||||
# Ensure and restart SSHD
 | 
			
		||||
if command -v systemctl >/dev/null 2>&1; then
 | 
			
		||||
  systemctl daemon-reload || true
 | 
			
		||||
  # Prefer running sshd as a service so it honors IPv6 ListenAddress from sshd_config
 | 
			
		||||
  systemctl disable --now ssh.socket 2>/dev/null || true
 | 
			
		||||
  systemctl mask ssh.socket 2>/dev/null || true
 | 
			
		||||
  systemctl enable --now ssh.service 2>/dev/null || true
 | 
			
		||||
  systemctl restart ssh.service 2>/dev/null || true
 | 
			
		||||
  # Apply netplan in case renderer did not start IPv6 yet
 | 
			
		||||
  command -v netplan >/dev/null 2>&1 && netplan apply 2>/dev/null || true
 | 
			
		||||
else
 | 
			
		||||
  service ssh restart || true
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Mark completion to avoid reruns if unit has a condition
 | 
			
		||||
mkdir -p /var/lib/hero
 | 
			
		||||
: > /var/lib/hero/cred-ensured
 | 
			
		||||
EOS
 | 
			
		||||
chmod 0755 "$MNT_ROOT/usr/local/sbin/hero-ensure-ubuntu-cred.sh"
 | 
			
		||||
 | 
			
		||||
# Install systemd unit to run on first boot
 | 
			
		||||
cat > "$MNT_ROOT/etc/systemd/system/hero-ensure-ubuntu-cred.service" << 'EOF'
 | 
			
		||||
[Unit]
 | 
			
		||||
Description=Hero: ensure ubuntu:ubuntu and SSH password auth
 | 
			
		||||
After=local-fs.target
 | 
			
		||||
Wants=local-fs.target
 | 
			
		||||
ConditionPathExists=!/var/lib/hero/cred-ensured
 | 
			
		||||
 | 
			
		||||
[Service]
 | 
			
		||||
Type=oneshot
 | 
			
		||||
ExecStart=/usr/local/sbin/hero-ensure-ubuntu-cred.sh
 | 
			
		||||
 | 
			
		||||
[Install]
 | 
			
		||||
WantedBy=multi-user.target
 | 
			
		||||
EOF
 | 
			
		||||
 | 
			
		||||
# Enable via symlink and best-effort systemctl in chroot
 | 
			
		||||
mkdir -p "$MNT_ROOT/etc/systemd/system/multi-user.target.wants"
 | 
			
		||||
ln -sf "/etc/systemd/system/hero-ensure-ubuntu-cred.service" "$MNT_ROOT/etc/systemd/system/multi-user.target.wants/hero-ensure-ubuntu-cred.service" || true
 | 
			
		||||
chroot "$MNT_ROOT" systemctl enable hero-ensure-ubuntu-cred.service 2>/dev/null || true
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Convert prepared image to raw (ensure source not locked)
 | 
			
		||||
umount \"$MNT_BOOT\" 2>/dev/null || true
 | 
			
		||||
umount \"$MNT_ROOT\" 2>/dev/null || true
 | 
			
		||||
if [ -n \"$NBD\" ]; then
 | 
			
		||||
  qemu-nbd --disconnect \"$NBD\" 2>/dev/null || true
 | 
			
		||||
umount "$MNT_BOOT" 2>/dev/null || true
 | 
			
		||||
umount "$MNT_ROOT" 2>/dev/null || true
 | 
			
		||||
if [ -n "$NBD" ]; then
 | 
			
		||||
  qemu-nbd --disconnect "$NBD" 2>/dev/null || true
 | 
			
		||||
  rmmod nbd 2>/dev/null || true
 | 
			
		||||
fi
 | 
			
		||||
rm -f \"$RAW\"
 | 
			
		||||
qemu-img convert -U -f qcow2 -O raw \"$WORK\" \"$RAW\"
 | 
			
		||||
rm -f "$RAW"
 | 
			
		||||
qemu-img convert -U -f qcow2 -O raw "$WORK" "$RAW"
 | 
			
		||||
 | 
			
		||||
# Output result triple ONLY on stdout, then prevent any further trap output
 | 
			
		||||
echo \"RESULT:$RAW|$ROOT_UUID|$BOOT_UUID\"
 | 
			
		||||
echo "RESULT:$RAW|$ROOT_UUID|$BOOT_UUID"
 | 
			
		||||
trap - EXIT
 | 
			
		||||
exit 0
 | 
			
		||||
",
 | 
			
		||||
"#,
 | 
			
		||||
                src = shell_escape(&src),
 | 
			
		||||
                vm_dir = shell_escape(&vm_dir),
 | 
			
		||||
                work = shell_escape(&work_qcow2),
 | 
			
		||||
@@ -498,13 +727,12 @@ exit 0
 | 
			
		||||
                vm_mac = vm_mac,
 | 
			
		||||
                dhcp4 = if opts.net.dhcp4 { "true" } else { "false" },
 | 
			
		||||
                dhcp6 = if dhcp6_effective { "true" } else { "false" },
 | 
			
		||||
                accept_ra = accept_ra,
 | 
			
		||||
                np_v6_block = np_v6_block,
 | 
			
		||||
                disable_ci_net = if disable_ci_net { "true" } else { "false" },
 | 
			
		||||
                disable_ci_net = if disable_ci_net { "true" } else { "false" }
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
            // image prep script printout for debugging:
 | 
			
		||||
            println!("{script}");
 | 
			
		||||
 | 
			
		||||
            // image prep script executed silently
 | 
			
		||||
            let res = run_script(&script)?;
 | 
			
		||||
            // Prefer a RESULT:-prefixed line (robust against extra stdout noise)
 | 
			
		||||
            let mut marker: Option<String> = None;
 | 
			
		||||
@@ -553,7 +781,9 @@ fn shell_escape(s: &str) -> String {
 | 
			
		||||
    if s.is_empty() {
 | 
			
		||||
        return "''".into();
 | 
			
		||||
    }
 | 
			
		||||
    if s.chars().all(|c| c.is_ascii_alphanumeric() || "-_./=:".contains(c)) {
 | 
			
		||||
    if s.chars()
 | 
			
		||||
        .all(|c| c.is_ascii_alphanumeric() || "-_./=:".contains(c))
 | 
			
		||||
    {
 | 
			
		||||
        return s.into();
 | 
			
		||||
    }
 | 
			
		||||
    let mut out = String::from("'");
 | 
			
		||||
@@ -566,4 +796,4 @@ fn shell_escape(s: &str) -> String {
 | 
			
		||||
    }
 | 
			
		||||
    out.push('\'');
 | 
			
		||||
    out
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -25,7 +25,7 @@ fn map_to_vmspec(spec: Map) -> Result<VmSpec, Box<EvalAltResult>> {
 | 
			
		||||
    let memory_mb = get_int(&spec, "memory_mb").unwrap_or(512) as u32;
 | 
			
		||||
    let cmdline = get_string(&spec, "cmdline");
 | 
			
		||||
    let extra_args = get_string_array(&spec, "extra_args");
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
    Ok(VmSpec {
 | 
			
		||||
        id,
 | 
			
		||||
        kernel_path,
 | 
			
		||||
@@ -37,6 +37,7 @@ fn map_to_vmspec(spec: Map) -> Result<VmSpec, Box<EvalAltResult>> {
 | 
			
		||||
        memory_mb,
 | 
			
		||||
        cmdline,
 | 
			
		||||
        extra_args,
 | 
			
		||||
        net_profile: None,
 | 
			
		||||
    })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -76,6 +77,8 @@ fn vmspec_to_map(s: &VmSpec) -> Map {
 | 
			
		||||
    } else {
 | 
			
		||||
        m.insert("extra_args".into(), Dynamic::UNIT);
 | 
			
		||||
    }
 | 
			
		||||
    // net_profile not exposed in Rhai yet; return UNIT for now
 | 
			
		||||
    m.insert("net_profile".into(), Dynamic::UNIT);
 | 
			
		||||
    m
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -167,6 +170,81 @@ pub fn cloudhv_vm_info(id: &str) -> Result<Map, Box<EvalAltResult>> {
 | 
			
		||||
    Ok(vmrecord_to_map(&rec))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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,
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub fn cloudhv_discover_ipv6_on_bridge(bridge_name: &str, mac_lower: &str) -> Dynamic {
 | 
			
		||||
    match crate::cloudhv::net::discover_ipv6_on_bridge(bridge_name, mac_lower) {
 | 
			
		||||
        Some(ip) => ip.into(),
 | 
			
		||||
        None => Dynamic::UNIT,
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// High-level network discovery that avoids hardcoded MAC/paths.
 | 
			
		||||
/// Returns a Rhai map with fields: ipv4, ipv6, mac, bridge, lease.
 | 
			
		||||
pub fn cloudhv_vm_network_info(id: &str, timeout_secs: i64) -> Result<Map, Box<EvalAltResult>> {
 | 
			
		||||
    let (ipv4, ipv6, mac, bridge, lease) =
 | 
			
		||||
        hv_to_rhai(cloudhv::vm_network_info(id, timeout_secs as u64))?;
 | 
			
		||||
    let mut m = Map::new();
 | 
			
		||||
    m.insert("vm_id".into(), id.to_string().into());
 | 
			
		||||
    m.insert("ipv4".into(), ipv4.map(Into::into).unwrap_or(Dynamic::UNIT));
 | 
			
		||||
    m.insert("ipv6".into(), ipv6.map(Into::into).unwrap_or(Dynamic::UNIT));
 | 
			
		||||
    m.insert("mac".into(), mac.map(Into::into).unwrap_or(Dynamic::UNIT));
 | 
			
		||||
    m.insert("bridge".into(), bridge.map(Into::into).unwrap_or(Dynamic::UNIT));
 | 
			
		||||
    m.insert("lease".into(), lease.map(Into::into).unwrap_or(Dynamic::UNIT));
 | 
			
		||||
    Ok(m)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Module registration
 | 
			
		||||
 | 
			
		||||
pub fn register_cloudhv_module(engine: &mut Engine) -> Result<(), Box<EvalAltResult>> {
 | 
			
		||||
@@ -176,5 +254,9 @@ pub fn register_cloudhv_module(engine: &mut Engine) -> Result<(), Box<EvalAltRes
 | 
			
		||||
    engine.register_fn("cloudhv_vm_delete", cloudhv_vm_delete);
 | 
			
		||||
    engine.register_fn("cloudhv_vm_list", cloudhv_vm_list);
 | 
			
		||||
    engine.register_fn("cloudhv_vm_info", cloudhv_vm_info);
 | 
			
		||||
    engine.register_fn("cloudhv_vm_network_info", cloudhv_vm_network_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(())
 | 
			
		||||
}
 | 
			
		||||
@@ -1,61 +1,121 @@
 | 
			
		||||
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};
 | 
			
		||||
use rhai::{Engine, EvalAltResult, Array};
 | 
			
		||||
 | 
			
		||||
fn builder_new(id: &str) -> CloudHvBuilder {
 | 
			
		||||
// Improved functional-style builder with better method names for fluent feel
 | 
			
		||||
fn cloudhv_builder(id: &str) -> CloudHvBuilder {
 | 
			
		||||
    CloudHvBuilder::new(id)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Functional, chainable-style helpers (consume and return the builder)
 | 
			
		||||
fn builder_memory_mb(mut b: CloudHvBuilder, mb: i64) -> CloudHvBuilder {
 | 
			
		||||
fn memory_mb(b: CloudHvBuilder, mb: i64) -> CloudHvBuilder {
 | 
			
		||||
    let mut b = b;
 | 
			
		||||
    if mb > 0 {
 | 
			
		||||
        b.memory_mb(mb as u32);
 | 
			
		||||
    }
 | 
			
		||||
    b
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn builder_vcpus(mut b: CloudHvBuilder, v: i64) -> CloudHvBuilder {
 | 
			
		||||
fn vcpus(b: CloudHvBuilder, v: i64) -> CloudHvBuilder {
 | 
			
		||||
    let mut b = b;
 | 
			
		||||
    if v > 0 {
 | 
			
		||||
        b.vcpus(v as u32);
 | 
			
		||||
    }
 | 
			
		||||
    b
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn builder_disk(mut b: CloudHvBuilder, path: &str) -> CloudHvBuilder {
 | 
			
		||||
fn disk(b: CloudHvBuilder, path: &str) -> CloudHvBuilder {
 | 
			
		||||
    let mut b = b;
 | 
			
		||||
    b.disk(path);
 | 
			
		||||
    b
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn builder_disk_from_flavor(mut b: CloudHvBuilder, flavor: &str) -> CloudHvBuilder {
 | 
			
		||||
fn disk_from_flavor(b: CloudHvBuilder, flavor: &str) -> CloudHvBuilder {
 | 
			
		||||
    let mut b = b;
 | 
			
		||||
    b.disk_from_flavor(flavor);
 | 
			
		||||
    b
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn builder_cmdline(mut b: CloudHvBuilder, c: &str) -> CloudHvBuilder {
 | 
			
		||||
fn cmdline(b: CloudHvBuilder, c: &str) -> CloudHvBuilder {
 | 
			
		||||
    let mut b = b;
 | 
			
		||||
    b.cmdline(c);
 | 
			
		||||
    b
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn builder_extra_arg(mut b: CloudHvBuilder, a: &str) -> CloudHvBuilder {
 | 
			
		||||
fn extra_arg(b: CloudHvBuilder, a: &str) -> CloudHvBuilder {
 | 
			
		||||
    let mut b = b;
 | 
			
		||||
    b.extra_arg(a);
 | 
			
		||||
    b
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn builder_no_default_net(mut b: CloudHvBuilder) -> CloudHvBuilder {
 | 
			
		||||
fn no_default_net(b: CloudHvBuilder) -> CloudHvBuilder {
 | 
			
		||||
    let mut b = b;
 | 
			
		||||
    b.no_default_net();
 | 
			
		||||
    b
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn builder_launch(mut b: CloudHvBuilder) -> Result<String, Box<EvalAltResult>> {
 | 
			
		||||
fn network_default_nat(b: CloudHvBuilder) -> CloudHvBuilder {
 | 
			
		||||
    let mut b = b;
 | 
			
		||||
    b.network_default_nat();
 | 
			
		||||
    b
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn network_none(b: CloudHvBuilder) -> CloudHvBuilder {
 | 
			
		||||
    let mut b = b;
 | 
			
		||||
    b.network_none();
 | 
			
		||||
    b
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn network_bridge_only(b: CloudHvBuilder) -> CloudHvBuilder {
 | 
			
		||||
    let mut b = b;
 | 
			
		||||
    b.network_bridge_only();
 | 
			
		||||
    b
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn network_custom(b: CloudHvBuilder, args: Array) -> CloudHvBuilder {
 | 
			
		||||
    let mut b = b;
 | 
			
		||||
    let mut v: Vec<String> = Vec::new();
 | 
			
		||||
    for it in args {
 | 
			
		||||
        if it.is_string() {
 | 
			
		||||
            v.push(it.clone().cast::<String>());
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    b.network_custom_cli(v);
 | 
			
		||||
    b
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
@@ -102,6 +162,8 @@ fn vm_easy_launch(flavor: &str, id: &str, memory_mb: i64, vcpus: i64) -> Result<
 | 
			
		||||
    if vcpus > 0 {
 | 
			
		||||
        b.vcpus(vcpus as u32);
 | 
			
		||||
    }
 | 
			
		||||
    // Default profile: NAT with IPv6 via Mycelium (opt-out via env)
 | 
			
		||||
    b.network_default_nat();
 | 
			
		||||
    b.launch().map_err(|e| {
 | 
			
		||||
        Box::new(EvalAltResult::ErrorRuntime(
 | 
			
		||||
            format!("vm_easy_launch failed at launch: {}", e).into(),
 | 
			
		||||
@@ -115,19 +177,25 @@ pub fn register_cloudhv_builder_module(engine: &mut Engine) -> Result<(), Box<Ev
 | 
			
		||||
    engine.register_type_with_name::<CloudHvBuilder>("CloudHvBuilder");
 | 
			
		||||
 | 
			
		||||
    // Factory
 | 
			
		||||
    engine.register_fn("cloudhv_builder", builder_new);
 | 
			
		||||
    engine.register_fn("cloudhv_builder", cloudhv_builder);
 | 
			
		||||
 | 
			
		||||
    // Chainable methods (functional style)
 | 
			
		||||
    engine.register_fn("memory_mb", builder_memory_mb);
 | 
			
		||||
    engine.register_fn("vcpus", builder_vcpus);
 | 
			
		||||
    engine.register_fn("disk", builder_disk);
 | 
			
		||||
    engine.register_fn("disk_from_flavor", builder_disk_from_flavor);
 | 
			
		||||
    engine.register_fn("cmdline", builder_cmdline);
 | 
			
		||||
    engine.register_fn("extra_arg", builder_extra_arg);
 | 
			
		||||
    engine.register_fn("no_default_net", builder_no_default_net);
 | 
			
		||||
    // Chainable methods (fluent functional style)
 | 
			
		||||
    engine.register_fn("memory_mb", memory_mb);
 | 
			
		||||
    engine.register_fn("vcpus", vcpus);
 | 
			
		||||
    engine.register_fn("disk", disk);
 | 
			
		||||
    engine.register_fn("disk_from_flavor", disk_from_flavor);
 | 
			
		||||
    engine.register_fn("cmdline", cmdline);
 | 
			
		||||
    engine.register_fn("extra_arg", extra_arg);
 | 
			
		||||
    engine.register_fn("no_default_net", no_default_net);
 | 
			
		||||
    // Networking profiles
 | 
			
		||||
    engine.register_fn("network_default_nat", network_default_nat);
 | 
			
		||||
    engine.register_fn("network_none", network_none);
 | 
			
		||||
    engine.register_fn("network_bridge_only", network_bridge_only);
 | 
			
		||||
    engine.register_fn("network_custom", network_custom);
 | 
			
		||||
 | 
			
		||||
    // Action
 | 
			
		||||
    engine.register_fn("launch", builder_launch);
 | 
			
		||||
    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);
 | 
			
		||||
 
 | 
			
		||||
@@ -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();
 | 
			
		||||
 
 | 
			
		||||
@@ -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 {
 | 
			
		||||
 
 | 
			
		||||
@@ -109,6 +109,8 @@ if !(prep_ok) {
 | 
			
		||||
// ------------------------------------------------------------------------------------
 | 
			
		||||
banner("PHASE 3: Launch via cloudhv_builder (disk from Phase 2)");
 | 
			
		||||
let b = cloudhv_builder(vmA);
 | 
			
		||||
// Explicitly select Default NAT networking (bridge + NAT + dnsmasq; IPv6 via Mycelium if enabled)
 | 
			
		||||
let b = network_default_nat(b);
 | 
			
		||||
let b = disk(b, prep_res.raw_disk);
 | 
			
		||||
let b = memory_mb(b, 4096);
 | 
			
		||||
let b = vcpus(b, 2);
 | 
			
		||||
@@ -183,6 +185,10 @@ try {
 | 
			
		||||
    throw "Stopping due to vm_easy_launch failure";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Allow time for VM to fully boot and SSH to be ready
 | 
			
		||||
print("Sleeping 30 seconds for VM to boot... You can try SSH during this time.");
 | 
			
		||||
sleep(30000000); // 30 seconds
 | 
			
		||||
 | 
			
		||||
// ------------------------------------------------------------------------------------
 | 
			
		||||
// Phase 7: Inspect VM B info, list VMs
 | 
			
		||||
// ------------------------------------------------------------------------------------
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										60
									
								
								packages/system/virt/tests/rhai/vm_clean_launch.rhai
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								packages/system/virt/tests/rhai/vm_clean_launch.rhai
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,60 @@
 | 
			
		||||
// Clean VM Launch Script
 | 
			
		||||
// Creates a VM using builder pattern with concise output
 | 
			
		||||
 | 
			
		||||
let vm_id = "vm-clean-test";
 | 
			
		||||
 | 
			
		||||
// Phase 0: Pre-clean any existing VM with the same id (best-effort)
 | 
			
		||||
// This avoids TAP "Resource busy" when a previous run is still active.
 | 
			
		||||
try {
 | 
			
		||||
    cloudhv_vm_stop(vm_id, true);
 | 
			
		||||
} catch (e) {
 | 
			
		||||
    // ignore
 | 
			
		||||
}
 | 
			
		||||
// brief wait to let processes exit and TAP release
 | 
			
		||||
wait_for_vm_boot(1);
 | 
			
		||||
try {
 | 
			
		||||
    cloudhv_vm_delete(vm_id, true);
 | 
			
		||||
} catch (e) {
 | 
			
		||||
    // ignore
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Phase 1: Host check
 | 
			
		||||
let hc = host_check();
 | 
			
		||||
if !(hc.ok == true) {
 | 
			
		||||
    throw "Host check failed: missing dependencies";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Phase 2: Create VM using fluent builder pattern
 | 
			
		||||
let vm_id_actual = "";
 | 
			
		||||
try {
 | 
			
		||||
    vm_id_actual = cloudhv_builder(vm_id)
 | 
			
		||||
        .disk_from_flavor("ubuntu")
 | 
			
		||||
        .network_default_nat()
 | 
			
		||||
        .memory_mb(4096)
 | 
			
		||||
        .vcpus(2)
 | 
			
		||||
        .launch();
 | 
			
		||||
} catch (e) {
 | 
			
		||||
    throw "VM launch failed: " + e.to_string();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Phase 3: Wait for VM to boot and get network configuration
 | 
			
		||||
wait_for_vm_boot(10);
 | 
			
		||||
 | 
			
		||||
// Phase 4: Discover VM IP addresses (robust, no hardcoded MAC/paths)
 | 
			
		||||
let net = cloudhv_vm_network_info(vm_id_actual, 30);
 | 
			
		||||
let ipv4 = net["ipv4"];    // Dynamic UNIT if not found yet
 | 
			
		||||
let ipv6 = net["ipv6"];    // Dynamic UNIT if not found
 | 
			
		||||
// Optional: you could also inspect net["mac"], net["bridge"], net["lease"]
 | 
			
		||||
 | 
			
		||||
// Phase 5: Display connection info
 | 
			
		||||
cloudhv_display_network_info(vm_id_actual, ipv4, ipv6);
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
try {
 | 
			
		||||
    cloudhv_vm_stop(vm_id_actual, false);
 | 
			
		||||
    cloudhv_vm_delete(vm_id_actual, true);
 | 
			
		||||
    print("VM stopped and cleaned up.");
 | 
			
		||||
} catch (e) {
 | 
			
		||||
    print("Warning: cleanup failed: " + e.to_string());
 | 
			
		||||
}
 | 
			
		||||
*/
 | 
			
		||||
		Reference in New Issue
	
	Block a user