diff --git a/packages/system/virt/src/cloudhv/builder.rs b/packages/system/virt/src/cloudhv/builder.rs index 4f08fca..00aff87 100644 --- a/packages/system/virt/src/cloudhv/builder.rs +++ b/packages/system/virt/src/cloudhv/builder.rs @@ -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, extra_args: Vec, no_default_net: bool, + /// Optional networking profile driving host provisioning and NIC injection + net_profile: Option, } 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>(&mut self, args: Vec) -> &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 { 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)?; diff --git a/packages/system/virt/src/cloudhv/mod.rs b/packages/system/virt/src/cloudhv/mod.rs index 30ae728..4596451 100644 --- a/packages/system/virt/src/cloudhv/mod.rs +++ b/packages/system/virt/src/cloudhv/mod.rs @@ -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, /// Extra args (raw) if you need to extend; keep minimal for Phase 2 pub extra_args: Option>, + /// Optional networking profile; when None, behavior follows explicit --net/--no-default-net or defaults + #[serde(default)] + pub net_profile: Option, } #[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 = None; - let mut mycelium_if_opt: Option = 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 = None; + let mut lease_for_disc: Option = 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 = 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 = 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(()) } diff --git a/packages/system/virt/src/cloudhv/net/mod.rs b/packages/system/virt/src/cloudhv/net/mod.rs new file mode 100644 index 0000000..a831027 --- /dev/null +++ b/packages/system/virt/src/cloudhv/net/mod.rs @@ -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 { + 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 { + 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::().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::().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 { + 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 = 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 { + 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 +} \ No newline at end of file diff --git a/packages/system/virt/src/cloudhv/net/profile.rs b/packages/system/virt/src/cloudhv/net/profile.rs new file mode 100644 index 0000000..8db20a3 --- /dev/null +++ b/packages/system/virt/src/cloudhv/net/profile.rs @@ -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, + /// Optional explicit mycelium interface name + #[serde(default)] + pub mycelium_if: Option, + /// Optional override for dnsmasq lease file + #[serde(default)] + pub lease_file: Option, +} + +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, + /// Optional: if provided, configure IPv6 on the bridge + #[serde(default)] + pub bridge_ipv6_cidr: Option, +} + +#[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), + /// Ensure bridge and tap only; no NAT/DHCP + BridgeOnly(BridgeOptions), +} + +impl Default for NetworkingProfileSpec { + fn default() -> Self { + NetworkingProfileSpec::DefaultNat(DefaultNatOptions::default()) + } +} \ No newline at end of file diff --git a/packages/system/virt/src/image_prep/mod.rs b/packages/system/virt/src/image_prep/mod.rs index 7050661..f399bca 100644 --- a/packages/system/virt/src/image_prep/mod.rs +++ b/packages/system/virt/src/image_prep/mod.rs @@ -44,7 +44,7 @@ pub struct NetPlanOpts { pub dhcp6: bool, /// Static IPv6 address to assign in guest (temporary behavior) pub ipv6_addr: Option, // e.g., "400::10/64" - pub gw6: Option, // e.g., "400::1" + pub gw6: Option, // 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 = None; @@ -189,7 +193,8 @@ pub fn image_prepare(opts: &ImagePrepOptions) -> Result() { let seg0 = ip.segments()[0]; if (seg0 & 0xFE00) == 0x0400 { @@ -217,8 +222,10 @@ pub fn image_prepare(opts: &ImagePrepOptions) -> Result Result/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 -} \ No newline at end of file +} diff --git a/packages/system/virt/src/rhai/cloudhv.rs b/packages/system/virt/src/rhai/cloudhv.rs index c39c0b9..2461207 100644 --- a/packages/system/virt/src/rhai/cloudhv.rs +++ b/packages/system/virt/src/rhai/cloudhv.rs @@ -25,7 +25,7 @@ fn map_to_vmspec(spec: Map) -> Result> { 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> { 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 } diff --git a/packages/system/virt/src/rhai/cloudhv_builder.rs b/packages/system/virt/src/rhai/cloudhv_builder.rs index 7bd4dde..b1feb15 100644 --- a/packages/system/virt/src/rhai/cloudhv_builder.rs +++ b/packages/system/virt/src/rhai/cloudhv_builder.rs @@ -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 = Vec::new(); + for it in args { + if it.is_string() { + v.push(it.clone().cast::()); + } + } + b.network_custom_cli(v); + b +} + fn builder_launch(mut b: CloudHvBuilder) -> Result> { 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