From 871e2bd1ed89b12955bf8ac2389fe9dbcd31a0ea Mon Sep 17 00:00:00 2001 From: Levon Tarver Date: Wed, 15 Apr 2026 18:24:34 +0000 Subject: [PATCH 1/7] extract loopback ip manager from maghemite --- Cargo.lock | 8 + Cargo.toml | 1 + loopback-ip-mgr/Cargo.toml | 8 + loopback-ip-mgr/src/lib.rs | 786 +++++++++++++++++++++++++++++++++++++ 4 files changed, 803 insertions(+) create mode 100644 loopback-ip-mgr/Cargo.toml create mode 100644 loopback-ip-mgr/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 99f5223..c80a7f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1170,6 +1170,14 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loopback-ip-mgr" +version = "0.1.0" +dependencies = [ + "libc", + "slog", +] + [[package]] name = "lru-slab" version = "0.1.2" diff --git a/Cargo.toml b/Cargo.toml index 94b8a72..6f52100 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ default-members = [ members = [ "lib", + "loopback-ip-mgr", "ztest", "examples/solo", "examples/duo", diff --git a/loopback-ip-mgr/Cargo.toml b/loopback-ip-mgr/Cargo.toml new file mode 100644 index 0000000..7d4ae12 --- /dev/null +++ b/loopback-ip-mgr/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "loopback-ip-mgr" +version = "0.1.0" +edition = "2021" + +[dependencies] +libc.workspace = true +slog.workspace = true diff --git a/loopback-ip-mgr/src/lib.rs b/loopback-ip-mgr/src/lib.rs new file mode 100644 index 0000000..92d994d --- /dev/null +++ b/loopback-ip-mgr/src/lib.rs @@ -0,0 +1,786 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Loopback IP address manager for use in tests. +//! +//! Provides [`LoopbackIpManager`] for managing temporary IP addresses on a +//! loopback interface with cross-process reference counting, ensuring proper +//! cleanup even if tests panic. + +use slog::{Logger, error, info}; +use std::fs::{File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::net::IpAddr; +use std::os::unix::io::AsRawFd; +use std::process::Command; +use std::sync::{Arc, Mutex}; + +/// Cross-platform file locking using libc's flock(2). +trait FileLockExt { + fn lock_exclusive(&self) -> std::io::Result<()>; +} + +impl FileLockExt for File { + fn lock_exclusive(&self) -> std::io::Result<()> { + let fd = self.as_raw_fd(); + let ret = unsafe { libc::flock(fd, libc::LOCK_EX) }; + if ret == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } + } +} + +struct ManagedIp { + address: IpAddr, + /// Number of [`IpAllocation`]s within this process currently using this IP. + /// The system-level install/uninstall only happens when this transitions + /// between 0 and 1. `use_count > 0` implies the IP is installed. + use_count: u32, + lockfile: Option, +} + +/// RAII guard that ensures proper cleanup of allocated IP addresses when +/// dropped, even if the test panics. +pub struct IpAllocation { + pub addresses: Vec, + pub manager: Arc>, +} + +impl Drop for IpAllocation { + fn drop(&mut self) { + let mut manager = self.manager.lock().expect("lock mutex"); + manager.uninstall_addresses(&self.addresses); + } +} + +/// Manages temporary IP addresses on a loopback interface. +/// +/// Supports multiple concurrent allocations both within a single process +/// (via in-process reference counting) and across processes (via lockfile-based +/// reference counting in `/tmp`). Addresses are only added to or removed from +/// the system when the reference count transitions between 0 and 1. +pub struct LoopbackIpManager { + ips: Vec, + ifname: String, + log: Logger, +} + +impl LoopbackIpManager { + pub fn new(ifname: &str, log: Logger) -> Self { + Self { + ips: Vec::new(), + ifname: ifname.to_string(), + log, + } + } + + fn add(&mut self, addresses: &[IpAddr]) { + for addr in addresses { + if !self.ips.iter().any(|ip| ip.address == *addr) { + self.ips.push(ManagedIp { + address: *addr, + use_count: 0, + lockfile: None, + }); + } + } + } + + /// Allocate IP addresses and return a guard that will clean them up on drop. + pub fn allocate( + manager: Arc>, + addresses: &[IpAddr], + ) -> Result { + { + let mut mgr = manager.lock().expect("lock mutex"); + mgr.add(addresses); + if let Err(e) = mgr.install(addresses) { + mgr.uninstall_addresses(addresses); + return Err(e); + } + } + Ok(IpAllocation { + addresses: addresses.to_vec(), + manager, + }) + } + + fn install(&mut self, addresses: &[IpAddr]) -> Result<(), std::io::Error> { + let ifname = self.ifname.clone(); + let log = self.log.clone(); + for ip in &mut self.ips { + if addresses.contains(&ip.address) { + Self::install_single_ip_static(&ifname, &log, ip)?; + } + } + Ok(()) + } + + /// Skips 127.0.0.1/::1 as they're always present on loopback interfaces. + fn install_single_ip_static( + ifname: &str, + log: &Logger, + ip: &mut ManagedIp, + ) -> Result<(), std::io::Error> { + if is_always_present(ip.address) { + info!(log, "skipping {} (always present on loopback)", ip.address); + ip.use_count += 1; + return Ok(()); + } + + // If already installed by another allocation in this process, nothing + // more to do — the system IP and lockfile refcount are already set up. + if ip.use_count > 0 { + ip.use_count += 1; + info!( + log, + "{}: already installed, use_count now {}", + ip.address, + ip.use_count, + ); + return Ok(()); + } + + // First use in this process: acquire the cross-process lockfile and + // install the IP if no other process has done so yet. + let lockfile_path = format!("/tmp/maghemite-ip-{}.lock", ip.address); + let mut lockfile = flock(&lockfile_path)?; + let refcount = read_refcount(&mut lockfile); + if refcount == 0 { + Self::add_ip_to_system(ifname, log, ip)?; + } + let new_refcount = refcount + 1; + info!( + log, + "{}: increment refcount {refcount}->{new_refcount}", ip.address, + ); + write_refcount(&mut lockfile, new_refcount)?; + ip.use_count += 1; + ip.lockfile = Some(lockfile); + Ok(()) + } + + fn add_ip_to_system( + ifname: &str, + log: &Logger, + ip: &ManagedIp, + ) -> Result<(), std::io::Error> { + let mask = match ip.address { + IpAddr::V4(_) => 32, + IpAddr::V6(_) => 128, + }; + let addr_str = format!("{}/{mask}", ip.address); + + #[cfg(target_os = "illumos")] + let output = { + let v = match ip.address { + IpAddr::V4(_) => "v4", + IpAddr::V6(_) => "v6", + }; + let mut ip_descr = format!("{v}{}", ip.address); + ip_descr.retain(|c| c.is_alphanumeric()); + let addr_obj = format!("{}/{}", ifname, ip_descr); + let cmd = [ + "ipadm", + "create-addr", + "-t", + "-T", + "static", + "-a", + &addr_str, + &addr_obj, + ]; + info!(log, "running cmd '{cmd:?}'"); + Command::new("pfexec").args(cmd).output()? + }; + + #[cfg(target_os = "linux")] + let output = Command::new("sudo") + .args(["ip", "addr", "add", &addr_str, "dev", ifname]) + .output()?; + + #[cfg(target_os = "macos")] + let output = { + let af = match ip.address { + IpAddr::V4(_) => "inet", + IpAddr::V6(_) => "inet6", + }; + Command::new("sudo") + .args(["ifconfig", ifname, af, &addr_str, "alias"]) + .output()? + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + // "Address already exists/assigned" is ok — another process beat us to it + if !stderr.to_lowercase().contains("already") { + error!(log, "failed to install {}: {stderr}", ip.address); + return Err(std::io::Error::other(format!( + "failed to install {}", + ip.address + ))); + } + } + info!(log, "added {} to system", ip.address); + Ok(()) + } + + /// Uninstall specific addresses (called by [`IpAllocation`] on drop). + pub fn uninstall_addresses(&mut self, addresses: &[IpAddr]) { + for addr in addresses { + self.uninstall_single_ip(*addr); + } + } + + /// Skips 127.0.0.1/::1 as they should always remain on loopback interfaces. + fn uninstall_single_ip(&mut self, target_addr: IpAddr) { + if is_always_present(target_addr) { + info!( + self.log, + "skipping {target_addr} cleanup (always present on loopback)" + ); + for ip in &mut self.ips { + if ip.address == target_addr { + ip.use_count = ip.use_count.saturating_sub(1); + break; + } + } + return; + } + + for ip in &mut self.ips { + if ip.address == target_addr && ip.use_count > 0 { + ip.use_count -= 1; + + if ip.use_count > 0 { + info!( + self.log, + "{}: use_count now {}, keeping installed", + ip.address, + ip.use_count, + ); + break; + } + + // Last in-process user: decrement the cross-process refcount + // and remove the IP from the system if no other process needs it. + if let Some(mut lockfile) = ip.lockfile.take() { + let lockfile_path = + format!("/tmp/maghemite-ip-{}.lock", ip.address); + let refcount = read_refcount(&mut lockfile); + let new_refcount = refcount.saturating_sub(1); + info!( + self.log, + "{}: decrement refcount {refcount}->{new_refcount}", + ip.address, + ); + + if new_refcount == 0 { + Self::remove_ip_from_system_static( + &self.ifname, + &self.log, + ip.address, + ); + drop(lockfile); + if let Err(e) = + std::fs::remove_file(&lockfile_path) + { + error!( + self.log, + "failed to remove lockfile {}: {e}", + lockfile_path + ); + } else { + info!( + self.log, + "removed lockfile {}", lockfile_path + ); + } + } else { + let _ = write_refcount(&mut lockfile, new_refcount); + } + } + + info!(self.log, "uninstalled {}", ip.address); + break; + } + } + } + + fn remove_ip_from_system_static( + ifname: &str, + log: &Logger, + addr: IpAddr, + ) { + #[cfg(target_os = "illumos")] + let output = { + let v = match addr { + IpAddr::V4(_) => "v4", + IpAddr::V6(_) => "v6", + }; + let mut ip_descr = format!("{v}{addr}"); + ip_descr.retain(|c| c.is_alphanumeric()); + let addr_obj = format!("{ifname}/{ip_descr}"); + Command::new("pfexec") + .args(["ipadm", "delete-addr", &addr_obj]) + .output() + }; + + #[cfg(target_os = "linux")] + let output = { + let mask = match addr { + IpAddr::V4(_) => 32, + IpAddr::V6(_) => 128, + }; + let addr_str = format!("{addr}/{mask}"); + Command::new("sudo") + .args(["ip", "addr", "del", &addr_str, "dev", ifname]) + .output() + }; + + #[cfg(target_os = "macos")] + let output = { + let af = match addr { + IpAddr::V4(_) => "inet", + IpAddr::V6(_) => "inet6", + }; + Command::new("sudo") + .args(["ifconfig", ifname, af, &addr.to_string(), "-alias"]) + .output() + }; + + match output { + Ok(output) => { + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let lc = stderr.to_lowercase(); + // These are acceptable — the IP wasn't present to begin with + if !lc.contains("not found") && !lc.contains("can't assign") + { + error!( + log, + "failed to remove {addr} from system: {stderr}" + ); + return; + } + } + info!(log, "removed {addr} from system"); + } + Err(e) => { + error!( + log, + "failed to execute remove command for {addr}: {e}" + ); + } + } + } +} + +/// Returns true for addresses that are always present on loopback interfaces +/// and should never be installed or removed by the manager. +fn is_always_present(addr: IpAddr) -> bool { + addr == IpAddr::V4(std::net::Ipv4Addr::LOCALHOST) + || addr == IpAddr::V6(std::net::Ipv6Addr::LOCALHOST) +} + +fn flock(path: &str) -> std::io::Result { + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path)?; + file.lock_exclusive()?; + Ok(file) +} + +fn read_refcount(file: &mut File) -> u32 { + let mut contents = String::new(); + file.seek(SeekFrom::Start(0)).ok(); + file.read_to_string(&mut contents).ok(); + contents.trim().parse().unwrap_or(0) +} + +fn write_refcount(file: &mut File, count: u32) -> std::io::Result<()> { + file.seek(SeekFrom::Start(0))?; + file.set_len(0)?; + write!(file, "{}", count)?; + file.flush()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::{Arc, Mutex}; + + fn nop_logger() -> slog::Logger { + slog::Logger::root(slog::Discard, slog::o!()) + } + + /// Returns a unique path in /tmp safe for use across parallel test threads. + fn temp_path() -> String { + static COUNTER: AtomicU64 = AtomicU64::new(0); + format!( + "/tmp/loopback-ip-mgr-test-{}-{}.lock", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed), + ) + } + + // ── is_always_present ───────────────────────────────────────────────────── + + #[test] + fn always_present_ipv4_localhost() { + assert!(is_always_present(IpAddr::V4(Ipv4Addr::LOCALHOST))); + } + + #[test] + fn always_present_ipv6_localhost() { + assert!(is_always_present(IpAddr::V6(Ipv6Addr::LOCALHOST))); + } + + #[test] + fn not_always_present_other_ipv4() { + assert!(!is_always_present(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)))); + assert!(!is_always_present(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)))); + } + + #[test] + fn not_always_present_other_ipv6() { + assert!(!is_always_present("::2".parse().unwrap())); + assert!(!is_always_present("fe80::1".parse().unwrap())); + } + + // ── read_refcount / write_refcount ──────────────────────────────────────── + + #[test] + fn read_refcount_empty_file_returns_zero() { + let path = temp_path(); + let mut file = File::create(&path).unwrap(); + assert_eq!(read_refcount(&mut file), 0); + std::fs::remove_file(&path).ok(); + } + + #[test] + fn write_then_read_refcount() { + let path = temp_path(); + let mut file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(&path) + .unwrap(); + write_refcount(&mut file, 42).unwrap(); + assert_eq!(read_refcount(&mut file), 42); + std::fs::remove_file(&path).ok(); + } + + #[test] + fn overwrite_refcount() { + let path = temp_path(); + let mut file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(&path) + .unwrap(); + write_refcount(&mut file, 10).unwrap(); + write_refcount(&mut file, 3).unwrap(); + assert_eq!(read_refcount(&mut file), 3); + std::fs::remove_file(&path).ok(); + } + + // ── flock ───────────────────────────────────────────────────────────────── + + #[test] + fn flock_creates_and_locks_file() { + let path = temp_path(); + { + let _f = flock(&path).expect("flock should succeed"); + assert!(std::path::Path::new(&path).exists()); + } + std::fs::remove_file(&path).ok(); + } + + // ── LoopbackIpManager – always-present addresses ────────────────────────── + // + // 127.0.0.1 and ::1 skip system command execution entirely, so these tests + // run without elevated privileges. + + #[test] + fn allocate_loopback_v4() { + let mgr = Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); + let addr = IpAddr::V4(Ipv4Addr::LOCALHOST); + let alloc = LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]) + .expect("allocate loopback v4"); + assert_eq!(alloc.addresses, vec![addr]); + let inner = mgr.lock().unwrap(); + let ip = inner.ips.iter().find(|ip| ip.address == addr).unwrap(); + assert_eq!(ip.use_count, 1); + } + + #[test] + fn allocate_loopback_v6() { + let mgr = Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); + let addr = IpAddr::V6(Ipv6Addr::LOCALHOST); + let alloc = LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]) + .expect("allocate loopback v6"); + assert_eq!(alloc.addresses, vec![addr]); + let inner = mgr.lock().unwrap(); + let ip = inner.ips.iter().find(|ip| ip.address == addr).unwrap(); + assert_eq!(ip.use_count, 1); + } + + #[test] + fn drop_allocation_resets_use_count() { + let mgr = Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); + let addr = IpAddr::V4(Ipv4Addr::LOCALHOST); + { + let _alloc = + LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).unwrap(); + assert_eq!( + mgr.lock().unwrap().ips.iter().find(|ip| ip.address == addr).unwrap().use_count, + 1 + ); + } + // After drop, use_count should be back to 0. + assert_eq!( + mgr.lock().unwrap().ips.iter().find(|ip| ip.address == addr).unwrap().use_count, + 0 + ); + } + + #[test] + fn double_allocate_increments_use_count() { + let mgr = Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); + let addr = IpAddr::V4(Ipv4Addr::LOCALHOST); + let _a1 = LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).unwrap(); + let _a2 = LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).unwrap(); + assert_eq!( + mgr.lock().unwrap().ips.iter().find(|ip| ip.address == addr).unwrap().use_count, + 2 + ); + } + + #[test] + fn use_count_decrements_on_partial_drop() { + let mgr = Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); + let addr = IpAddr::V4(Ipv4Addr::LOCALHOST); + let _a1 = LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).unwrap(); + { + let _a2 = + LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).unwrap(); + assert_eq!( + mgr.lock().unwrap().ips.iter().find(|ip| ip.address == addr).unwrap().use_count, + 2 + ); + } + // a2 dropped: back to 1. + assert_eq!( + mgr.lock().unwrap().ips.iter().find(|ip| ip.address == addr).unwrap().use_count, + 1 + ); + } + + #[test] + fn allocate_multiple_addresses() { + let mgr = Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); + let addrs = vec![ + IpAddr::V4(Ipv4Addr::LOCALHOST), + IpAddr::V6(Ipv6Addr::LOCALHOST), + ]; + let alloc = LoopbackIpManager::allocate(Arc::clone(&mgr), &addrs).unwrap(); + assert_eq!(alloc.addresses.len(), 2); + let inner = mgr.lock().unwrap(); + for addr in &addrs { + let ip = inner.ips.iter().find(|ip| ip.address == *addr).unwrap(); + assert_eq!(ip.use_count, 1); + } + } + + #[test] + fn add_does_not_duplicate_entries() { + let mgr = Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); + let addr = IpAddr::V4(Ipv4Addr::LOCALHOST); + let _a1 = LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).unwrap(); + let _a2 = LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).unwrap(); + let inner = mgr.lock().unwrap(); + assert_eq!(inner.ips.iter().filter(|ip| ip.address == addr).count(), 1); + } + + // ── System install / uninstall (illumos, requires pfexec) ───────────────── + // + // These tests actually add/remove addresses on lo0 using ipadm. They rely + // on the test runner being pfexec (see .cargo/config.toml). + // + // Test IPs are from 127.42.0.0/16, which is unused loopback space. + + #[cfg(target_os = "illumos")] + fn is_addr_installed(addr: IpAddr) -> bool { + let out = Command::new("ipadm") + .args(["show-addr", "-p", "-o", "addr"]) + .output() + .expect("ipadm show-addr"); + let prefix = addr.to_string(); + String::from_utf8_lossy(&out.stdout) + .lines() + .any(|line| line.trim().starts_with(&prefix)) + } + + #[test] + #[cfg(target_os = "illumos")] + fn install_and_uninstall_ipv4() { + let addr: IpAddr = "127.42.1.1".parse().unwrap(); + assert!(!is_addr_installed(addr), "127.42.1.1 already present; clean it up first"); + + let mgr = Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); + { + let _alloc = + LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).expect("allocate"); + assert!(is_addr_installed(addr), "address should be installed"); + } + assert!(!is_addr_installed(addr), "address should be removed after drop"); + } + + /// Two allocations from the same manager: the IP is installed once and + /// removed only after both allocations are dropped. + #[test] + #[cfg(target_os = "illumos")] + fn double_alloc_installs_once_removes_once() { + let addr: IpAddr = "127.42.1.2".parse().unwrap(); + assert!(!is_addr_installed(addr), "127.42.1.2 already present; clean it up first"); + + let mgr = Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); + let a1 = LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).unwrap(); + let a2 = LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).unwrap(); + assert!(is_addr_installed(addr)); + + drop(a2); + assert!(is_addr_installed(addr), "should still be installed while a1 is live"); + + drop(a1); + assert!(!is_addr_installed(addr), "should be removed after all allocations drop"); + } + + /// Cross-process refcount: a child process acquires the same IP, then exits. + /// The IP must persist while the parent still holds it and be removed only + /// after the parent drops its allocation too. + /// + /// Coordination uses two signal files: + /// - READY: child creates this once it has acquired the IP + /// - RELEASE: parent creates this to tell the child to drop and exit + /// + /// The child is launched by re-running the test binary with the helper test + /// `helper_cross_process_child` selected via an env-var guard. + #[test] + #[cfg(target_os = "illumos")] + fn cross_process_refcount() { + const ADDR: &str = "127.42.1.3"; + let ready = + format!("/tmp/loopback-mgr-test-xp-ready-{}", std::process::id()); + let release = + format!("/tmp/loopback-mgr-test-xp-release-{}", std::process::id()); + + let addr: IpAddr = ADDR.parse().unwrap(); + assert!(!is_addr_installed(addr), "127.42.1.3 already present; clean it up first"); + + let _ = std::fs::remove_file(&ready); + let _ = std::fs::remove_file(&release); + + // Spawn a child process that acquires the IP then waits for our signal. + // Re-running via pfexec ensures the child has the same privileges. + let mut child = Command::new("pfexec") + .arg(std::env::current_exe().unwrap()) + .env("LOOPBACK_HELPER_ADDR", ADDR) + .env("LOOPBACK_HELPER_READY", &ready) + .env("LOOPBACK_HELPER_RELEASE", &release) + .args(["helper_cross_process_child", "--nocapture"]) + .spawn() + .expect("spawn helper"); + + // Wait for child to signal readiness (IP installed, flock held). + let deadline = + std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + if std::path::Path::new(&ready).exists() { + break; + } + assert!( + std::time::Instant::now() < deadline, + "helper did not signal ready in time" + ); + std::thread::sleep(std::time::Duration::from_millis(50)); + } + assert!(is_addr_installed(addr), "child should have installed the IP"); + + // Tell the child to release and wait for it to exit. + // After the child exits its allocation is dropped (refcount 1 → 0), but + // because our allocation below also holds a reference the IP should + // remain installed. + // + // NOTE: we acquire the parent allocation AFTER the child exits so that + // the two exclusive flocks are never held concurrently (the library + // holds flock(LOCK_EX) for the allocation's entire lifetime, so two + // concurrent holders in the same kernel lock domain would deadlock). + File::create(&release).unwrap(); + let status = child.wait().expect("wait for child"); + assert!(status.success(), "helper exited with non-zero status"); + + // Child removed the IP (its was the sole holder; refcount 1 → 0). + // Now the parent re-installs it. + let mgr = Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); + let alloc = + LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).unwrap(); + assert!(is_addr_installed(addr)); + + drop(alloc); + assert!(!is_addr_installed(addr), "IP should be removed after parent drops"); + + let _ = std::fs::remove_file(&ready); + let _ = std::fs::remove_file(&release); + } + + /// Helper invoked as a child process by `cross_process_refcount`. + /// Skipped silently when the env-var guard is absent (normal test runs). + #[test] + #[cfg(target_os = "illumos")] + fn helper_cross_process_child() { + let addr_str = match std::env::var("LOOPBACK_HELPER_ADDR") { + Ok(s) => s, + Err(_) => return, + }; + let ready = std::env::var("LOOPBACK_HELPER_READY").unwrap(); + let release = std::env::var("LOOPBACK_HELPER_RELEASE").unwrap(); + + let addr: IpAddr = addr_str.parse().unwrap(); + let mgr = Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); + let _alloc = + LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).unwrap(); + + // Signal parent that the IP is installed and we're holding the flock. + File::create(&ready).unwrap(); + + // Wait for the parent's release signal. + let deadline = + std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + if std::path::Path::new(&release).exists() { + break; + } + assert!( + std::time::Instant::now() < deadline, + "helper timed out waiting for release signal" + ); + std::thread::sleep(std::time::Duration::from_millis(50)); + } + // _alloc dropped here → IP uninstalled (we're the only holder). + } +} From 23ada1d8fa82e08ca6528e0784eb441cd9184b14 Mon Sep 17 00:00:00 2001 From: Levon Tarver Date: Wed, 15 Apr 2026 20:09:44 +0000 Subject: [PATCH 2/7] cargo fmt --- loopback-ip-mgr/src/lib.rs | 162 ++++++++++++++++++++++++++----------- 1 file changed, 114 insertions(+), 48 deletions(-) diff --git a/loopback-ip-mgr/src/lib.rs b/loopback-ip-mgr/src/lib.rs index 92d994d..a7332fd 100644 --- a/loopback-ip-mgr/src/lib.rs +++ b/loopback-ip-mgr/src/lib.rs @@ -8,7 +8,7 @@ //! loopback interface with cross-process reference counting, ensuring proper //! cleanup even if tests panic. -use slog::{Logger, error, info}; +use slog::{error, info, Logger}; use std::fs::{File, OpenOptions}; use std::io::{Read, Seek, SeekFrom, Write}; use std::net::IpAddr; @@ -285,9 +285,7 @@ impl LoopbackIpManager { ip.address, ); drop(lockfile); - if let Err(e) = - std::fs::remove_file(&lockfile_path) - { + if let Err(e) = std::fs::remove_file(&lockfile_path) { error!( self.log, "failed to remove lockfile {}: {e}", @@ -310,11 +308,7 @@ impl LoopbackIpManager { } } - fn remove_ip_from_system_static( - ifname: &str, - log: &Logger, - addr: IpAddr, - ) { + fn remove_ip_from_system_static(ifname: &str, log: &Logger, addr: IpAddr) { #[cfg(target_os = "illumos")] let output = { let v = match addr { @@ -370,10 +364,7 @@ impl LoopbackIpManager { info!(log, "removed {addr} from system"); } Err(e) => { - error!( - log, - "failed to execute remove command for {addr}: {e}" - ); + error!(log, "failed to execute remove command for {addr}: {e}"); } } } @@ -448,7 +439,9 @@ mod tests { #[test] fn not_always_present_other_ipv4() { assert!(!is_always_present(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)))); - assert!(!is_always_present(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)))); + assert!(!is_always_present(IpAddr::V4(Ipv4Addr::new( + 192, 168, 1, 1 + )))); } #[test] @@ -517,7 +510,8 @@ mod tests { #[test] fn allocate_loopback_v4() { - let mgr = Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); + let mgr = + Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); let addr = IpAddr::V4(Ipv4Addr::LOCALHOST); let alloc = LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]) .expect("allocate loopback v4"); @@ -529,7 +523,8 @@ mod tests { #[test] fn allocate_loopback_v6() { - let mgr = Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); + let mgr = + Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); let addr = IpAddr::V6(Ipv6Addr::LOCALHOST); let alloc = LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]) .expect("allocate loopback v6"); @@ -541,63 +536,101 @@ mod tests { #[test] fn drop_allocation_resets_use_count() { - let mgr = Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); + let mgr = + Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); let addr = IpAddr::V4(Ipv4Addr::LOCALHOST); { let _alloc = LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).unwrap(); assert_eq!( - mgr.lock().unwrap().ips.iter().find(|ip| ip.address == addr).unwrap().use_count, + mgr.lock() + .unwrap() + .ips + .iter() + .find(|ip| ip.address == addr) + .unwrap() + .use_count, 1 ); } // After drop, use_count should be back to 0. assert_eq!( - mgr.lock().unwrap().ips.iter().find(|ip| ip.address == addr).unwrap().use_count, + mgr.lock() + .unwrap() + .ips + .iter() + .find(|ip| ip.address == addr) + .unwrap() + .use_count, 0 ); } #[test] fn double_allocate_increments_use_count() { - let mgr = Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); + let mgr = + Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); let addr = IpAddr::V4(Ipv4Addr::LOCALHOST); - let _a1 = LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).unwrap(); - let _a2 = LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).unwrap(); + let _a1 = + LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).unwrap(); + let _a2 = + LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).unwrap(); assert_eq!( - mgr.lock().unwrap().ips.iter().find(|ip| ip.address == addr).unwrap().use_count, + mgr.lock() + .unwrap() + .ips + .iter() + .find(|ip| ip.address == addr) + .unwrap() + .use_count, 2 ); } #[test] fn use_count_decrements_on_partial_drop() { - let mgr = Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); + let mgr = + Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); let addr = IpAddr::V4(Ipv4Addr::LOCALHOST); - let _a1 = LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).unwrap(); + let _a1 = + LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).unwrap(); { let _a2 = LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).unwrap(); assert_eq!( - mgr.lock().unwrap().ips.iter().find(|ip| ip.address == addr).unwrap().use_count, + mgr.lock() + .unwrap() + .ips + .iter() + .find(|ip| ip.address == addr) + .unwrap() + .use_count, 2 ); } // a2 dropped: back to 1. assert_eq!( - mgr.lock().unwrap().ips.iter().find(|ip| ip.address == addr).unwrap().use_count, + mgr.lock() + .unwrap() + .ips + .iter() + .find(|ip| ip.address == addr) + .unwrap() + .use_count, 1 ); } #[test] fn allocate_multiple_addresses() { - let mgr = Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); + let mgr = + Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); let addrs = vec![ IpAddr::V4(Ipv4Addr::LOCALHOST), IpAddr::V6(Ipv6Addr::LOCALHOST), ]; - let alloc = LoopbackIpManager::allocate(Arc::clone(&mgr), &addrs).unwrap(); + let alloc = + LoopbackIpManager::allocate(Arc::clone(&mgr), &addrs).unwrap(); assert_eq!(alloc.addresses.len(), 2); let inner = mgr.lock().unwrap(); for addr in &addrs { @@ -608,10 +641,13 @@ mod tests { #[test] fn add_does_not_duplicate_entries() { - let mgr = Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); + let mgr = + Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); let addr = IpAddr::V4(Ipv4Addr::LOCALHOST); - let _a1 = LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).unwrap(); - let _a2 = LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).unwrap(); + let _a1 = + LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).unwrap(); + let _a2 = + LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).unwrap(); let inner = mgr.lock().unwrap(); assert_eq!(inner.ips.iter().filter(|ip| ip.address == addr).count(), 1); } @@ -639,15 +675,22 @@ mod tests { #[cfg(target_os = "illumos")] fn install_and_uninstall_ipv4() { let addr: IpAddr = "127.42.1.1".parse().unwrap(); - assert!(!is_addr_installed(addr), "127.42.1.1 already present; clean it up first"); + assert!( + !is_addr_installed(addr), + "127.42.1.1 already present; clean it up first" + ); - let mgr = Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); + let mgr = + Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); { - let _alloc = - LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).expect("allocate"); + let _alloc = LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]) + .expect("allocate"); assert!(is_addr_installed(addr), "address should be installed"); } - assert!(!is_addr_installed(addr), "address should be removed after drop"); + assert!( + !is_addr_installed(addr), + "address should be removed after drop" + ); } /// Two allocations from the same manager: the IP is installed once and @@ -656,18 +699,30 @@ mod tests { #[cfg(target_os = "illumos")] fn double_alloc_installs_once_removes_once() { let addr: IpAddr = "127.42.1.2".parse().unwrap(); - assert!(!is_addr_installed(addr), "127.42.1.2 already present; clean it up first"); + assert!( + !is_addr_installed(addr), + "127.42.1.2 already present; clean it up first" + ); - let mgr = Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); - let a1 = LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).unwrap(); - let a2 = LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).unwrap(); + let mgr = + Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); + let a1 = + LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).unwrap(); + let a2 = + LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).unwrap(); assert!(is_addr_installed(addr)); drop(a2); - assert!(is_addr_installed(addr), "should still be installed while a1 is live"); + assert!( + is_addr_installed(addr), + "should still be installed while a1 is live" + ); drop(a1); - assert!(!is_addr_installed(addr), "should be removed after all allocations drop"); + assert!( + !is_addr_installed(addr), + "should be removed after all allocations drop" + ); } /// Cross-process refcount: a child process acquires the same IP, then exits. @@ -690,7 +745,10 @@ mod tests { format!("/tmp/loopback-mgr-test-xp-release-{}", std::process::id()); let addr: IpAddr = ADDR.parse().unwrap(); - assert!(!is_addr_installed(addr), "127.42.1.3 already present; clean it up first"); + assert!( + !is_addr_installed(addr), + "127.42.1.3 already present; clean it up first" + ); let _ = std::fs::remove_file(&ready); let _ = std::fs::remove_file(&release); @@ -719,7 +777,10 @@ mod tests { ); std::thread::sleep(std::time::Duration::from_millis(50)); } - assert!(is_addr_installed(addr), "child should have installed the IP"); + assert!( + is_addr_installed(addr), + "child should have installed the IP" + ); // Tell the child to release and wait for it to exit. // After the child exits its allocation is dropped (refcount 1 → 0), but @@ -736,13 +797,17 @@ mod tests { // Child removed the IP (its was the sole holder; refcount 1 → 0). // Now the parent re-installs it. - let mgr = Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); + let mgr = + Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); let alloc = LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).unwrap(); assert!(is_addr_installed(addr)); drop(alloc); - assert!(!is_addr_installed(addr), "IP should be removed after parent drops"); + assert!( + !is_addr_installed(addr), + "IP should be removed after parent drops" + ); let _ = std::fs::remove_file(&ready); let _ = std::fs::remove_file(&release); @@ -761,7 +826,8 @@ mod tests { let release = std::env::var("LOOPBACK_HELPER_RELEASE").unwrap(); let addr: IpAddr = addr_str.parse().unwrap(); - let mgr = Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); + let mgr = + Arc::new(Mutex::new(LoopbackIpManager::new("lo0", nop_logger()))); let _alloc = LoopbackIpManager::allocate(Arc::clone(&mgr), &[addr]).unwrap(); From 0d2d2ac09f6b990e6bbd7f40c2177498f3b80696 Mon Sep 17 00:00:00 2001 From: Levon Tarver Date: Thu, 30 Apr 2026 04:54:14 +0000 Subject: [PATCH 3/7] use network-interface for cross platform addr lookups --- Cargo.lock | 34 ++++++++++++++++++++++ Cargo.toml | 1 + loopback-ip-mgr/Cargo.toml | 1 + loopback-ip-mgr/src/lib.rs | 58 ++++++++++++++++++++------------------ 4 files changed, 67 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c80a7f8..22ea2b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1175,6 +1175,7 @@ name = "loopback-ip-mgr" version = "0.1.0" dependencies = [ "libc", + "network-interface", "slog", ] @@ -1212,6 +1213,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "network-interface" +version = "0.1.7" +source = "git+https://github.com/oxidecomputer/network-interface?branch=illumos#5a696e910333bdc50ef56cebe9cdd78e40127d87" +dependencies = [ + "cc", + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -2924,6 +2936,28 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 6f52100..496b60f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ zone = "0.3.1" regex = "1.12" smf = "0.2" libnet = { git = "https://github.com/oxidecomputer/netadm-sys", branch = "main" } +network-interface = { git = "https://github.com/oxidecomputer/network-interface", branch = "illumos" } uuid = { version = "1.22.0", features = [ "serde", "v4" ] } serde = "1.0" ron = "0.12" diff --git a/loopback-ip-mgr/Cargo.toml b/loopback-ip-mgr/Cargo.toml index 7d4ae12..b092585 100644 --- a/loopback-ip-mgr/Cargo.toml +++ b/loopback-ip-mgr/Cargo.toml @@ -5,4 +5,5 @@ edition = "2021" [dependencies] libc.workspace = true +network-interface.workspace = true slog.workspace = true diff --git a/loopback-ip-mgr/src/lib.rs b/loopback-ip-mgr/src/lib.rs index a7332fd..3f4339d 100644 --- a/loopback-ip-mgr/src/lib.rs +++ b/loopback-ip-mgr/src/lib.rs @@ -8,6 +8,7 @@ //! loopback interface with cross-process reference counting, ensuring proper //! cleanup even if tests panic. +use network_interface::{NetworkInterface, NetworkInterfaceConfig}; use slog::{error, info, Logger}; use std::fs::{File, OpenOptions}; use std::io::{Read, Seek, SeekFrom, Write}; @@ -168,6 +169,13 @@ impl LoopbackIpManager { log: &Logger, ip: &ManagedIp, ) -> Result<(), std::io::Error> { + // Another process may have installed the address while we held the + // flock. Skip the shell command to avoid a spurious error. + if is_addr_on_system(ip.address) { + info!(log, "{}: already on system, skipping install", ip.address); + return Ok(()); + } + let mask = match ip.address { IpAddr::V4(_) => 32, IpAddr::V6(_) => 128, @@ -215,14 +223,11 @@ impl LoopbackIpManager { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - // "Address already exists/assigned" is ok — another process beat us to it - if !stderr.to_lowercase().contains("already") { - error!(log, "failed to install {}: {stderr}", ip.address); - return Err(std::io::Error::other(format!( - "failed to install {}", - ip.address - ))); - } + error!(log, "failed to install {}: {stderr}", ip.address); + return Err(std::io::Error::other(format!( + "failed to install {}", + ip.address + ))); } info!(log, "added {} to system", ip.address); Ok(()) @@ -309,6 +314,12 @@ impl LoopbackIpManager { } fn remove_ip_from_system_static(ifname: &str, log: &Logger, addr: IpAddr) { + // Skip the shell command if the address isn't on the system at all. + if !is_addr_on_system(addr) { + info!(log, "{addr}: not on system, skipping removal"); + return; + } + #[cfg(target_os = "illumos")] let output = { let v = match addr { @@ -350,16 +361,8 @@ impl LoopbackIpManager { Ok(output) => { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - let lc = stderr.to_lowercase(); - // These are acceptable — the IP wasn't present to begin with - if !lc.contains("not found") && !lc.contains("can't assign") - { - error!( - log, - "failed to remove {addr} from system: {stderr}" - ); - return; - } + error!(log, "failed to remove {addr} from system: {stderr}"); + return; } info!(log, "removed {addr} from system"); } @@ -370,6 +373,15 @@ impl LoopbackIpManager { } } +/// Returns true if the address is currently present on any interface. +fn is_addr_on_system(addr: IpAddr) -> bool { + NetworkInterface::show() + .unwrap_or_default() + .into_iter() + .filter_map(|iface| iface.addr) + .any(|a| a.ip() == addr) +} + /// Returns true for addresses that are always present on loopback interfaces /// and should never be installed or removed by the manager. fn is_always_present(addr: IpAddr) -> bool { @@ -659,16 +671,8 @@ mod tests { // // Test IPs are from 127.42.0.0/16, which is unused loopback space. - #[cfg(target_os = "illumos")] fn is_addr_installed(addr: IpAddr) -> bool { - let out = Command::new("ipadm") - .args(["show-addr", "-p", "-o", "addr"]) - .output() - .expect("ipadm show-addr"); - let prefix = addr.to_string(); - String::from_utf8_lossy(&out.stdout) - .lines() - .any(|line| line.trim().starts_with(&prefix)) + is_addr_on_system(addr) } #[test] From 591099c90ea346e2fb48a856e9b6d8cb54d2d92a Mon Sep 17 00:00:00 2001 From: Levon Tarver Date: Thu, 30 Apr 2026 04:54:35 +0000 Subject: [PATCH 4/7] cargo fmt --- loopback-ip-mgr/src/lib.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/loopback-ip-mgr/src/lib.rs b/loopback-ip-mgr/src/lib.rs index 3f4339d..4870acc 100644 --- a/loopback-ip-mgr/src/lib.rs +++ b/loopback-ip-mgr/src/lib.rs @@ -361,7 +361,10 @@ impl LoopbackIpManager { Ok(output) => { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - error!(log, "failed to remove {addr} from system: {stderr}"); + error!( + log, + "failed to remove {addr} from system: {stderr}" + ); return; } info!(log, "removed {addr} from system"); From 8b9c630c323d0857ea1754ced733ba900353e6ca Mon Sep 17 00:00:00 2001 From: Levon Tarver Date: Thu, 30 Apr 2026 16:14:42 +0000 Subject: [PATCH 5/7] use netadm-sys for illumos --- Cargo.lock | 2 + loopback-ip-mgr/Cargo.toml | 4 + loopback-ip-mgr/src/lib.rs | 191 +++++++++++++++++++------------------ 3 files changed, 106 insertions(+), 91 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 22ea2b9..0cda217 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1175,7 +1175,9 @@ name = "loopback-ip-mgr" version = "0.1.0" dependencies = [ "libc", + "libnet", "network-interface", + "oxnet", "slog", ] diff --git a/loopback-ip-mgr/Cargo.toml b/loopback-ip-mgr/Cargo.toml index b092585..c3dc753 100644 --- a/loopback-ip-mgr/Cargo.toml +++ b/loopback-ip-mgr/Cargo.toml @@ -7,3 +7,7 @@ edition = "2021" libc.workspace = true network-interface.workspace = true slog.workspace = true + +[target.'cfg(target_os = "illumos")'.dependencies] +libnet.workspace = true +oxnet.workspace = true diff --git a/loopback-ip-mgr/src/lib.rs b/loopback-ip-mgr/src/lib.rs index 4870acc..1b744f8 100644 --- a/loopback-ip-mgr/src/lib.rs +++ b/loopback-ip-mgr/src/lib.rs @@ -14,6 +14,7 @@ use std::fs::{File, OpenOptions}; use std::io::{Read, Seek, SeekFrom, Write}; use std::net::IpAddr; use std::os::unix::io::AsRawFd; +#[cfg(any(not(target_os = "illumos"), test))] use std::process::Command; use std::sync::{Arc, Mutex}; @@ -176,61 +177,52 @@ impl LoopbackIpManager { return Ok(()); } - let mask = match ip.address { - IpAddr::V4(_) => 32, - IpAddr::V6(_) => 128, - }; - let addr_str = format!("{}/{mask}", ip.address); - #[cfg(target_os = "illumos")] - let output = { - let v = match ip.address { - IpAddr::V4(_) => "v4", - IpAddr::V6(_) => "v6", - }; - let mut ip_descr = format!("{v}{}", ip.address); - ip_descr.retain(|c| c.is_alphanumeric()); - let addr_obj = format!("{}/{}", ifname, ip_descr); - let cmd = [ - "ipadm", - "create-addr", - "-t", - "-T", - "static", - "-a", - &addr_str, - &addr_obj, - ]; - info!(log, "running cmd '{cmd:?}'"); - Command::new("pfexec").args(cmd).output()? - }; - - #[cfg(target_os = "linux")] - let output = Command::new("sudo") - .args(["ip", "addr", "add", &addr_str, "dev", ifname]) - .output()?; + { + let addr_obj = illumos_addr_obj(ifname, ip.address); + let net = oxnet::IpNet::host_net(ip.address); + info!(log, "creating addr object '{addr_obj}'"); + libnet::create_ipaddr(&addr_obj, net) + .map_err(|e| std::io::Error::other(e.to_string()))?; + info!(log, "added {} to system", ip.address); + return Ok(()); + } - #[cfg(target_os = "macos")] - let output = { - let af = match ip.address { - IpAddr::V4(_) => "inet", - IpAddr::V6(_) => "inet6", + #[cfg(not(target_os = "illumos"))] + { + let mask = match ip.address { + IpAddr::V4(_) => 32, + IpAddr::V6(_) => 128, + }; + let addr_str = format!("{}/{mask}", ip.address); + + #[cfg(target_os = "linux")] + let output = Command::new("sudo") + .args(["ip", "addr", "add", &addr_str, "dev", ifname]) + .output()?; + + #[cfg(target_os = "macos")] + let output = { + let af = match ip.address { + IpAddr::V4(_) => "inet", + IpAddr::V6(_) => "inet6", + }; + Command::new("sudo") + .args(["ifconfig", ifname, af, &addr_str, "alias"]) + .output()? }; - Command::new("sudo") - .args(["ifconfig", ifname, af, &addr_str, "alias"]) - .output()? - }; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - error!(log, "failed to install {}: {stderr}", ip.address); - return Err(std::io::Error::other(format!( - "failed to install {}", - ip.address - ))); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + error!(log, "failed to install {}: {stderr}", ip.address); + return Err(std::io::Error::other(format!( + "failed to install {}", + ip.address + ))); + } + info!(log, "added {} to system", ip.address); + Ok(()) } - info!(log, "added {} to system", ip.address); - Ok(()) } /// Uninstall specific addresses (called by [`IpAllocation`] on drop). @@ -321,61 +313,78 @@ impl LoopbackIpManager { } #[cfg(target_os = "illumos")] - let output = { - let v = match addr { - IpAddr::V4(_) => "v4", - IpAddr::V6(_) => "v6", - }; - let mut ip_descr = format!("{v}{addr}"); - ip_descr.retain(|c| c.is_alphanumeric()); - let addr_obj = format!("{ifname}/{ip_descr}"); - Command::new("pfexec") - .args(["ipadm", "delete-addr", &addr_obj]) - .output() - }; + { + let addr_obj = illumos_addr_obj(ifname, addr); + info!(log, "deleting addr object '{addr_obj}'"); + match libnet::delete_ipaddr(&addr_obj) { + Ok(()) => info!(log, "removed {addr} from system"), + Err(e) => error!(log, "failed to remove {addr} from system: {e}"), + } + return; + } - #[cfg(target_os = "linux")] - let output = { - let mask = match addr { - IpAddr::V4(_) => 32, - IpAddr::V6(_) => 128, + #[cfg(not(target_os = "illumos"))] + { + #[cfg(target_os = "linux")] + let output = { + let mask = match addr { + IpAddr::V4(_) => 32, + IpAddr::V6(_) => 128, + }; + let addr_str = format!("{addr}/{mask}"); + Command::new("sudo") + .args(["ip", "addr", "del", &addr_str, "dev", ifname]) + .output() }; - let addr_str = format!("{addr}/{mask}"); - Command::new("sudo") - .args(["ip", "addr", "del", &addr_str, "dev", ifname]) - .output() - }; - #[cfg(target_os = "macos")] - let output = { - let af = match addr { - IpAddr::V4(_) => "inet", - IpAddr::V6(_) => "inet6", + #[cfg(target_os = "macos")] + let output = { + let af = match addr { + IpAddr::V4(_) => "inet", + IpAddr::V6(_) => "inet6", + }; + Command::new("sudo") + .args(["ifconfig", ifname, af, &addr.to_string(), "-alias"]) + .output() }; - Command::new("sudo") - .args(["ifconfig", ifname, af, &addr.to_string(), "-alias"]) - .output() - }; - match output { - Ok(output) => { - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); + match output { + Ok(output) => { + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + error!( + log, + "failed to remove {addr} from system: {stderr}" + ); + return; + } + info!(log, "removed {addr} from system"); + } + Err(e) => { error!( log, - "failed to remove {addr} from system: {stderr}" + "failed to execute remove command for {addr}: {e}" ); - return; } - info!(log, "removed {addr} from system"); - } - Err(e) => { - error!(log, "failed to execute remove command for {addr}: {e}"); } } } } +/// Builds the ipadm address-object name used by libnet on illumos. +/// +/// Format: `/`, e.g. `lo0/v412741​11`. +#[cfg(target_os = "illumos")] +fn illumos_addr_obj(ifname: &str, addr: IpAddr) -> String { + let v = match addr { + IpAddr::V4(_) => "v4", + IpAddr::V6(_) => "v6", + }; + let mut descr = format!("{v}{addr}"); + descr.retain(|c| c.is_alphanumeric()); + format!("{ifname}/{descr}") +} + /// Returns true if the address is currently present on any interface. fn is_addr_on_system(addr: IpAddr) -> bool { NetworkInterface::show() From 662dcbafd7dede3788d638e84ff15a59a2b141cf Mon Sep 17 00:00:00 2001 From: Levon Tarver Date: Thu, 30 Apr 2026 16:15:01 +0000 Subject: [PATCH 6/7] cargo fmt --- loopback-ip-mgr/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/loopback-ip-mgr/src/lib.rs b/loopback-ip-mgr/src/lib.rs index 1b744f8..7d0e8a2 100644 --- a/loopback-ip-mgr/src/lib.rs +++ b/loopback-ip-mgr/src/lib.rs @@ -318,7 +318,9 @@ impl LoopbackIpManager { info!(log, "deleting addr object '{addr_obj}'"); match libnet::delete_ipaddr(&addr_obj) { Ok(()) => info!(log, "removed {addr} from system"), - Err(e) => error!(log, "failed to remove {addr} from system: {e}"), + Err(e) => { + error!(log, "failed to remove {addr} from system: {e}") + } } return; } From aa44de0ab2edd702322ef60dd9fec1d1e70348ad Mon Sep 17 00:00:00 2001 From: Levon Tarver Date: Thu, 30 Apr 2026 16:21:17 +0000 Subject: [PATCH 7/7] clippy --- loopback-ip-mgr/src/lib.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/loopback-ip-mgr/src/lib.rs b/loopback-ip-mgr/src/lib.rs index 7d0e8a2..cfb7667 100644 --- a/loopback-ip-mgr/src/lib.rs +++ b/loopback-ip-mgr/src/lib.rs @@ -185,7 +185,7 @@ impl LoopbackIpManager { libnet::create_ipaddr(&addr_obj, net) .map_err(|e| std::io::Error::other(e.to_string()))?; info!(log, "added {} to system", ip.address); - return Ok(()); + Ok(()) } #[cfg(not(target_os = "illumos"))] @@ -322,7 +322,6 @@ impl LoopbackIpManager { error!(log, "failed to remove {addr} from system: {e}") } } - return; } #[cfg(not(target_os = "illumos"))]