wip
This commit is contained in:
		| @@ -1,6 +1,7 @@ | ||||
| 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 +24,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 +40,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 +102,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 +199,7 @@ impl CloudHvBuilder { | ||||
|             } else { | ||||
|                 Some(self.extra_args.clone()) | ||||
|             }, | ||||
|             net_profile: self.net_profile.clone(), | ||||
|         }; | ||||
|  | ||||
|         let id = vm_create(&spec)?; | ||||
|   | ||||
| @@ -11,8 +11,10 @@ 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 +63,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)] | ||||
| @@ -394,72 +399,92 @@ 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(mut 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()); | ||||
|                         println!("auto-deriving mycelium address..."); | ||||
|                         let (_ifname, myc_addr) = net::mycelium_ipv6_addr(&if_hint)?; | ||||
|                         let (_pfx, router_cidr) = net::derive_ipv6_prefix_from_mycelium(&myc_addr)?; | ||||
|                         println!("derived router cidr for bridge: {}", router_cidr); | ||||
|                         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())?; | ||||
|                 net::ensure_nat(&nat.subnet_cidr)?; | ||||
|                 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)?; | ||||
|                 println!("TAP device for vm called: {tap_name}"); | ||||
|                 let mac = net::stable_mac_from_id(id); | ||||
|                 println!("MAC for vm: {mac}"); | ||||
|                 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)?; | ||||
|                 println!("TAP device for vm called: {tap_name}"); | ||||
|                 let mac = net::stable_mac_from_id(id); | ||||
|                 println!("MAC for vm: {mac}"); | ||||
|                 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 | ||||
| @@ -540,73 +565,40 @@ pub fn vm_start(id: &str) -> Result<(), CloudHvError> { | ||||
|     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"); | ||||
|     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()) | ||||
|     ); | ||||
|     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); | ||||
|         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 | ||||
|     })(); | ||||
|         let ipv6 = net::discover_ipv6_on_bridge(&bridge_name, &mac_lower); | ||||
|         println!( | ||||
|             "Got IPv6 from neighbor table on bridge: {}", | ||||
|             ipv6.clone().unwrap_or("not found".to_string()) | ||||
|         ); | ||||
|  | ||||
|     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("") | ||||
|     ); | ||||
|         println!( | ||||
|             "[cloudhv] VM '{}' guest addresses: IPv4={}, IPv6={}", | ||||
|             id, | ||||
|             ipv4.as_deref().unwrap_or(""), | ||||
|             ipv6.as_deref().unwrap_or("") | ||||
|         ); | ||||
|     } else { | ||||
|         println!( | ||||
|             "[cloudhv] VM '{}' guest addresses discovery skipped (no default bridge in use)", | ||||
|             id | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|   | ||||
							
								
								
									
										362
									
								
								packages/system/virt/src/cloudhv/net/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										362
									
								
								packages/system/virt/src/cloudhv/net/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,362 @@ | ||||
| use serde::{Deserialize, Serialize}; | ||||
|  | ||||
| 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. | ||||
| pub fn ensure_nat(subnet_cidr: &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 body = format!( | ||||
|         "set -e | ||||
| SUBNET={subnet} | ||||
|  | ||||
| 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 | ||||
|  | ||||
| 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 \\; }} | ||||
| # Only add rule if not present | ||||
| 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 | ||||
| ", | ||||
|         subnet = shell_escape(subnet_cidr), | ||||
|     ); | ||||
|     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 | ||||
|   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 | ||||
|  | ||||
| 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) | ||||
|  | ||||
| ip link show \"$TAP\" >/dev/null 2>&1 || 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. | ||||
| pub fn stable_mac_from_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 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 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()) | ||||
|     } | ||||
| } | ||||
| @@ -44,7 +44,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 { | ||||
| @@ -93,7 +93,10 @@ fn stable_mac_from_id(id: &str) -> String { | ||||
|     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) | ||||
|     format!( | ||||
|         "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", | ||||
|         b0, b1, b2, b3, b4, b5 | ||||
|     ) | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||||
| @@ -177,7 +180,8 @@ pub fn image_prepare(opts: &ImagePrepOptions) -> Result<ImagePrepResult, ImagePr | ||||
|             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()); | ||||
|             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 +193,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 { | ||||
| @@ -217,8 +222,10 @@ 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!( | ||||
| @@ -234,7 +241,7 @@ pub fn image_prepare(opts: &ImagePrepOptions) -> Result<ImagePrepResult, ImagePr | ||||
|             // 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 +251,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,11 +404,12 @@ 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: | ||||
| @@ -413,82 +421,297 @@ network: | ||||
|         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), | ||||
| @@ -499,7 +722,7 @@ exit 0 | ||||
|                 dhcp4 = if opts.net.dhcp4 { "true" } else { "false" }, | ||||
|                 dhcp6 = if dhcp6_effective { "true" } else { "false" }, | ||||
|                 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: | ||||
| @@ -553,7 +776,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 +791,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 | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| use crate::cloudhv::builder::CloudHvBuilder; | ||||
| use crate::hostcheck::host_check_deps; | ||||
| use crate::image_prep::{image_prepare, Flavor as ImgFlavor, ImagePrepOptions, NetPlanOpts}; | ||||
| use rhai::{Engine, EvalAltResult, Map}; | ||||
| use rhai::{Engine, EvalAltResult, Map, Array}; | ||||
|  | ||||
| fn builder_new(id: &str) -> CloudHvBuilder { | ||||
|     CloudHvBuilder::new(id) | ||||
| @@ -47,6 +47,30 @@ fn builder_no_default_net(mut b: CloudHvBuilder) -> CloudHvBuilder { | ||||
|     b | ||||
| } | ||||
|  | ||||
| // New networking profile helpers | ||||
| fn builder_network_default_nat(mut b: CloudHvBuilder) -> CloudHvBuilder { | ||||
|     b.network_default_nat(); | ||||
|     b | ||||
| } | ||||
| fn builder_network_none(mut b: CloudHvBuilder) -> CloudHvBuilder { | ||||
|     b.network_none(); | ||||
|     b | ||||
| } | ||||
| fn builder_network_bridge_only(mut b: CloudHvBuilder) -> CloudHvBuilder { | ||||
|     b.network_bridge_only(); | ||||
|     b | ||||
| } | ||||
| fn builder_network_custom(mut b: CloudHvBuilder, args: Array) -> CloudHvBuilder { | ||||
|     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 builder_launch(mut b: CloudHvBuilder) -> Result<String, Box<EvalAltResult>> { | ||||
|     b.launch().map_err(|e| { | ||||
|         Box::new(EvalAltResult::ErrorRuntime( | ||||
| @@ -102,6 +126,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(), | ||||
| @@ -125,6 +151,11 @@ pub fn register_cloudhv_builder_module(engine: &mut Engine) -> Result<(), Box<Ev | ||||
|     engine.register_fn("cmdline", builder_cmdline); | ||||
|     engine.register_fn("extra_arg", builder_extra_arg); | ||||
|     engine.register_fn("no_default_net", builder_no_default_net); | ||||
|     // Networking profiles | ||||
|     engine.register_fn("network_default_nat", builder_network_default_nat); | ||||
|     engine.register_fn("network_none", builder_network_none); | ||||
|     engine.register_fn("network_bridge_only", builder_network_bridge_only); | ||||
|     engine.register_fn("network_custom", builder_network_custom); | ||||
|  | ||||
|     // Action | ||||
|     engine.register_fn("launch", builder_launch); | ||||
|   | ||||
| @@ -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 | ||||
| // ------------------------------------------------------------------------------------ | ||||
|   | ||||
		Reference in New Issue
	
	Block a user