Skip to main content

xbp_cli/sdk/
network.rs

1use anyhow::{anyhow, Context, Result};
2use serde::{Deserialize, Serialize};
3use serde_json::Value as JsonValue;
4use serde_yaml::Value as YamlValue;
5use std::collections::{BTreeMap, BTreeSet, HashMap};
6use std::fmt::{Display, Formatter};
7use std::fs;
8use std::net::IpAddr;
9use std::path::{Path, PathBuf};
10use tokio::process::Command;
11
12const NETWORK_TEST_ROOT_ENV: &str = "XBP_NETWORK_TEST_ROOT";
13
14#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
15#[serde(rename_all = "snake_case")]
16pub enum NetworkBackend {
17    Netplan,
18    NetworkManager,
19    Ifupdown,
20    Ifcfg,
21    Runtime,
22    Unknown,
23}
24
25impl Display for NetworkBackend {
26    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
27        match self {
28            NetworkBackend::Netplan => write!(f, "netplan"),
29            NetworkBackend::NetworkManager => write!(f, "nm"),
30            NetworkBackend::Ifupdown => write!(f, "ifupdown"),
31            NetworkBackend::Ifcfg => write!(f, "ifcfg"),
32            NetworkBackend::Runtime => write!(f, "runtime"),
33            NetworkBackend::Unknown => write!(f, "unknown"),
34        }
35    }
36}
37
38#[derive(Clone, Debug, Serialize, Deserialize)]
39pub struct NetworkConfigSource {
40    pub backend: NetworkBackend,
41    pub path: String,
42    pub exists: bool,
43}
44
45#[derive(Clone, Debug, Serialize, Deserialize)]
46pub struct NetworkConfigListResponse {
47    pub detected_backend: NetworkBackend,
48    pub sources: Vec<NetworkConfigSource>,
49}
50
51#[derive(Clone, Debug, Serialize, Deserialize)]
52pub struct FloatingIpEntry {
53    pub backend: NetworkBackend,
54    pub interface: String,
55    pub address_cidr: String,
56    pub runtime: bool,
57    pub config: bool,
58    pub source_file: Option<String>,
59}
60
61#[derive(Clone, Debug, Serialize, Deserialize)]
62pub struct FloatingIpListResponse {
63    pub detected_backend: NetworkBackend,
64    pub items: Vec<FloatingIpEntry>,
65}
66
67#[derive(Clone, Debug, Serialize, Deserialize)]
68pub struct AddFloatingIpRequest {
69    pub ip: String,
70    pub cidr: Option<u8>,
71    pub interface: Option<String>,
72    pub label: Option<String>,
73    pub apply: bool,
74    pub dry_run: bool,
75}
76
77#[derive(Clone, Debug, Serialize, Deserialize)]
78pub struct AddFloatingIpResponse {
79    pub backend: NetworkBackend,
80    pub interface: String,
81    pub address_cidr: String,
82    pub changed: bool,
83    pub dry_run: bool,
84    pub applied: bool,
85    pub written_files: Vec<String>,
86    pub message: String,
87}
88
89#[derive(Clone, Debug)]
90struct NetworkPaths {
91    netplan_dir: PathBuf,
92    ifupdown_main: PathBuf,
93    ifupdown_dir: PathBuf,
94    nm_dir: PathBuf,
95    ifcfg_dir: PathBuf,
96}
97
98#[derive(Clone, Debug)]
99struct ConfigIp {
100    backend: NetworkBackend,
101    interface: String,
102    address_cidr: String,
103    source_file: String,
104}
105
106impl NetworkPaths {
107    fn load() -> Self {
108        let root = std::env::var(NETWORK_TEST_ROOT_ENV).ok();
109        let map = |absolute: &str| {
110            if let Some(root) = &root {
111                PathBuf::from(root).join(absolute.trim_start_matches('/'))
112            } else {
113                PathBuf::from(absolute)
114            }
115        };
116
117        Self {
118            netplan_dir: map("/etc/netplan"),
119            ifupdown_main: map("/etc/network/interfaces"),
120            ifupdown_dir: map("/etc/network/interfaces.d"),
121            nm_dir: map("/etc/NetworkManager/system-connections"),
122            ifcfg_dir: map("/etc/sysconfig/network-scripts"),
123        }
124    }
125}
126
127pub async fn list_network_config_sources() -> Result<NetworkConfigListResponse> {
128    ensure_linux_host()?;
129    let sources = discover_sources(&NetworkPaths::load())?;
130    let detected_backend = detect_backend_from_sources(&sources);
131    Ok(NetworkConfigListResponse {
132        detected_backend,
133        sources,
134    })
135}
136
137pub async fn list_floating_ips() -> Result<FloatingIpListResponse> {
138    ensure_linux_host()?;
139    let paths = NetworkPaths::load();
140    let sources = discover_sources(&paths)?;
141    let detected_backend = detect_backend_from_sources(&sources);
142
143    let config_entries = collect_config_floating_ips(&paths)?;
144    let runtime_entries = collect_runtime_ip_entries().await.unwrap_or_default();
145
146    let mut merged: BTreeMap<(String, String), FloatingIpEntry> = BTreeMap::new();
147
148    for entry in config_entries {
149        let key = (entry.interface.clone(), entry.address_cidr.clone());
150        merged.insert(
151            key,
152            FloatingIpEntry {
153                backend: entry.backend,
154                interface: entry.interface,
155                address_cidr: entry.address_cidr,
156                runtime: false,
157                config: true,
158                source_file: Some(entry.source_file),
159            },
160        );
161    }
162
163    for (interface, address_cidr) in runtime_entries {
164        let key = (interface.clone(), address_cidr.clone());
165        if let Some(existing) = merged.get_mut(&key) {
166            existing.runtime = true;
167        } else {
168            merged.insert(
169                key,
170                FloatingIpEntry {
171                    backend: NetworkBackend::Runtime,
172                    interface,
173                    address_cidr,
174                    runtime: true,
175                    config: false,
176                    source_file: None,
177                },
178            );
179        }
180    }
181
182    Ok(FloatingIpListResponse {
183        detected_backend,
184        items: merged.into_values().collect(),
185    })
186}
187
188pub async fn add_floating_ip(request: AddFloatingIpRequest) -> Result<AddFloatingIpResponse> {
189    ensure_linux_host()?;
190
191    let paths = NetworkPaths::load();
192    let sources = discover_sources(&paths)?;
193    let backend = detect_backend_from_sources(&sources);
194    if backend == NetworkBackend::Unknown {
195        return Err(anyhow!(
196            "No supported Linux network backend detected (netplan, NetworkManager, ifupdown, ifcfg)."
197        ));
198    }
199
200    let parsed_ip: IpAddr = request
201        .ip
202        .parse()
203        .with_context(|| format!("Invalid IP address: {}", request.ip))?;
204    let cidr = normalize_cidr(parsed_ip, request.cidr)?;
205    let address_cidr = format!("{}/{}", parsed_ip, cidr);
206    let interface = if let Some(value) = request.interface.clone() {
207        value
208    } else {
209        detect_default_interface(parsed_ip).await?
210    };
211
212    let mut written_files = Vec::new();
213    let changed = match backend {
214        NetworkBackend::Netplan => add_netplan_entry(
215            &paths,
216            &interface,
217            &address_cidr,
218            request.dry_run,
219            &mut written_files,
220        )?,
221        NetworkBackend::NetworkManager => add_network_manager_entry(
222            &paths,
223            &interface,
224            &address_cidr,
225            request.dry_run,
226            &mut written_files,
227        )?,
228        NetworkBackend::Ifupdown => add_ifupdown_entry(
229            &paths,
230            &interface,
231            &address_cidr,
232            request.dry_run,
233            &mut written_files,
234        )?,
235        NetworkBackend::Ifcfg => add_ifcfg_entry(
236            &paths,
237            &interface,
238            &address_cidr,
239            request.dry_run,
240            &mut written_files,
241        )?,
242        NetworkBackend::Runtime | NetworkBackend::Unknown => false,
243    };
244
245    let mut applied = false;
246    if request.apply && !request.dry_run && changed {
247        apply_backend_changes(backend).await?;
248        applied = true;
249    }
250
251    Ok(AddFloatingIpResponse {
252        backend,
253        interface,
254        address_cidr,
255        changed,
256        dry_run: request.dry_run,
257        applied,
258        written_files,
259        message: if changed {
260            if request.dry_run {
261                "Dry run complete; no files were written.".to_string()
262            } else if applied {
263                "Floating IP written and network apply/reload completed.".to_string()
264            } else {
265                "Floating IP written. Run with --apply to apply immediately.".to_string()
266            }
267        } else {
268            "Floating IP already present; no change applied.".to_string()
269        },
270    })
271}
272
273fn ensure_linux_host() -> Result<()> {
274    if cfg!(target_os = "linux") {
275        Ok(())
276    } else {
277        Err(anyhow!(
278            "`xbp network` is currently supported on Linux hosts only."
279        ))
280    }
281}
282
283fn discover_sources(paths: &NetworkPaths) -> Result<Vec<NetworkConfigSource>> {
284    let mut sources = vec![
285        NetworkConfigSource {
286            backend: NetworkBackend::Netplan,
287            path: paths.netplan_dir.display().to_string(),
288            exists: paths.netplan_dir.exists(),
289        },
290        NetworkConfigSource {
291            backend: NetworkBackend::NetworkManager,
292            path: paths.nm_dir.display().to_string(),
293            exists: paths.nm_dir.exists(),
294        },
295        NetworkConfigSource {
296            backend: NetworkBackend::Ifupdown,
297            path: paths.ifupdown_main.display().to_string(),
298            exists: paths.ifupdown_main.exists(),
299        },
300        NetworkConfigSource {
301            backend: NetworkBackend::Ifupdown,
302            path: paths.ifupdown_dir.display().to_string(),
303            exists: paths.ifupdown_dir.exists(),
304        },
305        NetworkConfigSource {
306            backend: NetworkBackend::Ifcfg,
307            path: paths.ifcfg_dir.display().to_string(),
308            exists: paths.ifcfg_dir.exists(),
309        },
310    ];
311
312    if paths.netplan_dir.exists() {
313        for path in list_files_with_extensions(&paths.netplan_dir, &["yaml", "yml"])? {
314            sources.push(NetworkConfigSource {
315                backend: NetworkBackend::Netplan,
316                path: path.display().to_string(),
317                exists: true,
318            });
319        }
320    }
321
322    if paths.nm_dir.exists() {
323        for path in list_files_with_extensions(&paths.nm_dir, &["nmconnection"])? {
324            sources.push(NetworkConfigSource {
325                backend: NetworkBackend::NetworkManager,
326                path: path.display().to_string(),
327                exists: true,
328            });
329        }
330    }
331
332    if paths.ifupdown_dir.exists() {
333        for path in list_all_files(&paths.ifupdown_dir)? {
334            sources.push(NetworkConfigSource {
335                backend: NetworkBackend::Ifupdown,
336                path: path.display().to_string(),
337                exists: true,
338            });
339        }
340    }
341
342    if paths.ifcfg_dir.exists() {
343        for path in list_ifcfg_files(&paths.ifcfg_dir)? {
344            sources.push(NetworkConfigSource {
345                backend: NetworkBackend::Ifcfg,
346                path: path.display().to_string(),
347                exists: true,
348            });
349        }
350    }
351
352    Ok(sources)
353}
354
355fn detect_backend_from_sources(sources: &[NetworkConfigSource]) -> NetworkBackend {
356    if sources
357        .iter()
358        .any(|source| source.backend == NetworkBackend::Netplan && source.exists)
359    {
360        return NetworkBackend::Netplan;
361    }
362    if sources
363        .iter()
364        .any(|source| source.backend == NetworkBackend::NetworkManager && source.exists)
365    {
366        return NetworkBackend::NetworkManager;
367    }
368    if sources
369        .iter()
370        .any(|source| source.backend == NetworkBackend::Ifupdown && source.exists)
371    {
372        return NetworkBackend::Ifupdown;
373    }
374    if sources
375        .iter()
376        .any(|source| source.backend == NetworkBackend::Ifcfg && source.exists)
377    {
378        return NetworkBackend::Ifcfg;
379    }
380    NetworkBackend::Unknown
381}
382
383fn list_files_with_extensions(dir: &Path, exts: &[&str]) -> Result<Vec<PathBuf>> {
384    let mut files = Vec::new();
385    for entry in fs::read_dir(dir).with_context(|| format!("Failed to read {}", dir.display()))? {
386        let path = entry?.path();
387        if !path.is_file() {
388            continue;
389        }
390        let ext = path.extension().and_then(|value| value.to_str());
391        if ext.map(|ext| exts.contains(&ext)).unwrap_or(false) {
392            files.push(path);
393        }
394    }
395    files.sort();
396    Ok(files)
397}
398
399fn list_all_files(dir: &Path) -> Result<Vec<PathBuf>> {
400    let mut files = Vec::new();
401    for entry in fs::read_dir(dir).with_context(|| format!("Failed to read {}", dir.display()))? {
402        let path = entry?.path();
403        if path.is_file() {
404            files.push(path);
405        }
406    }
407    files.sort();
408    Ok(files)
409}
410
411fn list_ifcfg_files(dir: &Path) -> Result<Vec<PathBuf>> {
412    let mut files = Vec::new();
413    for path in list_all_files(dir)? {
414        if path
415            .file_name()
416            .and_then(|value| value.to_str())
417            .map(|name| name.starts_with("ifcfg-"))
418            .unwrap_or(false)
419        {
420            files.push(path);
421        }
422    }
423    Ok(files)
424}
425
426fn normalize_cidr(ip: IpAddr, cidr: Option<u8>) -> Result<u8> {
427    let value = cidr.unwrap_or_else(|| if ip.is_ipv4() { 32 } else { 64 });
428    if ip.is_ipv4() && value > 32 {
429        return Err(anyhow!("IPv4 CIDR must be <= 32"));
430    }
431    if ip.is_ipv6() && value > 128 {
432        return Err(anyhow!("IPv6 CIDR must be <= 128"));
433    }
434    Ok(value)
435}
436
437async fn detect_default_interface(ip: IpAddr) -> Result<String> {
438    let command_args = if ip.is_ipv4() {
439        vec!["route", "show", "default"]
440    } else {
441        vec!["-6", "route", "show", "default"]
442    };
443
444    if let Some(output) = run_command_capture("ip", &command_args).await {
445        if let Some(interface) = parse_default_interface_from_route_output(&output) {
446            return Ok(interface);
447        }
448    }
449
450    Ok("eth0".to_string())
451}
452
453fn parse_default_interface_from_route_output(content: &str) -> Option<String> {
454    for line in content.lines() {
455        let parts: Vec<&str> = line.split_whitespace().collect();
456        for index in 0..parts.len() {
457            if parts[index] == "dev" {
458                return parts.get(index + 1).map(|value| (*value).to_string());
459            }
460        }
461    }
462    None
463}
464
465async fn run_command_capture(program: &str, args: &[&str]) -> Option<String> {
466    let output = Command::new(program).args(args).output().await.ok()?;
467    if !output.status.success() {
468        return None;
469    }
470    Some(String::from_utf8_lossy(&output.stdout).to_string())
471}
472
473fn add_netplan_entry(
474    paths: &NetworkPaths,
475    interface: &str,
476    address_cidr: &str,
477    dry_run: bool,
478    written_files: &mut Vec<String>,
479) -> Result<bool> {
480    let target_path = paths.netplan_dir.join("60-xbp-floating-ip.yaml");
481    let existing = if target_path.exists() {
482        fs::read_to_string(&target_path)
483            .with_context(|| format!("Failed to read {}", target_path.display()))?
484    } else {
485        String::new()
486    };
487    let (rendered, changed) = upsert_netplan_address(&existing, interface, address_cidr)?;
488    if changed && !dry_run {
489        fs::create_dir_all(&paths.netplan_dir)
490            .with_context(|| format!("Failed to create {}", paths.netplan_dir.display()))?;
491        fs::write(&target_path, rendered)
492            .with_context(|| format!("Failed to write {}", target_path.display()))?;
493        written_files.push(target_path.display().to_string());
494    }
495    Ok(changed)
496}
497
498fn upsert_netplan_address(
499    content: &str,
500    interface: &str,
501    address_cidr: &str,
502) -> Result<(String, bool)> {
503    let mut root = if content.trim().is_empty() {
504        YamlValue::Mapping(Default::default())
505    } else {
506        serde_yaml::from_str::<YamlValue>(content).context("Failed to parse netplan YAML")?
507    };
508
509    let root_map = root
510        .as_mapping_mut()
511        .ok_or_else(|| anyhow!("Netplan root must be a mapping"))?;
512    let network_key = YamlValue::String("network".to_string());
513    if !root_map.contains_key(&network_key) {
514        root_map.insert(network_key.clone(), YamlValue::Mapping(Default::default()));
515    }
516    let network_map = root_map
517        .get_mut(&network_key)
518        .and_then(YamlValue::as_mapping_mut)
519        .ok_or_else(|| anyhow!("netplan.network must be a mapping"))?;
520
521    network_map.insert(
522        YamlValue::String("version".to_string()),
523        YamlValue::Number(serde_yaml::Number::from(2)),
524    );
525    network_map.insert(
526        YamlValue::String("renderer".to_string()),
527        YamlValue::String("networkd".to_string()),
528    );
529
530    let ethernets_key = YamlValue::String("ethernets".to_string());
531    if !network_map.contains_key(&ethernets_key) {
532        network_map.insert(
533            ethernets_key.clone(),
534            YamlValue::Mapping(Default::default()),
535        );
536    }
537    let ethernets_map = network_map
538        .get_mut(&ethernets_key)
539        .and_then(YamlValue::as_mapping_mut)
540        .ok_or_else(|| anyhow!("netplan.network.ethernets must be a mapping"))?;
541
542    let iface_key = YamlValue::String(interface.to_string());
543    if !ethernets_map.contains_key(&iface_key) {
544        ethernets_map.insert(iface_key.clone(), YamlValue::Mapping(Default::default()));
545    }
546    let iface_map = ethernets_map
547        .get_mut(&iface_key)
548        .and_then(YamlValue::as_mapping_mut)
549        .ok_or_else(|| anyhow!("netplan interface entry must be a mapping"))?;
550
551    let addresses_key = YamlValue::String("addresses".to_string());
552    if !iface_map.contains_key(&addresses_key) {
553        iface_map.insert(addresses_key.clone(), YamlValue::Sequence(vec![]));
554    }
555    let addresses = iface_map
556        .get_mut(&addresses_key)
557        .and_then(YamlValue::as_sequence_mut)
558        .ok_or_else(|| anyhow!("netplan addresses must be a sequence"))?;
559
560    let already_exists = addresses.iter().any(|value| {
561        value
562            .as_str()
563            .map(|text| text == address_cidr)
564            .unwrap_or(false)
565    });
566    if !already_exists {
567        addresses.push(YamlValue::String(address_cidr.to_string()));
568    }
569
570    let rendered = serde_yaml::to_string(&root).context("Failed to render netplan YAML")?;
571    Ok((rendered, !already_exists))
572}
573
574fn add_ifupdown_entry(
575    paths: &NetworkPaths,
576    interface: &str,
577    address_cidr: &str,
578    dry_run: bool,
579    written_files: &mut Vec<String>,
580) -> Result<bool> {
581    let target_path = paths.ifupdown_dir.join("60-xbp-floating-ip.cfg");
582    let existing = if target_path.exists() {
583        fs::read_to_string(&target_path)
584            .with_context(|| format!("Failed to read {}", target_path.display()))?
585    } else {
586        String::new()
587    };
588    let mut entries = parse_ifupdown_entries(&existing);
589    let changed = entries.insert((interface.to_string(), address_cidr.to_string()));
590    if changed && !dry_run {
591        fs::create_dir_all(&paths.ifupdown_dir)
592            .with_context(|| format!("Failed to create {}", paths.ifupdown_dir.display()))?;
593        let rendered = render_ifupdown_entries(&entries);
594        fs::write(&target_path, rendered)
595            .with_context(|| format!("Failed to write {}", target_path.display()))?;
596        written_files.push(target_path.display().to_string());
597    }
598    Ok(changed)
599}
600
601fn parse_ifupdown_entries(content: &str) -> BTreeSet<(String, String)> {
602    let mut entries = BTreeSet::new();
603    let mut current_iface: Option<String> = None;
604    for raw_line in content.lines() {
605        let line = raw_line.trim();
606        if line.starts_with("iface ") {
607            let parts: Vec<&str> = line.split_whitespace().collect();
608            if let Some(name) = parts.get(1) {
609                current_iface = Some(name.split(':').next().unwrap_or(name).to_string());
610            }
611            continue;
612        }
613        if line.starts_with("address ") {
614            let address = line.trim_start_matches("address").trim();
615            if let Some(iface) = &current_iface {
616                entries.insert((iface.clone(), address.to_string()));
617            }
618        }
619    }
620    entries
621}
622
623fn render_ifupdown_entries(entries: &BTreeSet<(String, String)>) -> String {
624    let mut out = String::new();
625    out.push_str("# xbp-managed floating ips\n");
626    let mut alias_counter: HashMap<String, usize> = HashMap::new();
627    for (iface, address_cidr) in entries {
628        let counter = alias_counter.entry(iface.clone()).or_insert(0);
629        *counter += 1;
630        let alias_iface = format!("{}:{}", iface, *counter);
631        let (ip, cidr) = split_address_cidr(address_cidr);
632        let inet = if ip.contains(':') { "inet6" } else { "inet" };
633        out.push_str(&format!("auto {}\n", alias_iface));
634        out.push_str(&format!("iface {} {} static\n", alias_iface, inet));
635        out.push_str(&format!("    address {}\n", ip));
636        out.push_str(&format!("    netmask {}\n\n", cidr));
637    }
638    out
639}
640
641fn add_network_manager_entry(
642    paths: &NetworkPaths,
643    interface: &str,
644    address_cidr: &str,
645    dry_run: bool,
646    written_files: &mut Vec<String>,
647) -> Result<bool> {
648    let target_path = paths.nm_dir.join("60-xbp-floating-ip.nmconnection");
649    let existing = if target_path.exists() {
650        fs::read_to_string(&target_path)
651            .with_context(|| format!("Failed to read {}", target_path.display()))?
652    } else {
653        String::new()
654    };
655
656    let mut parsed = parse_nmconnection(&existing);
657    parsed
658        .entry("connection".to_string())
659        .or_default()
660        .insert("id".to_string(), "xbp-floating-ip".to_string());
661    parsed
662        .entry("connection".to_string())
663        .or_default()
664        .insert("type".to_string(), "ethernet".to_string());
665    parsed
666        .entry("connection".to_string())
667        .or_default()
668        .insert("interface-name".to_string(), interface.to_string());
669    parsed
670        .entry("connection".to_string())
671        .or_default()
672        .insert("autoconnect".to_string(), "true".to_string());
673
674    let family_section = if address_cidr.contains(':') {
675        "ipv6"
676    } else {
677        "ipv4"
678    };
679    let other_family = if family_section == "ipv6" {
680        "ipv4"
681    } else {
682        "ipv6"
683    };
684
685    let mut addresses = nmconnection_addresses(
686        parsed
687            .entry(family_section.to_string())
688            .or_default()
689            .clone(),
690    );
691    let changed = if addresses.iter().any(|value| value == address_cidr) {
692        false
693    } else {
694        addresses.push(address_cidr.to_string());
695        true
696    };
697
698    let family_map = parsed.entry(family_section.to_string()).or_default();
699    family_map.insert("method".to_string(), "manual".to_string());
700    family_map.insert("may-fail".to_string(), "true".to_string());
701    for (index, address) in addresses.iter().enumerate() {
702        family_map.insert(format!("address{}", index + 1), address.clone());
703    }
704
705    let other_map = parsed.entry(other_family.to_string()).or_default();
706    if !other_map.contains_key("method") {
707        other_map.insert("method".to_string(), "auto".to_string());
708    }
709    parsed.entry("proxy".to_string()).or_default();
710
711    if changed && !dry_run {
712        fs::create_dir_all(&paths.nm_dir)
713            .with_context(|| format!("Failed to create {}", paths.nm_dir.display()))?;
714        let rendered = render_nmconnection(&parsed);
715        fs::write(&target_path, rendered)
716            .with_context(|| format!("Failed to write {}", target_path.display()))?;
717        written_files.push(target_path.display().to_string());
718    }
719    Ok(changed)
720}
721
722fn parse_nmconnection(content: &str) -> BTreeMap<String, BTreeMap<String, String>> {
723    let mut sections: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
724    let mut current = "connection".to_string();
725    for raw_line in content.lines() {
726        let line = raw_line.trim();
727        if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
728            continue;
729        }
730        if line.starts_with('[') && line.ends_with(']') {
731            current = line.trim_matches(['[', ']']).to_string();
732            sections.entry(current.clone()).or_default();
733            continue;
734        }
735        if let Some((key, value)) = line.split_once('=') {
736            sections
737                .entry(current.clone())
738                .or_default()
739                .insert(key.trim().to_string(), value.trim().to_string());
740        }
741    }
742    sections
743}
744
745fn render_nmconnection(sections: &BTreeMap<String, BTreeMap<String, String>>) -> String {
746    let mut rendered = String::new();
747    for (name, values) in sections {
748        rendered.push_str(&format!("[{}]\n", name));
749        for (key, value) in values {
750            rendered.push_str(&format!("{}={}\n", key, value));
751        }
752        rendered.push('\n');
753    }
754    rendered
755}
756
757fn nmconnection_addresses(values: BTreeMap<String, String>) -> Vec<String> {
758    let mut items: Vec<(usize, String)> = values
759        .iter()
760        .filter_map(|(key, value)| {
761            key.strip_prefix("address")
762                .and_then(|suffix| suffix.parse::<usize>().ok())
763                .map(|index| {
764                    let address = value.split(',').next().unwrap_or(value).trim().to_string();
765                    (index, address)
766                })
767        })
768        .collect();
769    items.sort_by_key(|(index, _)| *index);
770    items.into_iter().map(|(_, value)| value).collect()
771}
772
773fn add_ifcfg_entry(
774    paths: &NetworkPaths,
775    interface: &str,
776    address_cidr: &str,
777    dry_run: bool,
778    written_files: &mut Vec<String>,
779) -> Result<bool> {
780    fs::create_dir_all(&paths.ifcfg_dir)
781        .with_context(|| format!("Failed to create {}", paths.ifcfg_dir.display()))?;
782
783    for file in list_ifcfg_files(&paths.ifcfg_dir)? {
784        let content = fs::read_to_string(&file).unwrap_or_default();
785        if content.contains(&format!("IPV6ADDR={}", address_cidr))
786            || split_address_cidr(address_cidr).0
787                == find_ifcfg_key_value(&content, "IPADDR").unwrap_or_default()
788        {
789            return Ok(false);
790        }
791    }
792
793    let sanitized = address_cidr
794        .replace([':', '.', '/'], "_")
795        .trim_matches('_')
796        .to_string();
797    let target_path = paths
798        .ifcfg_dir
799        .join(format!("ifcfg-{}-xbp-{}", interface, sanitized));
800    let alias_number = next_ifcfg_alias_number(&paths.ifcfg_dir, interface)?;
801    let (ip, cidr) = split_address_cidr(address_cidr);
802    let content = if ip.contains(':') {
803        format!(
804            "BOOTPROTO=none\nDEVICE={}:{}\nONBOOT=yes\nIPV6ADDR={}\nIPV6INIT=yes\n",
805            interface, alias_number, address_cidr
806        )
807    } else {
808        format!(
809            "BOOTPROTO=static\nDEVICE={}:{}\nIPADDR={}\nPREFIX={}\nTYPE=Ethernet\nUSERCTL=no\nONBOOT=yes\n",
810            interface, alias_number, ip, cidr
811        )
812    };
813
814    if !dry_run {
815        fs::write(&target_path, content)
816            .with_context(|| format!("Failed to write {}", target_path.display()))?;
817        written_files.push(target_path.display().to_string());
818    }
819    Ok(true)
820}
821
822fn next_ifcfg_alias_number(dir: &Path, interface: &str) -> Result<usize> {
823    let mut max = 0;
824    for file in list_ifcfg_files(dir)? {
825        let content = fs::read_to_string(&file).unwrap_or_default();
826        if let Some(device) = find_ifcfg_key_value(&content, "DEVICE") {
827            if let Some((base, suffix)) = device.split_once(':') {
828                if base == interface {
829                    if let Ok(number) = suffix.parse::<usize>() {
830                        if number > max {
831                            max = number;
832                        }
833                    }
834                }
835            }
836        }
837    }
838    Ok(max + 1)
839}
840
841fn split_address_cidr(address_cidr: &str) -> (&str, &str) {
842    match address_cidr.split_once('/') {
843        Some((ip, cidr)) => (ip, cidr),
844        None => (address_cidr, "32"),
845    }
846}
847
848fn find_ifcfg_key_value(content: &str, key: &str) -> Option<String> {
849    for line in content.lines() {
850        let trimmed = line.trim();
851        if let Some((line_key, value)) = trimmed.split_once('=') {
852            if line_key.trim() == key {
853                return Some(value.trim().trim_matches('"').to_string());
854            }
855        }
856    }
857    None
858}
859
860async fn apply_backend_changes(backend: NetworkBackend) -> Result<()> {
861    match backend {
862        NetworkBackend::Netplan => run_apply_command("netplan", &["apply"]).await,
863        NetworkBackend::NetworkManager => {
864            run_apply_command("nmcli", &["connection", "reload"]).await
865        }
866        NetworkBackend::Ifupdown => run_apply_command("service", &["networking", "restart"]).await,
867        NetworkBackend::Ifcfg => run_apply_command("systemctl", &["restart", "network"]).await,
868        NetworkBackend::Runtime | NetworkBackend::Unknown => Ok(()),
869    }
870}
871
872async fn run_apply_command(program: &str, args: &[&str]) -> Result<()> {
873    let output = Command::new(program)
874        .args(args)
875        .output()
876        .await
877        .with_context(|| format!("Failed to run {} {}", program, args.join(" ")))?;
878    if output.status.success() {
879        return Ok(());
880    }
881
882    let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
883    if stderr.contains("permission denied") {
884        let sudo_output = Command::new("sudo")
885            .arg("-n")
886            .arg(program)
887            .args(args)
888            .output()
889            .await
890            .with_context(|| format!("Failed to run sudo {} {}", program, args.join(" ")))?;
891        if sudo_output.status.success() {
892            return Ok(());
893        }
894        return Err(anyhow!(
895            "Failed to apply network changes via sudo: {}",
896            String::from_utf8_lossy(&sudo_output.stderr)
897        ));
898    }
899
900    Err(anyhow!(
901        "Failed to apply network changes: {}",
902        String::from_utf8_lossy(&output.stderr)
903    ))
904}
905
906fn collect_config_floating_ips(paths: &NetworkPaths) -> Result<Vec<ConfigIp>> {
907    let mut items = Vec::new();
908    items.extend(read_netplan_floating_ips(paths)?);
909    items.extend(read_ifupdown_floating_ips(paths)?);
910    items.extend(read_network_manager_floating_ips(paths)?);
911    items.extend(read_ifcfg_floating_ips(paths)?);
912    Ok(items)
913}
914
915fn read_netplan_floating_ips(paths: &NetworkPaths) -> Result<Vec<ConfigIp>> {
916    let mut entries = Vec::new();
917    if !paths.netplan_dir.exists() {
918        return Ok(entries);
919    }
920    for path in list_files_with_extensions(&paths.netplan_dir, &["yaml", "yml"])? {
921        let content = fs::read_to_string(&path).unwrap_or_default();
922        let parsed = serde_yaml::from_str::<YamlValue>(&content);
923        let Ok(root) = parsed else { continue };
924        let Some(net) = root
925            .as_mapping()
926            .and_then(|map| map.get(YamlValue::String("network".to_string())))
927            .and_then(YamlValue::as_mapping)
928        else {
929            continue;
930        };
931        let Some(ethernets) = net
932            .get(YamlValue::String("ethernets".to_string()))
933            .and_then(YamlValue::as_mapping)
934        else {
935            continue;
936        };
937        for (iface_key, iface_value) in ethernets {
938            let Some(iface) = iface_key.as_str() else {
939                continue;
940            };
941            let Some(addresses) = iface_value
942                .as_mapping()
943                .and_then(|map| map.get(YamlValue::String("addresses".to_string())))
944                .and_then(YamlValue::as_sequence)
945            else {
946                continue;
947            };
948            for addr in addresses {
949                if let Some(value) = addr.as_str() {
950                    entries.push(ConfigIp {
951                        backend: NetworkBackend::Netplan,
952                        interface: iface.to_string(),
953                        address_cidr: value.to_string(),
954                        source_file: path.display().to_string(),
955                    });
956                }
957            }
958        }
959    }
960    Ok(entries)
961}
962
963fn read_ifupdown_floating_ips(paths: &NetworkPaths) -> Result<Vec<ConfigIp>> {
964    let mut entries = Vec::new();
965    let mut files = Vec::new();
966    if paths.ifupdown_main.exists() {
967        files.push(paths.ifupdown_main.clone());
968    }
969    if paths.ifupdown_dir.exists() {
970        files.extend(list_all_files(&paths.ifupdown_dir)?);
971    }
972
973    for path in files {
974        let content = fs::read_to_string(&path).unwrap_or_default();
975        for (iface, address_cidr) in parse_ifupdown_entries(&content) {
976            entries.push(ConfigIp {
977                backend: NetworkBackend::Ifupdown,
978                interface: iface,
979                address_cidr,
980                source_file: path.display().to_string(),
981            });
982        }
983    }
984
985    Ok(entries)
986}
987
988fn read_network_manager_floating_ips(paths: &NetworkPaths) -> Result<Vec<ConfigIp>> {
989    let mut entries = Vec::new();
990    if !paths.nm_dir.exists() {
991        return Ok(entries);
992    }
993    for path in list_files_with_extensions(&paths.nm_dir, &["nmconnection"])? {
994        let content = fs::read_to_string(&path).unwrap_or_default();
995        let parsed = parse_nmconnection(&content);
996        let interface = parsed
997            .get("connection")
998            .and_then(|section| section.get("interface-name"))
999            .cloned()
1000            .unwrap_or_else(|| "eth0".to_string());
1001        for section_name in ["ipv4", "ipv6"] {
1002            if let Some(section) = parsed.get(section_name) {
1003                for address in nmconnection_addresses(section.clone()) {
1004                    entries.push(ConfigIp {
1005                        backend: NetworkBackend::NetworkManager,
1006                        interface: interface.clone(),
1007                        address_cidr: address,
1008                        source_file: path.display().to_string(),
1009                    });
1010                }
1011            }
1012        }
1013    }
1014    Ok(entries)
1015}
1016
1017fn read_ifcfg_floating_ips(paths: &NetworkPaths) -> Result<Vec<ConfigIp>> {
1018    let mut entries = Vec::new();
1019    if !paths.ifcfg_dir.exists() {
1020        return Ok(entries);
1021    }
1022    for path in list_ifcfg_files(&paths.ifcfg_dir)? {
1023        let content = fs::read_to_string(&path).unwrap_or_default();
1024        let device = find_ifcfg_key_value(&content, "DEVICE").unwrap_or_else(|| "eth0".to_string());
1025        let iface = device.split(':').next().unwrap_or("eth0").to_string();
1026        if let Some(ipv4) = find_ifcfg_key_value(&content, "IPADDR") {
1027            let prefix =
1028                find_ifcfg_key_value(&content, "PREFIX").unwrap_or_else(|| "32".to_string());
1029            entries.push(ConfigIp {
1030                backend: NetworkBackend::Ifcfg,
1031                interface: iface.clone(),
1032                address_cidr: format!("{}/{}", ipv4, prefix),
1033                source_file: path.display().to_string(),
1034            });
1035        }
1036        if let Some(ipv6) = find_ifcfg_key_value(&content, "IPV6ADDR") {
1037            entries.push(ConfigIp {
1038                backend: NetworkBackend::Ifcfg,
1039                interface: iface.clone(),
1040                address_cidr: ipv6,
1041                source_file: path.display().to_string(),
1042            });
1043        }
1044    }
1045    Ok(entries)
1046}
1047
1048async fn collect_runtime_ip_entries() -> Result<Vec<(String, String)>> {
1049    let Some(stdout) = run_command_capture("ip", &["-j", "addr", "show"]).await else {
1050        return Ok(Vec::new());
1051    };
1052    let parsed: JsonValue =
1053        serde_json::from_str(&stdout).context("Failed to parse `ip -j` JSON")?;
1054    let mut entries = Vec::new();
1055
1056    if let Some(items) = parsed.as_array() {
1057        for item in items {
1058            let interface = item
1059                .get("ifname")
1060                .and_then(JsonValue::as_str)
1061                .unwrap_or("unknown")
1062                .to_string();
1063            if let Some(addr_info) = item.get("addr_info").and_then(JsonValue::as_array) {
1064                for info in addr_info {
1065                    let family = info.get("family").and_then(JsonValue::as_str).unwrap_or("");
1066                    if family != "inet" && family != "inet6" {
1067                        continue;
1068                    }
1069                    let local = info.get("local").and_then(JsonValue::as_str).unwrap_or("");
1070                    let prefix = info
1071                        .get("prefixlen")
1072                        .and_then(JsonValue::as_u64)
1073                        .unwrap_or(0);
1074                    if local.is_empty() || prefix == 0 {
1075                        continue;
1076                    }
1077                    entries.push((interface.clone(), format!("{}/{}", local, prefix)));
1078                }
1079            }
1080        }
1081    }
1082    Ok(entries)
1083}
1084
1085#[cfg(test)]
1086mod tests {
1087    use super::{
1088        add_ifupdown_entry, detect_backend_from_sources, parse_default_interface_from_route_output,
1089        split_address_cidr, upsert_netplan_address, NetworkBackend, NetworkConfigSource,
1090        NetworkPaths,
1091    };
1092    use std::fs;
1093    use std::path::PathBuf;
1094    use std::sync::{Mutex, OnceLock};
1095
1096    fn env_lock() -> &'static Mutex<()> {
1097        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1098        LOCK.get_or_init(|| Mutex::new(()))
1099    }
1100
1101    fn with_test_root<F>(name: &str, test: F)
1102    where
1103        F: FnOnce(PathBuf),
1104    {
1105        let _guard = env_lock().lock().expect("env lock should be available");
1106        let mut root = std::env::temp_dir();
1107        root.push(format!("xbp-network-test-{}-{}", name, std::process::id()));
1108        let _ = fs::remove_dir_all(&root);
1109        fs::create_dir_all(&root).expect("temp root should be created");
1110        std::env::set_var(super::NETWORK_TEST_ROOT_ENV, root.display().to_string());
1111        test(root.clone());
1112        std::env::remove_var(super::NETWORK_TEST_ROOT_ENV);
1113        let _ = fs::remove_dir_all(&root);
1114    }
1115
1116    #[test]
1117    fn backend_detection_prioritizes_netplan() {
1118        let sources = vec![
1119            NetworkConfigSource {
1120                backend: NetworkBackend::Ifcfg,
1121                path: "/etc/sysconfig/network-scripts".to_string(),
1122                exists: true,
1123            },
1124            NetworkConfigSource {
1125                backend: NetworkBackend::Netplan,
1126                path: "/etc/netplan".to_string(),
1127                exists: true,
1128            },
1129        ];
1130        assert_eq!(
1131            detect_backend_from_sources(&sources),
1132            NetworkBackend::Netplan
1133        );
1134    }
1135
1136    #[test]
1137    fn parses_default_interface_from_route_text() {
1138        let route = "default via 172.31.1.1 dev enp1s0 proto dhcp src 172.31.1.2 metric 100";
1139        assert_eq!(
1140            parse_default_interface_from_route_output(route),
1141            Some("enp1s0".to_string())
1142        );
1143    }
1144
1145    #[test]
1146    fn netplan_upsert_is_idempotent() {
1147        let initial = "network:\n  version: 2\n  ethernets:\n    eth0:\n      addresses:\n        - 1.2.3.4/32\n";
1148        let (_, changed_first) =
1149            upsert_netplan_address(initial, "eth0", "1.2.3.4/32").expect("upsert should work");
1150        assert!(!changed_first);
1151
1152        let (rendered, changed_second) =
1153            upsert_netplan_address(initial, "eth0", "5.6.7.8/32").expect("upsert should work");
1154        assert!(changed_second);
1155        assert!(rendered.contains("5.6.7.8/32"));
1156    }
1157
1158    #[test]
1159    fn ifupdown_split_works() {
1160        assert_eq!(split_address_cidr("10.0.0.1/32"), ("10.0.0.1", "32"));
1161        assert_eq!(split_address_cidr("10.0.0.1"), ("10.0.0.1", "32"));
1162    }
1163
1164    #[test]
1165    fn dry_run_does_not_write_ifupdown_file() {
1166        with_test_root("dry-run", |root| {
1167            let paths = NetworkPaths::load();
1168            fs::create_dir_all(paths.ifupdown_dir.clone()).expect("interfaces.d should exist");
1169            let mut written = Vec::new();
1170            let changed = add_ifupdown_entry(&paths, "eth0", "1.2.3.4/32", true, &mut written)
1171                .expect("add should succeed");
1172            assert!(changed);
1173            assert!(written.is_empty());
1174
1175            let file = root.join("etc/network/interfaces.d/60-xbp-floating-ip.cfg");
1176            assert!(!file.exists());
1177        });
1178    }
1179}