Skip to main content

nd_300/diagnostics/
adapters.rs

1use std::collections::BTreeSet;
2
3use serde::Serialize;
4
5use super::DiagnosticResult;
6
7#[derive(Debug, Clone, Serialize)]
8pub struct AdapterInfo {
9    pub name: String,
10    pub adapter_type: String,
11    pub status: String,
12    pub has_ip: bool,
13    #[serde(skip_serializing_if = "Option::is_none")]
14    pub description: Option<String>,
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub mac_address: Option<String>,
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub link_speed_mbps: Option<u64>,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub rx_link_speed_mbps: Option<u64>,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub dns_servers: Option<Vec<String>>,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub gateways: Option<Vec<String>>,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub media_connect_state: Option<String>,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub physical_medium: Option<String>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub mtu: Option<u32>,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub ipv4_metric: Option<u32>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub driver_name: Option<String>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub driver_version: Option<String>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub driver_date: Option<String>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub problem_code: Option<u32>,
41}
42
43fn display_type(adapter_type: &str, name: &str, physical_medium: Option<&str>) -> &'static str {
44    // Priority 1: PhysicalMediumType from GetIfEntry2 (most accurate)
45    if let Some(pm) = physical_medium {
46        match pm {
47            "Native802_11" | "WirelessLan" => return "Wi-Fi",
48            "Bluetooth" => return "Bluetooth",
49            "BluetoothPAN" => return "BT PAN",
50            "Ethernet802_3" => return "Ethernet",
51            "WirelessWan" => return "WWAN",
52            _ => {} // fall through
53        }
54    }
55
56    // Priority 2: Name-based heuristics
57    let lower_name = name.to_lowercase();
58
59    if lower_name.contains("virtualbox")
60        || lower_name.contains("vmware")
61        || lower_name.contains("hyper-v")
62        || lower_name.contains("vethernet")
63        || lower_name.contains("docker")
64        || lower_name.contains("virtual")
65        || lower_name.contains("host-only")
66        || lower_name.contains("vm network")
67    {
68        return "Virtual";
69    }
70    if lower_name.contains("wi-fi")
71        || lower_name.contains("wireless")
72        || lower_name.contains("wlan")
73    {
74        return "Wi-Fi";
75    }
76    if lower_name.contains("bluetooth") {
77        if lower_name.contains("personal area network")
78            || lower_name.contains("pan")
79            || lower_name.contains("bnep")
80        {
81            return "BT PAN";
82        }
83        return "Bluetooth";
84    }
85
86    // Priority 3: adapter_type field (IfType-derived)
87    match adapter_type {
88        "Ieee80211" | "Wi-Fi" => "Wi-Fi",
89        t if t.contains("802.11") || t.to_lowercase().contains("wireless") => "Wi-Fi",
90        "Bluetooth" => "Bluetooth",
91        "EthernetCsmacd" | "Ethernet" => "Ethernet",
92        t if t.contains("802.3") || t.eq_ignore_ascii_case("ethernet") => "Ethernet",
93        "Tunnel" | "VPN/Tunnel" => "VPN",
94        "Virtual" => "Virtual",
95        "Ppp" => "PPP",
96        "Other" | "Unknown" => "Other",
97        _ => "Other",
98    }
99}
100
101/// Build a set of display types for adapters matching a given status,
102/// optionally appending link speed for active Wi-Fi adapters.
103fn types_by_status(adapters: &[AdapterInfo], status: &str) -> BTreeSet<String> {
104    adapters
105        .iter()
106        .filter(|a| a.status == status)
107        .map(|a| {
108            let dtype = display_type(&a.adapter_type, &a.name, a.physical_medium.as_deref());
109            // Append link speed for active Wi-Fi adapters
110            if status == "Active" && dtype == "Wi-Fi" {
111                if let Some(speed) = a.link_speed_mbps {
112                    if speed >= 1000 {
113                        return format!("Wi-Fi {:.1} Gbps", speed as f64 / 1000.0);
114                    } else if speed > 0 {
115                        return format!("Wi-Fi {} Mbps", speed);
116                    }
117                }
118            }
119            dtype.to_string()
120        })
121        .collect()
122}
123
124fn format_types(types: &BTreeSet<String>) -> String {
125    let v: Vec<&str> = types.iter().map(|s| s.as_str()).collect();
126    v.join(", ")
127}
128
129pub async fn check() -> (DiagnosticResult, Vec<AdapterInfo>) {
130    let adapters = collect_adapters().await;
131
132    let error_count = adapters.iter().filter(|a| a.status == "Error").count();
133    let disabled_count = adapters.iter().filter(|a| a.status == "Disabled").count();
134    let active_count = adapters.iter().filter(|a| a.status == "Active").count();
135
136    let result = if error_count > 0 {
137        let names: Vec<&str> = adapters
138            .iter()
139            .filter(|a| a.status == "Error")
140            .map(|a| a.name.as_str())
141            .collect();
142        DiagnosticResult::fail(
143            "Adapters",
144            format!(
145                "{} adapter{} with errors: {}",
146                error_count,
147                if error_count > 1 { "s" } else { "" },
148                names.join(", ")
149            ),
150        )
151    } else if disabled_count > 0 && active_count > 0 {
152        let active_types = types_by_status(&adapters, "Active");
153        let disabled_types = types_by_status(&adapters, "Disabled");
154        DiagnosticResult::warn(
155            "Adapters",
156            format!(
157                "{} active ({})\n{} disabled ({})",
158                active_count,
159                format_types(&active_types),
160                disabled_count,
161                format_types(&disabled_types),
162            ),
163        )
164    } else if active_count == 0 {
165        DiagnosticResult::fail("Adapters", "No active network adapters found")
166    } else {
167        let active_types = types_by_status(&adapters, "Active");
168        DiagnosticResult::ok(
169            "Adapters",
170            format!("{} active ({})", active_count, format_types(&active_types)),
171        )
172    };
173
174    (result, adapters)
175}
176
177async fn collect_adapters() -> Vec<AdapterInfo> {
178    #[cfg(windows)]
179    {
180        collect_adapters_windows().await
181    }
182
183    #[cfg(target_os = "macos")]
184    {
185        collect_adapters_macos().await
186    }
187
188    #[cfg(target_os = "linux")]
189    {
190        collect_adapters_linux().await
191    }
192}
193
194// ── GetIfEntry2 helper (Windows only) ────────────────────────────────────
195
196#[cfg(windows)]
197struct IfEntry2Data {
198    media_connect_state: u32, // 0=Unknown, 1=Connected, 2=Disconnected
199    physical_medium_type: u32,
200    admin_status: u32, // 1=Up, 2=Down, 3=Testing
201    transmit_link_speed: u64,
202    receive_link_speed: u64,
203    mtu: u32,
204}
205
206#[cfg(windows)]
207fn get_if_entry2(if_index: u32) -> Option<IfEntry2Data> {
208    use std::mem::zeroed;
209    use winapi::shared::netioapi::{GetIfEntry2, MIB_IF_ROW2};
210
211    if if_index == 0 {
212        return None;
213    }
214
215    unsafe {
216        let mut row: MIB_IF_ROW2 = zeroed();
217        row.InterfaceIndex = if_index;
218        let ret = GetIfEntry2(&mut row);
219        if ret != 0 {
220            return None;
221        }
222        Some(IfEntry2Data {
223            media_connect_state: row.MediaConnectState as u32,
224            physical_medium_type: row.PhysicalMediumType as u32,
225            admin_status: row.AdminStatus as u32,
226            transmit_link_speed: row.TransmitLinkSpeed,
227            receive_link_speed: row.ReceiveLinkSpeed,
228            mtu: row.Mtu,
229        })
230    }
231}
232
233/// Map PhysicalMediumType integer to a string we use for classification.
234#[cfg(windows)]
235fn physical_medium_name(pm: u32) -> Option<&'static str> {
236    // Values from NDIS_PHYSICAL_MEDIUM enum
237    match pm {
238        0 => None,                // Unspecified
239        1 => Some("WirelessLan"), // NdisPhysicalMediumWirelessLan
240        2 => Some("CableModem"),
241        3 => Some("PhoneLine"),
242        4 => Some("PowerLine"),
243        5 => Some("DSL"),
244        6 => Some("FibreChannel"),
245        7 => Some("1394"), // IEEE 1394 / FireWire
246        8 => Some("WirelessWan"),
247        9 => Some("Native802_11"), // NdisPhysicalMediumNative802_11
248        10 => Some("Bluetooth"),
249        11 => Some("Infiniband"),
250        12 => Some("WiMax"),
251        13 => Some("UWB"),
252        14 => Some("Ethernet802_3"), // NdisPhysicalMedium802_3
253        _ => None,
254    }
255}
256
257/// Derive adapter status from GetIfEntry2 + OperStatus.
258#[cfg(windows)]
259fn derive_status(oper_status: ipconfig::OperStatus, if2: Option<&IfEntry2Data>) -> String {
260    if let Some(data) = if2 {
261        // AdminStatus=2 means admin-disabled
262        if data.admin_status == 2 {
263            return "Disabled".to_string();
264        }
265        // Admin is up but media disconnected = no cable/signal
266        if data.admin_status == 1 && data.media_connect_state == 2 {
267            return "No Cable".to_string();
268        }
269    }
270
271    match oper_status {
272        ipconfig::OperStatus::IfOperStatusUp => "Active".to_string(),
273        ipconfig::OperStatus::IfOperStatusDown => "Down".to_string(),
274        ipconfig::OperStatus::IfOperStatusDormant => "Standby".to_string(),
275        ipconfig::OperStatus::IfOperStatusNotPresent => "Not Present".to_string(),
276        ipconfig::OperStatus::IfOperStatusLowerLayerDown => "Down".to_string(),
277        _ => "Unknown".to_string(),
278    }
279}
280
281#[cfg(windows)]
282fn format_mac(bytes: &[u8]) -> String {
283    bytes
284        .iter()
285        .map(|b| format!("{:02X}", b))
286        .collect::<Vec<_>>()
287        .join(":")
288}
289
290#[cfg(windows)]
291async fn collect_adapters_windows() -> Vec<AdapterInfo> {
292    tokio::task::spawn_blocking(|| {
293        let raw_adapters = match ipconfig::get_adapters() {
294            Ok(a) => a,
295            Err(_) => return Vec::new(),
296        };
297
298        let mut adapters = Vec::new();
299
300        for adapter in raw_adapters {
301            // Skip loopback
302            if adapter.if_type() == ipconfig::IfType::SoftwareLoopback {
303                continue;
304            }
305
306            // Skip adapters with no MAC (virtual/software adapters)
307            let mac = adapter.physical_address();
308            let is_zero_mac = mac.is_none_or(|m| m.iter().all(|b| *b == 0));
309            if is_zero_mac {
310                continue;
311            }
312
313            // Get extended info from GetIfEntry2
314            let if2 = get_if_entry2(adapter.ipv6_if_index());
315
316            let if_type_str = match adapter.if_type() {
317                ipconfig::IfType::EthernetCsmacd => "EthernetCsmacd",
318                ipconfig::IfType::Ieee80211 => "Ieee80211",
319                ipconfig::IfType::Tunnel => "Tunnel",
320                ipconfig::IfType::Ppp => "Ppp",
321                _ => "Other",
322            };
323
324            let oper_status = adapter.oper_status();
325            let status = derive_status(oper_status, if2.as_ref());
326            let has_ip = oper_status == ipconfig::OperStatus::IfOperStatusUp
327                && !adapter.ip_addresses().is_empty();
328
329            let physical_medium = if2
330                .as_ref()
331                .and_then(|d| physical_medium_name(d.physical_medium_type))
332                .map(|s| s.to_string());
333
334            let tx_speed_bps = if2
335                .as_ref()
336                .map(|d| d.transmit_link_speed)
337                .unwrap_or(adapter.transmit_link_speed());
338            let rx_speed_bps = if2
339                .as_ref()
340                .map(|d| d.receive_link_speed)
341                .unwrap_or(adapter.receive_link_speed());
342
343            let tx_mbps = tx_speed_bps / 1_000_000;
344            let rx_mbps = rx_speed_bps / 1_000_000;
345
346            let dns: Vec<String> = adapter
347                .dns_servers()
348                .iter()
349                .map(|ip| ip.to_string())
350                .collect();
351            let gws: Vec<String> = adapter.gateways().iter().map(|ip| ip.to_string()).collect();
352
353            let media_connect = if2.as_ref().map(|d| match d.media_connect_state {
354                1 => "Connected".to_string(),
355                2 => "Disconnected".to_string(),
356                _ => "Unknown".to_string(),
357            });
358
359            adapters.push(AdapterInfo {
360                name: adapter.friendly_name().to_string(),
361                adapter_type: if_type_str.to_string(),
362                status,
363                has_ip,
364                description: Some(adapter.description().to_string()),
365                mac_address: mac.map(format_mac),
366                link_speed_mbps: if tx_mbps > 0 { Some(tx_mbps) } else { None },
367                rx_link_speed_mbps: if rx_mbps > 0 { Some(rx_mbps) } else { None },
368                dns_servers: if dns.is_empty() { None } else { Some(dns) },
369                gateways: if gws.is_empty() { None } else { Some(gws) },
370                media_connect_state: media_connect,
371                physical_medium,
372                mtu: if2.as_ref().map(|d| d.mtu),
373                ipv4_metric: Some(adapter.ipv4_metric()),
374                driver_name: None,
375                driver_version: None,
376                driver_date: None,
377                problem_code: None,
378            });
379        }
380
381        adapters
382    })
383    .await
384    .unwrap_or_default()
385}
386
387/// Enrich adapter list with driver info from WMI (tech mode only).
388/// Matches by description (hardware chip name) which is more reliable
389/// than the old name-based substring match.
390#[cfg(windows)]
391pub async fn enrich_driver_info(adapters: &mut [AdapterInfo]) {
392    use std::collections::HashMap;
393    use wmi::{COMLibrary, WMIConnection};
394
395    // Extract driver data inside the blocking closure into Send-safe types
396    let driver_data: Vec<(String, Option<String>, Option<String>)> =
397        tokio::task::spawn_blocking(|| {
398            let com = match COMLibrary::new() {
399                Ok(c) => c,
400                Err(_) => return Vec::new(),
401            };
402            let wmi = match WMIConnection::new(com) {
403                Ok(w) => w,
404                Err(_) => return Vec::new(),
405            };
406
407            let query = "SELECT DeviceName, DriverVersion, DriverDate FROM Win32_PnPSignedDriver WHERE DeviceClass = 'NET'";
408            let results: Vec<HashMap<String, wmi::Variant>> = match wmi.raw_query(query) {
409                Ok(r) => r,
410                Err(_) => return Vec::new(),
411            };
412
413            results
414                .into_iter()
415                .filter_map(|row| {
416                    let name = match row.get("DeviceName") {
417                        Some(wmi::Variant::String(s)) => s.clone(),
418                        _ => return None,
419                    };
420                    let version = match row.get("DriverVersion") {
421                        Some(wmi::Variant::String(s)) => Some(s.clone()),
422                        _ => None,
423                    };
424                    let date = match row.get("DriverDate") {
425                        Some(wmi::Variant::String(s)) => Some(s.chars().take(10).collect()),
426                        _ => None,
427                    };
428                    Some((name, version, date))
429                })
430                .collect()
431        })
432        .await
433        .unwrap_or_default();
434
435    for (drv_name, version, date) in &driver_data {
436        for adapter in adapters.iter_mut() {
437            let matches = adapter.description.as_ref().is_some_and(|desc| {
438                desc.contains(drv_name.as_str()) || drv_name.contains(desc.as_str())
439            });
440
441            if matches {
442                adapter.driver_name = Some(drv_name.clone());
443                adapter.driver_version = version.clone();
444                adapter.driver_date = date.clone();
445            }
446        }
447    }
448}
449
450#[cfg(not(windows))]
451pub async fn enrich_driver_info(_adapters: &mut [AdapterInfo]) {
452    // No-op on non-Windows — driver info already collected per-platform
453}
454
455#[cfg(target_os = "macos")]
456async fn collect_adapters_macos() -> Vec<AdapterInfo> {
457    let mut adapters = Vec::new();
458
459    let mut cmd = tokio::process::Command::new("networksetup");
460    cmd.args(["-listallhardwareports"]);
461    if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
462        let text = String::from_utf8_lossy(&output.stdout);
463        let mut current_name = String::new();
464
465        for line in text.lines() {
466            if let Some(name) = line.strip_prefix("Hardware Port: ") {
467                current_name = name.trim().to_string();
468            } else if let Some(dev) = line.strip_prefix("Device: ") {
469                let current_device = dev.trim().to_string();
470
471                let status = if check_interface_up(&current_device).await {
472                    "Active"
473                } else {
474                    "Disconnected"
475                };
476
477                adapters.push(AdapterInfo {
478                    name: current_name.clone(),
479                    adapter_type: detect_macos_type(&current_name),
480                    status: status.to_string(),
481                    has_ip: status == "Active",
482                    description: None,
483                    mac_address: None,
484                    link_speed_mbps: None,
485                    rx_link_speed_mbps: None,
486                    dns_servers: None,
487                    gateways: None,
488                    media_connect_state: None,
489                    physical_medium: None,
490                    mtu: None,
491                    ipv4_metric: None,
492                    driver_name: Some(current_device.clone()),
493                    driver_version: None,
494                    driver_date: None,
495                    problem_code: None,
496                });
497            }
498        }
499    }
500
501    adapters
502}
503
504#[cfg(target_os = "macos")]
505fn detect_macos_type(name: &str) -> String {
506    let lower = name.to_lowercase();
507    if lower.contains("wi-fi") || lower.contains("airport") {
508        "Wi-Fi".to_string()
509    } else if lower.contains("ethernet") || lower.contains("thunderbolt") {
510        "Ethernet".to_string()
511    } else if lower.contains("bluetooth") {
512        "Bluetooth".to_string()
513    } else {
514        "Other".to_string()
515    }
516}
517
518#[cfg(target_os = "macos")]
519async fn check_interface_up(device: &str) -> bool {
520    let mut cmd = tokio::process::Command::new("ifconfig");
521    cmd.arg(device);
522    if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
523        let text = String::from_utf8_lossy(&output.stdout);
524        text.contains("status: active") || text.contains("inet ")
525    } else {
526        false
527    }
528}
529
530#[cfg(target_os = "linux")]
531async fn collect_adapters_linux() -> Vec<AdapterInfo> {
532    let mut adapters = Vec::new();
533
534    let mut cmd = tokio::process::Command::new("ip");
535    cmd.args(["-o", "link", "show"]);
536    if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
537        let text = String::from_utf8_lossy(&output.stdout);
538        for line in text.lines() {
539            let parts: Vec<&str> = line.split_whitespace().collect();
540            if parts.len() < 3 {
541                continue;
542            }
543
544            let name = parts[1].trim_end_matches(':');
545            if name == "lo" {
546                continue;
547            }
548
549            let is_up = line.contains("state UP") || line.contains(",UP");
550            let iface_type = if name.starts_with("wl") {
551                "Wi-Fi"
552            } else if name.starts_with("en") || name.starts_with("eth") {
553                "Ethernet"
554            } else if name.starts_with("tun") || name.starts_with("wg") {
555                "VPN/Tunnel"
556            } else if name.starts_with("docker")
557                || name.starts_with("veth")
558                || name.starts_with("br-")
559            {
560                "Virtual"
561            } else {
562                "Other"
563            };
564
565            let driver = get_linux_driver(name).await;
566
567            adapters.push(AdapterInfo {
568                name: name.to_string(),
569                adapter_type: iface_type.to_string(),
570                status: if is_up { "Active" } else { "Disconnected" }.to_string(),
571                has_ip: is_up,
572                description: None,
573                mac_address: None,
574                link_speed_mbps: None,
575                rx_link_speed_mbps: None,
576                dns_servers: None,
577                gateways: None,
578                media_connect_state: None,
579                physical_medium: None,
580                mtu: None,
581                ipv4_metric: None,
582                driver_name: driver,
583                driver_version: None,
584                driver_date: None,
585                problem_code: None,
586            });
587        }
588    }
589
590    adapters
591}
592
593#[cfg(target_os = "linux")]
594async fn get_linux_driver(iface: &str) -> Option<String> {
595    let path = format!("/sys/class/net/{}/device/driver", iface);
596    if let Ok(link) = tokio::fs::read_link(&path).await {
597        link.file_name()
598            .and_then(|n| n.to_str())
599            .map(|s| s.to_string())
600    } else {
601        None
602    }
603}