working ipv6 ip assignment + ssh with login/passwd

This commit is contained in:
Maxime Van Hees
2025-08-28 15:19:37 +02:00
parent 784f87db97
commit da3da0ae30
2 changed files with 302 additions and 61 deletions

View File

@@ -408,27 +408,35 @@ pub fn vm_start(id: &str) -> Result<(), CloudHvError> {
let dhcp_end =
std::env::var("HERO_VIRT_DHCP_END").unwrap_or_else(|_| "172.30.0.250".into());
// Optional IPv6 over Mycelium provisioning (bridge P::2/64 + RA) guarded by env and detection.
// 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 mut ipv6_enabled = ipv6_env.eq_ignore_ascii_case("1") || ipv6_env.eq_ignore_ascii_case("true");
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;
let enable_auto = ipv6_env.is_empty(); // auto-enable if mycelium is detected and not explicitly disabled
if ipv6_enabled || enable_auto {
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")
// 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());
ipv6_enabled = true;
} else if let Ok((ifname, myc_addr)) = get_mycelium_ipv6_addr(&mycelium_if_cfg) {
// Derive P::2/64 from the mycelium node address
if let Ok((_pfx, router_cidr)) = derive_ipv6_prefix_from_mycelium(&myc_addr) {
ipv6_bridge_cidr = Some(router_cidr);
mycelium_if_opt = Some(ifname);
ipv6_enabled = true;
}
} 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);
}
}
@@ -445,8 +453,10 @@ pub fn vm_start(id: &str) -> Result<(), CloudHvError> {
// 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));
@@ -465,6 +475,7 @@ pub fn vm_start(id: &str) -> Result<(), CloudHvError> {
log_file,
vm_pid_path(id).to_string_lossy()
);
println!("executing command:\n{heredoc}");
// Execute command; this will background cloud-hypervisor and return
let result = sal_process::run(&heredoc).execute();
match result {
@@ -489,6 +500,7 @@ pub fn vm_start(id: &str) -> Result<(), CloudHvError> {
Ok(s) => s.trim().parse::<i64>().ok(),
Err(_) => None,
};
println!("reading PID back: {} - (if 0 == not found)", pid.unwrap_or(0));
// Quick health check: ensure process did not exit immediately due to CLI errors (e.g., duplicate flags)
if let Some(pid_num) = pid {
@@ -496,6 +508,7 @@ pub fn vm_start(id: &str) -> Result<(), CloudHvError> {
if !proc_exists(pid_num) {
// Tail log to surface the error cause
let tail_cmd = format!("tail -n 200 {}", shell_escape(&log_file));
println!("executing tail_cmd command:\n{tail_cmd}");
let tail = sal_process::run(&tail_cmd).die(false).silent(true).execute();
let mut log_snip = String::new();
if let Ok(res) = tail {
@@ -524,6 +537,76 @@ pub fn vm_start(id: &str) -> Result<(), CloudHvError> {
let value = serde_json::to_value(&rec).map_err(|e| CloudHvError::JsonError(e.to_string()))?;
write_json(&vm_json_path(id), &value)?;
println!("wrote JSON for VM");
// Best-effort: discover and print guest IPv4/IPv6 addresses (default-net path)
// Give DHCP/ND a moment
println!("waiting 5 secs for DHCP/ND");
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();
// IPv4 from dnsmasq leases (pinned per-bridge leasefile)
// Path set in ensure_host_net_prereq_dnsmasq_nftables: /var/lib/misc/dnsmasq-hero-$BR.leases
let lease_path = std::env::var("HERO_VIRT_DHCP_LEASE_FILE")
.unwrap_or_else(|_| format!("/var/lib/misc/dnsmasq-hero-{}.leases", bridge_name));
// Parse dnsmasq leases directly to avoid shell quoting/pipelines
let ipv4 = (|| {
let deadline = std::time::Instant::now() + Duration::from_secs(12);
loop {
if let Ok(content) = fs::read_to_string(&lease_path) {
let mut last_ip: Option<String> = None;
for line in content.lines() {
let cols: Vec<&str> = line.split_whitespace().collect();
if cols.len() >= 3 && cols[1].eq_ignore_ascii_case(&mac_lower) {
last_ip = Some(cols[2].to_string());
}
}
if last_ip.is_some() {
return last_ip;
}
}
if std::time::Instant::now() >= deadline {
return None;
}
thread::sleep(Duration::from_millis(800));
}
})();
println!(
"Got IPv4 from dnsmasq lease ({}): {}",
lease_path,
ipv4.clone().unwrap_or("not found".to_string())
);
// IPv6 from neighbor table on the bridge (exclude link-local), parsed in Rust
let ipv6 = (|| {
let cmd = format!("ip -6 neigh show dev {}", bridge_name);
if let Ok(res) = sal_process::run(&cmd).silent(true).die(false).execute() {
if res.success {
let mac_pat = format!("lladdr {}", mac_lower);
for line in res.stdout.lines() {
let lt = line.trim();
if lt.to_lowercase().contains(&mac_pat) {
if let Some(addr) = lt.split_whitespace().next() {
if !addr.starts_with("fe80") && !addr.is_empty() {
return Some(addr.to_string());
}
}
}
}
}
}
None
})();
println!("Got IPv6 from neighbor table on bridge: {}", ipv6.clone().unwrap_or("not found".to_string()));
println!(
"[cloudhv] VM '{}' guest addresses: IPv4={}, IPv6={}",
id,
ipv4.as_deref().unwrap_or(""),
ipv6.as_deref().unwrap_or("")
);
Ok(())
}
@@ -711,50 +794,44 @@ fn stable_mac_from_id(id: &str) -> String {
format!("{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", b0, b1, b2, b3, b4, b5)
}
/// Discover the mycelium IPv6 address and validate the interface.
///
/// Returns (interface_name, address_string).
/// Discover the mycelium IPv6 address by inspecting the interface itself (no CLI dependency).
/// Returns (interface_name, first global IPv6 address found on the interface).
fn get_mycelium_ipv6_addr(iface_hint: &str) -> Result<(String, String), CloudHvError> {
// Parse `mycelium inspect` for "Address: ..."
let insp = sal_process::run("mycelium inspect").silent(true).die(false).execute();
let mut addr: Option<String> = None;
if let Ok(res) = insp {
if res.success {
for l in res.stdout.lines() {
let lt = l.trim();
if let Some(rest) = lt.strip_prefix("Address:") {
let val = rest.trim().trim_matches('"').to_string();
if !val.is_empty() {
addr = Some(val);
break;
}
}
}
}
}
let iface = std::env::var("HERO_VIRT_MYCELIUM_IF").unwrap_or_else(|_| iface_hint.to_string());
// Validate interface exists and has IPv6 configured (best-effort)
// Query IPv6 addresses on the interface
let cmd = format!("ip -6 addr show dev {}", shell_escape(&iface));
match sal_process::run(&cmd).silent(true).die(false).execute() {
Ok(r) if r.success => {
// proceed
}
let res = sal_process::run(&cmd).silent(true).die(false).execute();
let out = match res {
Ok(r) if r.success => r.stdout,
_ => {
return Err(CloudHvError::DependencyMissing(format!(
"mycelium interface '{}' not found or no IPv6 configured",
iface
)));
)))
}
};
// Extract the first global IPv6 address present on the interface.
for line in out.lines() {
let lt = line.trim();
// Example line: "inet6 578:9fcf:.../7 scope global"
if lt.starts_with("inet6 ") && lt.contains("scope global") {
let parts: Vec<&str> = lt.split_whitespace().collect();
if let Some(addr_cidr) = parts.get(1) {
let addr_only = addr_cidr.split('/').next().unwrap_or("").trim();
println!("got addr from host: {addr_only}");
if !addr_only.is_empty() && addr_only.parse::<std::net::Ipv6Addr>().is_ok() {
return Ok((iface, addr_only.to_string()));
}
}
}
}
if let Some(a) = addr {
Ok((iface, a))
} else {
Err(CloudHvError::DependencyMissing(
"failed to read mycelium IPv6 address via `mycelium inspect`".into(),
))
}
Err(CloudHvError::DependencyMissing(format!(
"no global IPv6 found on interface '{}'",
iface
)))
}
/// Derive a /64 prefix P from the mycelium IPv6 and return (P/64, P::2/64).
@@ -803,10 +880,15 @@ SUBNET={subnet}
DHCP_START={dstart}
DHCP_END={dend}
IPV6_CIDR={v6cidr}
LEASE_FILE=/var/lib/misc/dnsmasq-hero-$BR.leases
# Determine default WAN interface
WAN_IF=$(ip -o route show default | awk '{{print $5}}' | head -n1)
if [ -z \"$WAN_IF\" ]; then
echo \"No default WAN interface detected (required for IPv4 NAT)\" >&2
exit 2
fi
# Bridge creation (idempotent)
ip link show \"$BR\" >/dev/null 2>&1 || ip link add name \"$BR\" type bridge
ip addr replace \"$BR_ADDR\" dev \"$BR\"
@@ -829,24 +911,38 @@ nft list chain ip hero postrouting | grep -q \"ip saddr $SUBNET oifname \\\"$WAN
# dnsmasq DHCPv4 + RA/DHCPv6 config (idempotent)
mkdir -p /etc/dnsmasq.d
mkdir -p /var/lib/misc
CFG=/etc/dnsmasq.d/hero-$BR.conf
TMP=/etc/dnsmasq.d/.hero-$BR.conf.new
# Always include IPv4 section
cat >\"$TMP\" <<EOF
interface=$BR
bind-interfaces
dhcp-range=$DHCP_START,$DHCP_END,12h
dhcp-option=option:dns-server,1.1.1.1,8.8.8.8
EOF
RELOAD=0
CONF=/etc/dnsmasq.conf
# Ensure conf-dir includes /etc/dnsmasq.d (simple fixed-string check to avoid regex escapes in Rust)
if ! grep -qF \"conf-dir=/etc/dnsmasq.d\" \"$CONF\"; then
printf '%s\n' 'conf-dir=/etc/dnsmasq.d,*.conf' >> \"$CONF\"
RELOAD=1
fi
# Ensure lease file exists and is writable by dnsmasq user
touch \"$LEASE_FILE\" || true
chown dnsmasq:dnsmasq \"$LEASE_FILE\" 2>/dev/null || true
# Always include IPv4 section
printf '%s\n' \
\"interface=$BR\" \
\"bind-interfaces\" \
\"dhcp-authoritative\" \
\"dhcp-range=$DHCP_START,$DHCP_END,12h\" \
\"dhcp-option=option:dns-server,1.1.1.1,8.8.8.8\" \
\"dhcp-leasefile=$LEASE_FILE\" >\"$TMP\"
# Optionally append IPv6 RA/DHCPv6
if [ -n \"$IPV6_CIDR\" ]; then
cat >>\"$TMP\" <<'EOFV6'
enable-ra
dhcp-range=::,constructor:BR_PLACEHOLDER,ra-names,64,12h
dhcp-option=option6:dns-server,[2001:4860:4860::8888],[2606:4700:4700::1111]
EOFV6
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
@@ -861,6 +957,11 @@ else
rm -f \"$TMP\"
systemctl enable --now dnsmasq || true
fi
# Reload if main conf was updated to include conf-dir
if [ \"$RELOAD\" = \"1\" ]; then
systemctl reload dnsmasq || systemctl restart dnsmasq || true
fi
",
br = shell_escape(bridge_name),
br_addr = shell_escape(bridge_addr_cidr),
@@ -872,6 +973,7 @@ fi
// Use a unique heredoc delimiter to avoid clashing with inner <<EOF blocks
let heredoc_net = format!("bash -e -s <<'HERONET'\n{}\nHERONET\n", body);
println!("executing command:\n{heredoc_net}");
match sal_process::run(&heredoc_net).silent(true).execute() {
Ok(res) if res.success => Ok(()),