use std::fs; use std::path::Path; use std::process::{Command, Output}; use std::thread; use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use uuid::Uuid; use crate::error::{AppError, Result}; const BRIDGE_IF: &str = "okvm-br0"; const PROFILE_PREFIX: &str = "one-kvm-otg"; const JOURNAL_PATH: &str = "/run/one-kvm/otg-network-bridge.json"; const JOURNAL_VERSION: u8 = 2; const NETWORK_MANAGER_DEVICE_WAIT_TIMEOUT: Duration = Duration::from_secs(10); const DHCP_IDENTITY_PROPERTIES: &[&str] = &[ "ipv4.dhcp-client-id", "ipv4.dhcp-iaid", "ipv4.dhcp-hostname", "ipv4.dhcp-fqdn", "ipv4.dhcp-send-hostname", "ipv4.dhcp-hostname-flags", ]; const STATIC_IPV4_PROPERTIES: &[&str] = &[ "ipv4.dns", "ipv4.dns-search", "ipv4.dns-options", "ipv4.dns-priority", "ipv4.routes", "ipv4.route-table", "ipv4.routing-rules", "ipv4.never-default", "ipv4.may-fail", "ipv4.ignore-auto-routes", "ipv4.ignore-auto-dns", ]; #[typeshare] #[derive(Debug, Clone, Serialize)] pub struct NetworkInterfaceInfo { pub name: String, pub interface_type: String, pub state: String, pub connection: String, pub addresses: Vec, pub has_default_route: bool, pub bridge_supported: bool, pub reason: Option, } #[derive(Debug, Clone, PartialEq, Eq)] struct NetworkManagerDevice { name: String, interface_type: String, state: String, connection: String, addresses: Vec, has_default_route: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] struct BridgeJournal { version: u8, uplink: String, existing_bridge: bool, original_connection_uuid: Option, bridge_profile_uuid: Option, #[serde(default)] bridge_profile_name: Option, uplink_profile_uuid: Option, #[serde(default)] uplink_profile_name: Option, usb_profile_uuid: String, #[serde(default)] usb_profile_name: Option, } #[derive(Debug)] struct TransactionProfiles { bridge_name: String, bridge_uuid: String, uplink_name: String, uplink_uuid: String, usb_name: String, usb_uuid: String, } impl TransactionProfiles { fn new() -> Self { let transaction = Uuid::new_v4().simple().to_string(); let suffix = &transaction[..12]; Self { bridge_name: format!("{PROFILE_PREFIX}-bridge-{suffix}"), bridge_uuid: String::new(), uplink_name: format!("{PROFILE_PREFIX}-uplink-{suffix}"), uplink_uuid: String::new(), usb_name: format!("{PROFILE_PREFIX}-usb-{suffix}"), usb_uuid: String::new(), } } } #[derive(Debug, Clone)] pub struct NetworkBridgeRuntime { journal: BridgeJournal, } impl NetworkBridgeRuntime { pub fn activate(requested: &str, usb_interface: &str) -> Result { ensure_command("nmcli")?; ensure_command("ip")?; let interfaces = list_network_interfaces()?; let selected = select_bridge_candidate(&interfaces, requested)?; prepare_device_for_network_manager(usb_interface, "ethernet")?; Self::activate_physical_uplink(&selected.name, &selected.connection, usb_interface) } fn activate_physical_uplink( uplink: &str, original_connection: &str, usb_interface: &str, ) -> Result { if original_connection.is_empty() || original_connection == "--" { return Err(AppError::BadRequest(format!( "Ethernet interface {uplink} has no active NetworkManager connection" ))); } reset_bridge_interface()?; let original_connection_uuid = active_connection_uuid(uplink)?; let ipv4_method = connection_value(&original_connection_uuid, "ipv4.method")?; if !matches!(ipv4_method.as_str(), "auto" | "manual") { return Err(AppError::BadRequest(format!( "Connection {original_connection} uses unsupported ipv4.method={ipv4_method}" ))); } let ipv6_method = connection_value(&original_connection_uuid, "ipv6.method")?; if !matches!(ipv6_method.as_str(), "auto" | "disabled" | "ignore") { return Err(AppError::BadRequest(format!( "Connection {original_connection} uses unsupported ipv6.method={ipv6_method}" ))); } let ipv4_metric = connection_value(&original_connection_uuid, "ipv4.route-metric")?; let ipv6_metric = connection_value(&original_connection_uuid, "ipv6.route-metric")?; let original_had_default_route = default_route(uplink).is_some(); let mac_path = Path::new("/sys/class/net").join(uplink).join("address"); let uplink_mac = fs::read_to_string(&mac_path) .map_err(|e| { AppError::Internal(format!("Failed to read {}: {}", mac_path.display(), e)) })? .trim() .to_string(); let mut profiles = TransactionProfiles::new(); let mut journal = BridgeJournal { version: JOURNAL_VERSION, uplink: uplink.to_string(), existing_bridge: false, original_connection_uuid: Some(original_connection_uuid.clone()), bridge_profile_uuid: None, bridge_profile_name: Some(profiles.bridge_name.clone()), uplink_profile_uuid: None, uplink_profile_name: Some(profiles.uplink_name.clone()), usb_profile_uuid: String::new(), usb_profile_name: Some(profiles.usb_name.clone()), }; write_journal(&journal)?; let prepare_result: Result<()> = (|| { create_bridge_interface(&uplink_mac)?; run_nmcli(&[ "connection", "add", "type", "bridge", "ifname", BRIDGE_IF, "con-name", &profiles.bridge_name, ])?; profiles.bridge_uuid = connection_value(&profiles.bridge_name, "connection.uuid")?; journal.bridge_profile_uuid = Some(profiles.bridge_uuid.clone()); write_journal(&journal)?; run_nmcli(&[ "connection", "modify", &profiles.bridge_uuid, "connection.interface-name", BRIDGE_IF, "bridge.mac-address", &uplink_mac, "bridge.stp", "no", "ipv6.method", &ipv6_method, "connection.autoconnect", "no", ])?; configure_ipv4_profile( &original_connection_uuid, &profiles.bridge_uuid, &ipv4_method, )?; for (property, value) in [ ("ipv4.route-metric", ipv4_metric.as_str()), ("ipv6.route-metric", ipv6_metric.as_str()), ] { if !value.is_empty() && value != "-1" { run_nmcli(&[ "connection", "modify", &profiles.bridge_uuid, property, value, ])?; } } run_nmcli(&[ "connection", "add", "type", "ethernet", "ifname", uplink, "con-name", &profiles.uplink_name, "master", BRIDGE_IF, "slave-type", "bridge", "connection.autoconnect", "no", ])?; profiles.uplink_uuid = connection_value(&profiles.uplink_name, "connection.uuid")?; journal.uplink_profile_uuid = Some(profiles.uplink_uuid.clone()); write_journal(&journal)?; run_nmcli(&[ "connection", "add", "type", "ethernet", "ifname", usb_interface, "con-name", &profiles.usb_name, "master", BRIDGE_IF, "slave-type", "bridge", "connection.autoconnect", "no", ])?; profiles.usb_uuid = connection_value(&profiles.usb_name, "connection.uuid")?; journal.usb_profile_uuid = profiles.usb_uuid.clone(); write_journal(&journal)?; Ok(()) })(); if let Err(error) = prepare_result { return Err(restore_or_combine(&journal, error)); } let result = (|| { run_nmcli(&["connection", "down", "uuid", &original_connection_uuid])?; activate_connection( "bridge", &profiles.bridge_name, &profiles.bridge_uuid, Some(BRIDGE_IF), )?; activate_connection( "uplink", &profiles.uplink_name, &profiles.uplink_uuid, Some(uplink), )?; activate_connection( "USB", &profiles.usb_name, &profiles.usb_uuid, Some(usb_interface), )?; let deadline = Instant::now() + Duration::from_secs(35); while Instant::now() < deadline { if first_ipv4_address(BRIDGE_IF).is_some() && (!original_had_default_route || default_route(BRIDGE_IF).is_some()) { break; } thread::sleep(Duration::from_secs(1)); } let address = first_ipv4_address(BRIDGE_IF).ok_or_else(|| { AppError::Internal( "OTG bridge did not obtain an IPv4 address from upstream DHCP".to_string(), ) })?; let route = default_route(BRIDGE_IF); if original_had_default_route && route.is_none() { return Err(AppError::Internal( "OTG bridge did not obtain the original default route".to_string(), )); } if let Some(route) = route.as_deref() { if let Some(gateway) = gateway_from_route(route) { if let Err(error) = run_command("ping", &["-c", "1", "-W", "2", gateway]) { tracing::warn!( "OTG bridge gateway ICMP diagnostic failed for {}: {}", gateway, error ); } } } Ok(address) })(); match result { Ok(_address) => Ok(Self { journal }), Err(error) => Err(restore_or_combine(&journal, error)), } } pub fn deactivate(&self) -> Result<()> { restore_from_journal(&self.journal) } pub fn recover_stale_transaction() -> Result<()> { let value = match fs::read_to_string(JOURNAL_PATH) { Ok(value) => value, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), Err(error) => { return Err(AppError::Internal(format!( "Failed to read OTG network recovery journal: {error}" ))) } }; match serde_json::from_str::(&value) { Ok(journal) if journal.version == JOURNAL_VERSION => restore_from_journal(&journal), Ok(journal) => Err(AppError::Config(format!( "Unsupported OTG network recovery journal version {}", journal.version ))), Err(error) => Err(AppError::Config(format!( "Invalid OTG network recovery journal: {error}" ))), } } } pub fn list_network_interfaces() -> Result> { let devices = enumerate_network_manager_devices()?; Ok(bridge_candidates(devices, is_physical_network_interface)) } fn enumerate_network_manager_devices() -> Result> { ensure_command("nmcli")?; let output = run_command( "nmcli", &[ "-t", "--escape", "no", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device", "status", ], )?; let text = String::from_utf8_lossy(&output.stdout); let mut devices = parse_network_manager_devices(&text); for device in &mut devices { device.addresses = ipv4_addresses(&device.name); device.has_default_route = default_route(&device.name).is_some(); } Ok(devices) } fn parse_network_manager_devices(text: &str) -> Vec { let mut devices = Vec::new(); for line in text.lines() { let fields = line.splitn(4, ':').collect::>(); if fields.len() != 4 || fields[0].is_empty() { continue; } devices.push(NetworkManagerDevice { name: fields[0].to_string(), interface_type: fields[1].to_string(), state: fields[2].to_string(), connection: fields[3].to_string(), addresses: Vec::new(), has_default_route: false, }); } devices } fn bridge_candidates( devices: Vec, is_physical: impl Fn(&str) -> bool, ) -> Vec { devices .into_iter() .filter(|device| { device.interface_type == "ethernet" && device.state == "connected" && !device.connection.is_empty() && device.connection != "--" && is_physical(&device.name) }) .map(|device| NetworkInterfaceInfo { name: device.name, interface_type: device.interface_type, state: device.state, connection: device.connection, addresses: device.addresses, has_default_route: device.has_default_route, bridge_supported: true, reason: None, }) .collect() } fn is_physical_network_interface(name: &str) -> bool { Path::new("/sys/class/net") .join(name) .join("device") .exists() } fn select_bridge_candidate<'a>( interfaces: &'a [NetworkInterfaceInfo], requested: &str, ) -> Result<&'a NetworkInterfaceInfo> { if requested.trim().is_empty() { return interfaces .iter() .max_by_key(|item| item.has_default_route) .ok_or_else(|| { AppError::Config( "No connected physical NetworkManager Ethernet interface is available for OTG bridging" .to_string(), ) }); } interfaces .iter() .find(|item| item.name == requested) .ok_or_else(|| { AppError::Config(format!( "Network interface {requested} is not a connected physical NetworkManager Ethernet interface" )) }) } fn restore_from_journal(journal: &BridgeJournal) -> Result<()> { let mut errors = Vec::new(); for (kind, profile_uuid, profile_name) in [ ( "USB", (!journal.usb_profile_uuid.is_empty()).then_some(journal.usb_profile_uuid.as_str()), journal.usb_profile_name.as_deref(), ), ( "uplink", journal.uplink_profile_uuid.as_deref(), journal.uplink_profile_name.as_deref(), ), ( "bridge", journal.bridge_profile_uuid.as_deref(), journal.bridge_profile_name.as_deref(), ), ] { if let Err(error) = delete_owned_connection(profile_uuid, profile_name) { let profile = profile_uuid.or(profile_name).unwrap_or("unknown"); errors.push(format!( "failed to remove owned {kind} profile {profile}: {error}" )); } } if !journal.existing_bridge { if let Err(error) = delete_bridge_interface() { errors.push(format!( "failed to remove owned bridge interface {BRIDGE_IF}: {error}" )); } if let Some(ref original_uuid) = journal.original_connection_uuid { if let Err(error) = run_nmcli(&[ "connection", "up", "uuid", original_uuid, "ifname", &journal.uplink, ]) { errors.push(format!( "failed to restore original profile {original_uuid}: {error}" )); } } } if !errors.is_empty() { return Err(AppError::Config(errors.join("; "))); } match fs::remove_file(JOURNAL_PATH) { Ok(()) => Ok(()), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(error) => Err(AppError::Internal(format!( "Failed to remove OTG network recovery journal: {error}" ))), } } fn restore_or_combine(journal: &BridgeJournal, primary: AppError) -> AppError { match restore_from_journal(journal) { Ok(()) => primary, Err(rollback) => AppError::Config(format!("{primary}; bridge rollback failed: {rollback}")), } } fn reset_bridge_interface() -> Result<()> { for profile_uuid in connection_uuids()? { if connection_value(&profile_uuid, "connection.interface-name")? == BRIDGE_IF { tracing::warn!( "Removing NetworkManager profile {} bound to reserved interface {}", profile_uuid, BRIDGE_IF ); run_nmcli(&["connection", "delete", "uuid", &profile_uuid])?; } } delete_bridge_interface() } fn create_bridge_interface(mac_address: &str) -> Result<()> { run_command("ip", &["link", "add", "name", BRIDGE_IF, "type", "bridge"])?; run_command( "ip", &["link", "set", "dev", BRIDGE_IF, "address", mac_address], )?; prepare_device_for_network_manager(BRIDGE_IF, "bridge") } fn delete_bridge_interface() -> Result<()> { if !Path::new("/sys/class/net").join(BRIDGE_IF).exists() { return Ok(()); } run_command("ip", &["link", "delete", BRIDGE_IF, "type", "bridge"])?; Ok(()) } fn prepare_device_for_network_manager(interface: &str, expected_type: &str) -> Result<()> { run_command("ip", &["link", "set", interface, "up"])?; let deadline = Instant::now() + NETWORK_MANAGER_DEVICE_WAIT_TIMEOUT; let mut requested_managed = false; while Instant::now() < deadline { match enumerate_network_manager_devices() { Ok(devices) => { if let Some(device) = devices.iter().find(|device| device.name == interface) { if device.interface_type != expected_type { return Err(AppError::BadRequest(format!( "One-KVM interface {interface} has NetworkManager type {}, expected {expected_type}", device.interface_type, ))); } if device.state != "unmanaged" { return Ok(()); } if !requested_managed { tracing::info!( "Marking One-KVM interface {} as managed by NetworkManager", interface ); run_nmcli(&["device", "set", interface, "managed", "yes"])?; requested_managed = true; } } } Err(error) => { tracing::debug!( "Waiting for NetworkManager to discover One-KVM interface {}: {}", interface, error ); } } thread::sleep(Duration::from_millis(100)); } Err(AppError::Internal(format!( "NetworkManager did not discover One-KVM {expected_type} interface {interface} within {} seconds", NETWORK_MANAGER_DEVICE_WAIT_TIMEOUT.as_secs() ))) } fn activate_connection(kind: &str, name: &str, uuid: &str, interface: Option<&str>) -> Result<()> { let result = match interface { Some(interface) => run_nmcli(&["connection", "up", name, "ifname", interface]), None => run_nmcli(&["connection", "up", name]), }; result.map_err(|error| { let target = interface .map(|value| format!(" on {value}")) .unwrap_or_default(); AppError::Internal(format!( "Failed to activate One-KVM {kind} profile {name} ({uuid}){target}: {error}" )) })?; Ok(()) } fn active_connection_uuid(interface: &str) -> Result { let output = run_nmcli(&[ "--escape", "no", "-g", "GENERAL.CON-UUID", "device", "show", interface, ])?; let uuid = String::from_utf8_lossy(&output.stdout).trim().to_string(); if uuid.is_empty() || uuid == "--" { return Err(AppError::BadRequest(format!( "Ethernet interface {interface} has no active NetworkManager profile UUID" ))); } Ok(uuid) } fn connection_uuids() -> Result> { let output = run_nmcli(&["-t", "--escape", "no", "-f", "UUID", "connection", "show"])?; Ok(String::from_utf8_lossy(&output.stdout) .lines() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) .collect()) } fn delete_owned_connection(profile_uuid: Option<&str>, profile_name: Option<&str>) -> Result<()> { if let Some(profile_uuid) = profile_uuid { if connection_uuids()?.iter().any(|uuid| uuid == profile_uuid) { return run_nmcli(&["connection", "delete", "uuid", profile_uuid]).map(|_| ()); } } let Some(profile_name) = profile_name else { return Ok(()); }; let output = run_nmcli(&["-t", "--escape", "no", "-f", "NAME", "connection", "show"])?; if !String::from_utf8_lossy(&output.stdout) .lines() .any(|name| name == profile_name) { return Ok(()); } run_nmcli(&["connection", "delete", "id", profile_name]).map(|_| ()) } fn copy_connection_properties(source: &str, target: &str, properties: &[&str]) -> Result<()> { for property in properties { let Ok(value) = connection_value(source, property) else { tracing::debug!( "Skipping unsupported NetworkManager property {} while configuring OTG bridge", property ); continue; }; if value.is_empty() || value == "--" { continue; } run_nmcli(&["connection", "modify", target, property, &value])?; } Ok(()) } fn configure_ipv4_profile(source: &str, target: &str, method: &str) -> Result<()> { match method { "auto" => { run_nmcli(&["connection", "modify", target, "ipv4.method", "auto"])?; copy_connection_properties(source, target, DHCP_IDENTITY_PROPERTIES) } "manual" => { let addresses = connection_value(source, "ipv4.addresses")?; if addresses.is_empty() || addresses == "--" { return Err(AppError::BadRequest( "Static IPv4 profile has no ipv4.addresses value".to_string(), )); } let gateway = connection_value(source, "ipv4.gateway")?; if gateway.is_empty() || gateway == "--" { run_nmcli(&[ "connection", "modify", target, "ipv4.method", "manual", "ipv4.addresses", &addresses, ])?; } else { run_nmcli(&[ "connection", "modify", target, "ipv4.method", "manual", "ipv4.addresses", &addresses, "ipv4.gateway", &gateway, ])?; } copy_connection_properties(source, target, STATIC_IPV4_PROPERTIES) } _ => Err(AppError::BadRequest(format!( "Unsupported IPv4 method {method}" ))), } } fn write_journal(journal: &BridgeJournal) -> Result<()> { let path = Path::new(JOURNAL_PATH); if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| { AppError::Internal(format!("Failed to create {}: {}", parent.display(), e)) })?; } let value = serde_json::to_vec(journal) .map_err(|e| AppError::Internal(format!("Failed to serialize bridge journal: {e}")))?; let temporary = path.with_extension("json.tmp"); fs::write(&temporary, value) .map_err(|e| AppError::Internal(format!("Failed to write bridge recovery journal: {e}")))?; fs::rename(&temporary, path) .map_err(|e| AppError::Internal(format!("Failed to commit bridge recovery journal: {e}"))) } fn connection_value(connection: &str, property: &str) -> Result { let output = run_nmcli(&[ "--escape", "no", "-g", property, "connection", "show", connection, ])?; Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } fn first_ipv4_address(interface: &str) -> Option { ipv4_addresses(interface).into_iter().next() } fn ipv4_addresses(interface: &str) -> Vec { let Ok(output) = Command::new("ip") .args(["-4", "-o", "address", "show", "dev", interface]) .output() else { return Vec::new(); }; String::from_utf8_lossy(&output.stdout) .lines() .filter_map(|line| { let fields = line.split_whitespace().collect::>(); fields .iter() .position(|field| *field == "inet") .and_then(|index| fields.get(index + 1)) .map(|value| (*value).to_string()) }) .collect() } fn default_route(interface: &str) -> Option { let output = Command::new("ip") .args(["-4", "route", "show", "default", "dev", interface]) .output() .ok()?; String::from_utf8_lossy(&output.stdout) .lines() .find(|line| !line.trim().is_empty()) .map(str::to_string) } fn gateway_from_route(route: &str) -> Option<&str> { let fields = route.split_whitespace().collect::>(); fields .windows(2) .find_map(|part| (part[0] == "via").then_some(part[1])) } fn ensure_command(name: &str) -> Result<()> { let status = Command::new(name).arg("--version").output(); if status.is_err() { return Err(AppError::BadRequest(format!( "OTG bridge requires the {name} command" ))); } Ok(()) } fn run_nmcli(args: &[&str]) -> Result { run_command("nmcli", args) } fn run_command(command: &str, args: &[&str]) -> Result { let output = Command::new(command) .env("LC_ALL", "C") .args(args) .output() .map_err(|e| { AppError::Internal(format!( "Failed to execute {command} {}: {e}", args.join(" ") )) })?; if output.status.success() { return Ok(output); } let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); Err(AppError::Internal(format!( "{command} {} failed: {}", args.join(" "), if stderr.is_empty() { stdout } else { stderr } ))) } #[cfg(test)] mod tests { use super::*; fn device( name: &str, interface_type: &str, state: &str, connection: &str, has_default_route: bool, ) -> NetworkManagerDevice { NetworkManagerDevice { name: name.to_string(), interface_type: interface_type.to_string(), state: state.to_string(), connection: connection.to_string(), addresses: Vec::new(), has_default_route, } } #[test] fn bridge_journal_round_trip() { let journal = BridgeJournal { version: JOURNAL_VERSION, uplink: "eth0".to_string(), existing_bridge: false, original_connection_uuid: Some("original-uuid".to_string()), bridge_profile_uuid: Some("bridge-uuid".to_string()), bridge_profile_name: Some("bridge-name".to_string()), uplink_profile_uuid: Some("uplink-uuid".to_string()), uplink_profile_name: Some("uplink-name".to_string()), usb_profile_uuid: "usb-uuid".to_string(), usb_profile_name: Some("usb-name".to_string()), }; let value = serde_json::to_string(&journal).unwrap(); let decoded: BridgeJournal = serde_json::from_str(&value).unwrap(); assert_eq!(decoded.uplink, "eth0"); assert_eq!(decoded.bridge_profile_uuid.as_deref(), Some("bridge-uuid")); } #[test] fn bridge_journal_accepts_legacy_entries_without_profile_names() { let value = r#"{ "version": 2, "uplink": "eth0", "existing_bridge": false, "original_connection_uuid": "original-uuid", "bridge_profile_uuid": "bridge-uuid", "uplink_profile_uuid": "uplink-uuid", "usb_profile_uuid": "usb-uuid" }"#; let decoded: BridgeJournal = serde_json::from_str(value).unwrap(); assert_eq!(decoded.bridge_profile_name, None); assert_eq!(decoded.uplink_profile_name, None); assert_eq!(decoded.usb_profile_name, None); } #[test] fn transaction_profiles_use_unique_names() { let first = TransactionProfiles::new(); let second = TransactionProfiles::new(); assert_ne!(first.bridge_name, second.bridge_name); assert!(first.usb_name.starts_with(PROFILE_PREFIX)); assert!(first.bridge_uuid.is_empty()); assert!(first.uplink_uuid.is_empty()); assert!(first.usb_uuid.is_empty()); } #[test] fn gateway_is_optional_diagnostic_data() { assert_eq!( gateway_from_route("default via 192.0.2.1 dev okvm-br0"), Some("192.0.2.1") ); assert_eq!(gateway_from_route("default dev okvm-br0"), None); } #[test] fn dhcp_identity_properties_include_client_id_and_hostname() { assert!(DHCP_IDENTITY_PROPERTIES.contains(&"ipv4.dhcp-client-id")); assert!(DHCP_IDENTITY_PROPERTIES.contains(&"ipv4.dhcp-iaid")); assert!(DHCP_IDENTITY_PROPERTIES.contains(&"ipv4.dhcp-hostname")); } #[test] fn static_ipv4_properties_cover_dns_routes_and_policy() { assert!(STATIC_IPV4_PROPERTIES.contains(&"ipv4.dns")); assert!(STATIC_IPV4_PROPERTIES.contains(&"ipv4.routes")); assert!(STATIC_IPV4_PROPERTIES.contains(&"ipv4.route-table")); assert!(STATIC_IPV4_PROPERTIES.contains(&"ipv4.never-default")); } #[test] fn full_network_manager_enumeration_keeps_runtime_devices() { let devices = parse_network_manager_devices( "eth0:ethernet:connected:Wired connection 1\n\ usb0:ethernet:disconnected:--\n\ okvm-br0:bridge:unmanaged:--\n", ); assert_eq!( devices .iter() .map(|device| device.name.as_str()) .collect::>(), ["eth0", "usb0", "okvm-br0"] ); } #[test] fn bridge_candidates_only_keep_connected_physical_ethernet() { let devices = vec![ device("eth0", "ethernet", "connected", "one-kvm-otg-uplink", false), device("wlx76012dc07213", "wifi", "connected", "Wi-Fi", true), device("okvm-br0", "bridge", "connected", "Bridge", true), device("usb0", "ethernet", "connected", "USB", false), device("lo", "loopback", "connected", "lo", false), device("bond0", "bond", "connected", "Bond", false), device("tun0", "tun", "connected", "Tunnel", false), device("veth0", "ethernet", "connected", "Virtual", false), device("eth1", "ethernet", "disconnected", "--", false), device("eth2", "ethernet", "connected", "--", false), device("eth3", "ethernet", "connected", "", false), ]; let candidates = bridge_candidates(devices, |name| { matches!(name, "eth0" | "eth1" | "eth2" | "eth3") }); assert_eq!(candidates.len(), 1); assert_eq!(candidates[0].name, "eth0"); assert!(candidates[0].bridge_supported); assert_eq!(candidates[0].reason, None); } #[test] fn automatic_bridge_selection_prefers_default_route() { let candidates = bridge_candidates( vec![ device("eth0", "ethernet", "connected", "Wired 1", false), device("eth1", "ethernet", "connected", "Wired 2", true), ], |_| true, ); let selected = select_bridge_candidate(&candidates, "").unwrap(); assert_eq!(selected.name, "eth1"); } #[test] fn bridge_selection_reports_when_no_candidate_exists() { let error = select_bridge_candidate(&[], "").unwrap_err(); assert!(matches!(error, AppError::Config(_))); assert!(error.to_string().contains("connected physical")); } }