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 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 _ => {} }
54 }
55
56 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 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
101fn 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 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#[cfg(windows)]
197struct IfEntry2Data {
198 media_connect_state: u32, physical_medium_type: u32,
200 admin_status: u32, 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#[cfg(windows)]
235fn physical_medium_name(pm: u32) -> Option<&'static str> {
236 match pm {
238 0 => None, 1 => Some("WirelessLan"), 2 => Some("CableModem"),
241 3 => Some("PhoneLine"),
242 4 => Some("PowerLine"),
243 5 => Some("DSL"),
244 6 => Some("FibreChannel"),
245 7 => Some("1394"), 8 => Some("WirelessWan"),
247 9 => Some("Native802_11"), 10 => Some("Bluetooth"),
249 11 => Some("Infiniband"),
250 12 => Some("WiMax"),
251 13 => Some("UWB"),
252 14 => Some("Ethernet802_3"), _ => None,
254 }
255}
256
257#[cfg(windows)]
259fn derive_status(oper_status: ipconfig::OperStatus, if2: Option<&IfEntry2Data>) -> String {
260 if let Some(data) = if2 {
261 if data.admin_status == 2 {
263 return "Disabled".to_string();
264 }
265 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 if adapter.if_type() == ipconfig::IfType::SoftwareLoopback {
303 continue;
304 }
305
306 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 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#[cfg(windows)]
391pub async fn enrich_driver_info(adapters: &mut [AdapterInfo]) {
392 use std::collections::HashMap;
393 use wmi::{COMLibrary, WMIConnection};
394
395 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 }
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(¤t_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(¤t_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}