wip
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
use crate::cloudhv::{vm_create, vm_start, CloudHvError, VmSpec};
|
use crate::cloudhv::{vm_create, vm_start, CloudHvError, VmSpec};
|
||||||
use crate::image_prep::{image_prepare, Flavor as ImgFlavor, ImagePrepOptions, NetPlanOpts};
|
use crate::image_prep::{image_prepare, Flavor as ImgFlavor, ImagePrepOptions, NetPlanOpts};
|
||||||
use sal_process;
|
use sal_process;
|
||||||
|
use crate::cloudhv::net::{NetworkingProfileSpec, DefaultNatOptions, BridgeOptions};
|
||||||
|
|
||||||
/// Cloud Hypervisor VM Builder focused on Rhai ergonomics.
|
/// Cloud Hypervisor VM Builder focused on Rhai ergonomics.
|
||||||
///
|
///
|
||||||
@@ -23,6 +24,8 @@ pub struct CloudHvBuilder {
|
|||||||
cmdline: Option<String>,
|
cmdline: Option<String>,
|
||||||
extra_args: Vec<String>,
|
extra_args: Vec<String>,
|
||||||
no_default_net: bool,
|
no_default_net: bool,
|
||||||
|
/// Optional networking profile driving host provisioning and NIC injection
|
||||||
|
net_profile: Option<NetworkingProfileSpec>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CloudHvBuilder {
|
impl CloudHvBuilder {
|
||||||
@@ -37,6 +40,7 @@ impl CloudHvBuilder {
|
|||||||
// Enforce --seccomp false by default using extra args
|
// Enforce --seccomp false by default using extra args
|
||||||
extra_args: vec!["--seccomp".into(), "false".into()],
|
extra_args: vec!["--seccomp".into(), "false".into()],
|
||||||
no_default_net: false,
|
no_default_net: false,
|
||||||
|
net_profile: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +102,40 @@ impl CloudHvBuilder {
|
|||||||
self
|
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
|
/// Resolve absolute path to hypervisor-fw from /images
|
||||||
fn resolve_hypervisor_fw() -> Result<String, CloudHvError> {
|
fn resolve_hypervisor_fw() -> Result<String, CloudHvError> {
|
||||||
let p = "/images/hypervisor-fw";
|
let p = "/images/hypervisor-fw";
|
||||||
@@ -161,6 +199,7 @@ impl CloudHvBuilder {
|
|||||||
} else {
|
} else {
|
||||||
Some(self.extra_args.clone())
|
Some(self.extra_args.clone())
|
||||||
},
|
},
|
||||||
|
net_profile: self.net_profile.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let id = vm_create(&spec)?;
|
let id = vm_create(&spec)?;
|
||||||
|
@@ -11,8 +11,10 @@ use std::hash::{Hash, Hasher};
|
|||||||
use sal_os;
|
use sal_os;
|
||||||
use sal_process;
|
use sal_process;
|
||||||
use crate::qcow2;
|
use crate::qcow2;
|
||||||
|
use crate::cloudhv::net::{NetworkingProfileSpec, DefaultNatOptions};
|
||||||
|
|
||||||
pub mod builder;
|
pub mod builder;
|
||||||
|
pub mod net;
|
||||||
|
|
||||||
/// Error type for Cloud Hypervisor operations
|
/// Error type for Cloud Hypervisor operations
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -61,6 +63,9 @@ pub struct VmSpec {
|
|||||||
pub cmdline: Option<String>,
|
pub cmdline: Option<String>,
|
||||||
/// Extra args (raw) if you need to extend; keep minimal for Phase 2
|
/// Extra args (raw) if you need to extend; keep minimal for Phase 2
|
||||||
pub extra_args: Option<Vec<String>>,
|
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)]
|
#[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"))
|
.map(|v| v.iter().any(|tok| tok == "--net" || tok == "--no-default-net"))
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
if !has_user_net {
|
// Track chosen bridge/lease for later discovery
|
||||||
// Networking prerequisites (bridge + NAT via nftables + dnsmasq DHCP)
|
let mut bridge_for_disc: Option<String> = None;
|
||||||
// Defaults can be overridden via env:
|
let mut lease_for_disc: Option<String> = None;
|
||||||
// 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());
|
// Determine effective networking profile
|
||||||
let bridge_addr_cidr =
|
let profile_effective = if let Some(p) = rec.spec.net_profile.clone() {
|
||||||
std::env::var("HERO_VIRT_BRIDGE_ADDR_CIDR").unwrap_or_else(|_| "172.30.0.1/24".into());
|
Some(p)
|
||||||
let subnet_cidr =
|
} else if has_user_net {
|
||||||
std::env::var("HERO_VIRT_SUBNET_CIDR").unwrap_or_else(|_| "172.30.0.0/24".into());
|
// User provided explicit --net or --no-default-net; do not provision
|
||||||
let dhcp_start =
|
None
|
||||||
std::env::var("HERO_VIRT_DHCP_START").unwrap_or_else(|_| "172.30.0.50".into());
|
} else {
|
||||||
let dhcp_end =
|
// Default behavior: NAT profile
|
||||||
std::env::var("HERO_VIRT_DHCP_END").unwrap_or_else(|_| "172.30.0.250".into());
|
Some(NetworkingProfileSpec::DefaultNat(DefaultNatOptions::default()))
|
||||||
|
};
|
||||||
// IPv6 over Mycelium: enabled by default.
|
|
||||||
// If explicitly disabled via HERO_VIRT_IPV6_ENABLE=false|0, we skip.
|
if let Some(profile) = profile_effective {
|
||||||
// If enabled but Mycelium is not detected, return an error.
|
match profile {
|
||||||
let ipv6_env = std::env::var("HERO_VIRT_IPV6_ENABLE").unwrap_or_else(|_| "".into());
|
NetworkingProfileSpec::DefaultNat(mut nat) => {
|
||||||
let ipv6_requested = match ipv6_env.to_lowercase().as_str() {
|
// IPv6 handling (auto via Mycelium unless disabled)
|
||||||
"" | "1" | "true" | "yes" => true,
|
let mut ipv6_bridge_cidr: Option<String> = None;
|
||||||
"0" | "false" | "no" => false,
|
if nat.ipv6_enable {
|
||||||
_ => true,
|
if let Ok(cidr) = std::env::var("HERO_VIRT_IPV6_BRIDGE_CIDR") {
|
||||||
};
|
// Validate mycelium iface presence if specified or default
|
||||||
let mycelium_if_cfg = std::env::var("HERO_VIRT_MYCELIUM_IF").unwrap_or_else(|_| "mycelium".into());
|
let if_hint = nat.mycelium_if.clone().unwrap_or_else(|| "mycelium".into());
|
||||||
let mut ipv6_bridge_cidr: Option<String> = None;
|
let _ = net::mycelium_ipv6_addr(&if_hint)?;
|
||||||
let mut mycelium_if_opt: Option<String> = None;
|
ipv6_bridge_cidr = Some(cidr);
|
||||||
|
} else {
|
||||||
if ipv6_requested {
|
let if_hint = nat.mycelium_if.clone().unwrap_or_else(|| "mycelium".into());
|
||||||
if let Ok(cidr) = std::env::var("HERO_VIRT_IPV6_BRIDGE_CIDR") {
|
println!("auto-deriving mycelium address...");
|
||||||
// Explicit override for bridge IPv6 (e.g., "400:...::2/64") but still require mycelium iface presence.
|
let (_ifname, myc_addr) = net::mycelium_ipv6_addr(&if_hint)?;
|
||||||
// Validate mycelium interface and that it has IPv6 configured.
|
let (_pfx, router_cidr) = net::derive_ipv6_prefix_from_mycelium(&myc_addr)?;
|
||||||
let _ = get_mycelium_ipv6_addr(&mycelium_if_cfg)?; // returns DependencyMissing on failure
|
println!("derived router cidr for bridge: {}", router_cidr);
|
||||||
ipv6_bridge_cidr = Some(cidr);
|
ipv6_bridge_cidr = Some(router_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...");
|
// Ensure bridge, NAT, and DHCP
|
||||||
let (ifname, myc_addr) = get_mycelium_ipv6_addr(&mycelium_if_cfg)?;
|
net::ensure_bridge(&nat.bridge_name, &nat.bridge_addr_cidr, ipv6_bridge_cidr.as_deref())?;
|
||||||
println!("on if {ifname}, got myc addr: {myc_addr}");
|
net::ensure_nat(&nat.subnet_cidr)?;
|
||||||
let (_pfx, router_cidr) = derive_ipv6_prefix_from_mycelium(&myc_addr)?;
|
let lease_used = net::ensure_dnsmasq(
|
||||||
println!("derived pfx: {_pfx} and router cidr: {router_cidr}");
|
&nat.bridge_name,
|
||||||
ipv6_bridge_cidr = Some(router_cidr);
|
&nat.dhcp_start,
|
||||||
mycelium_if_opt = Some(ifname);
|
&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
|
// 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");
|
println!("wrote JSON for VM");
|
||||||
|
|
||||||
// Best-effort: discover and print guest IPv4/IPv6 addresses (default-net path)
|
// Best-effort: discover and print guest IPv4/IPv6 addresses (default-net path)
|
||||||
// Give DHCP/ND a moment
|
|
||||||
println!("waiting 5 secs for DHCP/ND");
|
println!("waiting 5 secs for DHCP/ND");
|
||||||
thread::sleep(Duration::from_millis(5000));
|
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 = net::stable_mac_from_id(id).to_lowercase();
|
||||||
let mac_lower = stable_mac_from_id(id).to_lowercase();
|
|
||||||
|
|
||||||
// IPv4 from dnsmasq leases (pinned per-bridge leasefile)
|
if let Some(bridge_name) = bridge_for_disc.clone() {
|
||||||
// Path set in ensure_host_net_prereq_dnsmasq_nftables: /var/lib/misc/dnsmasq-hero-$BR.leases
|
let lease_path = lease_for_disc.unwrap_or_else(|| {
|
||||||
let lease_path = std::env::var("HERO_VIRT_DHCP_LEASE_FILE")
|
std::env::var("HERO_VIRT_DHCP_LEASE_FILE")
|
||||||
.unwrap_or_else(|_| format!("/var/lib/misc/dnsmasq-hero-{}.leases", bridge_name));
|
.unwrap_or_else(|_| format!("/var/lib/misc/dnsmasq-hero-{}.leases", bridge_name))
|
||||||
// Parse dnsmasq leases directly to avoid shell quoting/pipelines
|
});
|
||||||
let ipv4 = (|| {
|
let ipv4 = net::discover_ipv4_from_leases(&lease_path, &mac_lower, 12);
|
||||||
let deadline = std::time::Instant::now() + Duration::from_secs(12);
|
println!(
|
||||||
loop {
|
"Got IPv4 from dnsmasq lease ({}): {}",
|
||||||
if let Ok(content) = fs::read_to_string(&lease_path) {
|
lease_path,
|
||||||
let mut last_ip: Option<String> = None;
|
ipv4.clone().unwrap_or("not found".to_string())
|
||||||
for line in content.lines() {
|
);
|
||||||
let cols: Vec<&str> = line.split_whitespace().collect();
|
|
||||||
if cols.len() >= 3 && cols[1].eq_ignore_ascii_case(&mac_lower) {
|
|
||||||
last_ip = Some(cols[2].to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if last_ip.is_some() {
|
|
||||||
return last_ip;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if std::time::Instant::now() >= deadline {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
thread::sleep(Duration::from_millis(800));
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
println!(
|
|
||||||
"Got IPv4 from dnsmasq lease ({}): {}",
|
|
||||||
lease_path,
|
|
||||||
ipv4.clone().unwrap_or("not found".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
// IPv6 from neighbor table on the bridge (exclude link-local), parsed in Rust
|
let ipv6 = net::discover_ipv6_on_bridge(&bridge_name, &mac_lower);
|
||||||
let ipv6 = (|| {
|
println!(
|
||||||
let cmd = format!("ip -6 neigh show dev {}", bridge_name);
|
"Got IPv6 from neighbor table on bridge: {}",
|
||||||
if let Ok(res) = sal_process::run(&cmd).silent(true).die(false).execute() {
|
ipv6.clone().unwrap_or("not found".to_string())
|
||||||
if res.success {
|
);
|
||||||
let mac_pat = format!("lladdr {}", mac_lower);
|
|
||||||
for line in res.stdout.lines() {
|
|
||||||
let lt = line.trim();
|
|
||||||
if lt.to_lowercase().contains(&mac_pat) {
|
|
||||||
if let Some(addr) = lt.split_whitespace().next() {
|
|
||||||
if !addr.starts_with("fe80") && !addr.is_empty() {
|
|
||||||
return Some(addr.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
})();
|
|
||||||
|
|
||||||
println!("Got IPv6 from neighbor table on bridge: {}", ipv6.clone().unwrap_or("not found".to_string()));
|
println!(
|
||||||
|
"[cloudhv] VM '{}' guest addresses: IPv4={}, IPv6={}",
|
||||||
println!(
|
id,
|
||||||
"[cloudhv] VM '{}' guest addresses: IPv4={}, IPv6={}",
|
ipv4.as_deref().unwrap_or(""),
|
||||||
id,
|
ipv6.as_deref().unwrap_or("")
|
||||||
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
362
packages/system/virt/src/cloudhv/net/mod.rs
Normal file
362
packages/system/virt/src/cloudhv/net/mod.rs
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use sal_process;
|
||||||
|
|
||||||
|
use crate::cloudhv::CloudHvError;
|
||||||
|
|
||||||
|
pub mod profile;
|
||||||
|
pub use profile::{BridgeOptions, DefaultNatOptions, NetworkingProfileSpec};
|
||||||
|
|
||||||
|
// Local shell escaping (keep independent from parent module)
|
||||||
|
fn shell_escape(s: &str) -> String {
|
||||||
|
if s.is_empty() {
|
||||||
|
return "''".into();
|
||||||
|
}
|
||||||
|
if s.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || "-_./=:".contains(c))
|
||||||
|
{
|
||||||
|
return s.into();
|
||||||
|
}
|
||||||
|
let mut out = String::from("'");
|
||||||
|
for ch in s.chars() {
|
||||||
|
if ch == '\'' {
|
||||||
|
out.push_str("'\"'\"'");
|
||||||
|
} else {
|
||||||
|
out.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('\'');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_heredoc(label: &str, body: &str) -> Result<(), CloudHvError> {
|
||||||
|
let script = format!("bash -e -s <<'{label}'\n{body}\n{label}\n", label = label, body = body);
|
||||||
|
match sal_process::run(&script).silent(true).die(false).execute() {
|
||||||
|
Ok(res) if res.success => Ok(()),
|
||||||
|
Ok(res) => Err(CloudHvError::CommandFailed(format!(
|
||||||
|
"{} failed: {}{}",
|
||||||
|
label, res.stdout, res.stderr
|
||||||
|
))),
|
||||||
|
Err(e) => Err(CloudHvError::CommandFailed(format!(
|
||||||
|
"{} failed: {}",
|
||||||
|
label, e
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure the Linux bridge exists and has IPv4 (and optional IPv6) configured.
|
||||||
|
/// Also enables IPv4 forwarding (and IPv6 forwarding when v6 provided).
|
||||||
|
pub fn ensure_bridge(
|
||||||
|
bridge_name: &str,
|
||||||
|
bridge_addr_cidr: &str,
|
||||||
|
ipv6_bridge_cidr: Option<&str>,
|
||||||
|
) -> Result<(), CloudHvError> {
|
||||||
|
// deps: ip
|
||||||
|
if sal_process::which("ip").is_none() {
|
||||||
|
return Err(CloudHvError::DependencyMissing(
|
||||||
|
"ip not found on PATH".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let v6 = ipv6_bridge_cidr.unwrap_or("");
|
||||||
|
let body = format!(
|
||||||
|
"set -e
|
||||||
|
BR={br}
|
||||||
|
BR_ADDR={br_addr}
|
||||||
|
IPV6_CIDR={v6cidr}
|
||||||
|
|
||||||
|
ip link show \"$BR\" >/dev/null 2>&1 || ip link add name \"$BR\" type bridge
|
||||||
|
ip addr replace \"$BR_ADDR\" dev \"$BR\"
|
||||||
|
ip link set \"$BR\" up
|
||||||
|
|
||||||
|
# IPv6 address and forwarding (optional)
|
||||||
|
if [ -n \"$IPV6_CIDR\" ]; then
|
||||||
|
ip -6 addr replace \"$IPV6_CIDR\" dev \"$BR\"
|
||||||
|
sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# IPv4 forwarding (idempotent)
|
||||||
|
sysctl -w net.ipv4.ip_forward=1 >/dev/null || true
|
||||||
|
",
|
||||||
|
br = shell_escape(bridge_name),
|
||||||
|
br_addr = shell_escape(bridge_addr_cidr),
|
||||||
|
v6cidr = shell_escape(v6),
|
||||||
|
);
|
||||||
|
run_heredoc("HEROBRIDGE", &body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure nftables NAT masquerading for the given subnet toward the default WAN interface.
|
||||||
|
/// Creates table/chain if missing and adds/keeps a single masquerade rule.
|
||||||
|
pub fn ensure_nat(subnet_cidr: &str) -> Result<(), CloudHvError> {
|
||||||
|
for bin in ["ip", "nft"] {
|
||||||
|
if sal_process::which(bin).is_none() {
|
||||||
|
return Err(CloudHvError::DependencyMissing(format!(
|
||||||
|
"{} not found on PATH",
|
||||||
|
bin
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let body = format!(
|
||||||
|
"set -e
|
||||||
|
SUBNET={subnet}
|
||||||
|
|
||||||
|
WAN_IF=$(ip -o route show default | awk '{{print $5}}' | head -n1)
|
||||||
|
if [ -z \"$WAN_IF\" ]; then
|
||||||
|
echo \"No default WAN interface detected (required for IPv4 NAT)\" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
nft list table ip hero >/dev/null 2>&1 || nft add table ip hero
|
||||||
|
nft list chain ip hero postrouting >/dev/null 2>&1 || nft add chain ip hero postrouting {{ type nat hook postrouting priority 100 \\; }}
|
||||||
|
# Only add rule if not present
|
||||||
|
nft list chain ip hero postrouting | grep -q \"ip saddr $SUBNET oifname \\\"$WAN_IF\\\" masquerade\" \
|
||||||
|
|| nft add rule ip hero postrouting ip saddr $SUBNET oifname \"$WAN_IF\" masquerade
|
||||||
|
",
|
||||||
|
subnet = shell_escape(subnet_cidr),
|
||||||
|
);
|
||||||
|
run_heredoc("HERONAT", &body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure dnsmasq DHCP is configured for the bridge. Returns the lease file path used.
|
||||||
|
/// This function is idempotent; it writes a deterministic conf and reloads/enables dnsmasq.
|
||||||
|
pub fn ensure_dnsmasq(
|
||||||
|
bridge_name: &str,
|
||||||
|
dhcp_start: &str,
|
||||||
|
dhcp_end: &str,
|
||||||
|
ipv6_bridge_cidr: Option<&str>,
|
||||||
|
lease_file_override: Option<&str>,
|
||||||
|
) -> Result<String, CloudHvError> {
|
||||||
|
for bin in ["dnsmasq", "systemctl"] {
|
||||||
|
if sal_process::which(bin).is_none() {
|
||||||
|
return Err(CloudHvError::DependencyMissing(format!(
|
||||||
|
"{} not found on PATH",
|
||||||
|
bin
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let lease_file = lease_file_override
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| format!("/var/lib/misc/dnsmasq-hero-{}.leases", bridge_name));
|
||||||
|
let v6 = ipv6_bridge_cidr.unwrap_or("");
|
||||||
|
let body = format!(
|
||||||
|
"set -e
|
||||||
|
BR={br}
|
||||||
|
DHCP_START={dstart}
|
||||||
|
DHCP_END={dend}
|
||||||
|
LEASE_FILE={lease}
|
||||||
|
IPV6_CIDR={v6cidr}
|
||||||
|
|
||||||
|
mkdir -p /etc/dnsmasq.d
|
||||||
|
mkdir -p /var/lib/misc
|
||||||
|
|
||||||
|
CFG=/etc/dnsmasq.d/hero-$BR.conf
|
||||||
|
TMP=/etc/dnsmasq.d/.hero-$BR.conf.new
|
||||||
|
|
||||||
|
# Ensure main conf includes our conf-dir
|
||||||
|
CONF=/etc/dnsmasq.conf
|
||||||
|
RELOAD=0
|
||||||
|
if ! grep -qF \"conf-dir=/etc/dnsmasq.d\" \"$CONF\" 2>/dev/null; then
|
||||||
|
printf '%s\\n' 'conf-dir=/etc/dnsmasq.d,*.conf' >> \"$CONF\"
|
||||||
|
RELOAD=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure lease file and ownership (best effort)
|
||||||
|
touch \"$LEASE_FILE\" || true
|
||||||
|
chown dnsmasq:dnsmasq \"$LEASE_FILE\" 2>/dev/null || true
|
||||||
|
|
||||||
|
# IPv4 section
|
||||||
|
printf '%s\\n' \
|
||||||
|
\"interface=$BR\" \
|
||||||
|
\"bind-interfaces\" \
|
||||||
|
\"dhcp-authoritative\" \
|
||||||
|
\"dhcp-range=$DHCP_START,$DHCP_END,12h\" \
|
||||||
|
\"dhcp-option=option:dns-server,1.1.1.1,8.8.8.8\" \
|
||||||
|
\"dhcp-leasefile=$LEASE_FILE\" >\"$TMP\"
|
||||||
|
|
||||||
|
# Optional IPv6 RA/DHCPv6
|
||||||
|
if [ -n \"$IPV6_CIDR\" ]; then
|
||||||
|
printf '%s\\n' \
|
||||||
|
\"enable-ra\" \
|
||||||
|
\"dhcp-range=::,constructor:BR_PLACEHOLDER,ra-names,64,12h\" \
|
||||||
|
\"dhcp-option=option6:dns-server,[2001:4860:4860::8888],[2606:4700:4700::1111]\" >>\"$TMP\"
|
||||||
|
sed -i \"s/BR_PLACEHOLDER/$BR/g\" \"$TMP\"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f \"$CFG\" ] || ! cmp -s \"$CFG\" \"$TMP\"; then
|
||||||
|
mv \"$TMP\" \"$CFG\"
|
||||||
|
if systemctl is-active --quiet dnsmasq; then
|
||||||
|
systemctl reload dnsmasq || systemctl restart dnsmasq || true
|
||||||
|
else
|
||||||
|
systemctl enable --now dnsmasq || true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
rm -f \"$TMP\"
|
||||||
|
systemctl enable --now dnsmasq || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ \"$RELOAD\" = \"1\" ]; then
|
||||||
|
systemctl reload dnsmasq || systemctl restart dnsmasq || true
|
||||||
|
fi
|
||||||
|
",
|
||||||
|
br = shell_escape(bridge_name),
|
||||||
|
dstart = shell_escape(dhcp_start),
|
||||||
|
dend = shell_escape(dhcp_end),
|
||||||
|
lease = shell_escape(&lease_file),
|
||||||
|
v6cidr = shell_escape(v6),
|
||||||
|
);
|
||||||
|
run_heredoc("HERODNSMASQ", &body)?;
|
||||||
|
Ok(lease_file)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deterministic TAP name from VM id (Linux IFNAMSIZ safe)
|
||||||
|
pub fn tap_name_for_id(id: &str) -> String {
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut h = DefaultHasher::new();
|
||||||
|
id.hash(&mut h);
|
||||||
|
let v = h.finish();
|
||||||
|
let hex = format!("{:016x}", v);
|
||||||
|
format!("tap-{}", &hex[..10])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure a per-VM TAP exists, enslaved to the bridge, and up.
|
||||||
|
/// Assign ownership to current user/group so CH can open the fd unprivileged.
|
||||||
|
pub fn ensure_tap_for_vm(bridge_name: &str, id: &str) -> Result<String, CloudHvError> {
|
||||||
|
if sal_process::which("ip").is_none() {
|
||||||
|
return Err(CloudHvError::DependencyMissing(
|
||||||
|
"ip not found on PATH".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let tap = tap_name_for_id(id);
|
||||||
|
let body = format!(
|
||||||
|
"set -e
|
||||||
|
BR={br}
|
||||||
|
TAP={tap}
|
||||||
|
UIDX=$(id -u)
|
||||||
|
GIDX=$(id -g)
|
||||||
|
|
||||||
|
ip link show \"$TAP\" >/dev/null 2>&1 || ip tuntap add dev \"$TAP\" mode tap user \"$UIDX\" group \"$GIDX\"
|
||||||
|
ip link set \"$TAP\" master \"$BR\" 2>/dev/null || true
|
||||||
|
ip link set \"$TAP\" up
|
||||||
|
",
|
||||||
|
br = shell_escape(bridge_name),
|
||||||
|
tap = shell_escape(&tap),
|
||||||
|
);
|
||||||
|
run_heredoc("HEROTAP", &body)?;
|
||||||
|
Ok(tap)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable locally-administered unicast MAC derived from VM id.
|
||||||
|
pub fn stable_mac_from_id(id: &str) -> String {
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut h = DefaultHasher::new();
|
||||||
|
id.hash(&mut h);
|
||||||
|
let v = h.finish();
|
||||||
|
let b0 = (((v >> 40) & 0xff) as u8 & 0xfe) | 0x02; // locally administered, unicast
|
||||||
|
let b1 = ((v >> 32) & 0xff) as u8;
|
||||||
|
let b2 = ((v >> 24) & 0xff) as u8;
|
||||||
|
let b3 = ((v >> 16) & 0xff) as u8;
|
||||||
|
let b4 = ((v >> 8) & 0xff) as u8;
|
||||||
|
let b5 = (v & 0xff) as u8;
|
||||||
|
format!(
|
||||||
|
"{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
|
||||||
|
b0, b1, b2, b3, b4, b5
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discover the mycelium IPv6 global address on iface (or env override).
|
||||||
|
/// Returns (iface_name, address).
|
||||||
|
pub fn mycelium_ipv6_addr(iface_hint: &str) -> Result<(String, String), CloudHvError> {
|
||||||
|
let iface = std::env::var("HERO_VIRT_MYCELIUM_IF").unwrap_or_else(|_| iface_hint.to_string());
|
||||||
|
let cmd = format!("ip -6 addr show dev {}", shell_escape(&iface));
|
||||||
|
let res = sal_process::run(&cmd).silent(true).die(false).execute();
|
||||||
|
let out = match res {
|
||||||
|
Ok(r) if r.success => r.stdout,
|
||||||
|
_ => {
|
||||||
|
return Err(CloudHvError::DependencyMissing(format!(
|
||||||
|
"mycelium interface '{}' not found or no IPv6 configured",
|
||||||
|
iface
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for line in out.lines() {
|
||||||
|
let lt = line.trim();
|
||||||
|
if lt.starts_with("inet6 ") && lt.contains("scope global") {
|
||||||
|
let parts: Vec<&str> = lt.split_whitespace().collect();
|
||||||
|
if let Some(addr_cidr) = parts.get(1) {
|
||||||
|
let addr_only = addr_cidr.split('/').next().unwrap_or("").trim();
|
||||||
|
if !addr_only.is_empty() && addr_only.parse::<std::net::Ipv6Addr>().is_ok() {
|
||||||
|
return Ok((iface, addr_only.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(CloudHvError::DependencyMissing(format!(
|
||||||
|
"no global IPv6 found on interface '{}'",
|
||||||
|
iface
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive (prefix /64, router /64 string) from a mycelium IPv6 address string.
|
||||||
|
pub fn derive_ipv6_prefix_from_mycelium(m: &str) -> Result<(String, String), CloudHvError> {
|
||||||
|
let ip = m.parse::<std::net::Ipv6Addr>().map_err(|e| {
|
||||||
|
CloudHvError::InvalidSpec(format!("invalid mycelium IPv6 address '{}': {}", m, e))
|
||||||
|
})?;
|
||||||
|
let seg = ip.segments();
|
||||||
|
let pfx = std::net::Ipv6Addr::new(seg[0], seg[1], seg[2], seg[3], 0, 0, 0, 0);
|
||||||
|
let router = std::net::Ipv6Addr::new(seg[0], seg[1], seg[2], seg[3], 0, 0, 0, 2);
|
||||||
|
let pfx_str = format!("{}/64", pfx);
|
||||||
|
let router_cidr = format!("{}/64", router);
|
||||||
|
Ok((pfx_str, router_cidr))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a dnsmasq lease file to find last IPv4 by MAC (lowercased).
|
||||||
|
/// Polls up to timeout_secs with 800ms sleep, returns None on timeout.
|
||||||
|
pub fn discover_ipv4_from_leases(
|
||||||
|
lease_path: &str,
|
||||||
|
mac_lower: &str,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Option<String> {
|
||||||
|
use std::fs;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
|
||||||
|
loop {
|
||||||
|
if let Ok(content) = fs::read_to_string(lease_path) {
|
||||||
|
let mut last_ip: Option<String> = None;
|
||||||
|
for line in content.lines() {
|
||||||
|
let cols: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if cols.len() >= 3 && cols[1].eq_ignore_ascii_case(mac_lower) {
|
||||||
|
last_ip = Some(cols[2].to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if last_ip.is_some() {
|
||||||
|
return last_ip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(800));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search IPv6 neighbor table on bridge for an entry matching MAC (lladdr), excluding link-local.
|
||||||
|
pub fn discover_ipv6_on_bridge(bridge_name: &str, mac_lower: &str) -> Option<String> {
|
||||||
|
let cmd = format!("ip -6 neigh show dev {}", shell_escape(bridge_name));
|
||||||
|
if let Ok(res) = sal_process::run(&cmd).silent(true).die(false).execute() {
|
||||||
|
if res.success {
|
||||||
|
let mac_pat = format!("lladdr {}", mac_lower);
|
||||||
|
for line in res.stdout.lines() {
|
||||||
|
let lt = line.trim();
|
||||||
|
if lt.to_lowercase().contains(&mac_pat) {
|
||||||
|
if let Some(addr) = lt.split_whitespace().next() {
|
||||||
|
if !addr.starts_with("fe80") && !addr.is_empty() {
|
||||||
|
return Some(addr.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
95
packages/system/virt/src/cloudhv/net/profile.rs
Normal file
95
packages/system/virt/src/cloudhv/net/profile.rs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DefaultNatOptions {
|
||||||
|
#[serde(default = "DefaultNatOptions::default_bridge_name")]
|
||||||
|
pub bridge_name: String,
|
||||||
|
#[serde(default = "DefaultNatOptions::default_bridge_addr")]
|
||||||
|
pub bridge_addr_cidr: String,
|
||||||
|
#[serde(default = "DefaultNatOptions::default_subnet")]
|
||||||
|
pub subnet_cidr: String,
|
||||||
|
#[serde(default = "DefaultNatOptions::default_dhcp_start")]
|
||||||
|
pub dhcp_start: String,
|
||||||
|
#[serde(default = "DefaultNatOptions::default_dhcp_end")]
|
||||||
|
pub dhcp_end: String,
|
||||||
|
#[serde(default = "DefaultNatOptions::default_ipv6_enable")]
|
||||||
|
pub ipv6_enable: bool,
|
||||||
|
/// Optional: if set, use this IPv6 on bridge (e.g. "400:...::2/64"), else derive via mycelium
|
||||||
|
#[serde(default)]
|
||||||
|
pub bridge_ipv6_cidr: Option<String>,
|
||||||
|
/// Optional explicit mycelium interface name
|
||||||
|
#[serde(default)]
|
||||||
|
pub mycelium_if: Option<String>,
|
||||||
|
/// Optional override for dnsmasq lease file
|
||||||
|
#[serde(default)]
|
||||||
|
pub lease_file: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DefaultNatOptions {
|
||||||
|
fn default_bridge_name() -> String {
|
||||||
|
std::env::var("HERO_VIRT_BRIDGE_NAME").unwrap_or_else(|_| "br-hero".into())
|
||||||
|
}
|
||||||
|
fn default_bridge_addr() -> String {
|
||||||
|
std::env::var("HERO_VIRT_BRIDGE_ADDR_CIDR").unwrap_or_else(|_| "172.30.0.1/24".into())
|
||||||
|
}
|
||||||
|
fn default_subnet() -> String {
|
||||||
|
std::env::var("HERO_VIRT_SUBNET_CIDR").unwrap_or_else(|_| "172.30.0.0/24".into())
|
||||||
|
}
|
||||||
|
fn default_dhcp_start() -> String {
|
||||||
|
std::env::var("HERO_VIRT_DHCP_START").unwrap_or_else(|_| "172.30.0.50".into())
|
||||||
|
}
|
||||||
|
fn default_dhcp_end() -> String {
|
||||||
|
std::env::var("HERO_VIRT_DHCP_END").unwrap_or_else(|_| "172.30.0.250".into())
|
||||||
|
}
|
||||||
|
fn default_ipv6_enable() -> bool {
|
||||||
|
match std::env::var("HERO_VIRT_IPV6_ENABLE").map(|v| v.to_lowercase()) {
|
||||||
|
Ok(s) if s == "0" || s == "false" || s == "no" => false,
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DefaultNatOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
bridge_name: Self::default_bridge_name(),
|
||||||
|
bridge_addr_cidr: Self::default_bridge_addr(),
|
||||||
|
subnet_cidr: Self::default_subnet(),
|
||||||
|
dhcp_start: Self::default_dhcp_start(),
|
||||||
|
dhcp_end: Self::default_dhcp_end(),
|
||||||
|
ipv6_enable: Self::default_ipv6_enable(),
|
||||||
|
bridge_ipv6_cidr: None,
|
||||||
|
mycelium_if: None,
|
||||||
|
lease_file: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct BridgeOptions {
|
||||||
|
#[serde(default = "DefaultNatOptions::default_bridge_name")]
|
||||||
|
pub bridge_name: String,
|
||||||
|
/// Optional: if provided, configure IPv4 on the bridge
|
||||||
|
#[serde(default)]
|
||||||
|
pub bridge_addr_cidr: Option<String>,
|
||||||
|
/// Optional: if provided, configure IPv6 on the bridge
|
||||||
|
#[serde(default)]
|
||||||
|
pub bridge_ipv6_cidr: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", content = "opts")]
|
||||||
|
pub enum NetworkingProfileSpec {
|
||||||
|
DefaultNat(DefaultNatOptions),
|
||||||
|
NoNet,
|
||||||
|
/// Pass-through user args to CH; currently informational in VmSpec
|
||||||
|
CustomCli(Vec<String>),
|
||||||
|
/// Ensure bridge and tap only; no NAT/DHCP
|
||||||
|
BridgeOnly(BridgeOptions),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for NetworkingProfileSpec {
|
||||||
|
fn default() -> Self {
|
||||||
|
NetworkingProfileSpec::DefaultNat(DefaultNatOptions::default())
|
||||||
|
}
|
||||||
|
}
|
@@ -44,7 +44,7 @@ pub struct NetPlanOpts {
|
|||||||
pub dhcp6: bool,
|
pub dhcp6: bool,
|
||||||
/// Static IPv6 address to assign in guest (temporary behavior)
|
/// Static IPv6 address to assign in guest (temporary behavior)
|
||||||
pub ipv6_addr: Option<String>, // e.g., "400::10/64"
|
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 {
|
fn default_dhcp4() -> bool {
|
||||||
@@ -93,7 +93,10 @@ fn stable_mac_from_id(id: &str) -> String {
|
|||||||
let b3 = ((v >> 16) & 0xff) as u8;
|
let b3 = ((v >> 16) & 0xff) as u8;
|
||||||
let b4 = ((v >> 8) & 0xff) as u8;
|
let b4 = ((v >> 8) & 0xff) as u8;
|
||||||
let b5 = (v & 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -177,7 +180,8 @@ pub fn image_prepare(opts: &ImagePrepOptions) -> Result<ImagePrepResult, ImagePr
|
|||||||
let static_v6 = std::env::var("HERO_VIRT_IPV6_STATIC_GUEST")
|
let static_v6 = std::env::var("HERO_VIRT_IPV6_STATIC_GUEST")
|
||||||
.map(|v| matches!(v.to_lowercase().as_str(), "" | "1" | "true" | "yes"))
|
.map(|v| matches!(v.to_lowercase().as_str(), "" | "1" | "true" | "yes"))
|
||||||
.unwrap_or(true);
|
.unwrap_or(true);
|
||||||
let myc_if = std::env::var("HERO_VIRT_MYCELIUM_IF").unwrap_or_else(|_| "mycelium".into());
|
let myc_if =
|
||||||
|
std::env::var("HERO_VIRT_MYCELIUM_IF").unwrap_or_else(|_| "mycelium".into());
|
||||||
|
|
||||||
// Discover host mycelium global IPv6 in 400::/7 from the interface
|
// Discover host mycelium global IPv6 in 400::/7 from the interface
|
||||||
let mut host_v6: Option<Ipv6Addr> = None;
|
let mut host_v6: Option<Ipv6Addr> = None;
|
||||||
@@ -189,7 +193,8 @@ pub fn image_prepare(opts: &ImagePrepOptions) -> Result<ImagePrepResult, ImagePr
|
|||||||
let lt = l.trim();
|
let lt = l.trim();
|
||||||
if lt.starts_with("inet6 ") && lt.contains("scope global") {
|
if lt.starts_with("inet6 ") && lt.contains("scope global") {
|
||||||
if let Some(addr_cidr) = lt.split_whitespace().nth(1) {
|
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>() {
|
if let Ok(ip) = addr_only.parse::<Ipv6Addr>() {
|
||||||
let seg0 = ip.segments()[0];
|
let seg0 = ip.segments()[0];
|
||||||
if (seg0 & 0xFE00) == 0x0400 {
|
if (seg0 & 0xFE00) == 0x0400 {
|
||||||
@@ -217,8 +222,10 @@ pub fn image_prepare(opts: &ImagePrepOptions) -> Result<ImagePrepResult, ImagePr
|
|||||||
if suffix == 0 || suffix == 2 {
|
if suffix == 0 || suffix == 2 {
|
||||||
suffix = 0x100;
|
suffix = 0x100;
|
||||||
}
|
}
|
||||||
let guest_ip = Ipv6Addr::new(seg[0], seg[1], seg[2], seg[3], 0, 0, 0, suffix).to_string();
|
let guest_ip =
|
||||||
let gw_ip = Ipv6Addr::new(seg[0], seg[1], seg[2], seg[3], 0, 0, 0, 2).to_string();
|
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
|
// Inject a YAML block for static v6
|
||||||
np_v6_block = format!(
|
np_v6_block = format!(
|
||||||
@@ -234,7 +241,7 @@ pub fn image_prepare(opts: &ImagePrepOptions) -> Result<ImagePrepResult, ImagePr
|
|||||||
// Compute stable MAC (must match what vm_start() uses) and use it to match NIC in netplan.
|
// 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 vm_mac = stable_mac_from_id(&opts.id);
|
||||||
let script = format!(
|
let script = format!(
|
||||||
"#!/bin/bash -e
|
r#"#!/bin/bash -e
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SRC={src}
|
SRC={src}
|
||||||
@@ -244,146 +251,146 @@ MNT_ROOT={mnt_root}
|
|||||||
MNT_BOOT={mnt_boot}
|
MNT_BOOT={mnt_boot}
|
||||||
RAW={raw}
|
RAW={raw}
|
||||||
|
|
||||||
mkdir -p \"$VM_DIR\"
|
mkdir -p "$VM_DIR"
|
||||||
mkdir -p \"$(dirname \"$MNT_ROOT\")\"
|
mkdir -p "$(dirname "$MNT_ROOT")"
|
||||||
mkdir -p \"$MNT_ROOT\" \"$MNT_BOOT\"
|
mkdir -p "$MNT_ROOT" "$MNT_BOOT"
|
||||||
|
|
||||||
# Make per-VM working copy (reflink if supported)
|
# 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
|
# Load NBD with sufficient partitions
|
||||||
modprobe nbd max_part=63
|
modprobe nbd max_part=63
|
||||||
|
|
||||||
# Pick a free /dev/nbdX and connect the qcow2
|
# Pick a free /dev/nbdX and connect the qcow2
|
||||||
NBD=\"\"
|
NBD=""
|
||||||
for i in $(seq 0 15); do
|
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)
|
# Skip devices that have any mounted partitions (avoid reusing in-use NBDs)
|
||||||
if findmnt -rn -S \"$DEV\" >/dev/null 2>&1 || \
|
if findmnt -rn -S "$DEV" >/dev/null 2>&1 || \
|
||||||
findmnt -rn -S \"${{DEV}}p1\" >/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}}p14" >/dev/null 2>&1 || \
|
||||||
findmnt -rn -S \"${{DEV}}p15\" >/dev/null 2>&1 || \
|
findmnt -rn -S "${{DEV}}p15" >/dev/null 2>&1 || \
|
||||||
findmnt -rn -S \"${{DEV}}p16\" >/dev/null 2>&1; then
|
findmnt -rn -S "${{DEV}}p16" >/dev/null 2>&1; then
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
# Ensure it's not connected (ignore errors if already disconnected)
|
# Ensure it's not connected (ignore errors if already disconnected)
|
||||||
qemu-nbd --disconnect \"$DEV\" >/dev/null 2>&1 || true
|
qemu-nbd --disconnect "$DEV" >/dev/null 2>&1 || true
|
||||||
if qemu-nbd --format=qcow2 --connect=\"$DEV\" \"$WORK\"; then
|
if qemu-nbd --format=qcow2 --connect="$DEV" "$WORK"; then
|
||||||
NBD=\"$DEV\"
|
NBD="$DEV"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
if [ -z \"$NBD\" ]; then
|
if [ -z "$NBD" ]; then
|
||||||
echo \"No free /dev/nbdX device available\" >&2
|
echo "No free /dev/nbdX device available" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo \"Selected NBD: $NBD\" >&2
|
echo "Selected NBD: $NBD" >&2
|
||||||
|
|
||||||
# Settle and probe partitions
|
# Settle and probe partitions
|
||||||
udevadm settle >/dev/null 2>&1 || true
|
udevadm settle >/dev/null 2>&1 || true
|
||||||
blockdev --rereadpt \"$NBD\" >/dev/null 2>&1 || true
|
blockdev --rereadpt "$NBD" >/dev/null 2>&1 || true
|
||||||
partprobe \"$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
|
for t in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
|
||||||
if [ -b \"${{NBD}}p1\" ]; then
|
if [ -b "${{NBD}}p1" ]; then
|
||||||
sz=$(blockdev --getsize64 \"${{NBD}}p1\" 2>/dev/null || echo 0)
|
sz=$(blockdev --getsize64 "${{NBD}}p1" 2>/dev/null || echo 0)
|
||||||
if [ \"$sz\" -gt 0 ]; then
|
if [ "$sz" -gt 0 ]; then
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
sleep 0.4
|
sleep 0.4
|
||||||
udevadm settle >/dev/null 2>&1 || true
|
udevadm settle >/dev/null 2>&1 || true
|
||||||
blockdev --rereadpt \"$NBD\" >/dev/null 2>&1 || true
|
blockdev --rereadpt "$NBD" >/dev/null 2>&1 || true
|
||||||
partprobe \"$NBD\" >/dev/null 2>&1 || true
|
partprobe "$NBD" >/dev/null 2>&1 || true
|
||||||
done
|
done
|
||||||
|
|
||||||
ROOT_DEV=\"${{NBD}}p1\"
|
ROOT_DEV="${{NBD}}p1"
|
||||||
# Prefer p16, else p15
|
# Prefer p16, else p15
|
||||||
if [ -b \"${{NBD}}p16\" ]; then
|
if [ -b "${{NBD}}p16" ]; then
|
||||||
BOOT_DEV=\"${{NBD}}p16\"
|
BOOT_DEV="${{NBD}}p16"
|
||||||
elif [ -b \"${{NBD}}p15\" ]; then
|
elif [ -b "${{NBD}}p15" ]; then
|
||||||
BOOT_DEV=\"${{NBD}}p15\"
|
BOOT_DEV="${{NBD}}p15"
|
||||||
else
|
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
|
exit 33
|
||||||
fi
|
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
|
if [ ! -b "$ROOT_DEV" ]; then
|
||||||
echo \"Root partition not found: $ROOT_DEV\" >&2
|
echo "Root partition not found: $ROOT_DEV" >&2
|
||||||
exit 32
|
exit 32
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cleanup() {{
|
cleanup() {{
|
||||||
set +e
|
set +e
|
||||||
umount \"$MNT_BOOT\" 2>/dev/null || true
|
umount "$MNT_BOOT" 2>/dev/null || true
|
||||||
umount \"$MNT_ROOT\" 2>/dev/null || true
|
umount "$MNT_ROOT" 2>/dev/null || true
|
||||||
[ -n \"$NBD\" ] && qemu-nbd --disconnect \"$NBD\" 2>/dev/null || true
|
[ -n "$NBD" ] && qemu-nbd --disconnect "$NBD" 2>/dev/null || true
|
||||||
rmmod nbd 2>/dev/null || true
|
rmmod nbd 2>/dev/null || true
|
||||||
}}
|
}}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
# Ensure partitions are readable before mounting
|
# Ensure partitions are readable before mounting
|
||||||
for t in 1 2 3 4 5 6 7 8; do
|
for t in 1 2 3 4 5 6 7 8; do
|
||||||
szr=$(blockdev --getsize64 \"$ROOT_DEV\" 2>/dev/null || echo 0)
|
szr=$(blockdev --getsize64 "$ROOT_DEV" 2>/dev/null || echo 0)
|
||||||
szb=$(blockdev --getsize64 \"$BOOT_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
|
if [ "$szr" -gt 0 ] && [ "$szb" -gt 0 ] && blkid "$ROOT_DEV" >/dev/null 2>&1; then
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
sleep 0.4
|
sleep 0.4
|
||||||
udevadm settle >/dev/null 2>&1 || true
|
udevadm settle >/dev/null 2>&1 || true
|
||||||
blockdev --rereadpt \"$NBD\" >/dev/null 2>&1 || true
|
blockdev --rereadpt "$NBD" >/dev/null 2>&1 || true
|
||||||
partprobe \"$NBD\" >/dev/null 2>&1 || true
|
partprobe "$NBD" >/dev/null 2>&1 || true
|
||||||
done
|
done
|
||||||
|
|
||||||
# Mount and mutate (with retries to avoid races)
|
# Mount and mutate (with retries to avoid races)
|
||||||
mounted_root=0
|
mounted_root=0
|
||||||
for t in 1 2 3 4 5 6 7 8 9 10; do
|
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
|
mounted_root=1
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
sleep 0.5
|
sleep 0.5
|
||||||
udevadm settle >/dev/null 2>&1 || true
|
udevadm settle >/dev/null 2>&1 || true
|
||||||
partprobe \"$NBD\" >/dev/null 2>&1 || true
|
partprobe "$NBD" >/dev/null 2>&1 || true
|
||||||
done
|
done
|
||||||
if [ \"$mounted_root\" -ne 1 ]; then
|
if [ "$mounted_root" -ne 1 ]; then
|
||||||
echo \"Failed to mount root $ROOT_DEV\" >&2
|
echo "Failed to mount root $ROOT_DEV" >&2
|
||||||
exit 32
|
exit 32
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mounted_boot=0
|
mounted_boot=0
|
||||||
for t in 1 2 3 4 5; do
|
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
|
mounted_boot=1
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
sleep 0.5
|
sleep 0.5
|
||||||
udevadm settle >/dev/null 2>&1 || true
|
udevadm settle >/dev/null 2>&1 || true
|
||||||
partprobe \"$NBD\" >/dev/null 2>&1 || true
|
partprobe "$NBD" >/dev/null 2>&1 || true
|
||||||
done
|
done
|
||||||
if [ \"$mounted_boot\" -ne 1 ]; then
|
if [ "$mounted_boot" -ne 1 ]; then
|
||||||
echo \"Failed to mount boot $BOOT_DEV\" >&2
|
echo "Failed to mount boot "$BOOT_DEV"" >&2
|
||||||
exit 33
|
exit 33
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Change UUIDs (best-effort)
|
# Change UUIDs (best-effort)
|
||||||
tune2fs -U random \"$ROOT_DEV\" || true
|
tune2fs -U random "$ROOT_DEV" || true
|
||||||
tune2fs -U random \"$BOOT_DEV\" || true
|
tune2fs -U random "$BOOT_DEV" || true
|
||||||
|
|
||||||
ROOT_UUID=$(blkid -o value -s UUID \"$ROOT_DEV\")
|
ROOT_UUID=$(blkid -o value -s UUID "$ROOT_DEV")
|
||||||
BOOT_UUID=$(blkid -o value -s UUID \"$BOOT_DEV\")
|
BOOT_UUID=$(blkid -o value -s UUID "$BOOT_DEV")
|
||||||
|
|
||||||
# Update fstab
|
# Update fstab
|
||||||
sed -i \"s/UUID=[a-f0-9-]* \\/ /UUID=$ROOT_UUID \\/ /\" \"$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\"
|
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!)
|
# Minimal grub.cfg (note: braces escaped for Rust format!)
|
||||||
mkdir -p \"$MNT_BOOT/grub\"
|
mkdir -p "$MNT_BOOT/grub"
|
||||||
KERNEL=$(ls -1 \"$MNT_BOOT\"/vmlinuz-* | sort -V | tail -n1 | xargs -n1 basename)
|
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)
|
INITRD=$(ls -1 "$MNT_BOOT"/initrd.img-* | sort -V | tail -n1 | xargs -n1 basename)
|
||||||
cat > \"$MNT_BOOT/grub/grub.cfg\" << EOF
|
cat > "$MNT_BOOT/grub/grub.cfg" << EOF
|
||||||
set default=0
|
set default=0
|
||||||
set timeout=3
|
set timeout=3
|
||||||
menuentry 'Ubuntu Cloud' {{
|
menuentry 'Ubuntu Cloud' {{
|
||||||
@@ -397,11 +404,12 @@ menuentry 'Ubuntu Cloud' {{
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Netplan config
|
# Netplan config
|
||||||
rm -f \"$MNT_ROOT\"/etc/netplan/*.yaml
|
rm -f "$MNT_ROOT"/etc/netplan/*.yaml
|
||||||
mkdir -p \"$MNT_ROOT\"/etc/netplan
|
mkdir -p "$MNT_ROOT"/etc/netplan
|
||||||
cat > \"$MNT_ROOT/etc/netplan/01-netconfig.yaml\" << EOF
|
cat > "$MNT_ROOT/etc/netplan/01-netconfig.yaml" << EOF
|
||||||
network:
|
network:
|
||||||
version: 2
|
version: 2
|
||||||
|
renderer: networkd
|
||||||
ethernets:
|
ethernets:
|
||||||
eth0:
|
eth0:
|
||||||
match:
|
match:
|
||||||
@@ -413,82 +421,297 @@ network:
|
|||||||
addresses: [8.8.8.8, 1.1.1.1, 2001:4860:4860::8888]
|
addresses: [8.8.8.8, 1.1.1.1, 2001:4860:4860::8888]
|
||||||
EOF
|
EOF
|
||||||
# Enable SSH password authentication and set a default password for 'ubuntu'
|
# Enable SSH password authentication and set a default password for 'ubuntu'
|
||||||
mkdir -p \"$MNT_ROOT/etc/cloud/cloud.cfg.d\"
|
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\"
|
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\"
|
mkdir -p "$MNT_ROOT/etc/ssh/sshd_config.d"
|
||||||
cat > \"$MNT_ROOT/etc/ssh/sshd_config.d/99-hero-password-auth.conf\" << EOF
|
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
|
PasswordAuthentication yes
|
||||||
KbdInteractiveAuthentication yes
|
KbdInteractiveAuthentication yes
|
||||||
UsePAM yes
|
UsePAM yes
|
||||||
|
PubkeyAuthentication no
|
||||||
EOF
|
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'
|
# Set password for default user 'ubuntu'
|
||||||
if chroot \"$MNT_ROOT\" getent passwd ubuntu >/dev/null 2>&1; then
|
if chroot "$MNT_ROOT" getent passwd ubuntu >/dev/null 2>&1; then
|
||||||
chroot \"$MNT_ROOT\" bash -c \"echo 'ubuntu:ubuntu' | chpasswd\" || true
|
echo 'ubuntu:ubuntu' | chroot "$MNT_ROOT" /usr/sbin/chpasswd || true
|
||||||
fi
|
fi
|
||||||
# Ensure openssh-server is present (some cloud images may omit it)
|
# Ensure openssh-server is present (some cloud images may omit it)
|
||||||
# Ensure SSH service enabled and keys generated on boot
|
# Ensure SSH service enabled and keys generated on boot
|
||||||
chroot \"$MNT_ROOT\" systemctl unmask ssh 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 2>/dev/null || true
|
||||||
chroot \"$MNT_ROOT\" systemctl enable ssh-keygen.service 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
|
# 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
|
AddressFamily any
|
||||||
ListenAddress ::
|
ListenAddress ::
|
||||||
ListenAddress 0.0.0.0
|
ListenAddress 0.0.0.0
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# If UFW is present, allow SSH and disable firewall (for tests)
|
# Ensure sshd waits for network to be online (helps IPv6 readiness)
|
||||||
if chroot \"$MNT_ROOT\" command -v ufw >/dev/null 2>&1; then
|
mkdir -p "$MNT_ROOT/etc/systemd/system/ssh.service.d"
|
||||||
chroot \"$MNT_ROOT\" ufw allow OpenSSH || true
|
cat > "$MNT_ROOT/etc/systemd/system/ssh.service.d/override.conf" << 'EOF'
|
||||||
chroot \"$MNT_ROOT\" ufw disable || true
|
[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
|
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
|
# Ensure required packages present before user/password changes
|
||||||
chroot \"$MNT_ROOT\" bash -c \"apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends openssh-server\" || true
|
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
|
fi
|
||||||
# Ensure default user 'ubuntu' exists (fallback for minimal images)
|
# Ensure default user 'ubuntu' exists (fallback for minimal images)
|
||||||
if ! chroot \"$MNT_ROOT\" id -u ubuntu >/dev/null 2>&1; then
|
if ! chroot "$MNT_ROOT" id -u ubuntu >/dev/null 2>&1; then
|
||||||
chroot \"$MNT_ROOT\" useradd -m -s /bin/bash ubuntu || true
|
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
|
echo "ubuntu ALL=(ALL) NOPASSWD:ALL" > "$MNT_ROOT/etc/sudoers.d/90-ubuntu" || true
|
||||||
chmod 0440 \"$MNT_ROOT/etc/sudoers.d/90-ubuntu\" || true
|
chmod 0440 "$MNT_ROOT/etc/sudoers.d/90-ubuntu" || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Re-assert password (covers both existing and newly created users)
|
# Re-assert password (covers both existing and newly created users)
|
||||||
if chroot \"$MNT_ROOT\" getent passwd ubuntu >/dev/null 2>&1; then
|
if chroot "$MNT_ROOT" getent passwd ubuntu >/dev/null 2>&1; then
|
||||||
chroot \"$MNT_ROOT\" bash -c \"echo 'ubuntu:ubuntu' | chpasswd\" || true
|
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
|
fi
|
||||||
|
|
||||||
# Pre-generate host SSH keys so sshd can start immediately
|
# Pre-generate host SSH keys so sshd can start immediately
|
||||||
chroot \"$MNT_ROOT\" ssh-keygen -A 2>/dev/null || true
|
chroot "$MNT_ROOT" ssh-keygen -A 2>/dev/null || true
|
||||||
mkdir -p \"$MNT_ROOT/var/run/sshd\"
|
mkdir -p "$MNT_ROOT/var/run/sshd"
|
||||||
|
|
||||||
# Also enable socket activation as a fallback
|
# Ensure sshd runs as a regular service and not via socket (binds IPv4+IPv6)
|
||||||
chroot \"$MNT_ROOT\" systemctl enable ssh.socket 2>/dev/null || true
|
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)
|
# Disable cloud-init networking (optional but default)
|
||||||
if [ \"{disable_ci_net}\" = \"true\" ]; then
|
if [ "{disable_ci_net}" = "true" ]; then
|
||||||
mkdir -p \"$MNT_ROOT/etc/cloud/cloud.cfg.d\"
|
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\"
|
echo "network: {{{{config: disabled}}}}" > "$MNT_ROOT/etc/cloud/cloud.cfg.d/99-disable-network-config.cfg"
|
||||||
fi
|
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)
|
# Convert prepared image to raw (ensure source not locked)
|
||||||
umount \"$MNT_BOOT\" 2>/dev/null || true
|
umount "$MNT_BOOT" 2>/dev/null || true
|
||||||
umount \"$MNT_ROOT\" 2>/dev/null || true
|
umount "$MNT_ROOT" 2>/dev/null || true
|
||||||
if [ -n \"$NBD\" ]; then
|
if [ -n "$NBD" ]; then
|
||||||
qemu-nbd --disconnect \"$NBD\" 2>/dev/null || true
|
qemu-nbd --disconnect "$NBD" 2>/dev/null || true
|
||||||
rmmod nbd 2>/dev/null || true
|
rmmod nbd 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
rm -f \"$RAW\"
|
rm -f "$RAW"
|
||||||
qemu-img convert -U -f qcow2 -O raw \"$WORK\" \"$RAW\"
|
qemu-img convert -U -f qcow2 -O raw "$WORK" "$RAW"
|
||||||
|
|
||||||
# Output result triple ONLY on stdout, then prevent any further trap output
|
# 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
|
trap - EXIT
|
||||||
exit 0
|
exit 0
|
||||||
",
|
"#,
|
||||||
src = shell_escape(&src),
|
src = shell_escape(&src),
|
||||||
vm_dir = shell_escape(&vm_dir),
|
vm_dir = shell_escape(&vm_dir),
|
||||||
work = shell_escape(&work_qcow2),
|
work = shell_escape(&work_qcow2),
|
||||||
@@ -499,7 +722,7 @@ exit 0
|
|||||||
dhcp4 = if opts.net.dhcp4 { "true" } else { "false" },
|
dhcp4 = if opts.net.dhcp4 { "true" } else { "false" },
|
||||||
dhcp6 = if dhcp6_effective { "true" } else { "false" },
|
dhcp6 = if dhcp6_effective { "true" } else { "false" },
|
||||||
np_v6_block = np_v6_block,
|
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:
|
// image prep script printout for debugging:
|
||||||
@@ -553,7 +776,9 @@ fn shell_escape(s: &str) -> String {
|
|||||||
if s.is_empty() {
|
if s.is_empty() {
|
||||||
return "''".into();
|
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();
|
return s.into();
|
||||||
}
|
}
|
||||||
let mut out = String::from("'");
|
let mut out = String::from("'");
|
||||||
@@ -566,4 +791,4 @@ fn shell_escape(s: &str) -> String {
|
|||||||
}
|
}
|
||||||
out.push('\'');
|
out.push('\'');
|
||||||
out
|
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 memory_mb = get_int(&spec, "memory_mb").unwrap_or(512) as u32;
|
||||||
let cmdline = get_string(&spec, "cmdline");
|
let cmdline = get_string(&spec, "cmdline");
|
||||||
let extra_args = get_string_array(&spec, "extra_args");
|
let extra_args = get_string_array(&spec, "extra_args");
|
||||||
|
|
||||||
Ok(VmSpec {
|
Ok(VmSpec {
|
||||||
id,
|
id,
|
||||||
kernel_path,
|
kernel_path,
|
||||||
@@ -37,6 +37,7 @@ fn map_to_vmspec(spec: Map) -> Result<VmSpec, Box<EvalAltResult>> {
|
|||||||
memory_mb,
|
memory_mb,
|
||||||
cmdline,
|
cmdline,
|
||||||
extra_args,
|
extra_args,
|
||||||
|
net_profile: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +77,8 @@ fn vmspec_to_map(s: &VmSpec) -> Map {
|
|||||||
} else {
|
} else {
|
||||||
m.insert("extra_args".into(), Dynamic::UNIT);
|
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
|
m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
use crate::cloudhv::builder::CloudHvBuilder;
|
use crate::cloudhv::builder::CloudHvBuilder;
|
||||||
use crate::hostcheck::host_check_deps;
|
use crate::hostcheck::host_check_deps;
|
||||||
use crate::image_prep::{image_prepare, Flavor as ImgFlavor, ImagePrepOptions, NetPlanOpts};
|
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 {
|
fn builder_new(id: &str) -> CloudHvBuilder {
|
||||||
CloudHvBuilder::new(id)
|
CloudHvBuilder::new(id)
|
||||||
@@ -47,6 +47,30 @@ fn builder_no_default_net(mut b: CloudHvBuilder) -> CloudHvBuilder {
|
|||||||
b
|
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>> {
|
fn builder_launch(mut b: CloudHvBuilder) -> Result<String, Box<EvalAltResult>> {
|
||||||
b.launch().map_err(|e| {
|
b.launch().map_err(|e| {
|
||||||
Box::new(EvalAltResult::ErrorRuntime(
|
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 {
|
if vcpus > 0 {
|
||||||
b.vcpus(vcpus as u32);
|
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| {
|
b.launch().map_err(|e| {
|
||||||
Box::new(EvalAltResult::ErrorRuntime(
|
Box::new(EvalAltResult::ErrorRuntime(
|
||||||
format!("vm_easy_launch failed at launch: {}", e).into(),
|
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("cmdline", builder_cmdline);
|
||||||
engine.register_fn("extra_arg", builder_extra_arg);
|
engine.register_fn("extra_arg", builder_extra_arg);
|
||||||
engine.register_fn("no_default_net", builder_no_default_net);
|
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
|
// Action
|
||||||
engine.register_fn("launch", builder_launch);
|
engine.register_fn("launch", builder_launch);
|
||||||
|
@@ -109,6 +109,8 @@ if !(prep_ok) {
|
|||||||
// ------------------------------------------------------------------------------------
|
// ------------------------------------------------------------------------------------
|
||||||
banner("PHASE 3: Launch via cloudhv_builder (disk from Phase 2)");
|
banner("PHASE 3: Launch via cloudhv_builder (disk from Phase 2)");
|
||||||
let b = cloudhv_builder(vmA);
|
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 = disk(b, prep_res.raw_disk);
|
||||||
let b = memory_mb(b, 4096);
|
let b = memory_mb(b, 4096);
|
||||||
let b = vcpus(b, 2);
|
let b = vcpus(b, 2);
|
||||||
@@ -183,6 +185,10 @@ try {
|
|||||||
throw "Stopping due to vm_easy_launch failure";
|
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
|
// Phase 7: Inspect VM B info, list VMs
|
||||||
// ------------------------------------------------------------------------------------
|
// ------------------------------------------------------------------------------------
|
||||||
|
Reference in New Issue
Block a user