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(ðernets_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(ðernets_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) = ¤t_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}