Compare commits
2 Commits
network_se
...
network_se
Author | SHA1 | Date | |
---|---|---|---|
|
0f4ed1d64d | ||
|
f4512b66cf |
@@ -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,108 @@ 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())?;
|
||||
// Derive IPv6 subnet for NAT
|
||||
let ipv6_subnet = ipv6_bridge_cidr.as_ref().map(|cidr| {
|
||||
let parts: Vec<&str> = cidr.split('/').collect();
|
||||
if parts.len() == 2 {
|
||||
let addr = parts[0];
|
||||
if let Ok(ip) = addr.parse::<std::net::Ipv6Addr>() {
|
||||
let seg = ip.segments();
|
||||
let pfx = std::net::Ipv6Addr::new(seg[0], seg[1], seg[2], seg[3], 0, 0, 0, 0);
|
||||
format!("{}/64", pfx)
|
||||
} else {
|
||||
"".to_string()
|
||||
}
|
||||
} else {
|
||||
"".to_string()
|
||||
}
|
||||
});
|
||||
net::ensure_nat(&nat.subnet_cidr, ipv6_subnet.as_deref())?;
|
||||
let lease_used = net::ensure_dnsmasq(
|
||||
&nat.bridge_name,
|
||||
&nat.dhcp_start,
|
||||
&nat.dhcp_end,
|
||||
ipv6_bridge_cidr.as_deref(),
|
||||
nat.lease_file.as_deref(),
|
||||
)?;
|
||||
|
||||
bridge_for_disc = Some(nat.bridge_name.clone());
|
||||
lease_for_disc = Some(lease_used.clone());
|
||||
|
||||
// TAP + NIC args
|
||||
let tap_name = net::ensure_tap_for_vm(&nat.bridge_name, id)?;
|
||||
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 +581,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(())
|
||||
}
|
||||
|
376
packages/system/virt/src/cloudhv/net/mod.rs
Normal file
376
packages/system/virt/src/cloudhv/net/mod.rs
Normal file
@@ -0,0 +1,376 @@
|
||||
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.
|
||||
/// If ipv6_subnet is provided, also sets up IPv6 NAT.
|
||||
pub fn ensure_nat(subnet_cidr: &str, ipv6_subnet: Option<&str>) -> Result<(), CloudHvError> {
|
||||
for bin in ["ip", "nft"] {
|
||||
if sal_process::which(bin).is_none() {
|
||||
return Err(CloudHvError::DependencyMissing(format!(
|
||||
"{} not found on PATH",
|
||||
bin
|
||||
)));
|
||||
}
|
||||
}
|
||||
let v6_subnet = ipv6_subnet.unwrap_or("");
|
||||
let body = format!(
|
||||
"set -e
|
||||
SUBNET={subnet}
|
||||
IPV6_SUBNET={v6subnet}
|
||||
|
||||
WAN_IF=$(ip -o route show default | awk '{{print $5}}' | head -n1)
|
||||
if [ -z \"$WAN_IF\" ]; then
|
||||
echo \"No default WAN interface detected (required for NAT)\" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# IPv4 NAT
|
||||
nft list table ip hero >/dev/null 2>&1 || nft add table ip hero
|
||||
nft list chain ip hero postrouting >/dev/null 2>&1 || nft add chain ip hero postrouting {{ type nat hook postrouting priority 100 \\; }}
|
||||
nft list chain ip hero postrouting | grep -q \"ip saddr $SUBNET oifname \\\"$WAN_IF\\\" masquerade\" \
|
||||
|| nft add rule ip hero postrouting ip saddr $SUBNET oifname \"$WAN_IF\" masquerade
|
||||
|
||||
# IPv6 NAT (if subnet provided)
|
||||
if [ -n \"$IPV6_SUBNET\" ]; then
|
||||
nft list table ip6 hero >/dev/null 2>&1 || nft add table ip6 hero
|
||||
nft list chain ip6 hero postrouting >/dev/null 2>&1 || nft add chain ip6 hero postrouting {{ type nat hook postrouting priority 100 \\; }}
|
||||
nft list chain ip6 hero postrouting | grep -q \"ip6 saddr $IPV6_SUBNET oifname \\\"$WAN_IF\\\" masquerade\" \
|
||||
|| nft add rule ip6 hero postrouting ip6 saddr $IPV6_SUBNET oifname \"$WAN_IF\" masquerade
|
||||
fi
|
||||
",
|
||||
subnet = shell_escape(subnet_cidr),
|
||||
v6subnet = shell_escape(v6_subnet),
|
||||
);
|
||||
run_heredoc("HERONAT", &body)
|
||||
}
|
||||
|
||||
/// Ensure dnsmasq DHCP is configured for the bridge. Returns the lease file path used.
|
||||
/// This function is idempotent; it writes a deterministic conf and reloads/enables dnsmasq.
|
||||
pub fn ensure_dnsmasq(
|
||||
bridge_name: &str,
|
||||
dhcp_start: &str,
|
||||
dhcp_end: &str,
|
||||
ipv6_bridge_cidr: Option<&str>,
|
||||
lease_file_override: Option<&str>,
|
||||
) -> Result<String, CloudHvError> {
|
||||
for bin in ["dnsmasq", "systemctl"] {
|
||||
if sal_process::which(bin).is_none() {
|
||||
return Err(CloudHvError::DependencyMissing(format!(
|
||||
"{} not found on PATH",
|
||||
bin
|
||||
)));
|
||||
}
|
||||
}
|
||||
let lease_file = lease_file_override
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| format!("/var/lib/misc/dnsmasq-hero-{}.leases", bridge_name));
|
||||
let v6 = ipv6_bridge_cidr.unwrap_or("");
|
||||
let body = format!(
|
||||
"set -e
|
||||
BR={br}
|
||||
DHCP_START={dstart}
|
||||
DHCP_END={dend}
|
||||
LEASE_FILE={lease}
|
||||
IPV6_CIDR={v6cidr}
|
||||
|
||||
mkdir -p /etc/dnsmasq.d
|
||||
mkdir -p /var/lib/misc
|
||||
|
||||
CFG=/etc/dnsmasq.d/hero-$BR.conf
|
||||
TMP=/etc/dnsmasq.d/.hero-$BR.conf.new
|
||||
|
||||
# Ensure main conf includes our conf-dir
|
||||
CONF=/etc/dnsmasq.conf
|
||||
RELOAD=0
|
||||
if ! grep -qF \"conf-dir=/etc/dnsmasq.d\" \"$CONF\" 2>/dev/null; then
|
||||
printf '%s\\n' 'conf-dir=/etc/dnsmasq.d,*.conf' >> \"$CONF\"
|
||||
RELOAD=1
|
||||
fi
|
||||
|
||||
# Ensure lease file and ownership (best effort)
|
||||
touch \"$LEASE_FILE\" || true
|
||||
chown dnsmasq:dnsmasq \"$LEASE_FILE\" 2>/dev/null || true
|
||||
|
||||
# IPv4 section
|
||||
printf '%s\\n' \
|
||||
\"interface=$BR\" \
|
||||
\"bind-interfaces\" \
|
||||
\"dhcp-authoritative\" \
|
||||
\"dhcp-range=$DHCP_START,$DHCP_END,12h\" \
|
||||
\"dhcp-option=option:dns-server,1.1.1.1,8.8.8.8\" \
|
||||
\"dhcp-leasefile=$LEASE_FILE\" >\"$TMP\"
|
||||
|
||||
# Optional IPv6 RA/DHCPv6
|
||||
if [ -n \"$IPV6_CIDR\" ]; then
|
||||
BRIDGE_ADDR=\"${{IPV6_CIDR%/*}}\"
|
||||
BRIDGE_PREFIX=$(echo \"$IPV6_CIDR\" | cut -d: -f1-4)::
|
||||
printf '%s\\n' \
|
||||
\"enable-ra\" \
|
||||
\"dhcp-range=$BRIDGE_PREFIX,ra-names,12h\" \
|
||||
\"dhcp-option=option6:dns-server,[2001:4860:4860::8888]\" \
|
||||
\"dhcp-option=option6:route,3600,400::/7,[$BRIDGE_ADDR]\" >>\"$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)]
|
||||
@@ -172,12 +175,13 @@ pub fn image_prepare(opts: &ImagePrepOptions) -> Result<ImagePrepResult, ImagePr
|
||||
// Build bash script that performs all steps and echos "RAW|ROOT_UUID|BOOT_UUID" at end
|
||||
let disable_ci_net = opts.disable_cloud_init_net;
|
||||
|
||||
// IPv6 static guest assignment (derive from mycelium interface) - enabled by default
|
||||
// If HERO_VIRT_IPV6_STATIC_GUEST=false, keep dynamic behavior (SLAAC/DHCPv6).
|
||||
// IPv6 static guest assignment (derive from mycelium interface) - disabled by default to use RA
|
||||
// If HERO_VIRT_IPV6_STATIC_GUEST=true, use static IPv6; else use RA/SLAAC.
|
||||
let static_v6 = std::env::var("HERO_VIRT_IPV6_STATIC_GUEST")
|
||||
.map(|v| matches!(v.to_lowercase().as_str(), "" | "1" | "true" | "yes"))
|
||||
.unwrap_or(true);
|
||||
let myc_if = std::env::var("HERO_VIRT_MYCELIUM_IF").unwrap_or_else(|_| "mycelium".into());
|
||||
.unwrap_or(false);
|
||||
let myc_if =
|
||||
std::env::var("HERO_VIRT_MYCELIUM_IF").unwrap_or_else(|_| "mycelium".into());
|
||||
|
||||
// Discover host mycelium global IPv6 in 400::/7 from the interface
|
||||
let mut host_v6: Option<Ipv6Addr> = None;
|
||||
@@ -189,7 +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 {
|
||||
@@ -206,6 +211,7 @@ pub fn image_prepare(opts: &ImagePrepOptions) -> Result<ImagePrepResult, ImagePr
|
||||
|
||||
// Derive per-host /64 from mycelium and deterministic per-VM guest address
|
||||
let mut np_v6_block = String::new();
|
||||
let mut accept_ra = String::new();
|
||||
let mut dhcp6_effective = opts.net.dhcp6;
|
||||
if static_v6 {
|
||||
if let Some(h) = host_v6 {
|
||||
@@ -217,24 +223,30 @@ pub fn image_prepare(opts: &ImagePrepOptions) -> Result<ImagePrepResult, ImagePr
|
||||
if suffix == 0 || suffix == 2 {
|
||||
suffix = 0x100;
|
||||
}
|
||||
let guest_ip = Ipv6Addr::new(seg[0], seg[1], seg[2], seg[3], 0, 0, 0, suffix).to_string();
|
||||
let gw_ip = Ipv6Addr::new(seg[0], seg[1], seg[2], seg[3], 0, 0, 0, 2).to_string();
|
||||
let guest_ip =
|
||||
Ipv6Addr::new(seg[0], seg[1], seg[2], seg[3], 0, 0, 0, suffix).to_string();
|
||||
let gw_ip =
|
||||
Ipv6Addr::new(seg[0], seg[1], seg[2], seg[3], 0, 0, 0, 2).to_string();
|
||||
|
||||
// Inject a YAML block for static v6
|
||||
np_v6_block = format!(
|
||||
" addresses:\n - {}/64\n routes:\n - to: \"::/0\"\n via: {}\n",
|
||||
guest_ip, gw_ip
|
||||
" addresses:\n - {}/64\n routes:\n - to: \"::/0\"\n via: {}\n - to: \"400::/7\"\n via: {}\n",
|
||||
guest_ip, gw_ip, gw_ip
|
||||
);
|
||||
// Disable dhcp6 when we provide a static address
|
||||
dhcp6_effective = false;
|
||||
}
|
||||
} else {
|
||||
// Use RA for IPv6
|
||||
accept_ra = "\n accept-ra: true".to_string();
|
||||
dhcp6_effective = false;
|
||||
}
|
||||
|
||||
// Keep script small and robust; avoid brace-heavy awk to simplify escaping.
|
||||
// Compute stable MAC (must match what vm_start() uses) and use it to match NIC in netplan.
|
||||
let vm_mac = stable_mac_from_id(&opts.id);
|
||||
let script = format!(
|
||||
"#!/bin/bash -e
|
||||
r#"#!/bin/bash -e
|
||||
set -euo pipefail
|
||||
|
||||
SRC={src}
|
||||
@@ -244,146 +256,146 @@ MNT_ROOT={mnt_root}
|
||||
MNT_BOOT={mnt_boot}
|
||||
RAW={raw}
|
||||
|
||||
mkdir -p \"$VM_DIR\"
|
||||
mkdir -p \"$(dirname \"$MNT_ROOT\")\"
|
||||
mkdir -p \"$MNT_ROOT\" \"$MNT_BOOT\"
|
||||
mkdir -p "$VM_DIR"
|
||||
mkdir -p "$(dirname "$MNT_ROOT")"
|
||||
mkdir -p "$MNT_ROOT" "$MNT_BOOT"
|
||||
|
||||
# Make per-VM working copy (reflink if supported)
|
||||
cp --reflink=auto -f \"$SRC\" \"$WORK\"
|
||||
cp --reflink=auto -f "$SRC" "$WORK"
|
||||
|
||||
# Load NBD with sufficient partitions
|
||||
modprobe nbd max_part=63
|
||||
|
||||
# Pick a free /dev/nbdX and connect the qcow2
|
||||
NBD=\"\"
|
||||
NBD=""
|
||||
for i in $(seq 0 15); do
|
||||
DEV=\"/dev/nbd$i\"
|
||||
DEV="/dev/nbd$i"
|
||||
# Skip devices that have any mounted partitions (avoid reusing in-use NBDs)
|
||||
if findmnt -rn -S \"$DEV\" >/dev/null 2>&1 || \
|
||||
findmnt -rn -S \"${{DEV}}p1\" >/dev/null 2>&1 || \
|
||||
findmnt -rn -S \"${{DEV}}p14\" >/dev/null 2>&1 || \
|
||||
findmnt -rn -S \"${{DEV}}p15\" >/dev/null 2>&1 || \
|
||||
findmnt -rn -S \"${{DEV}}p16\" >/dev/null 2>&1; then
|
||||
if findmnt -rn -S "$DEV" >/dev/null 2>&1 || \
|
||||
findmnt -rn -S "${{DEV}}p1" >/dev/null 2>&1 || \
|
||||
findmnt -rn -S "${{DEV}}p14" >/dev/null 2>&1 || \
|
||||
findmnt -rn -S "${{DEV}}p15" >/dev/null 2>&1 || \
|
||||
findmnt -rn -S "${{DEV}}p16" >/dev/null 2>&1; then
|
||||
continue
|
||||
fi
|
||||
# Ensure it's not connected (ignore errors if already disconnected)
|
||||
qemu-nbd --disconnect \"$DEV\" >/dev/null 2>&1 || true
|
||||
if qemu-nbd --format=qcow2 --connect=\"$DEV\" \"$WORK\"; then
|
||||
NBD=\"$DEV\"
|
||||
qemu-nbd --disconnect "$DEV" >/dev/null 2>&1 || true
|
||||
if qemu-nbd --format=qcow2 --connect="$DEV" "$WORK"; then
|
||||
NBD="$DEV"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z \"$NBD\" ]; then
|
||||
echo \"No free /dev/nbdX device available\" >&2
|
||||
if [ -z "$NBD" ]; then
|
||||
echo "No free /dev/nbdX device available" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo \"Selected NBD: $NBD\" >&2
|
||||
echo "Selected NBD: $NBD" >&2
|
||||
|
||||
# Settle and probe partitions
|
||||
udevadm settle >/dev/null 2>&1 || true
|
||||
blockdev --rereadpt \"$NBD\" >/dev/null 2>&1 || true
|
||||
partprobe \"$NBD\" >/dev/null 2>&1 || true
|
||||
blockdev --rereadpt "$NBD" >/dev/null 2>&1 || true
|
||||
partprobe "$NBD" >/dev/null 2>&1 || true
|
||||
for t in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
|
||||
if [ -b \"${{NBD}}p1\" ]; then
|
||||
sz=$(blockdev --getsize64 \"${{NBD}}p1\" 2>/dev/null || echo 0)
|
||||
if [ \"$sz\" -gt 0 ]; then
|
||||
if [ -b "${{NBD}}p1" ]; then
|
||||
sz=$(blockdev --getsize64 "${{NBD}}p1" 2>/dev/null || echo 0)
|
||||
if [ "$sz" -gt 0 ]; then
|
||||
break
|
||||
fi
|
||||
fi
|
||||
sleep 0.4
|
||||
udevadm settle >/dev/null 2>&1 || true
|
||||
blockdev --rereadpt \"$NBD\" >/dev/null 2>&1 || true
|
||||
partprobe \"$NBD\" >/dev/null 2>&1 || true
|
||||
blockdev --rereadpt "$NBD" >/dev/null 2>&1 || true
|
||||
partprobe "$NBD" >/dev/null 2>&1 || true
|
||||
done
|
||||
|
||||
ROOT_DEV=\"${{NBD}}p1\"
|
||||
ROOT_DEV="${{NBD}}p1"
|
||||
# Prefer p16, else p15
|
||||
if [ -b \"${{NBD}}p16\" ]; then
|
||||
BOOT_DEV=\"${{NBD}}p16\"
|
||||
elif [ -b \"${{NBD}}p15\" ]; then
|
||||
BOOT_DEV=\"${{NBD}}p15\"
|
||||
if [ -b "${{NBD}}p16" ]; then
|
||||
BOOT_DEV="${{NBD}}p16"
|
||||
elif [ -b "${{NBD}}p15" ]; then
|
||||
BOOT_DEV="${{NBD}}p15"
|
||||
else
|
||||
echo \"Boot partition not found on $NBD (tried p16 and p15)\" >&2
|
||||
echo "Boot partition not found on $NBD (tried p16 and p15)" >&2
|
||||
exit 33
|
||||
fi
|
||||
|
||||
echo \"ROOT_DEV=$ROOT_DEV BOOT_DEV=$BOOT_DEV\" >&2
|
||||
echo "ROOT_DEV=$ROOT_DEV BOOT_DEV=$BOOT_DEV" >&2
|
||||
|
||||
if [ ! -b \"$ROOT_DEV\" ]; then
|
||||
echo \"Root partition not found: $ROOT_DEV\" >&2
|
||||
if [ ! -b "$ROOT_DEV" ]; then
|
||||
echo "Root partition not found: $ROOT_DEV" >&2
|
||||
exit 32
|
||||
fi
|
||||
|
||||
cleanup() {{
|
||||
set +e
|
||||
umount \"$MNT_BOOT\" 2>/dev/null || true
|
||||
umount \"$MNT_ROOT\" 2>/dev/null || true
|
||||
[ -n \"$NBD\" ] && qemu-nbd --disconnect \"$NBD\" 2>/dev/null || true
|
||||
umount "$MNT_BOOT" 2>/dev/null || true
|
||||
umount "$MNT_ROOT" 2>/dev/null || true
|
||||
[ -n "$NBD" ] && qemu-nbd --disconnect "$NBD" 2>/dev/null || true
|
||||
rmmod nbd 2>/dev/null || true
|
||||
}}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Ensure partitions are readable before mounting
|
||||
for t in 1 2 3 4 5 6 7 8; do
|
||||
szr=$(blockdev --getsize64 \"$ROOT_DEV\" 2>/dev/null || echo 0)
|
||||
szb=$(blockdev --getsize64 \"$BOOT_DEV\" 2>/dev/null || echo 0)
|
||||
if [ \"$szr\" -gt 0 ] && [ \"$szb\" -gt 0 ] && blkid \"$ROOT_DEV\" >/dev/null 2>&1; then
|
||||
szr=$(blockdev --getsize64 "$ROOT_DEV" 2>/dev/null || echo 0)
|
||||
szb=$(blockdev --getsize64 "$BOOT_DEV" 2>/dev/null || echo 0)
|
||||
if [ "$szr" -gt 0 ] && [ "$szb" -gt 0 ] && blkid "$ROOT_DEV" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 0.4
|
||||
udevadm settle >/dev/null 2>&1 || true
|
||||
blockdev --rereadpt \"$NBD\" >/dev/null 2>&1 || true
|
||||
partprobe \"$NBD\" >/dev/null 2>&1 || true
|
||||
blockdev --rereadpt "$NBD" >/dev/null 2>&1 || true
|
||||
partprobe "$NBD" >/dev/null 2>&1 || true
|
||||
done
|
||||
|
||||
# Mount and mutate (with retries to avoid races)
|
||||
mounted_root=0
|
||||
for t in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if mount \"$ROOT_DEV\" \"$MNT_ROOT\"; then
|
||||
if mount "$ROOT_DEV" "$MNT_ROOT"; then
|
||||
mounted_root=1
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
udevadm settle >/dev/null 2>&1 || true
|
||||
partprobe \"$NBD\" >/dev/null 2>&1 || true
|
||||
partprobe "$NBD" >/dev/null 2>&1 || true
|
||||
done
|
||||
if [ \"$mounted_root\" -ne 1 ]; then
|
||||
echo \"Failed to mount root $ROOT_DEV\" >&2
|
||||
if [ "$mounted_root" -ne 1 ]; then
|
||||
echo "Failed to mount root $ROOT_DEV" >&2
|
||||
exit 32
|
||||
fi
|
||||
|
||||
mounted_boot=0
|
||||
for t in 1 2 3 4 5; do
|
||||
if mount \"$BOOT_DEV\" \"$MNT_BOOT\"; then
|
||||
if mount "$BOOT_DEV" "$MNT_BOOT"; then
|
||||
mounted_boot=1
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
udevadm settle >/dev/null 2>&1 || true
|
||||
partprobe \"$NBD\" >/dev/null 2>&1 || true
|
||||
partprobe "$NBD" >/dev/null 2>&1 || true
|
||||
done
|
||||
if [ \"$mounted_boot\" -ne 1 ]; then
|
||||
echo \"Failed to mount boot $BOOT_DEV\" >&2
|
||||
if [ "$mounted_boot" -ne 1 ]; then
|
||||
echo "Failed to mount boot "$BOOT_DEV"" >&2
|
||||
exit 33
|
||||
fi
|
||||
|
||||
# Change UUIDs (best-effort)
|
||||
tune2fs -U random \"$ROOT_DEV\" || true
|
||||
tune2fs -U random \"$BOOT_DEV\" || true
|
||||
tune2fs -U random "$ROOT_DEV" || true
|
||||
tune2fs -U random "$BOOT_DEV" || true
|
||||
|
||||
ROOT_UUID=$(blkid -o value -s UUID \"$ROOT_DEV\")
|
||||
BOOT_UUID=$(blkid -o value -s UUID \"$BOOT_DEV\")
|
||||
ROOT_UUID=$(blkid -o value -s UUID "$ROOT_DEV")
|
||||
BOOT_UUID=$(blkid -o value -s UUID "$BOOT_DEV")
|
||||
|
||||
# Update fstab
|
||||
sed -i \"s/UUID=[a-f0-9-]* \\/ /UUID=$ROOT_UUID \\/ /\" \"$MNT_ROOT/etc/fstab\"
|
||||
sed -i \"s/UUID=[a-f0-9-]* \\/boot /UUID=$BOOT_UUID \\/boot /\" \"$MNT_ROOT/etc/fstab\"
|
||||
sed -i "s/UUID=[a-f0-9-]* \\/ /UUID=$ROOT_UUID \\/ /" "$MNT_ROOT/etc/fstab"
|
||||
sed -i "s/UUID=[a-f0-9-]* \\/boot /UUID=$BOOT_UUID \\/boot /" "$MNT_ROOT/etc/fstab"
|
||||
|
||||
# Minimal grub.cfg (note: braces escaped for Rust format!)
|
||||
mkdir -p \"$MNT_BOOT/grub\"
|
||||
KERNEL=$(ls -1 \"$MNT_BOOT\"/vmlinuz-* | sort -V | tail -n1 | xargs -n1 basename)
|
||||
INITRD=$(ls -1 \"$MNT_BOOT\"/initrd.img-* | sort -V | tail -n1 | xargs -n1 basename)
|
||||
cat > \"$MNT_BOOT/grub/grub.cfg\" << EOF
|
||||
mkdir -p "$MNT_BOOT/grub"
|
||||
KERNEL=$(ls -1 "$MNT_BOOT"/vmlinuz-* | sort -V | tail -n1 | xargs -n1 basename)
|
||||
INITRD=$(ls -1 "$MNT_BOOT"/initrd.img-* | sort -V | tail -n1 | xargs -n1 basename)
|
||||
cat > "$MNT_BOOT/grub/grub.cfg" << EOF
|
||||
set default=0
|
||||
set timeout=3
|
||||
menuentry 'Ubuntu Cloud' {{
|
||||
@@ -397,98 +409,314 @@ menuentry 'Ubuntu Cloud' {{
|
||||
EOF
|
||||
|
||||
# Netplan config
|
||||
rm -f \"$MNT_ROOT\"/etc/netplan/*.yaml
|
||||
mkdir -p \"$MNT_ROOT\"/etc/netplan
|
||||
cat > \"$MNT_ROOT/etc/netplan/01-netconfig.yaml\" << EOF
|
||||
rm -f "$MNT_ROOT"/etc/netplan/*.yaml
|
||||
mkdir -p "$MNT_ROOT"/etc/netplan
|
||||
cat > "$MNT_ROOT/etc/netplan/01-netconfig.yaml" << EOF
|
||||
network:
|
||||
version: 2
|
||||
renderer: networkd
|
||||
ethernets:
|
||||
eth0:
|
||||
match:
|
||||
macaddress: {vm_mac}
|
||||
set-name: eth0
|
||||
dhcp4: {dhcp4}
|
||||
dhcp6: {dhcp6}
|
||||
{np_v6_block} nameservers:
|
||||
dhcp6: {dhcp6}{accept_ra}{np_v6_block}
|
||||
nameservers:
|
||||
addresses: [8.8.8.8, 1.1.1.1, 2001:4860:4860::8888]
|
||||
EOF
|
||||
# Enable SSH password authentication and set a default password for 'ubuntu'
|
||||
mkdir -p \"$MNT_ROOT/etc/cloud/cloud.cfg.d\"
|
||||
printf '%s\n' 'ssh_pwauth: true' > \"$MNT_ROOT/etc/cloud/cloud.cfg.d/99-ssh-password-auth.cfg\"
|
||||
mkdir -p "$MNT_ROOT/etc/cloud/cloud.cfg.d"
|
||||
printf '%s\n' 'ssh_pwauth: true' > "$MNT_ROOT/etc/cloud/cloud.cfg.d/99-ssh-password-auth.cfg"
|
||||
|
||||
mkdir -p \"$MNT_ROOT/etc/ssh/sshd_config.d\"
|
||||
cat > \"$MNT_ROOT/etc/ssh/sshd_config.d/99-hero-password-auth.conf\" << EOF
|
||||
mkdir -p "$MNT_ROOT/etc/ssh/sshd_config.d"
|
||||
cat > "$MNT_ROOT/etc/ssh/sshd_config.d/99-hero-password-auth.conf" << EOF
|
||||
# Hero test: force password auth, explicitly disable pubkey to avoid client auto-trying keys
|
||||
PasswordAuthentication yes
|
||||
KbdInteractiveAuthentication yes
|
||||
UsePAM yes
|
||||
PubkeyAuthentication no
|
||||
EOF
|
||||
|
||||
# Remove any AuthenticationMethods directives that might force publickey-only
|
||||
if [ -f "$MNT_ROOT/etc/ssh/sshd_config" ]; then
|
||||
sed -i -E 's/^[[:space:]]*AuthenticationMethods[[:space:]].*$/# hero: removed AuthenticationMethods/' "$MNT_ROOT/etc/ssh/sshd_config" 2>/dev/null || true
|
||||
fi
|
||||
if [ -d "$MNT_ROOT/etc/ssh/sshd_config.d" ]; then
|
||||
find "$MNT_ROOT/etc/ssh/sshd_config.d" -type f -name '*.conf' -exec sed -i -E 's/^[[:space:]]*AuthenticationMethods[[:space:]].*$/# hero: removed AuthenticationMethods/' {} + 2>/dev/null \; || true
|
||||
fi
|
||||
|
||||
# Set password for default user 'ubuntu'
|
||||
if chroot \"$MNT_ROOT\" getent passwd ubuntu >/dev/null 2>&1; then
|
||||
chroot \"$MNT_ROOT\" bash -c \"echo 'ubuntu:ubuntu' | chpasswd\" || true
|
||||
if chroot "$MNT_ROOT" getent passwd ubuntu >/dev/null 2>&1; then
|
||||
echo 'ubuntu:ubuntu' | chroot "$MNT_ROOT" /usr/sbin/chpasswd || true
|
||||
fi
|
||||
# Ensure openssh-server is present (some cloud images may omit it)
|
||||
# Ensure SSH service enabled and keys generated on boot
|
||||
chroot \"$MNT_ROOT\" systemctl unmask ssh 2>/dev/null || true
|
||||
chroot \"$MNT_ROOT\" systemctl enable ssh 2>/dev/null || true
|
||||
chroot \"$MNT_ROOT\" systemctl enable ssh-keygen.service 2>/dev/null || true
|
||||
chroot "$MNT_ROOT" systemctl unmask ssh 2>/dev/null || true
|
||||
chroot "$MNT_ROOT" systemctl enable ssh 2>/dev/null || true
|
||||
chroot "$MNT_ROOT" systemctl enable ssh-keygen.service 2>/dev/null || true
|
||||
|
||||
# Ensure sshd listens on both IPv4 and IPv6 explicitly
|
||||
cat > \"$MNT_ROOT/etc/ssh/sshd_config.d/99-hero-address-family.conf\" << EOF
|
||||
cat > "$MNT_ROOT/etc/ssh/sshd_config.d/99-hero-address-family.conf" << EOF
|
||||
AddressFamily any
|
||||
ListenAddress ::
|
||||
ListenAddress 0.0.0.0
|
||||
EOF
|
||||
|
||||
# If UFW is present, allow SSH and disable firewall (for tests)
|
||||
if chroot \"$MNT_ROOT\" command -v ufw >/dev/null 2>&1; then
|
||||
chroot \"$MNT_ROOT\" ufw allow OpenSSH || true
|
||||
chroot \"$MNT_ROOT\" ufw disable || true
|
||||
# Ensure sshd waits for network to be online (helps IPv6 readiness)
|
||||
mkdir -p "$MNT_ROOT/etc/systemd/system/ssh.service.d"
|
||||
cat > "$MNT_ROOT/etc/systemd/system/ssh.service.d/override.conf" << 'EOF'
|
||||
[Unit]
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
EOF
|
||||
|
||||
# Ensure sshd_config includes conf.d include so our drop-ins are loaded
|
||||
if ! grep -qE '^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config\.d/\*\.conf' "$MNT_ROOT/etc/ssh/sshd_config"; then
|
||||
echo 'Include /etc/ssh/sshd_config.d/*.conf' >> "$MNT_ROOT/etc/ssh/sshd_config"
|
||||
fi
|
||||
if ! chroot \"$MNT_ROOT\" test -x /usr/sbin/sshd; then
|
||||
cp -f /etc/resolv.conf \"$MNT_ROOT/etc/resolv.conf\" 2>/dev/null || true
|
||||
chroot \"$MNT_ROOT\" bash -c \"apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends openssh-server\" || true
|
||||
|
||||
# Ensure required packages present before user/password changes
|
||||
cp -f /etc/resolv.conf "$MNT_ROOT/etc/resolv.conf" 2>/dev/null || true
|
||||
chroot "$MNT_ROOT" bash -c "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends passwd openssh-server" || true
|
||||
|
||||
# Remove previously forced AuthenticationMethods drop-in (old)
|
||||
rm -f "$MNT_ROOT/etc/ssh/sshd_config.d/99-hero-authmethods.conf"
|
||||
|
||||
# Force explicit password-only auth to avoid publickey-only negotiation from server
|
||||
# Removed AuthenticationMethods to avoid config issues
|
||||
|
||||
# Ensure our overrides are last-wins even if main sshd_config sets different values after Include
|
||||
cat >> "$MNT_ROOT/etc/ssh/sshd_config" << 'EOF'
|
||||
# hero override (appended last)
|
||||
PasswordAuthentication yes
|
||||
KbdInteractiveAuthentication yes
|
||||
UsePAM yes
|
||||
PubkeyAuthentication no
|
||||
EOF
|
||||
|
||||
# If UFW is present, allow SSH and disable firewall (for tests)
|
||||
if chroot "$MNT_ROOT" command -v ufw >/dev/null 2>&1; then
|
||||
chroot "$MNT_ROOT" ufw allow OpenSSH || true
|
||||
chroot "$MNT_ROOT" ufw disable || true
|
||||
fi
|
||||
if ! chroot "$MNT_ROOT" test -x /usr/sbin/sshd; then
|
||||
cp -f /etc/resolv.conf "$MNT_ROOT/etc/resolv.conf" 2>/dev/null || true
|
||||
chroot "$MNT_ROOT" bash -c "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends openssh-server" || true
|
||||
fi
|
||||
# Ensure user management utilities are present (useradd, chpasswd)
|
||||
if ! chroot "$MNT_ROOT" command -v /usr/sbin/useradd >/dev/null 2>&1 || \
|
||||
! chroot "$MNT_ROOT" command -v /usr/sbin/chpasswd >/dev/null 2>&1; then
|
||||
cp -f /etc/resolv.conf "$MNT_ROOT/etc/resolv.conf" 2>/dev/null || true
|
||||
chroot "$MNT_ROOT" bash -c "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y passwd adduser" || true
|
||||
fi
|
||||
# Ensure user management utilities are present (useradd, chpasswd)
|
||||
if ! chroot "$MNT_ROOT" command -v /usr/sbin/useradd >/dev/null 2>&1 || \
|
||||
! chroot "$MNT_ROOT" command -v /usr/sbin/chpasswd >/dev/null 2>&1; then
|
||||
cp -f /etc/resolv.conf "$MNT_ROOT/etc/resolv.conf" 2>/dev/null || true
|
||||
chroot "$MNT_ROOT" bash -c "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y passwd adduser" || true
|
||||
fi
|
||||
|
||||
# Ensure user management utilities are present (useradd, chpasswd)
|
||||
if ! chroot "$MNT_ROOT" command -v /usr/sbin/useradd >/dev/null 2>&1 || \
|
||||
! chroot "$MNT_ROOT" command -v /usr/sbin/chpasswd >/dev/null 2>&1; then
|
||||
cp -f /etc/resolv.conf "$MNT_ROOT/etc/resolv.conf" 2>/dev/null || true
|
||||
chroot "$MNT_ROOT" bash -c "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y passwd adduser" || true
|
||||
fi
|
||||
|
||||
# Ensure shadow utilities present (useradd/chpasswd)
|
||||
if ! chroot "$MNT_ROOT" command -v /usr/sbin/useradd >/dev/null 2>&1 || \
|
||||
! chroot "$MNT_ROOT" command -v /usr/sbin/chpasswd >/dev/null 2>&1; then
|
||||
cp -f /etc/resolv.conf "$MNT_ROOT/etc/resolv.conf" 2>/dev/null || true
|
||||
chroot "$MNT_ROOT" bash -c "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y passwd" || true
|
||||
fi
|
||||
# Ensure default user 'ubuntu' exists (fallback for minimal images)
|
||||
if ! chroot \"$MNT_ROOT\" id -u ubuntu >/dev/null 2>&1; then
|
||||
chroot \"$MNT_ROOT\" useradd -m -s /bin/bash ubuntu || true
|
||||
echo \"ubuntu ALL=(ALL) NOPASSWD:ALL\" > \"$MNT_ROOT/etc/sudoers.d/90-ubuntu\" || true
|
||||
chmod 0440 \"$MNT_ROOT/etc/sudoers.d/90-ubuntu\" || true
|
||||
if ! chroot "$MNT_ROOT" id -u ubuntu >/dev/null 2>&1; then
|
||||
chroot "$MNT_ROOT" /usr/sbin/useradd -m -s /bin/bash ubuntu || true
|
||||
echo "ubuntu ALL=(ALL) NOPASSWD:ALL" > "$MNT_ROOT/etc/sudoers.d/90-ubuntu" || true
|
||||
chmod 0440 "$MNT_ROOT/etc/sudoers.d/90-ubuntu" || true
|
||||
fi
|
||||
|
||||
# Re-assert password (covers both existing and newly created users)
|
||||
if chroot \"$MNT_ROOT\" getent passwd ubuntu >/dev/null 2>&1; then
|
||||
chroot \"$MNT_ROOT\" bash -c \"echo 'ubuntu:ubuntu' | chpasswd\" || true
|
||||
if chroot "$MNT_ROOT" getent passwd ubuntu >/dev/null 2>&1; then
|
||||
echo 'ubuntu:ubuntu' | chroot "$MNT_ROOT" /usr/sbin/chpasswd || true
|
||||
fi
|
||||
# Ensure account is unlocked (some cloud images ship locked local users)
|
||||
chroot "$MNT_ROOT" /usr/bin/passwd -u ubuntu 2>/dev/null || true
|
||||
chroot "$MNT_ROOT" /usr/sbin/usermod -U ubuntu 2>/dev/null || true
|
||||
|
||||
# Robustly set ubuntu password offline; generate hash on host and set inside chroot
|
||||
UBUNTU_HASH="$(openssl passwd -6 'ubuntu' 2>/dev/null || python3 - <<'PY'
|
||||
import crypt
|
||||
print(crypt.crypt('ubuntu', crypt.mksalt(crypt.METHOD_SHA512)))
|
||||
PY
|
||||
)"
|
||||
if [ -n "$UBUNTU_HASH" ] && chroot "$MNT_ROOT" getent passwd ubuntu >/dev/null 2>&1; then
|
||||
printf 'ubuntu:%s\n' "$UBUNTU_HASH" | chroot "$MNT_ROOT" /usr/sbin/chpasswd -e || true
|
||||
# Ensure account is not expired/locked and has sane aging
|
||||
chroot "$MNT_ROOT" /usr/bin/chage -I -1 -m 0 -M 99999 -E -1 ubuntu 2>/dev/null || true
|
||||
chroot "$MNT_ROOT" /usr/bin/passwd -u ubuntu 2>/dev/null || true
|
||||
chroot "$MNT_ROOT" /usr/sbin/usermod -U ubuntu 2>/dev/null || true
|
||||
# Debug: show status and shadow entry (for test logs)
|
||||
chroot "$MNT_ROOT" /usr/bin/passwd -S ubuntu 2>/dev/null || true
|
||||
chroot "$MNT_ROOT" bash -c "grep '^ubuntu:' /etc/shadow || true" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Also set root password and allow root login for test debugging
|
||||
if chroot "$MNT_ROOT" getent passwd root >/dev/null 2>&1; then
|
||||
echo 'root:root' | chroot "$MNT_ROOT" /usr/sbin/chpasswd || true
|
||||
chroot "$MNT_ROOT" /usr/bin/passwd -u root 2>/dev/null || true
|
||||
chroot "$MNT_ROOT" /usr/bin/chage -I -1 -m 0 -M 99999 -E -1 root 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Pre-generate host SSH keys so sshd can start immediately
|
||||
chroot \"$MNT_ROOT\" ssh-keygen -A 2>/dev/null || true
|
||||
mkdir -p \"$MNT_ROOT/var/run/sshd\"
|
||||
chroot "$MNT_ROOT" ssh-keygen -A 2>/dev/null || true
|
||||
mkdir -p "$MNT_ROOT/var/run/sshd"
|
||||
|
||||
# Also enable socket activation as a fallback
|
||||
chroot \"$MNT_ROOT\" systemctl enable ssh.socket 2>/dev/null || true
|
||||
# Ensure sshd runs as a regular service and not via socket (binds IPv4+IPv6)
|
||||
chroot "$MNT_ROOT" systemctl disable --now ssh.socket 2>/dev/null || true
|
||||
chroot "$MNT_ROOT" systemctl mask ssh.socket 2>/dev/null || true
|
||||
chroot "$MNT_ROOT" systemctl enable ssh.service 2>/dev/null || true
|
||||
chroot "$MNT_ROOT" systemctl restart ssh.service 2>/dev/null || true
|
||||
|
||||
# Disable cloud-init networking (optional but default)
|
||||
if [ \"{disable_ci_net}\" = \"true\" ]; then
|
||||
mkdir -p \"$MNT_ROOT/etc/cloud/cloud.cfg.d\"
|
||||
echo \"network: {{config: disabled}}\" > \"$MNT_ROOT/etc/cloud/cloud.cfg.d/99-disable-network-config.cfg\"
|
||||
if [ "{disable_ci_net}" = "true" ]; then
|
||||
mkdir -p "$MNT_ROOT/etc/cloud/cloud.cfg.d"
|
||||
echo "network: {{{{config: disabled}}}}" > "$MNT_ROOT/etc/cloud/cloud.cfg.d/99-disable-network-config.cfg"
|
||||
fi
|
||||
|
||||
# Fully disable cloud-init on first boot for deterministic tests
|
||||
mkdir -p "$MNT_ROOT/etc/cloud"
|
||||
: > "$MNT_ROOT/etc/cloud/cloud-init.disabled"
|
||||
|
||||
# Belt-and-braces: mask cloud-init services offline (no systemd required)
|
||||
mkdir -p "$MNT_ROOT/etc/systemd/system"
|
||||
for s in cloud-init.service cloud-config.service cloud-final.service cloud-init-local.service; do
|
||||
ln -sf /dev/null "$MNT_ROOT/etc/systemd/system/$s" || true
|
||||
done
|
||||
|
||||
|
||||
# First-boot fallback: ensure ubuntu:ubuntu credentials and SSH password auth
|
||||
mkdir -p "$MNT_ROOT/usr/local/sbin"
|
||||
cat > "$MNT_ROOT/usr/local/sbin/hero-ensure-ubuntu-cred.sh" << 'EOS'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Guarantee ubuntu user exists
|
||||
if ! id -u ubuntu >/dev/null 2>&1; then
|
||||
useradd -m -s /bin/bash ubuntu || true
|
||||
fi
|
||||
|
||||
# Ensure sudo without password
|
||||
mkdir -p /etc/sudoers.d
|
||||
echo "ubuntu ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/90-ubuntu
|
||||
chmod 0440 /etc/sudoers.d/90-ubuntu
|
||||
|
||||
# Set password 'ubuntu' (hashed)
|
||||
UBUNTU_HASH="$(openssl passwd -6 'ubuntu' 2>/dev/null || python3 - <<'PY'
|
||||
import crypt
|
||||
print(crypt.crypt('ubuntu', crypt.mksalt(crypt.METHOD_SHA512)))
|
||||
PY
|
||||
)"
|
||||
if [ -n "$UBUNTU_HASH" ]; then
|
||||
printf 'ubuntu:%s\n' "$UBUNTU_HASH" | chpasswd -e || true
|
||||
chage -I -1 -m 0 -M 99999 -E -1 ubuntu 2>/dev/null || true
|
||||
passwd -u ubuntu 2>/dev/null || true
|
||||
usermod -U ubuntu 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# SSHD password-auth settings
|
||||
mkdir -p /etc/ssh/sshd_config.d
|
||||
cat > /etc/ssh/sshd_config.d/99-hero-password-auth.conf << EOF
|
||||
PasswordAuthentication yes
|
||||
KbdInteractiveAuthentication yes
|
||||
UsePAM yes
|
||||
PubkeyAuthentication no
|
||||
EOF
|
||||
|
||||
cat > /etc/ssh/sshd_config.d/99-hero-address-family.conf << EOF
|
||||
AddressFamily any
|
||||
ListenAddress ::
|
||||
ListenAddress 0.0.0.0
|
||||
EOF
|
||||
|
||||
# Ensure sshd waits for network-online at first boot as well
|
||||
mkdir -p /etc/systemd/system/ssh.service.d
|
||||
cat > /etc/systemd/system/ssh.service.d/override.conf << 'EOF'
|
||||
[Unit]
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
EOF
|
||||
|
||||
# Remove any AuthenticationMethods directives from drop-ins that could conflict
|
||||
if [ -f /etc/ssh/sshd_config ]; then
|
||||
sed -i -E 's/^[[:space:]]*AuthenticationMethods[[:space:]].*$/# hero: removed AuthenticationMethods/' /etc/ssh/sshd_config 2>/dev/null || true
|
||||
fi
|
||||
if [ -d /etc/ssh/sshd_config.d ]; then
|
||||
find /etc/ssh/sshd_config.d -type f -name '*.conf' -exec sed -i -E 's/^[[:space:]]*AuthenticationMethods[[:space:]].*$/# hero: removed AuthenticationMethods/' {} + 2>/dev/null \; || true
|
||||
fi
|
||||
|
||||
# Ensure Include covers drop-ins
|
||||
grep -qE '^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config\.d/\*\.conf' /etc/ssh/sshd_config || \
|
||||
echo 'Include /etc/ssh/sshd_config.d/*.conf' >> /etc/ssh/sshd_config
|
||||
|
||||
# Ensure and restart SSHD
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl daemon-reload || true
|
||||
# Prefer running sshd as a service so it honors IPv6 ListenAddress from sshd_config
|
||||
systemctl disable --now ssh.socket 2>/dev/null || true
|
||||
systemctl mask ssh.socket 2>/dev/null || true
|
||||
systemctl enable --now ssh.service 2>/dev/null || true
|
||||
systemctl restart ssh.service 2>/dev/null || true
|
||||
# Apply netplan in case renderer did not start IPv6 yet
|
||||
command -v netplan >/dev/null 2>&1 && netplan apply 2>/dev/null || true
|
||||
else
|
||||
service ssh restart || true
|
||||
fi
|
||||
|
||||
# Mark completion to avoid reruns if unit has a condition
|
||||
mkdir -p /var/lib/hero
|
||||
: > /var/lib/hero/cred-ensured
|
||||
EOS
|
||||
chmod 0755 "$MNT_ROOT/usr/local/sbin/hero-ensure-ubuntu-cred.sh"
|
||||
|
||||
# Install systemd unit to run on first boot
|
||||
cat > "$MNT_ROOT/etc/systemd/system/hero-ensure-ubuntu-cred.service" << 'EOF'
|
||||
[Unit]
|
||||
Description=Hero: ensure ubuntu:ubuntu and SSH password auth
|
||||
After=local-fs.target
|
||||
Wants=local-fs.target
|
||||
ConditionPathExists=!/var/lib/hero/cred-ensured
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/hero-ensure-ubuntu-cred.sh
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# Enable via symlink and best-effort systemctl in chroot
|
||||
mkdir -p "$MNT_ROOT/etc/systemd/system/multi-user.target.wants"
|
||||
ln -sf "/etc/systemd/system/hero-ensure-ubuntu-cred.service" "$MNT_ROOT/etc/systemd/system/multi-user.target.wants/hero-ensure-ubuntu-cred.service" || true
|
||||
chroot "$MNT_ROOT" systemctl enable hero-ensure-ubuntu-cred.service 2>/dev/null || true
|
||||
|
||||
|
||||
# Convert prepared image to raw (ensure source not locked)
|
||||
umount \"$MNT_BOOT\" 2>/dev/null || true
|
||||
umount \"$MNT_ROOT\" 2>/dev/null || true
|
||||
if [ -n \"$NBD\" ]; then
|
||||
qemu-nbd --disconnect \"$NBD\" 2>/dev/null || true
|
||||
umount "$MNT_BOOT" 2>/dev/null || true
|
||||
umount "$MNT_ROOT" 2>/dev/null || true
|
||||
if [ -n "$NBD" ]; then
|
||||
qemu-nbd --disconnect "$NBD" 2>/dev/null || true
|
||||
rmmod nbd 2>/dev/null || true
|
||||
fi
|
||||
rm -f \"$RAW\"
|
||||
qemu-img convert -U -f qcow2 -O raw \"$WORK\" \"$RAW\"
|
||||
rm -f "$RAW"
|
||||
qemu-img convert -U -f qcow2 -O raw "$WORK" "$RAW"
|
||||
|
||||
# Output result triple ONLY on stdout, then prevent any further trap output
|
||||
echo \"RESULT:$RAW|$ROOT_UUID|$BOOT_UUID\"
|
||||
echo "RESULT:$RAW|$ROOT_UUID|$BOOT_UUID"
|
||||
trap - EXIT
|
||||
exit 0
|
||||
",
|
||||
"#,
|
||||
src = shell_escape(&src),
|
||||
vm_dir = shell_escape(&vm_dir),
|
||||
work = shell_escape(&work_qcow2),
|
||||
@@ -498,8 +726,9 @@ exit 0
|
||||
vm_mac = vm_mac,
|
||||
dhcp4 = if opts.net.dhcp4 { "true" } else { "false" },
|
||||
dhcp6 = if dhcp6_effective { "true" } else { "false" },
|
||||
accept_ra = accept_ra,
|
||||
np_v6_block = np_v6_block,
|
||||
disable_ci_net = if disable_ci_net { "true" } else { "false" },
|
||||
disable_ci_net = if disable_ci_net { "true" } else { "false" }
|
||||
);
|
||||
|
||||
// image prep script printout for debugging:
|
||||
@@ -553,7 +782,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 +797,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