use std::collections::BTreeSet;
use serde::Serialize;
use super::DiagnosticResult;
#[derive(Debug, Clone, Serialize)]
pub struct AdapterInfo {
pub name: String,
pub adapter_type: String,
pub status: String,
pub has_ip: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mac_address: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub link_speed_mbps: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rx_link_speed_mbps: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dns_servers: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gateways: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub media_connect_state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub physical_medium: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mtu: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ipv4_metric: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub driver_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub driver_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub driver_date: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub problem_code: Option<u32>,
}
fn display_type(adapter_type: &str, name: &str, physical_medium: Option<&str>) -> &'static str {
if let Some(pm) = physical_medium {
match pm {
"Native802_11" | "WirelessLan" => return "Wi-Fi",
"Bluetooth" => return "Bluetooth",
"BluetoothPAN" => return "BT PAN",
"Ethernet802_3" => return "Ethernet",
"WirelessWan" => return "WWAN",
_ => {} }
}
let lower_name = name.to_lowercase();
if lower_name.contains("virtualbox")
|| lower_name.contains("vmware")
|| lower_name.contains("hyper-v")
|| lower_name.contains("vethernet")
|| lower_name.contains("docker")
|| lower_name.contains("virtual")
|| lower_name.contains("host-only")
|| lower_name.contains("vm network")
{
return "Virtual";
}
if lower_name.contains("wi-fi")
|| lower_name.contains("wireless")
|| lower_name.contains("wlan")
{
return "Wi-Fi";
}
if lower_name.contains("bluetooth") {
if lower_name.contains("personal area network")
|| lower_name.contains("pan")
|| lower_name.contains("bnep")
{
return "BT PAN";
}
return "Bluetooth";
}
match adapter_type {
"Ieee80211" | "Wi-Fi" => "Wi-Fi",
t if t.contains("802.11") || t.to_lowercase().contains("wireless") => "Wi-Fi",
"Bluetooth" => "Bluetooth",
"EthernetCsmacd" | "Ethernet" => "Ethernet",
t if t.contains("802.3") || t.eq_ignore_ascii_case("ethernet") => "Ethernet",
"Tunnel" | "VPN/Tunnel" => "VPN",
"Virtual" => "Virtual",
"Ppp" => "PPP",
"Other" | "Unknown" => "Other",
_ => "Other",
}
}
fn types_by_status(adapters: &[AdapterInfo], status: &str) -> BTreeSet<String> {
adapters
.iter()
.filter(|a| a.status == status)
.map(|a| {
let dtype = display_type(&a.adapter_type, &a.name, a.physical_medium.as_deref());
if status == "Active" && dtype == "Wi-Fi" {
if let Some(speed) = a.link_speed_mbps {
if speed >= 1000 {
return format!("Wi-Fi {:.1} Gbps", speed as f64 / 1000.0);
} else if speed > 0 {
return format!("Wi-Fi {} Mbps", speed);
}
}
}
dtype.to_string()
})
.collect()
}
fn format_types(types: &BTreeSet<String>) -> String {
let v: Vec<&str> = types.iter().map(|s| s.as_str()).collect();
v.join(", ")
}
pub async fn check() -> (DiagnosticResult, Vec<AdapterInfo>) {
let adapters = collect_adapters().await;
let error_count = adapters.iter().filter(|a| a.status == "Error").count();
let disabled_count = adapters.iter().filter(|a| a.status == "Disabled").count();
let active_count = adapters.iter().filter(|a| a.status == "Active").count();
let result = if error_count > 0 {
let names: Vec<&str> = adapters
.iter()
.filter(|a| a.status == "Error")
.map(|a| a.name.as_str())
.collect();
DiagnosticResult::fail(
"Adapters",
format!(
"{} adapter{} with errors: {}",
error_count,
if error_count > 1 { "s" } else { "" },
names.join(", ")
),
)
} else if disabled_count > 0 && active_count > 0 {
let active_types = types_by_status(&adapters, "Active");
let disabled_types = types_by_status(&adapters, "Disabled");
DiagnosticResult::warn(
"Adapters",
format!(
"{} active ({})\n{} disabled ({})",
active_count,
format_types(&active_types),
disabled_count,
format_types(&disabled_types),
),
)
} else if active_count == 0 {
DiagnosticResult::fail("Adapters", "No active network adapters found")
} else {
let active_types = types_by_status(&adapters, "Active");
DiagnosticResult::ok(
"Adapters",
format!("{} active ({})", active_count, format_types(&active_types)),
)
};
(result, adapters)
}
async fn collect_adapters() -> Vec<AdapterInfo> {
#[cfg(windows)]
{
collect_adapters_windows().await
}
#[cfg(target_os = "macos")]
{
collect_adapters_macos().await
}
#[cfg(target_os = "linux")]
{
collect_adapters_linux().await
}
}
#[cfg(windows)]
struct IfEntry2Data {
media_connect_state: u32, physical_medium_type: u32,
admin_status: u32, transmit_link_speed: u64,
receive_link_speed: u64,
mtu: u32,
}
#[cfg(windows)]
fn get_if_entry2(if_index: u32) -> Option<IfEntry2Data> {
use std::mem::zeroed;
use winapi::shared::netioapi::{GetIfEntry2, MIB_IF_ROW2};
if if_index == 0 {
return None;
}
unsafe {
let mut row: MIB_IF_ROW2 = zeroed();
row.InterfaceIndex = if_index;
let ret = GetIfEntry2(&mut row);
if ret != 0 {
return None;
}
Some(IfEntry2Data {
media_connect_state: row.MediaConnectState as u32,
physical_medium_type: row.PhysicalMediumType as u32,
admin_status: row.AdminStatus as u32,
transmit_link_speed: row.TransmitLinkSpeed,
receive_link_speed: row.ReceiveLinkSpeed,
mtu: row.Mtu,
})
}
}
#[cfg(windows)]
fn physical_medium_name(pm: u32) -> Option<&'static str> {
match pm {
0 => None, 1 => Some("WirelessLan"), 2 => Some("CableModem"),
3 => Some("PhoneLine"),
4 => Some("PowerLine"),
5 => Some("DSL"),
6 => Some("FibreChannel"),
7 => Some("1394"), 8 => Some("WirelessWan"),
9 => Some("Native802_11"), 10 => Some("Bluetooth"),
11 => Some("Infiniband"),
12 => Some("WiMax"),
13 => Some("UWB"),
14 => Some("Ethernet802_3"), _ => None,
}
}
#[cfg(windows)]
fn derive_status(oper_status: ipconfig::OperStatus, if2: Option<&IfEntry2Data>) -> String {
if let Some(data) = if2 {
if data.admin_status == 2 {
return "Disabled".to_string();
}
if data.admin_status == 1 && data.media_connect_state == 2 {
return "No Cable".to_string();
}
}
match oper_status {
ipconfig::OperStatus::IfOperStatusUp => "Active".to_string(),
ipconfig::OperStatus::IfOperStatusDown => "Down".to_string(),
ipconfig::OperStatus::IfOperStatusDormant => "Standby".to_string(),
ipconfig::OperStatus::IfOperStatusNotPresent => "Not Present".to_string(),
ipconfig::OperStatus::IfOperStatusLowerLayerDown => "Down".to_string(),
_ => "Unknown".to_string(),
}
}
#[cfg(windows)]
fn format_mac(bytes: &[u8]) -> String {
bytes
.iter()
.map(|b| format!("{:02X}", b))
.collect::<Vec<_>>()
.join(":")
}
#[cfg(windows)]
async fn collect_adapters_windows() -> Vec<AdapterInfo> {
tokio::task::spawn_blocking(|| {
let raw_adapters = match ipconfig::get_adapters() {
Ok(a) => a,
Err(_) => return Vec::new(),
};
let mut adapters = Vec::new();
for adapter in raw_adapters {
if adapter.if_type() == ipconfig::IfType::SoftwareLoopback {
continue;
}
let mac = adapter.physical_address();
let is_zero_mac = mac.is_none_or(|m| m.iter().all(|b| *b == 0));
if is_zero_mac {
continue;
}
let if2 = get_if_entry2(adapter.ipv6_if_index());
let if_type_str = match adapter.if_type() {
ipconfig::IfType::EthernetCsmacd => "EthernetCsmacd",
ipconfig::IfType::Ieee80211 => "Ieee80211",
ipconfig::IfType::Tunnel => "Tunnel",
ipconfig::IfType::Ppp => "Ppp",
_ => "Other",
};
let oper_status = adapter.oper_status();
let status = derive_status(oper_status, if2.as_ref());
let has_ip = oper_status == ipconfig::OperStatus::IfOperStatusUp
&& !adapter.ip_addresses().is_empty();
let physical_medium = if2
.as_ref()
.and_then(|d| physical_medium_name(d.physical_medium_type))
.map(|s| s.to_string());
let tx_speed_bps = if2
.as_ref()
.map(|d| d.transmit_link_speed)
.unwrap_or(adapter.transmit_link_speed());
let rx_speed_bps = if2
.as_ref()
.map(|d| d.receive_link_speed)
.unwrap_or(adapter.receive_link_speed());
let tx_mbps = tx_speed_bps / 1_000_000;
let rx_mbps = rx_speed_bps / 1_000_000;
let dns: Vec<String> = adapter
.dns_servers()
.iter()
.map(|ip| ip.to_string())
.collect();
let gws: Vec<String> = adapter.gateways().iter().map(|ip| ip.to_string()).collect();
let media_connect = if2.as_ref().map(|d| match d.media_connect_state {
1 => "Connected".to_string(),
2 => "Disconnected".to_string(),
_ => "Unknown".to_string(),
});
adapters.push(AdapterInfo {
name: adapter.friendly_name().to_string(),
adapter_type: if_type_str.to_string(),
status,
has_ip,
description: Some(adapter.description().to_string()),
mac_address: mac.map(format_mac),
link_speed_mbps: if tx_mbps > 0 { Some(tx_mbps) } else { None },
rx_link_speed_mbps: if rx_mbps > 0 { Some(rx_mbps) } else { None },
dns_servers: if dns.is_empty() { None } else { Some(dns) },
gateways: if gws.is_empty() { None } else { Some(gws) },
media_connect_state: media_connect,
physical_medium,
mtu: if2.as_ref().map(|d| d.mtu),
ipv4_metric: Some(adapter.ipv4_metric()),
driver_name: None,
driver_version: None,
driver_date: None,
problem_code: None,
});
}
adapters
})
.await
.unwrap_or_default()
}
#[cfg(windows)]
pub async fn enrich_driver_info(adapters: &mut [AdapterInfo]) {
use std::collections::HashMap;
use wmi::{COMLibrary, WMIConnection};
let driver_data: Vec<(String, Option<String>, Option<String>)> =
tokio::task::spawn_blocking(|| {
let com = match COMLibrary::new() {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let wmi = match WMIConnection::new(com) {
Ok(w) => w,
Err(_) => return Vec::new(),
};
let query = "SELECT DeviceName, DriverVersion, DriverDate FROM Win32_PnPSignedDriver WHERE DeviceClass = 'NET'";
let results: Vec<HashMap<String, wmi::Variant>> = match wmi.raw_query(query) {
Ok(r) => r,
Err(_) => return Vec::new(),
};
results
.into_iter()
.filter_map(|row| {
let name = match row.get("DeviceName") {
Some(wmi::Variant::String(s)) => s.clone(),
_ => return None,
};
let version = match row.get("DriverVersion") {
Some(wmi::Variant::String(s)) => Some(s.clone()),
_ => None,
};
let date = match row.get("DriverDate") {
Some(wmi::Variant::String(s)) => Some(s.chars().take(10).collect()),
_ => None,
};
Some((name, version, date))
})
.collect()
})
.await
.unwrap_or_default();
for (drv_name, version, date) in &driver_data {
for adapter in adapters.iter_mut() {
let matches = adapter.description.as_ref().is_some_and(|desc| {
desc.contains(drv_name.as_str()) || drv_name.contains(desc.as_str())
});
if matches {
adapter.driver_name = Some(drv_name.clone());
adapter.driver_version = version.clone();
adapter.driver_date = date.clone();
}
}
}
}
#[cfg(not(windows))]
pub async fn enrich_driver_info(_adapters: &mut [AdapterInfo]) {
}
#[cfg(target_os = "macos")]
async fn collect_adapters_macos() -> Vec<AdapterInfo> {
let mut adapters = Vec::new();
let mut cmd = tokio::process::Command::new("networksetup");
cmd.args(["-listallhardwareports"]);
if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
let text = String::from_utf8_lossy(&output.stdout);
let mut current_name = String::new();
for line in text.lines() {
if let Some(name) = line.strip_prefix("Hardware Port: ") {
current_name = name.trim().to_string();
} else if let Some(dev) = line.strip_prefix("Device: ") {
let current_device = dev.trim().to_string();
let status = if check_interface_up(¤t_device).await {
"Active"
} else {
"Disconnected"
};
adapters.push(AdapterInfo {
name: current_name.clone(),
adapter_type: detect_macos_type(¤t_name),
status: status.to_string(),
has_ip: status == "Active",
description: None,
mac_address: None,
link_speed_mbps: None,
rx_link_speed_mbps: None,
dns_servers: None,
gateways: None,
media_connect_state: None,
physical_medium: None,
mtu: None,
ipv4_metric: None,
driver_name: Some(current_device.clone()),
driver_version: None,
driver_date: None,
problem_code: None,
});
}
}
}
adapters
}
#[cfg(target_os = "macos")]
fn detect_macos_type(name: &str) -> String {
let lower = name.to_lowercase();
if lower.contains("wi-fi") || lower.contains("airport") {
"Wi-Fi".to_string()
} else if lower.contains("ethernet") || lower.contains("thunderbolt") {
"Ethernet".to_string()
} else if lower.contains("bluetooth") {
"Bluetooth".to_string()
} else {
"Other".to_string()
}
}
#[cfg(target_os = "macos")]
async fn check_interface_up(device: &str) -> bool {
let mut cmd = tokio::process::Command::new("ifconfig");
cmd.arg(device);
if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
let text = String::from_utf8_lossy(&output.stdout);
text.contains("status: active") || text.contains("inet ")
} else {
false
}
}
#[cfg(target_os = "linux")]
async fn collect_adapters_linux() -> Vec<AdapterInfo> {
let mut adapters = Vec::new();
let mut cmd = tokio::process::Command::new("ip");
cmd.args(["-o", "link", "show"]);
if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 3 {
continue;
}
let name = parts[1].trim_end_matches(':');
if name == "lo" {
continue;
}
let is_up = line.contains("state UP") || line.contains(",UP");
let iface_type = if name.starts_with("wl") {
"Wi-Fi"
} else if name.starts_with("en") || name.starts_with("eth") {
"Ethernet"
} else if name.starts_with("tun") || name.starts_with("wg") {
"VPN/Tunnel"
} else if name.starts_with("docker")
|| name.starts_with("veth")
|| name.starts_with("br-")
{
"Virtual"
} else {
"Other"
};
let driver = get_linux_driver(name).await;
adapters.push(AdapterInfo {
name: name.to_string(),
adapter_type: iface_type.to_string(),
status: if is_up { "Active" } else { "Disconnected" }.to_string(),
has_ip: is_up,
description: None,
mac_address: None,
link_speed_mbps: None,
rx_link_speed_mbps: None,
dns_servers: None,
gateways: None,
media_connect_state: None,
physical_medium: None,
mtu: None,
ipv4_metric: None,
driver_name: driver,
driver_version: None,
driver_date: None,
problem_code: None,
});
}
}
adapters
}
#[cfg(target_os = "linux")]
async fn get_linux_driver(iface: &str) -> Option<String> {
let path = format!("/sys/class/net/{}/device/driver", iface);
if let Ok(link) = tokio::fs::read_link(&path).await {
link.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
} else {
None
}
}