use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use serde_yaml::Value as YamlValue;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::fmt::{Display, Formatter};
use std::fs;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use tokio::process::Command;
const NETWORK_TEST_ROOT_ENV: &str = "XBP_NETWORK_TEST_ROOT";
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum NetworkBackend {
Netplan,
NetworkManager,
Ifupdown,
Ifcfg,
Runtime,
Unknown,
}
impl Display for NetworkBackend {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
NetworkBackend::Netplan => write!(f, "netplan"),
NetworkBackend::NetworkManager => write!(f, "nm"),
NetworkBackend::Ifupdown => write!(f, "ifupdown"),
NetworkBackend::Ifcfg => write!(f, "ifcfg"),
NetworkBackend::Runtime => write!(f, "runtime"),
NetworkBackend::Unknown => write!(f, "unknown"),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct NetworkConfigSource {
pub backend: NetworkBackend,
pub path: String,
pub exists: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct NetworkConfigListResponse {
pub detected_backend: NetworkBackend,
pub sources: Vec<NetworkConfigSource>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FloatingIpEntry {
pub backend: NetworkBackend,
pub interface: String,
pub address_cidr: String,
pub runtime: bool,
pub config: bool,
pub source_file: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FloatingIpListResponse {
pub detected_backend: NetworkBackend,
pub items: Vec<FloatingIpEntry>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AddFloatingIpRequest {
pub ip: String,
pub cidr: Option<u8>,
pub interface: Option<String>,
pub label: Option<String>,
pub apply: bool,
pub dry_run: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AddFloatingIpResponse {
pub backend: NetworkBackend,
pub interface: String,
pub address_cidr: String,
pub changed: bool,
pub dry_run: bool,
pub applied: bool,
pub written_files: Vec<String>,
pub message: String,
}
#[derive(Clone, Debug)]
struct NetworkPaths {
netplan_dir: PathBuf,
ifupdown_main: PathBuf,
ifupdown_dir: PathBuf,
nm_dir: PathBuf,
ifcfg_dir: PathBuf,
}
#[derive(Clone, Debug)]
struct ConfigIp {
backend: NetworkBackend,
interface: String,
address_cidr: String,
source_file: String,
}
impl NetworkPaths {
fn load() -> Self {
let root = std::env::var(NETWORK_TEST_ROOT_ENV).ok();
let map = |absolute: &str| {
if let Some(root) = &root {
PathBuf::from(root).join(absolute.trim_start_matches('/'))
} else {
PathBuf::from(absolute)
}
};
Self {
netplan_dir: map("/etc/netplan"),
ifupdown_main: map("/etc/network/interfaces"),
ifupdown_dir: map("/etc/network/interfaces.d"),
nm_dir: map("/etc/NetworkManager/system-connections"),
ifcfg_dir: map("/etc/sysconfig/network-scripts"),
}
}
}
pub async fn list_network_config_sources() -> Result<NetworkConfigListResponse> {
ensure_linux_host()?;
let sources = discover_sources(&NetworkPaths::load())?;
let detected_backend = detect_backend_from_sources(&sources);
Ok(NetworkConfigListResponse {
detected_backend,
sources,
})
}
pub async fn list_floating_ips() -> Result<FloatingIpListResponse> {
ensure_linux_host()?;
let paths = NetworkPaths::load();
let sources = discover_sources(&paths)?;
let detected_backend = detect_backend_from_sources(&sources);
let config_entries = collect_config_floating_ips(&paths)?;
let runtime_entries = collect_runtime_ip_entries().await.unwrap_or_default();
let mut merged: BTreeMap<(String, String), FloatingIpEntry> = BTreeMap::new();
for entry in config_entries {
let key = (entry.interface.clone(), entry.address_cidr.clone());
merged.insert(
key,
FloatingIpEntry {
backend: entry.backend,
interface: entry.interface,
address_cidr: entry.address_cidr,
runtime: false,
config: true,
source_file: Some(entry.source_file),
},
);
}
for (interface, address_cidr) in runtime_entries {
let key = (interface.clone(), address_cidr.clone());
if let Some(existing) = merged.get_mut(&key) {
existing.runtime = true;
} else {
merged.insert(
key,
FloatingIpEntry {
backend: NetworkBackend::Runtime,
interface,
address_cidr,
runtime: true,
config: false,
source_file: None,
},
);
}
}
Ok(FloatingIpListResponse {
detected_backend,
items: merged.into_values().collect(),
})
}
pub async fn add_floating_ip(request: AddFloatingIpRequest) -> Result<AddFloatingIpResponse> {
ensure_linux_host()?;
let paths = NetworkPaths::load();
let sources = discover_sources(&paths)?;
let backend = detect_backend_from_sources(&sources);
if backend == NetworkBackend::Unknown {
return Err(anyhow!(
"No supported Linux network backend detected (netplan, NetworkManager, ifupdown, ifcfg)."
));
}
let parsed_ip: IpAddr = request
.ip
.parse()
.with_context(|| format!("Invalid IP address: {}", request.ip))?;
let cidr = normalize_cidr(parsed_ip, request.cidr)?;
let address_cidr = format!("{}/{}", parsed_ip, cidr);
let interface = if let Some(value) = request.interface.clone() {
value
} else {
detect_default_interface(parsed_ip).await?
};
let mut written_files = Vec::new();
let changed = match backend {
NetworkBackend::Netplan => add_netplan_entry(
&paths,
&interface,
&address_cidr,
request.dry_run,
&mut written_files,
)?,
NetworkBackend::NetworkManager => add_network_manager_entry(
&paths,
&interface,
&address_cidr,
request.dry_run,
&mut written_files,
)?,
NetworkBackend::Ifupdown => add_ifupdown_entry(
&paths,
&interface,
&address_cidr,
request.dry_run,
&mut written_files,
)?,
NetworkBackend::Ifcfg => add_ifcfg_entry(
&paths,
&interface,
&address_cidr,
request.dry_run,
&mut written_files,
)?,
NetworkBackend::Runtime | NetworkBackend::Unknown => false,
};
let mut applied = false;
if request.apply && !request.dry_run && changed {
apply_backend_changes(backend).await?;
applied = true;
}
Ok(AddFloatingIpResponse {
backend,
interface,
address_cidr,
changed,
dry_run: request.dry_run,
applied,
written_files,
message: if changed {
if request.dry_run {
"Dry run complete; no files were written.".to_string()
} else if applied {
"Floating IP written and network apply/reload completed.".to_string()
} else {
"Floating IP written. Run with --apply to apply immediately.".to_string()
}
} else {
"Floating IP already present; no change applied.".to_string()
},
})
}
fn ensure_linux_host() -> Result<()> {
if cfg!(target_os = "linux") {
Ok(())
} else {
Err(anyhow!(
"`xbp network` is currently supported on Linux hosts only."
))
}
}
fn discover_sources(paths: &NetworkPaths) -> Result<Vec<NetworkConfigSource>> {
let mut sources = Vec::new();
sources.push(NetworkConfigSource {
backend: NetworkBackend::Netplan,
path: paths.netplan_dir.display().to_string(),
exists: paths.netplan_dir.exists(),
});
sources.push(NetworkConfigSource {
backend: NetworkBackend::NetworkManager,
path: paths.nm_dir.display().to_string(),
exists: paths.nm_dir.exists(),
});
sources.push(NetworkConfigSource {
backend: NetworkBackend::Ifupdown,
path: paths.ifupdown_main.display().to_string(),
exists: paths.ifupdown_main.exists(),
});
sources.push(NetworkConfigSource {
backend: NetworkBackend::Ifupdown,
path: paths.ifupdown_dir.display().to_string(),
exists: paths.ifupdown_dir.exists(),
});
sources.push(NetworkConfigSource {
backend: NetworkBackend::Ifcfg,
path: paths.ifcfg_dir.display().to_string(),
exists: paths.ifcfg_dir.exists(),
});
if paths.netplan_dir.exists() {
for path in list_files_with_extensions(&paths.netplan_dir, &["yaml", "yml"])? {
sources.push(NetworkConfigSource {
backend: NetworkBackend::Netplan,
path: path.display().to_string(),
exists: true,
});
}
}
if paths.nm_dir.exists() {
for path in list_files_with_extensions(&paths.nm_dir, &["nmconnection"])? {
sources.push(NetworkConfigSource {
backend: NetworkBackend::NetworkManager,
path: path.display().to_string(),
exists: true,
});
}
}
if paths.ifupdown_dir.exists() {
for path in list_all_files(&paths.ifupdown_dir)? {
sources.push(NetworkConfigSource {
backend: NetworkBackend::Ifupdown,
path: path.display().to_string(),
exists: true,
});
}
}
if paths.ifcfg_dir.exists() {
for path in list_ifcfg_files(&paths.ifcfg_dir)? {
sources.push(NetworkConfigSource {
backend: NetworkBackend::Ifcfg,
path: path.display().to_string(),
exists: true,
});
}
}
Ok(sources)
}
fn detect_backend_from_sources(sources: &[NetworkConfigSource]) -> NetworkBackend {
if sources
.iter()
.any(|source| source.backend == NetworkBackend::Netplan && source.exists)
{
return NetworkBackend::Netplan;
}
if sources
.iter()
.any(|source| source.backend == NetworkBackend::NetworkManager && source.exists)
{
return NetworkBackend::NetworkManager;
}
if sources
.iter()
.any(|source| source.backend == NetworkBackend::Ifupdown && source.exists)
{
return NetworkBackend::Ifupdown;
}
if sources
.iter()
.any(|source| source.backend == NetworkBackend::Ifcfg && source.exists)
{
return NetworkBackend::Ifcfg;
}
NetworkBackend::Unknown
}
fn list_files_with_extensions(dir: &Path, exts: &[&str]) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
for entry in fs::read_dir(dir).with_context(|| format!("Failed to read {}", dir.display()))? {
let path = entry?.path();
if !path.is_file() {
continue;
}
let ext = path.extension().and_then(|value| value.to_str());
if ext.map(|ext| exts.contains(&ext)).unwrap_or(false) {
files.push(path);
}
}
files.sort();
Ok(files)
}
fn list_all_files(dir: &Path) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
for entry in fs::read_dir(dir).with_context(|| format!("Failed to read {}", dir.display()))? {
let path = entry?.path();
if path.is_file() {
files.push(path);
}
}
files.sort();
Ok(files)
}
fn list_ifcfg_files(dir: &Path) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
for path in list_all_files(dir)? {
if path
.file_name()
.and_then(|value| value.to_str())
.map(|name| name.starts_with("ifcfg-"))
.unwrap_or(false)
{
files.push(path);
}
}
Ok(files)
}
fn normalize_cidr(ip: IpAddr, cidr: Option<u8>) -> Result<u8> {
let value = cidr.unwrap_or_else(|| if ip.is_ipv4() { 32 } else { 64 });
if ip.is_ipv4() && value > 32 {
return Err(anyhow!("IPv4 CIDR must be <= 32"));
}
if ip.is_ipv6() && value > 128 {
return Err(anyhow!("IPv6 CIDR must be <= 128"));
}
Ok(value)
}
async fn detect_default_interface(ip: IpAddr) -> Result<String> {
let command_args = if ip.is_ipv4() {
vec!["route", "show", "default"]
} else {
vec!["-6", "route", "show", "default"]
};
if let Some(output) = run_command_capture("ip", &command_args).await {
if let Some(interface) = parse_default_interface_from_route_output(&output) {
return Ok(interface);
}
}
Ok("eth0".to_string())
}
fn parse_default_interface_from_route_output(content: &str) -> Option<String> {
for line in content.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
for index in 0..parts.len() {
if parts[index] == "dev" {
return parts.get(index + 1).map(|value| (*value).to_string());
}
}
}
None
}
async fn run_command_capture(program: &str, args: &[&str]) -> Option<String> {
let output = Command::new(program).args(args).output().await.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).to_string())
}
fn add_netplan_entry(
paths: &NetworkPaths,
interface: &str,
address_cidr: &str,
dry_run: bool,
written_files: &mut Vec<String>,
) -> Result<bool> {
let target_path = paths.netplan_dir.join("60-xbp-floating-ip.yaml");
let existing = if target_path.exists() {
fs::read_to_string(&target_path)
.with_context(|| format!("Failed to read {}", target_path.display()))?
} else {
String::new()
};
let (rendered, changed) = upsert_netplan_address(&existing, interface, address_cidr)?;
if changed && !dry_run {
fs::create_dir_all(&paths.netplan_dir)
.with_context(|| format!("Failed to create {}", paths.netplan_dir.display()))?;
fs::write(&target_path, rendered)
.with_context(|| format!("Failed to write {}", target_path.display()))?;
written_files.push(target_path.display().to_string());
}
Ok(changed)
}
fn upsert_netplan_address(
content: &str,
interface: &str,
address_cidr: &str,
) -> Result<(String, bool)> {
let mut root = if content.trim().is_empty() {
YamlValue::Mapping(Default::default())
} else {
serde_yaml::from_str::<YamlValue>(content).context("Failed to parse netplan YAML")?
};
let root_map = root
.as_mapping_mut()
.ok_or_else(|| anyhow!("Netplan root must be a mapping"))?;
let network_key = YamlValue::String("network".to_string());
if !root_map.contains_key(&network_key) {
root_map.insert(network_key.clone(), YamlValue::Mapping(Default::default()));
}
let network_map = root_map
.get_mut(&network_key)
.and_then(YamlValue::as_mapping_mut)
.ok_or_else(|| anyhow!("netplan.network must be a mapping"))?;
network_map.insert(
YamlValue::String("version".to_string()),
YamlValue::Number(serde_yaml::Number::from(2)),
);
network_map.insert(
YamlValue::String("renderer".to_string()),
YamlValue::String("networkd".to_string()),
);
let ethernets_key = YamlValue::String("ethernets".to_string());
if !network_map.contains_key(ðernets_key) {
network_map.insert(
ethernets_key.clone(),
YamlValue::Mapping(Default::default()),
);
}
let ethernets_map = network_map
.get_mut(ðernets_key)
.and_then(YamlValue::as_mapping_mut)
.ok_or_else(|| anyhow!("netplan.network.ethernets must be a mapping"))?;
let iface_key = YamlValue::String(interface.to_string());
if !ethernets_map.contains_key(&iface_key) {
ethernets_map.insert(iface_key.clone(), YamlValue::Mapping(Default::default()));
}
let iface_map = ethernets_map
.get_mut(&iface_key)
.and_then(YamlValue::as_mapping_mut)
.ok_or_else(|| anyhow!("netplan interface entry must be a mapping"))?;
let addresses_key = YamlValue::String("addresses".to_string());
if !iface_map.contains_key(&addresses_key) {
iface_map.insert(addresses_key.clone(), YamlValue::Sequence(vec![]));
}
let addresses = iface_map
.get_mut(&addresses_key)
.and_then(YamlValue::as_sequence_mut)
.ok_or_else(|| anyhow!("netplan addresses must be a sequence"))?;
let already_exists = addresses.iter().any(|value| {
value
.as_str()
.map(|text| text == address_cidr)
.unwrap_or(false)
});
if !already_exists {
addresses.push(YamlValue::String(address_cidr.to_string()));
}
let rendered = serde_yaml::to_string(&root).context("Failed to render netplan YAML")?;
Ok((rendered, !already_exists))
}
fn add_ifupdown_entry(
paths: &NetworkPaths,
interface: &str,
address_cidr: &str,
dry_run: bool,
written_files: &mut Vec<String>,
) -> Result<bool> {
let target_path = paths.ifupdown_dir.join("60-xbp-floating-ip.cfg");
let existing = if target_path.exists() {
fs::read_to_string(&target_path)
.with_context(|| format!("Failed to read {}", target_path.display()))?
} else {
String::new()
};
let mut entries = parse_ifupdown_entries(&existing);
let changed = entries.insert((interface.to_string(), address_cidr.to_string()));
if changed && !dry_run {
fs::create_dir_all(&paths.ifupdown_dir)
.with_context(|| format!("Failed to create {}", paths.ifupdown_dir.display()))?;
let rendered = render_ifupdown_entries(&entries);
fs::write(&target_path, rendered)
.with_context(|| format!("Failed to write {}", target_path.display()))?;
written_files.push(target_path.display().to_string());
}
Ok(changed)
}
fn parse_ifupdown_entries(content: &str) -> BTreeSet<(String, String)> {
let mut entries = BTreeSet::new();
let mut current_iface: Option<String> = None;
for raw_line in content.lines() {
let line = raw_line.trim();
if line.starts_with("iface ") {
let parts: Vec<&str> = line.split_whitespace().collect();
if let Some(name) = parts.get(1) {
current_iface = Some(name.split(':').next().unwrap_or(name).to_string());
}
continue;
}
if line.starts_with("address ") {
let address = line.trim_start_matches("address").trim();
if let Some(iface) = ¤t_iface {
entries.insert((iface.clone(), address.to_string()));
}
}
}
entries
}
fn render_ifupdown_entries(entries: &BTreeSet<(String, String)>) -> String {
let mut out = String::new();
out.push_str("# xbp-managed floating ips\n");
let mut alias_counter: HashMap<String, usize> = HashMap::new();
for (iface, address_cidr) in entries {
let counter = alias_counter.entry(iface.clone()).or_insert(0);
*counter += 1;
let alias_iface = format!("{}:{}", iface, *counter);
let (ip, cidr) = split_address_cidr(address_cidr);
let inet = if ip.contains(':') { "inet6" } else { "inet" };
out.push_str(&format!("auto {}\n", alias_iface));
out.push_str(&format!("iface {} {} static\n", alias_iface, inet));
out.push_str(&format!(" address {}\n", ip));
out.push_str(&format!(" netmask {}\n\n", cidr));
}
out
}
fn add_network_manager_entry(
paths: &NetworkPaths,
interface: &str,
address_cidr: &str,
dry_run: bool,
written_files: &mut Vec<String>,
) -> Result<bool> {
let target_path = paths.nm_dir.join("60-xbp-floating-ip.nmconnection");
let existing = if target_path.exists() {
fs::read_to_string(&target_path)
.with_context(|| format!("Failed to read {}", target_path.display()))?
} else {
String::new()
};
let mut parsed = parse_nmconnection(&existing);
parsed
.entry("connection".to_string())
.or_default()
.insert("id".to_string(), "xbp-floating-ip".to_string());
parsed
.entry("connection".to_string())
.or_default()
.insert("type".to_string(), "ethernet".to_string());
parsed
.entry("connection".to_string())
.or_default()
.insert("interface-name".to_string(), interface.to_string());
parsed
.entry("connection".to_string())
.or_default()
.insert("autoconnect".to_string(), "true".to_string());
let family_section = if address_cidr.contains(':') {
"ipv6"
} else {
"ipv4"
};
let other_family = if family_section == "ipv6" {
"ipv4"
} else {
"ipv6"
};
let mut addresses = nmconnection_addresses(
parsed
.entry(family_section.to_string())
.or_default()
.clone(),
);
let changed = if addresses.iter().any(|value| value == address_cidr) {
false
} else {
addresses.push(address_cidr.to_string());
true
};
let family_map = parsed.entry(family_section.to_string()).or_default();
family_map.insert("method".to_string(), "manual".to_string());
family_map.insert("may-fail".to_string(), "true".to_string());
for (index, address) in addresses.iter().enumerate() {
family_map.insert(format!("address{}", index + 1), address.clone());
}
let other_map = parsed.entry(other_family.to_string()).or_default();
if !other_map.contains_key("method") {
other_map.insert("method".to_string(), "auto".to_string());
}
parsed.entry("proxy".to_string()).or_default();
if changed && !dry_run {
fs::create_dir_all(&paths.nm_dir)
.with_context(|| format!("Failed to create {}", paths.nm_dir.display()))?;
let rendered = render_nmconnection(&parsed);
fs::write(&target_path, rendered)
.with_context(|| format!("Failed to write {}", target_path.display()))?;
written_files.push(target_path.display().to_string());
}
Ok(changed)
}
fn parse_nmconnection(content: &str) -> BTreeMap<String, BTreeMap<String, String>> {
let mut sections: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
let mut current = "connection".to_string();
for raw_line in content.lines() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
if line.starts_with('[') && line.ends_with(']') {
current = line.trim_matches(['[', ']']).to_string();
sections.entry(current.clone()).or_default();
continue;
}
if let Some((key, value)) = line.split_once('=') {
sections
.entry(current.clone())
.or_default()
.insert(key.trim().to_string(), value.trim().to_string());
}
}
sections
}
fn render_nmconnection(sections: &BTreeMap<String, BTreeMap<String, String>>) -> String {
let mut rendered = String::new();
for (name, values) in sections {
rendered.push_str(&format!("[{}]\n", name));
for (key, value) in values {
rendered.push_str(&format!("{}={}\n", key, value));
}
rendered.push('\n');
}
rendered
}
fn nmconnection_addresses(values: BTreeMap<String, String>) -> Vec<String> {
let mut items: Vec<(usize, String)> = values
.iter()
.filter_map(|(key, value)| {
key.strip_prefix("address")
.and_then(|suffix| suffix.parse::<usize>().ok())
.map(|index| {
let address = value.split(',').next().unwrap_or(value).trim().to_string();
(index, address)
})
})
.collect();
items.sort_by_key(|(index, _)| *index);
items.into_iter().map(|(_, value)| value).collect()
}
fn add_ifcfg_entry(
paths: &NetworkPaths,
interface: &str,
address_cidr: &str,
dry_run: bool,
written_files: &mut Vec<String>,
) -> Result<bool> {
fs::create_dir_all(&paths.ifcfg_dir)
.with_context(|| format!("Failed to create {}", paths.ifcfg_dir.display()))?;
for file in list_ifcfg_files(&paths.ifcfg_dir)? {
let content = fs::read_to_string(&file).unwrap_or_default();
if content.contains(&format!("IPV6ADDR={}", address_cidr))
|| split_address_cidr(address_cidr).0
== find_ifcfg_key_value(&content, "IPADDR").unwrap_or_default()
{
return Ok(false);
}
}
let sanitized = address_cidr
.replace([':', '.', '/'], "_")
.trim_matches('_')
.to_string();
let target_path = paths
.ifcfg_dir
.join(format!("ifcfg-{}-xbp-{}", interface, sanitized));
let alias_number = next_ifcfg_alias_number(&paths.ifcfg_dir, interface)?;
let (ip, cidr) = split_address_cidr(address_cidr);
let content = if ip.contains(':') {
format!(
"BOOTPROTO=none\nDEVICE={}:{}\nONBOOT=yes\nIPV6ADDR={}\nIPV6INIT=yes\n",
interface, alias_number, address_cidr
)
} else {
format!(
"BOOTPROTO=static\nDEVICE={}:{}\nIPADDR={}\nPREFIX={}\nTYPE=Ethernet\nUSERCTL=no\nONBOOT=yes\n",
interface, alias_number, ip, cidr
)
};
if !dry_run {
fs::write(&target_path, content)
.with_context(|| format!("Failed to write {}", target_path.display()))?;
written_files.push(target_path.display().to_string());
}
Ok(true)
}
fn next_ifcfg_alias_number(dir: &Path, interface: &str) -> Result<usize> {
let mut max = 0;
for file in list_ifcfg_files(dir)? {
let content = fs::read_to_string(&file).unwrap_or_default();
if let Some(device) = find_ifcfg_key_value(&content, "DEVICE") {
if let Some((base, suffix)) = device.split_once(':') {
if base == interface {
if let Ok(number) = suffix.parse::<usize>() {
if number > max {
max = number;
}
}
}
}
}
}
Ok(max + 1)
}
fn split_address_cidr(address_cidr: &str) -> (&str, &str) {
match address_cidr.split_once('/') {
Some((ip, cidr)) => (ip, cidr),
None => (address_cidr, "32"),
}
}
fn find_ifcfg_key_value(content: &str, key: &str) -> Option<String> {
for line in content.lines() {
let trimmed = line.trim();
if let Some((line_key, value)) = trimmed.split_once('=') {
if line_key.trim() == key {
return Some(value.trim().trim_matches('"').to_string());
}
}
}
None
}
async fn apply_backend_changes(backend: NetworkBackend) -> Result<()> {
match backend {
NetworkBackend::Netplan => run_apply_command("netplan", &["apply"]).await,
NetworkBackend::NetworkManager => {
run_apply_command("nmcli", &["connection", "reload"]).await
}
NetworkBackend::Ifupdown => run_apply_command("service", &["networking", "restart"]).await,
NetworkBackend::Ifcfg => run_apply_command("systemctl", &["restart", "network"]).await,
NetworkBackend::Runtime | NetworkBackend::Unknown => Ok(()),
}
}
async fn run_apply_command(program: &str, args: &[&str]) -> Result<()> {
let output = Command::new(program)
.args(args)
.output()
.await
.with_context(|| format!("Failed to run {} {}", program, args.join(" ")))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
if stderr.contains("permission denied") {
let sudo_output = Command::new("sudo")
.arg("-n")
.arg(program)
.args(args)
.output()
.await
.with_context(|| format!("Failed to run sudo {} {}", program, args.join(" ")))?;
if sudo_output.status.success() {
return Ok(());
}
return Err(anyhow!(
"Failed to apply network changes via sudo: {}",
String::from_utf8_lossy(&sudo_output.stderr)
));
}
Err(anyhow!(
"Failed to apply network changes: {}",
String::from_utf8_lossy(&output.stderr)
))
}
fn collect_config_floating_ips(paths: &NetworkPaths) -> Result<Vec<ConfigIp>> {
let mut items = Vec::new();
items.extend(read_netplan_floating_ips(paths)?);
items.extend(read_ifupdown_floating_ips(paths)?);
items.extend(read_network_manager_floating_ips(paths)?);
items.extend(read_ifcfg_floating_ips(paths)?);
Ok(items)
}
fn read_netplan_floating_ips(paths: &NetworkPaths) -> Result<Vec<ConfigIp>> {
let mut entries = Vec::new();
if !paths.netplan_dir.exists() {
return Ok(entries);
}
for path in list_files_with_extensions(&paths.netplan_dir, &["yaml", "yml"])? {
let content = fs::read_to_string(&path).unwrap_or_default();
let parsed = serde_yaml::from_str::<YamlValue>(&content);
let Ok(root) = parsed else { continue };
let Some(net) = root
.as_mapping()
.and_then(|map| map.get(YamlValue::String("network".to_string())))
.and_then(YamlValue::as_mapping)
else {
continue;
};
let Some(ethernets) = net
.get(YamlValue::String("ethernets".to_string()))
.and_then(YamlValue::as_mapping)
else {
continue;
};
for (iface_key, iface_value) in ethernets {
let Some(iface) = iface_key.as_str() else {
continue;
};
let Some(addresses) = iface_value
.as_mapping()
.and_then(|map| map.get(YamlValue::String("addresses".to_string())))
.and_then(YamlValue::as_sequence)
else {
continue;
};
for addr in addresses {
if let Some(value) = addr.as_str() {
entries.push(ConfigIp {
backend: NetworkBackend::Netplan,
interface: iface.to_string(),
address_cidr: value.to_string(),
source_file: path.display().to_string(),
});
}
}
}
}
Ok(entries)
}
fn read_ifupdown_floating_ips(paths: &NetworkPaths) -> Result<Vec<ConfigIp>> {
let mut entries = Vec::new();
let mut files = Vec::new();
if paths.ifupdown_main.exists() {
files.push(paths.ifupdown_main.clone());
}
if paths.ifupdown_dir.exists() {
files.extend(list_all_files(&paths.ifupdown_dir)?);
}
for path in files {
let content = fs::read_to_string(&path).unwrap_or_default();
for (iface, address_cidr) in parse_ifupdown_entries(&content) {
entries.push(ConfigIp {
backend: NetworkBackend::Ifupdown,
interface: iface,
address_cidr,
source_file: path.display().to_string(),
});
}
}
Ok(entries)
}
fn read_network_manager_floating_ips(paths: &NetworkPaths) -> Result<Vec<ConfigIp>> {
let mut entries = Vec::new();
if !paths.nm_dir.exists() {
return Ok(entries);
}
for path in list_files_with_extensions(&paths.nm_dir, &["nmconnection"])? {
let content = fs::read_to_string(&path).unwrap_or_default();
let parsed = parse_nmconnection(&content);
let interface = parsed
.get("connection")
.and_then(|section| section.get("interface-name"))
.cloned()
.unwrap_or_else(|| "eth0".to_string());
for section_name in ["ipv4", "ipv6"] {
if let Some(section) = parsed.get(section_name) {
for address in nmconnection_addresses(section.clone()) {
entries.push(ConfigIp {
backend: NetworkBackend::NetworkManager,
interface: interface.clone(),
address_cidr: address,
source_file: path.display().to_string(),
});
}
}
}
}
Ok(entries)
}
fn read_ifcfg_floating_ips(paths: &NetworkPaths) -> Result<Vec<ConfigIp>> {
let mut entries = Vec::new();
if !paths.ifcfg_dir.exists() {
return Ok(entries);
}
for path in list_ifcfg_files(&paths.ifcfg_dir)? {
let content = fs::read_to_string(&path).unwrap_or_default();
let device = find_ifcfg_key_value(&content, "DEVICE").unwrap_or_else(|| "eth0".to_string());
let iface = device.split(':').next().unwrap_or("eth0").to_string();
if let Some(ipv4) = find_ifcfg_key_value(&content, "IPADDR") {
let prefix =
find_ifcfg_key_value(&content, "PREFIX").unwrap_or_else(|| "32".to_string());
entries.push(ConfigIp {
backend: NetworkBackend::Ifcfg,
interface: iface.clone(),
address_cidr: format!("{}/{}", ipv4, prefix),
source_file: path.display().to_string(),
});
}
if let Some(ipv6) = find_ifcfg_key_value(&content, "IPV6ADDR") {
entries.push(ConfigIp {
backend: NetworkBackend::Ifcfg,
interface: iface.clone(),
address_cidr: ipv6,
source_file: path.display().to_string(),
});
}
}
Ok(entries)
}
async fn collect_runtime_ip_entries() -> Result<Vec<(String, String)>> {
let Some(stdout) = run_command_capture("ip", &["-j", "addr", "show"]).await else {
return Ok(Vec::new());
};
let parsed: JsonValue =
serde_json::from_str(&stdout).context("Failed to parse `ip -j` JSON")?;
let mut entries = Vec::new();
if let Some(items) = parsed.as_array() {
for item in items {
let interface = item
.get("ifname")
.and_then(JsonValue::as_str)
.unwrap_or("unknown")
.to_string();
if let Some(addr_info) = item.get("addr_info").and_then(JsonValue::as_array) {
for info in addr_info {
let family = info.get("family").and_then(JsonValue::as_str).unwrap_or("");
if family != "inet" && family != "inet6" {
continue;
}
let local = info.get("local").and_then(JsonValue::as_str).unwrap_or("");
let prefix = info
.get("prefixlen")
.and_then(JsonValue::as_u64)
.unwrap_or(0);
if local.is_empty() || prefix == 0 {
continue;
}
entries.push((interface.clone(), format!("{}/{}", local, prefix)));
}
}
}
}
Ok(entries)
}
#[cfg(test)]
mod tests {
use super::{
add_ifupdown_entry, detect_backend_from_sources, parse_default_interface_from_route_output,
split_address_cidr, upsert_netplan_address, NetworkBackend, NetworkConfigSource,
NetworkPaths,
};
use std::fs;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn with_test_root<F>(name: &str, test: F)
where
F: FnOnce(PathBuf),
{
let _guard = env_lock().lock().expect("env lock should be available");
let mut root = std::env::temp_dir();
root.push(format!("xbp-network-test-{}-{}", name, std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).expect("temp root should be created");
std::env::set_var(super::NETWORK_TEST_ROOT_ENV, root.display().to_string());
test(root.clone());
std::env::remove_var(super::NETWORK_TEST_ROOT_ENV);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn backend_detection_prioritizes_netplan() {
let sources = vec![
NetworkConfigSource {
backend: NetworkBackend::Ifcfg,
path: "/etc/sysconfig/network-scripts".to_string(),
exists: true,
},
NetworkConfigSource {
backend: NetworkBackend::Netplan,
path: "/etc/netplan".to_string(),
exists: true,
},
];
assert_eq!(
detect_backend_from_sources(&sources),
NetworkBackend::Netplan
);
}
#[test]
fn parses_default_interface_from_route_text() {
let route = "default via 172.31.1.1 dev enp1s0 proto dhcp src 172.31.1.2 metric 100";
assert_eq!(
parse_default_interface_from_route_output(route),
Some("enp1s0".to_string())
);
}
#[test]
fn netplan_upsert_is_idempotent() {
let initial = "network:\n version: 2\n ethernets:\n eth0:\n addresses:\n - 1.2.3.4/32\n";
let (_, changed_first) =
upsert_netplan_address(initial, "eth0", "1.2.3.4/32").expect("upsert should work");
assert!(!changed_first);
let (rendered, changed_second) =
upsert_netplan_address(initial, "eth0", "5.6.7.8/32").expect("upsert should work");
assert!(changed_second);
assert!(rendered.contains("5.6.7.8/32"));
}
#[test]
fn ifupdown_split_works() {
assert_eq!(split_address_cidr("10.0.0.1/32"), ("10.0.0.1", "32"));
assert_eq!(split_address_cidr("10.0.0.1"), ("10.0.0.1", "32"));
}
#[test]
fn dry_run_does_not_write_ifupdown_file() {
with_test_root("dry-run", |root| {
let paths = NetworkPaths::load();
fs::create_dir_all(paths.ifupdown_dir.clone()).expect("interfaces.d should exist");
let mut written = Vec::new();
let changed = add_ifupdown_entry(&paths, "eth0", "1.2.3.4/32", true, &mut written)
.expect("add should succeed");
assert!(changed);
assert!(written.is_empty());
let file = root.join("etc/network/interfaces.d/60-xbp-floating-ip.cfg");
assert!(!file.exists());
});
}
}