From 9f2a964f52f8e606fe6b337c9379b41daa2e1eff Mon Sep 17 00:00:00 2001 From: shellrow Date: Sun, 3 May 2026 00:44:04 +0900 Subject: [PATCH 1/2] feat: add DHCP enabled detection --- examples/default_interface.rs | 1 + examples/list_interfaces.rs | 1 + src/interface/interface.rs | 11 ++ src/os/android/interface.rs | 1 + src/os/linux/dhcp.rs | 195 ++++++++++++++++++++++++++++ src/os/linux/interface.rs | 2 + src/os/linux/mod.rs | 2 + src/os/macos/interface.rs | 5 +- src/os/macos/sc.rs | 236 +++++++++++++++++++++++++++++++++- src/os/unix/interface.rs | 1 + src/os/windows/interface.rs | 2 + 11 files changed, 449 insertions(+), 8 deletions(-) create mode 100644 src/os/linux/dhcp.rs diff --git a/examples/default_interface.rs b/examples/default_interface.rs index 58242e3..6c36a36 100644 --- a/examples/default_interface.rs +++ b/examples/default_interface.rs @@ -29,6 +29,7 @@ fn main() { println!("\tTransmit Speed: {:?}", interface.transmit_speed); println!("\tReceive Speed: {:?}", interface.receive_speed); println!("\tAuto-negotiate: {:?}", interface.auto_negotiate); + println!("\tDHCP enabled: {:?}", interface.dhcp_enabled); println!("\tStats: {:?}", interface.stats); if let Some(gateway) = interface.gateway { println!("Default Gateway"); diff --git a/examples/list_interfaces.rs b/examples/list_interfaces.rs index feda458..9ca30c8 100644 --- a/examples/list_interfaces.rs +++ b/examples/list_interfaces.rs @@ -61,6 +61,7 @@ fn main() { println!("\tTransmit Speed: {:?}", interface.transmit_speed); println!("\tReceive Speed: {:?}", interface.receive_speed); println!("\tAuto-negotiate: {:?}", interface.auto_negotiate); + println!("\tDHCP enabled: {:?}", interface.dhcp_enabled); println!("\tStats: {:?}", interface.stats); #[cfg(feature = "gateway")] if let Some(gateway) = interface.gateway { diff --git a/src/interface/interface.rs b/src/interface/interface.rs index 9c6cda8..2a579f3 100644 --- a/src/interface/interface.rs +++ b/src/interface/interface.rs @@ -83,6 +83,11 @@ pub struct Interface { /// /// It may `None` if reading this information has not been implemented for a specific OS. pub auto_negotiate: Option, + /// Whether this interface is configured to use DHCP. + /// + /// This may be `None` if reading this information is not available on the current platform, + /// or not applicable to the interface. + pub dhcp_enabled: Option, /// Traffic counters captured when the interface snapshot was collected. /// /// The counters are cumulative totals reported by the OS, typically since boot. @@ -138,6 +143,7 @@ impl Interface { transmit_speed: None, receive_speed: None, auto_negotiate: None, + dhcp_enabled: None, stats: None, #[cfg(feature = "gateway")] gateway: None, @@ -275,6 +281,11 @@ mod tests { use ipnet::{Ipv4Net, Ipv6Net}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + #[test] + fn dummy_initializes_dhcp_enabled_as_unknown() { + assert_eq!(Interface::dummy().dhcp_enabled, None); + } + #[test] fn global_helpers_filter() { let mut itf = Interface::dummy(); diff --git a/src/os/android/interface.rs b/src/os/android/interface.rs index 00abf41..7dcb4ed 100644 --- a/src/os/android/interface.rs +++ b/src/os/android/interface.rs @@ -95,6 +95,7 @@ pub fn interfaces() -> Vec { transmit_speed: None, receive_speed: None, auto_negotiate: None, + dhcp_enabled: None, stats: r.stats.clone(), #[cfg(feature = "gateway")] gateway: None, diff --git a/src/os/linux/dhcp.rs b/src/os/linux/dhcp.rs new file mode 100644 index 0000000..c94c974 --- /dev/null +++ b/src/os/linux/dhcp.rs @@ -0,0 +1,195 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +pub(crate) fn dhcp_enabled(iface_name: &str, ifindex: u32) -> Option { + systemd_networkd_dhcp_enabled(ifindex) + .or_else(|| network_manager_dhcp_enabled(iface_name)) + .or_else(|| dhclient_lease_detected(iface_name).then_some(true)) +} + +fn systemd_networkd_dhcp_enabled(ifindex: u32) -> Option { + if ifindex == 0 { + return None; + } + let path = PathBuf::from(format!("/run/systemd/netif/links/{ifindex}")); + let content = fs::read_to_string(path).ok()?; + parse_systemd_networkd_link(&content) +} + +fn network_manager_dhcp_enabled(iface_name: &str) -> Option { + for dir in [ + "/run/NetworkManager/system-connections", + "/etc/NetworkManager/system-connections", + ] { + let Some(value) = network_manager_dir_dhcp_enabled(Path::new(dir), iface_name) else { + continue; + }; + return Some(value); + } + None +} + +fn network_manager_dir_dhcp_enabled(dir: &Path, iface_name: &str) -> Option { + let entries = fs::read_dir(dir).ok()?; + for entry in entries.flatten() { + let file_type = entry.file_type().ok()?; + if !file_type.is_file() { + continue; + } + let content = fs::read_to_string(entry.path()).ok()?; + if let Some(value) = parse_network_manager_connection(&content, iface_name) { + return Some(value); + } + } + None +} + +fn dhclient_lease_detected(iface_name: &str) -> bool { + for dir in [ + "/run/NetworkManager", + "/var/lib/NetworkManager", + "/var/lib/dhcp", + "/var/lib/dhclient", + ] { + if dhclient_lease_in_dir(Path::new(dir), iface_name) { + return true; + } + } + false +} + +fn dhclient_lease_in_dir(dir: &Path, iface_name: &str) -> bool { + let Ok(entries) = fs::read_dir(dir) else { + return false; + }; + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_file() { + continue; + } + let file_name = entry.file_name().to_string_lossy().to_string(); + if !file_name.contains(iface_name) || !file_name.contains("lease") { + continue; + } + if let Ok(content) = fs::read_to_string(entry.path()) { + if content.contains("lease") || content.contains("dhcp") { + return true; + } + } + } + false +} + +fn parse_systemd_networkd_link(content: &str) -> Option { + for line in content.lines() { + let Some((key, value)) = split_key_value(line) else { + continue; + }; + if key != "DHCP" { + continue; + } + return parse_systemd_dhcp_value(value); + } + None +} + +fn parse_systemd_dhcp_value(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "yes" | "true" | "ipv4" => Some(true), + "no" | "false" | "none" | "ipv6" => Some(false), + _ => None, + } +} + +fn parse_network_manager_connection(content: &str, iface_name: &str) -> Option { + let mut section = ""; + let mut connection_matches = false; + let mut ipv4_method = None; + + for raw_line in content.lines() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + if let Some(name) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { + section = name.trim(); + continue; + } + let Some((key, value)) = split_key_value(line) else { + continue; + }; + match (section, key) { + ("connection", "interface-name") => { + connection_matches = value == iface_name; + } + ("ipv4", "method") => { + ipv4_method = Some(value); + } + _ => {} + } + } + + if !connection_matches { + return None; + } + parse_network_manager_ipv4_method(ipv4_method?) +} + +fn parse_network_manager_ipv4_method(method: &str) -> Option { + match method.trim().to_ascii_lowercase().as_str() { + "auto" => Some(true), + "manual" | "disabled" | "link-local" | "shared" => Some(false), + _ => None, + } +} + +fn split_key_value(line: &str) -> Option<(&str, &str)> { + let (key, value) = line.split_once('=')?; + Some((key.trim(), value.trim())) +} + +#[cfg(test)] +mod tests { + use super::{ + parse_network_manager_connection, parse_network_manager_ipv4_method, + parse_systemd_networkd_link, + }; + + #[test] + fn parses_systemd_networkd_dhcp_values() { + assert_eq!( + parse_systemd_networkd_link("ADMIN_STATE=configured\nDHCP=yes\n"), + Some(true) + ); + assert_eq!(parse_systemd_networkd_link("DHCP=ipv4\n"), Some(true)); + assert_eq!(parse_systemd_networkd_link("DHCP=no\n"), Some(false)); + assert_eq!(parse_systemd_networkd_link("DHCP=ipv6\n"), Some(false)); + assert_eq!(parse_systemd_networkd_link("STATE=routable\n"), None); + } + + #[test] + fn parses_network_manager_connection_for_matching_interface() { + let content = "\ +[connection] +id=Wired +interface-name=eth0 + +[ipv4] +method=auto +"; + assert_eq!( + parse_network_manager_connection(content, "eth0"), + Some(true) + ); + assert_eq!(parse_network_manager_connection(content, "wlan0"), None); + } + + #[test] + fn parses_network_manager_static_methods() { + assert_eq!(parse_network_manager_ipv4_method("manual"), Some(false)); + assert_eq!(parse_network_manager_ipv4_method("disabled"), Some(false)); + assert_eq!(parse_network_manager_ipv4_method("unknown"), None); + } +} diff --git a/src/os/linux/interface.rs b/src/os/linux/interface.rs index ce2ab44..bda737a 100644 --- a/src/os/linux/interface.rs +++ b/src/os/linux/interface.rs @@ -71,6 +71,7 @@ pub fn interfaces() -> Vec { transmit_speed: None, receive_speed: None, auto_negotiate: None, + dhcp_enabled: None, stats: None, #[cfg(feature = "gateway")] gateway: None, @@ -137,6 +138,7 @@ pub fn interfaces() -> Vec { let if_speed = super::sysfs::get_interface_speed(&iface.name); iface.transmit_speed = if_speed; iface.receive_speed = if_speed; + iface.dhcp_enabled = super::dhcp::dhcp_enabled(&iface.name, iface.index); iface.oper_state = super::sysfs::operstate(&iface.name); if iface.stats.is_none() { diff --git a/src/os/linux/mod.rs b/src/os/linux/mod.rs index 516663e..2d3d677 100644 --- a/src/os/linux/mod.rs +++ b/src/os/linux/mod.rs @@ -1,5 +1,7 @@ pub mod arp; #[cfg(not(target_os = "android"))] +mod dhcp; +#[cfg(not(target_os = "android"))] pub mod flags; #[cfg(not(target_os = "android"))] pub mod interface; diff --git a/src/os/macos/interface.rs b/src/os/macos/interface.rs index 757917b..607e270 100644 --- a/src/os/macos/interface.rs +++ b/src/os/macos/interface.rs @@ -1,5 +1,5 @@ use crate::os::darwin::types::{get_functional_type, interface_type_by_name}; -use crate::os::macos::sc::{get_sc_interface_map, read_sc_interfaces_plist_map}; +use crate::os::macos::sc::{get_sc_interface_map, read_sc_plist_interface_map}; use crate::{ interface::interface::Interface, os::{ @@ -15,7 +15,7 @@ pub fn interfaces() -> Vec { let mut ifaces: Vec = unix_interfaces(); let if_extra_map: HashMap = - read_sc_interfaces_plist_map().unwrap_or_else(|_| { + read_sc_plist_interface_map().unwrap_or_else(|_| { // Fallback to SCNetworkInterfaceCopyAll ... get_sc_interface_map() }); @@ -40,6 +40,7 @@ pub fn interfaces() -> Vec { iface.if_type = sc_type; } iface.friendly_name = sc_inface.friendly_name.clone(); + iface.dhcp_enabled = sc_inface.dhcp_enabled; } if iface.if_type == InterfaceType::Wireless80211 { diff --git a/src/os/macos/sc.rs b/src/os/macos/sc.rs index 0d40790..63a6f13 100644 --- a/src/os/macos/sc.rs +++ b/src/os/macos/sc.rs @@ -8,6 +8,7 @@ use std::collections::HashMap; use std::io::Cursor; const SC_NWIF_PATH: &str = "/Library/Preferences/SystemConfiguration/NetworkInterfaces.plist"; +const SC_PREFS_PATH: &str = "/Library/Preferences/SystemConfiguration/preferences.plist"; #[derive(Clone, Debug, Default)] pub(crate) struct SCInterface { @@ -19,21 +20,21 @@ pub(crate) struct SCInterface { pub sc_type: Option, #[allow(dead_code)] pub active: Option, + pub dhcp_enabled: Option, } impl SCInterface { pub fn if_type(&self) -> Option { - if let Some(sc_type) = &self.sc_type { - Some(map_sc_interface_type(sc_type)) - } else { - None - } + self.sc_type + .as_ref() + .map(|sc_type| map_sc_interface_type(sc_type)) } } fn map_sc_interface_type(type_id: &str) -> InterfaceType { match type_id { "Bridge" => InterfaceType::Bridge, + "AirPort" => InterfaceType::Wireless80211, "Ethernet" => InterfaceType::Ethernet, "IEEE80211" => InterfaceType::Wireless80211, "Loopback" => InterfaceType::Loopback, @@ -73,7 +74,7 @@ pub(crate) fn get_sc_interface_map() -> HashMap { let mac: Option = sc_iface .hardware_address_string() - .and_then(|mac_str| Some(MacAddr::from_hex_format(&mac_str.to_string()))); + .map(|mac_str| MacAddr::from_hex_format(&mac_str.to_string())); if_map.insert( name.clone(), @@ -83,6 +84,7 @@ pub(crate) fn get_sc_interface_map() -> HashMap { sc_type: sc_if_type, mac, active: None, + dhcp_enabled: None, }, ); } @@ -153,6 +155,7 @@ fn load_sc_interfaces_plist_map(bytes: &[u8]) -> HashMap { sc_type, mac, active, + dhcp_enabled: None, }, ); } @@ -160,8 +163,229 @@ fn load_sc_interfaces_plist_map(bytes: &[u8]) -> HashMap { map } +fn load_sc_preferences_plist_map(bytes: &[u8]) -> HashMap { + let mut map = HashMap::new(); + + let v = match plist::Value::from_reader(Cursor::new(bytes)) { + Ok(v) => v, + Err(_) => return map, + }; + let dict = match v.as_dictionary() { + Some(d) => d, + None => return map, + }; + let services = match dict.get("NetworkServices").and_then(|v| v.as_dictionary()) { + Some(d) => d, + None => return map, + }; + + for service_id in current_set_service_order(dict) { + if let Some(service) = services.get(&service_id).and_then(|v| v.as_dictionary()) { + insert_service_metadata(&mut map, service); + } + } + + for service in services.values().filter_map(|v| v.as_dictionary()) { + insert_service_metadata(&mut map, service); + } + + map +} + +fn current_set_service_order(dict: &plist::Dictionary) -> Vec { + let Some(current_set) = dict.get("CurrentSet").and_then(|v| v.as_string()) else { + return Vec::new(); + }; + let Some(set_id) = current_set.strip_prefix("/Sets/") else { + return Vec::new(); + }; + + dict.get("Sets") + .and_then(|v| v.as_dictionary()) + .and_then(|sets| sets.get(set_id)) + .and_then(|v| v.as_dictionary()) + .and_then(|set| set.get("Network")) + .and_then(|v| v.as_dictionary()) + .and_then(|network| network.get("Global")) + .and_then(|v| v.as_dictionary()) + .and_then(|global| global.get("IPv4")) + .and_then(|v| v.as_dictionary()) + .and_then(|ipv4| ipv4.get("ServiceOrder")) + .and_then(|v| v.as_array()) + .map(|order| { + order + .iter() + .filter_map(|v| v.as_string().map(ToOwned::to_owned)) + .collect() + }) + .unwrap_or_default() +} + +fn insert_service_metadata(map: &mut HashMap, service: &plist::Dictionary) { + let Some(interface) = service.get("Interface").and_then(|v| v.as_dictionary()) else { + return; + }; + let Some(bsd_name) = interface.get("DeviceName").and_then(|v| v.as_string()) else { + return; + }; + if bsd_name.is_empty() || map.contains_key(bsd_name) { + return; + } + + let friendly_name = service + .get("UserDefinedName") + .and_then(|v| v.as_string()) + .or_else(|| interface.get("UserDefinedName").and_then(|v| v.as_string())) + .map(ToOwned::to_owned); + let sc_type = interface + .get("Hardware") + .and_then(|v| v.as_string()) + .or_else(|| interface.get("Type").and_then(|v| v.as_string())) + .map(ToOwned::to_owned); + let dhcp_enabled = service + .get("IPv4") + .and_then(|v| v.as_dictionary()) + .and_then(|ipv4| ipv4.get("ConfigMethod")) + .and_then(|v| v.as_string()) + .and_then(map_ipv4_config_method_to_dhcp); + + map.insert( + bsd_name.to_string(), + SCInterface { + bsd_name: bsd_name.to_string(), + mac: None, + friendly_name, + sc_type, + active: None, + dhcp_enabled, + }, + ); +} + +fn map_ipv4_config_method_to_dhcp(method: &str) -> Option { + match method { + "DHCP" => Some(true), + "Manual" | "BOOTP" | "INFORM" | "LinkLocal" | "PPP" | "Automatic" | "Off" => Some(false), + _ => None, + } +} + +fn merge_sc_interface_maps( + mut base: HashMap, + overlay: HashMap, +) -> HashMap { + for (name, overlay_iface) in overlay { + let entry = base.entry(name).or_insert_with(|| SCInterface { + bsd_name: overlay_iface.bsd_name.clone(), + ..SCInterface::default() + }); + + if entry.mac.is_none() { + entry.mac = overlay_iface.mac; + } + if overlay_iface.friendly_name.is_some() { + entry.friendly_name = overlay_iface.friendly_name; + } + if overlay_iface.sc_type.is_some() { + entry.sc_type = overlay_iface.sc_type; + } + if entry.active.is_none() { + entry.active = overlay_iface.active; + } + if overlay_iface.dhcp_enabled.is_some() { + entry.dhcp_enabled = overlay_iface.dhcp_enabled; + } + } + base +} + /// Read and parse the NetworkInterfaces.plist file into a map of `BSD name -> SCInterface`. pub(crate) fn read_sc_interfaces_plist_map() -> std::io::Result> { let bytes = std::fs::read(SC_NWIF_PATH)?; Ok(load_sc_interfaces_plist_map(&bytes)) } + +/// Read macOS SystemConfiguration plist files into a map of `BSD name -> SCInterface`. +pub(crate) fn read_sc_plist_interface_map() -> std::io::Result> { + let preferences = std::fs::read(SC_PREFS_PATH) + .map(|bytes| load_sc_preferences_plist_map(&bytes)) + .unwrap_or_default(); + + match read_sc_interfaces_plist_map() { + Ok(interfaces) => Ok(merge_sc_interface_maps(interfaces, preferences)), + Err(_err) if !preferences.is_empty() => Ok(preferences), + Err(err) => Err(err), + } +} + +#[cfg(test)] +mod tests { + use super::{load_sc_preferences_plist_map, map_ipv4_config_method_to_dhcp}; + + #[test] + fn maps_ipv4_config_methods_to_dhcp_state() { + assert_eq!(map_ipv4_config_method_to_dhcp("DHCP"), Some(true)); + assert_eq!(map_ipv4_config_method_to_dhcp("Manual"), Some(false)); + assert_eq!(map_ipv4_config_method_to_dhcp("Automatic"), Some(false)); + assert_eq!(map_ipv4_config_method_to_dhcp("Unknown"), None); + } + + #[test] + fn parses_preferences_network_service_metadata() { + let plist = br#" + + + + CurrentSet + /Sets/SET + NetworkServices + + SERVICE + + IPv4 + + ConfigMethod + DHCP + + Interface + + DeviceName + en1 + Hardware + AirPort + UserDefinedName + Wi-Fi + + UserDefinedName + Wi-Fi + + + Sets + + SET + + Network + + Global + + IPv4 + + ServiceOrder + + SERVICE + + + + + + + +"#; + + let map = load_sc_preferences_plist_map(plist); + let iface = map.get("en1").unwrap(); + assert_eq!(iface.friendly_name.as_deref(), Some("Wi-Fi")); + assert_eq!(iface.sc_type.as_deref(), Some("AirPort")); + assert_eq!(iface.dhcp_enabled, Some(true)); + } +} diff --git a/src/os/unix/interface.rs b/src/os/unix/interface.rs index 6054a40..d4b50e2 100644 --- a/src/os/unix/interface.rs +++ b/src/os/unix/interface.rs @@ -165,6 +165,7 @@ fn unix_interfaces_inner( transmit_speed: None, receive_speed: None, auto_negotiate: None, + dhcp_enabled: None, stats, #[cfg(feature = "gateway")] gateway: None, diff --git a/src/os/windows/interface.rs b/src/os/windows/interface.rs index 9cc8254..26dabfc 100644 --- a/src/os/windows/interface.rs +++ b/src/os/windows/interface.rs @@ -2,6 +2,7 @@ use std::net::IpAddr; use windows_sys::Win32::Foundation::{ERROR_BUFFER_OVERFLOW, NO_ERROR}; use windows_sys::Win32::NetworkManagement::IpHelper::{ GAA_FLAG_INCLUDE_GATEWAYS, GetAdaptersAddresses, IP_ADAPTER_ADDRESSES_LH, + IP_ADAPTER_DHCP_ENABLED, }; use windows_sys::Win32::NetworkManagement::Ndis::NET_IF_OPER_STATUS_UP; use windows_sys::Win32::Networking::WinSock::{ @@ -269,6 +270,7 @@ pub fn interfaces() -> Vec { transmit_speed: sanitize_u64(cur.TransmitLinkSpeed), receive_speed: sanitize_u64(cur.ReceiveLinkSpeed), auto_negotiate: None, + dhcp_enabled: Some(unsafe { cur.Anonymous2.Flags } & IP_ADAPTER_DHCP_ENABLED != 0), stats, #[cfg(feature = "gateway")] gateway: if default_gateway.mac_addr == MacAddr::zero() { From c395e25badec2b8e1f0f9e5a3983db11c6dfa435 Mon Sep 17 00:00:00 2001 From: shellrow Date: Sun, 3 May 2026 18:01:37 +0900 Subject: [PATCH 2/2] feat: split DHCP detection by IP version --- examples/default_interface.rs | 3 +- examples/list_interfaces.rs | 3 +- src/interface/interface.rs | 21 ++- src/os/android/interface.rs | 3 +- src/os/linux/dhcp.rs | 252 ++++++++++++++++++++++++---------- src/os/linux/interface.rs | 7 +- src/os/macos/interface.rs | 3 +- src/os/macos/sc.rs | 73 +++++++--- src/os/unix/interface.rs | 3 +- src/os/windows/interface.rs | 5 +- 10 files changed, 271 insertions(+), 102 deletions(-) diff --git a/examples/default_interface.rs b/examples/default_interface.rs index 6c36a36..c8fd959 100644 --- a/examples/default_interface.rs +++ b/examples/default_interface.rs @@ -29,7 +29,8 @@ fn main() { println!("\tTransmit Speed: {:?}", interface.transmit_speed); println!("\tReceive Speed: {:?}", interface.receive_speed); println!("\tAuto-negotiate: {:?}", interface.auto_negotiate); - println!("\tDHCP enabled: {:?}", interface.dhcp_enabled); + println!("\tDHCPv4 enabled: {:?}", interface.dhcp_v4_enabled); + println!("\tDHCPv6 enabled: {:?}", interface.dhcp_v6_enabled); println!("\tStats: {:?}", interface.stats); if let Some(gateway) = interface.gateway { println!("Default Gateway"); diff --git a/examples/list_interfaces.rs b/examples/list_interfaces.rs index 9ca30c8..bdc4ca3 100644 --- a/examples/list_interfaces.rs +++ b/examples/list_interfaces.rs @@ -61,7 +61,8 @@ fn main() { println!("\tTransmit Speed: {:?}", interface.transmit_speed); println!("\tReceive Speed: {:?}", interface.receive_speed); println!("\tAuto-negotiate: {:?}", interface.auto_negotiate); - println!("\tDHCP enabled: {:?}", interface.dhcp_enabled); + println!("\tDHCPv4 enabled: {:?}", interface.dhcp_v4_enabled); + println!("\tDHCPv6 enabled: {:?}", interface.dhcp_v6_enabled); println!("\tStats: {:?}", interface.stats); #[cfg(feature = "gateway")] if let Some(gateway) = interface.gateway { diff --git a/src/interface/interface.rs b/src/interface/interface.rs index 2a579f3..9644752 100644 --- a/src/interface/interface.rs +++ b/src/interface/interface.rs @@ -83,11 +83,16 @@ pub struct Interface { /// /// It may `None` if reading this information has not been implemented for a specific OS. pub auto_negotiate: Option, - /// Whether this interface is configured to use DHCP. + /// Whether this interface is configured to use DHCP for IPv4. /// - /// This may be `None` if reading this information is not available on the current platform, - /// or not applicable to the interface. - pub dhcp_enabled: Option, + /// This may be `None` if reading this information is not available on the + /// current platform, not implemented, or not applicable to the interface. + pub dhcp_v4_enabled: Option, + /// Whether this interface is configured to use DHCP for IPv6. + /// + /// This may be `None` if reading this information is not available on the + /// current platform, not implemented, or not applicable to the interface. + pub dhcp_v6_enabled: Option, /// Traffic counters captured when the interface snapshot was collected. /// /// The counters are cumulative totals reported by the OS, typically since boot. @@ -143,7 +148,8 @@ impl Interface { transmit_speed: None, receive_speed: None, auto_negotiate: None, - dhcp_enabled: None, + dhcp_v4_enabled: None, + dhcp_v6_enabled: None, stats: None, #[cfg(feature = "gateway")] gateway: None, @@ -282,8 +288,9 @@ mod tests { use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; #[test] - fn dummy_initializes_dhcp_enabled_as_unknown() { - assert_eq!(Interface::dummy().dhcp_enabled, None); + fn dummy_initializes_dhcp_state_as_unknown() { + assert_eq!(Interface::dummy().dhcp_v4_enabled, None); + assert_eq!(Interface::dummy().dhcp_v6_enabled, None); } #[test] diff --git a/src/os/android/interface.rs b/src/os/android/interface.rs index 7dcb4ed..c6c46e5 100644 --- a/src/os/android/interface.rs +++ b/src/os/android/interface.rs @@ -95,7 +95,8 @@ pub fn interfaces() -> Vec { transmit_speed: None, receive_speed: None, auto_negotiate: None, - dhcp_enabled: None, + dhcp_v4_enabled: None, + dhcp_v6_enabled: None, stats: r.stats.clone(), #[cfg(feature = "gateway")] gateway: None, diff --git a/src/os/linux/dhcp.rs b/src/os/linux/dhcp.rs index c94c974..1c19708 100644 --- a/src/os/linux/dhcp.rs +++ b/src/os/linux/dhcp.rs @@ -1,66 +1,71 @@ use std::fs; use std::path::{Path, PathBuf}; -pub(crate) fn dhcp_enabled(iface_name: &str, ifindex: u32) -> Option { - systemd_networkd_dhcp_enabled(ifindex) - .or_else(|| network_manager_dhcp_enabled(iface_name)) - .or_else(|| dhclient_lease_detected(iface_name).then_some(true)) +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct DhcpState { + pub v4: Option, + pub v6: Option, } -fn systemd_networkd_dhcp_enabled(ifindex: u32) -> Option { +impl DhcpState { + fn merge_missing(&mut self, other: DhcpState) { + if self.v4.is_none() { + self.v4 = other.v4; + } + if self.v6.is_none() { + self.v6 = other.v6; + } + } +} + +pub(crate) fn dhcp_state(iface_name: &str, ifindex: u32) -> DhcpState { + let mut state = systemd_networkd_dhcp_state(ifindex); + state.merge_missing(systemd_lease_dhcp_state(ifindex)); + state.merge_missing(network_manager_dhcp_state(iface_name)); + state +} + +fn systemd_networkd_dhcp_state(ifindex: u32) -> DhcpState { if ifindex == 0 { - return None; + return DhcpState::default(); } let path = PathBuf::from(format!("/run/systemd/netif/links/{ifindex}")); - let content = fs::read_to_string(path).ok()?; + let Ok(content) = fs::read_to_string(path) else { + return DhcpState::default(); + }; parse_systemd_networkd_link(&content) } -fn network_manager_dhcp_enabled(iface_name: &str) -> Option { - for dir in [ - "/run/NetworkManager/system-connections", - "/etc/NetworkManager/system-connections", - ] { - let Some(value) = network_manager_dir_dhcp_enabled(Path::new(dir), iface_name) else { - continue; - }; - return Some(value); +fn systemd_lease_dhcp_state(ifindex: u32) -> DhcpState { + if ifindex == 0 { + return DhcpState::default(); } - None -} - -fn network_manager_dir_dhcp_enabled(dir: &Path, iface_name: &str) -> Option { - let entries = fs::read_dir(dir).ok()?; - for entry in entries.flatten() { - let file_type = entry.file_type().ok()?; - if !file_type.is_file() { - continue; - } - let content = fs::read_to_string(entry.path()).ok()?; - if let Some(value) = parse_network_manager_connection(&content, iface_name) { - return Some(value); - } + let path = PathBuf::from(format!("/run/systemd/netif/leases/{ifindex}")); + match fs::read_to_string(path) { + Ok(_) => DhcpState { + v4: Some(true), + v6: None, + }, + Err(_) => DhcpState::default(), } - None } -fn dhclient_lease_detected(iface_name: &str) -> bool { +fn network_manager_dhcp_state(iface_name: &str) -> DhcpState { for dir in [ - "/run/NetworkManager", - "/var/lib/NetworkManager", - "/var/lib/dhcp", - "/var/lib/dhclient", + "/run/NetworkManager/system-connections", + "/etc/NetworkManager/system-connections", ] { - if dhclient_lease_in_dir(Path::new(dir), iface_name) { - return true; + let state = network_manager_dir_dhcp_state(Path::new(dir), iface_name); + if state.v4.is_some() || state.v6.is_some() { + return state; } } - false + DhcpState::default() } -fn dhclient_lease_in_dir(dir: &Path, iface_name: &str) -> bool { +fn network_manager_dir_dhcp_state(dir: &Path, iface_name: &str) -> DhcpState { let Ok(entries) = fs::read_dir(dir) else { - return false; + return DhcpState::default(); }; for entry in entries.flatten() { let Ok(file_type) = entry.file_type() else { @@ -69,44 +74,75 @@ fn dhclient_lease_in_dir(dir: &Path, iface_name: &str) -> bool { if !file_type.is_file() { continue; } - let file_name = entry.file_name().to_string_lossy().to_string(); - if !file_name.contains(iface_name) || !file_name.contains("lease") { + let Ok(content) = fs::read_to_string(entry.path()) else { continue; - } - if let Ok(content) = fs::read_to_string(entry.path()) { - if content.contains("lease") || content.contains("dhcp") { - return true; - } + }; + let state = parse_network_manager_connection(&content, iface_name); + if state.v4.is_some() || state.v6.is_some() { + return state; } } - false + DhcpState::default() } -fn parse_systemd_networkd_link(content: &str) -> Option { +fn parse_systemd_networkd_link(content: &str) -> DhcpState { + let mut inferred = DhcpState::default(); + let mut explicit = DhcpState::default(); + for line in content.lines() { let Some((key, value)) = split_key_value(line) else { continue; }; - if key != "DHCP" { + if key.starts_with("DHCP4_CLIENT_") { + inferred.v4 = Some(true); + continue; + } + if key.starts_with("DHCP6_CLIENT_") { + inferred.v6 = Some(true); continue; } - return parse_systemd_dhcp_value(value); + if key == "DHCP" { + explicit = parse_systemd_dhcp_value(value); + } } - None + + let mut result = inferred; + if explicit.v4.is_some() { + result.v4 = explicit.v4; + } + if explicit.v6.is_some() { + result.v6 = explicit.v6; + } + result } -fn parse_systemd_dhcp_value(value: &str) -> Option { +fn parse_systemd_dhcp_value(value: &str) -> DhcpState { match value.trim().to_ascii_lowercase().as_str() { - "yes" | "true" | "ipv4" => Some(true), - "no" | "false" | "none" | "ipv6" => Some(false), - _ => None, + "yes" | "true" | "both" => DhcpState { + v4: Some(true), + v6: Some(true), + }, + "ipv4" => DhcpState { + v4: Some(true), + v6: Some(false), + }, + "ipv6" => DhcpState { + v4: Some(false), + v6: Some(true), + }, + "no" | "false" | "none" => DhcpState { + v4: Some(false), + v6: Some(false), + }, + _ => DhcpState::default(), } } -fn parse_network_manager_connection(content: &str, iface_name: &str) -> Option { +fn parse_network_manager_connection(content: &str, iface_name: &str) -> DhcpState { let mut section = ""; let mut connection_matches = false; let mut ipv4_method = None; + let mut ipv6_method = None; for raw_line in content.lines() { let line = raw_line.trim(); @@ -127,14 +163,21 @@ fn parse_network_manager_connection(content: &str, iface_name: &str) -> Option { ipv4_method = Some(value); } + ("ipv6", "method") => { + ipv6_method = Some(value); + } _ => {} } } if !connection_matches { - return None; + return DhcpState::default(); + } + + DhcpState { + v4: ipv4_method.and_then(parse_network_manager_ipv4_method), + v6: ipv6_method.and_then(parse_network_manager_ipv6_method), } - parse_network_manager_ipv4_method(ipv4_method?) } fn parse_network_manager_ipv4_method(method: &str) -> Option { @@ -145,6 +188,14 @@ fn parse_network_manager_ipv4_method(method: &str) -> Option { } } +fn parse_network_manager_ipv6_method(method: &str) -> Option { + match method.trim().to_ascii_lowercase().as_str() { + "dhcp" => Some(true), + "manual" | "disabled" | "link-local" => Some(false), + _ => None, + } +} + fn split_key_value(line: &str) -> Option<(&str, &str)> { let (key, value) = line.split_once('=')?; Some((key.trim(), value.trim())) @@ -153,20 +204,62 @@ fn split_key_value(line: &str) -> Option<(&str, &str)> { #[cfg(test)] mod tests { use super::{ - parse_network_manager_connection, parse_network_manager_ipv4_method, - parse_systemd_networkd_link, + DhcpState, parse_network_manager_connection, parse_network_manager_ipv4_method, + parse_network_manager_ipv6_method, parse_systemd_networkd_link, }; #[test] fn parses_systemd_networkd_dhcp_values() { assert_eq!( parse_systemd_networkd_link("ADMIN_STATE=configured\nDHCP=yes\n"), - Some(true) + DhcpState { + v4: Some(true), + v6: Some(true), + } + ); + assert_eq!( + parse_systemd_networkd_link("DHCP=ipv4\n"), + DhcpState { + v4: Some(true), + v6: Some(false), + } + ); + assert_eq!( + parse_systemd_networkd_link("DHCP=ipv6\n"), + DhcpState { + v4: Some(false), + v6: Some(true), + } + ); + } + + #[test] + fn detects_systemd_runtime_client_keys() { + assert_eq!( + parse_systemd_networkd_link("DHCP4_CLIENT_ADDRESS=192.0.2.10\n"), + DhcpState { + v4: Some(true), + v6: None, + } + ); + assert_eq!( + parse_systemd_networkd_link("DHCP6_CLIENT_DUID=00:01:00:01\n"), + DhcpState { + v4: None, + v6: Some(true), + } + ); + } + + #[test] + fn explicit_systemd_dhcp_value_overrides_runtime_keys() { + assert_eq!( + parse_systemd_networkd_link("DHCP4_CLIENT_ADDRESS=192.0.2.10\nDHCP=no\n"), + DhcpState { + v4: Some(false), + v6: Some(false), + } ); - assert_eq!(parse_systemd_networkd_link("DHCP=ipv4\n"), Some(true)); - assert_eq!(parse_systemd_networkd_link("DHCP=no\n"), Some(false)); - assert_eq!(parse_systemd_networkd_link("DHCP=ipv6\n"), Some(false)); - assert_eq!(parse_systemd_networkd_link("STATE=routable\n"), None); } #[test] @@ -178,18 +271,37 @@ interface-name=eth0 [ipv4] method=auto + +[ipv6] +method=dhcp "; assert_eq!( parse_network_manager_connection(content, "eth0"), - Some(true) + DhcpState { + v4: Some(true), + v6: Some(true), + } + ); + assert_eq!( + parse_network_manager_connection(content, "wlan0"), + DhcpState::default() ); - assert_eq!(parse_network_manager_connection(content, "wlan0"), None); } #[test] - fn parses_network_manager_static_methods() { + fn parses_network_manager_ipv4_methods() { + assert_eq!(parse_network_manager_ipv4_method("auto"), Some(true)); assert_eq!(parse_network_manager_ipv4_method("manual"), Some(false)); assert_eq!(parse_network_manager_ipv4_method("disabled"), Some(false)); assert_eq!(parse_network_manager_ipv4_method("unknown"), None); } + + #[test] + fn parses_network_manager_ipv6_methods() { + assert_eq!(parse_network_manager_ipv6_method("dhcp"), Some(true)); + assert_eq!(parse_network_manager_ipv6_method("auto"), None); + assert_eq!(parse_network_manager_ipv6_method("manual"), Some(false)); + assert_eq!(parse_network_manager_ipv6_method("disabled"), Some(false)); + assert_eq!(parse_network_manager_ipv6_method("link-local"), Some(false)); + } } diff --git a/src/os/linux/interface.rs b/src/os/linux/interface.rs index bda737a..72830ba 100644 --- a/src/os/linux/interface.rs +++ b/src/os/linux/interface.rs @@ -71,7 +71,8 @@ pub fn interfaces() -> Vec { transmit_speed: None, receive_speed: None, auto_negotiate: None, - dhcp_enabled: None, + dhcp_v4_enabled: None, + dhcp_v6_enabled: None, stats: None, #[cfg(feature = "gateway")] gateway: None, @@ -138,7 +139,9 @@ pub fn interfaces() -> Vec { let if_speed = super::sysfs::get_interface_speed(&iface.name); iface.transmit_speed = if_speed; iface.receive_speed = if_speed; - iface.dhcp_enabled = super::dhcp::dhcp_enabled(&iface.name, iface.index); + let dhcp = super::dhcp::dhcp_state(&iface.name, iface.index); + iface.dhcp_v4_enabled = dhcp.v4; + iface.dhcp_v6_enabled = dhcp.v6; iface.oper_state = super::sysfs::operstate(&iface.name); if iface.stats.is_none() { diff --git a/src/os/macos/interface.rs b/src/os/macos/interface.rs index 607e270..bb89d30 100644 --- a/src/os/macos/interface.rs +++ b/src/os/macos/interface.rs @@ -40,7 +40,8 @@ pub fn interfaces() -> Vec { iface.if_type = sc_type; } iface.friendly_name = sc_inface.friendly_name.clone(); - iface.dhcp_enabled = sc_inface.dhcp_enabled; + iface.dhcp_v4_enabled = sc_inface.dhcp_v4_enabled; + iface.dhcp_v6_enabled = sc_inface.dhcp_v6_enabled; } if iface.if_type == InterfaceType::Wireless80211 { diff --git a/src/os/macos/sc.rs b/src/os/macos/sc.rs index 63a6f13..ee39e9c 100644 --- a/src/os/macos/sc.rs +++ b/src/os/macos/sc.rs @@ -20,7 +20,8 @@ pub(crate) struct SCInterface { pub sc_type: Option, #[allow(dead_code)] pub active: Option, - pub dhcp_enabled: Option, + pub dhcp_v4_enabled: Option, + pub dhcp_v6_enabled: Option, } impl SCInterface { @@ -84,7 +85,8 @@ pub(crate) fn get_sc_interface_map() -> HashMap { sc_type: sc_if_type, mac, active: None, - dhcp_enabled: None, + dhcp_v4_enabled: None, + dhcp_v6_enabled: None, }, ); } @@ -155,7 +157,8 @@ fn load_sc_interfaces_plist_map(bytes: &[u8]) -> HashMap { sc_type, mac, active, - dhcp_enabled: None, + dhcp_v4_enabled: None, + dhcp_v6_enabled: None, }, ); } @@ -242,12 +245,18 @@ fn insert_service_metadata(map: &mut HashMap, service: &pli .and_then(|v| v.as_string()) .or_else(|| interface.get("Type").and_then(|v| v.as_string())) .map(ToOwned::to_owned); - let dhcp_enabled = service + let dhcp_v4_enabled = service .get("IPv4") .and_then(|v| v.as_dictionary()) .and_then(|ipv4| ipv4.get("ConfigMethod")) .and_then(|v| v.as_string()) - .and_then(map_ipv4_config_method_to_dhcp); + .and_then(map_ipv4_config_method_to_dhcp_v4); + let dhcp_v6_enabled = service + .get("IPv6") + .and_then(|v| v.as_dictionary()) + .and_then(|ipv6| ipv6.get("ConfigMethod")) + .and_then(|v| v.as_string()) + .and_then(map_ipv6_config_method_to_dhcp_v6); map.insert( bsd_name.to_string(), @@ -257,15 +266,23 @@ fn insert_service_metadata(map: &mut HashMap, service: &pli friendly_name, sc_type, active: None, - dhcp_enabled, + dhcp_v4_enabled, + dhcp_v6_enabled, }, ); } -fn map_ipv4_config_method_to_dhcp(method: &str) -> Option { +fn map_ipv4_config_method_to_dhcp_v4(method: &str) -> Option { match method { "DHCP" => Some(true), - "Manual" | "BOOTP" | "INFORM" | "LinkLocal" | "PPP" | "Automatic" | "Off" => Some(false), + "Manual" | "Off" => Some(false), + _ => None, + } +} + +fn map_ipv6_config_method_to_dhcp_v6(method: &str) -> Option { + match method { + "Manual" | "LinkLocal" | "Off" => Some(false), _ => None, } } @@ -292,8 +309,11 @@ fn merge_sc_interface_maps( if entry.active.is_none() { entry.active = overlay_iface.active; } - if overlay_iface.dhcp_enabled.is_some() { - entry.dhcp_enabled = overlay_iface.dhcp_enabled; + if overlay_iface.dhcp_v4_enabled.is_some() { + entry.dhcp_v4_enabled = overlay_iface.dhcp_v4_enabled; + } + if overlay_iface.dhcp_v6_enabled.is_some() { + entry.dhcp_v6_enabled = overlay_iface.dhcp_v6_enabled; } } base @@ -320,14 +340,27 @@ pub(crate) fn read_sc_plist_interface_map() -> std::io::Result UserDefinedName Wi-Fi + IPv6 + + ConfigMethod + Automatic + Sets @@ -386,6 +424,7 @@ mod tests { let iface = map.get("en1").unwrap(); assert_eq!(iface.friendly_name.as_deref(), Some("Wi-Fi")); assert_eq!(iface.sc_type.as_deref(), Some("AirPort")); - assert_eq!(iface.dhcp_enabled, Some(true)); + assert_eq!(iface.dhcp_v4_enabled, Some(true)); + assert_eq!(iface.dhcp_v6_enabled, None); } } diff --git a/src/os/unix/interface.rs b/src/os/unix/interface.rs index d4b50e2..fd4f805 100644 --- a/src/os/unix/interface.rs +++ b/src/os/unix/interface.rs @@ -165,7 +165,8 @@ fn unix_interfaces_inner( transmit_speed: None, receive_speed: None, auto_negotiate: None, - dhcp_enabled: None, + dhcp_v4_enabled: None, + dhcp_v6_enabled: None, stats, #[cfg(feature = "gateway")] gateway: None, diff --git a/src/os/windows/interface.rs b/src/os/windows/interface.rs index 26dabfc..b93397c 100644 --- a/src/os/windows/interface.rs +++ b/src/os/windows/interface.rs @@ -270,7 +270,10 @@ pub fn interfaces() -> Vec { transmit_speed: sanitize_u64(cur.TransmitLinkSpeed), receive_speed: sanitize_u64(cur.ReceiveLinkSpeed), auto_negotiate: None, - dhcp_enabled: Some(unsafe { cur.Anonymous2.Flags } & IP_ADAPTER_DHCP_ENABLED != 0), + dhcp_v4_enabled: Some( + unsafe { cur.Anonymous2.Flags } & IP_ADAPTER_DHCP_ENABLED != 0, + ), + dhcp_v6_enabled: None, stats, #[cfg(feature = "gateway")] gateway: if default_gateway.mac_addr == MacAddr::zero() {