Skip to main content

nd_300/diagnostics/
interfaces.rs

1use serde::Serialize;
2use sysinfo::Networks;
3
4use super::DiagnosticResult;
5
6#[derive(Debug, Clone, Serialize)]
7pub struct InterfaceInfo {
8    pub name: String,
9    pub mac: String,
10    pub ip_addresses: Vec<String>,
11    pub is_up: bool,
12    pub interface_type: String,
13    pub rx_bytes: u64,
14    pub tx_bytes: u64,
15}
16
17/// Owned, Send-safe per-interface fields extracted from `sysinfo::Networks`
18/// inside the blocking closure, so the (blocking) enumeration runs off the
19/// async runtime while the data crosses back to the async loop below.
20struct RawInterface {
21    name: String,
22    mac_bytes: [u8; 6],
23    ip_addrs: Vec<String>,
24    rx_bytes: u64,
25    tx_bytes: u64,
26}
27
28pub async fn check() -> (DiagnosticResult, Vec<InterfaceInfo>) {
29    // `Networks::new_with_refreshed_list` is a synchronous, blocking system
30    // enumeration — the heaviest sync call in the core diagnostics. Run it off
31    // the async runtime and return owned data. A JoinError falls back to an
32    // empty list (same as no interfaces).
33    let raw_interfaces: Vec<RawInterface> = tokio::task::spawn_blocking(|| {
34        let networks = Networks::new_with_refreshed_list();
35        networks
36            .iter()
37            .map(|(name, data)| RawInterface {
38                name: name.clone(),
39                mac_bytes: data.mac_address().0,
40                ip_addrs: data
41                    .ip_networks()
42                    .iter()
43                    .map(|n| n.addr.to_string())
44                    .collect(),
45                rx_bytes: data.total_received(),
46                tx_bytes: data.total_transmitted(),
47            })
48            .collect()
49    })
50    .await
51    .unwrap_or_default();
52
53    let mut details = Vec::new();
54    let mut active_count = 0;
55    let mut wifi_info = String::new();
56
57    for raw in raw_interfaces {
58        let mac = format_mac(raw.mac_bytes);
59        let ip_addrs = raw.ip_addrs;
60        // Preserve exact original semantics: up iff it has at least one IP and
61        // has received bytes.
62        let is_up = !ip_addrs.is_empty() && raw.rx_bytes > 0;
63
64        let iface_type = detect_interface_type(&raw.name);
65
66        if is_up {
67            active_count += 1;
68            if iface_type == "Wi-Fi" && wifi_info.is_empty() {
69                // Kept OUTSIDE the blocking closure: this is its own async,
70                // timeout-wrapped subprocess call with platform cfg branches.
71                wifi_info = get_wifi_summary().await;
72            }
73        }
74
75        details.push(InterfaceInfo {
76            name: raw.name,
77            mac,
78            ip_addresses: ip_addrs,
79            is_up,
80            interface_type: iface_type,
81            rx_bytes: raw.rx_bytes,
82            tx_bytes: raw.tx_bytes,
83        });
84    }
85
86    let result = if active_count == 0 {
87        DiagnosticResult::fail("Network", "No active network interfaces found")
88    } else if !wifi_info.is_empty() {
89        DiagnosticResult::ok("Network", format!("Connected via {}", wifi_info))
90    } else if active_count == 1 {
91        let active = details.iter().find(|i| i.is_up);
92        let desc = match active {
93            Some(iface) => format!("Connected via {}", iface.interface_type),
94            None => "Connected".to_string(),
95        };
96        DiagnosticResult::ok("Network", desc)
97    } else {
98        DiagnosticResult::ok("Network", format!("{} active interfaces", active_count))
99    };
100
101    (result, details)
102}
103
104fn detect_interface_type(name: &str) -> String {
105    let lower = name.to_lowercase();
106    if lower.contains("wi-fi")
107        || lower.contains("wifi")
108        || lower.contains("wlan")
109        || lower.contains("wlp")
110    {
111        "Wi-Fi".to_string()
112    } else if lower.contains("eth")
113        || lower.contains("enp")
114        || lower.contains("eno")
115        || lower.contains("ethernet")
116    {
117        "Ethernet".to_string()
118    } else if lower == "lo" || lower == "lo0" || (lower.starts_with("lo") && lower.len() <= 3) {
119        "Loopback".to_string()
120    } else if lower.contains("tun")
121        || lower.contains("tap")
122        || lower.contains("wg")
123        || lower.contains("utun")
124    {
125        "VPN/Tunnel".to_string()
126    } else if lower.contains("bluetooth") || lower.contains("bnep") {
127        "Bluetooth".to_string()
128    } else if lower.contains("docker") || lower.contains("veth") || lower.contains("br-") {
129        "Virtual".to_string()
130    } else {
131        "Unknown".to_string()
132    }
133}
134
135fn format_mac(bytes: [u8; 6]) -> String {
136    if bytes == [0, 0, 0, 0, 0, 0] {
137        return "N/A".to_string();
138    }
139    format!(
140        "{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
141        bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5]
142    )
143}
144
145async fn get_wifi_summary() -> String {
146    #[cfg(windows)]
147    {
148        let mut cmd = tokio::process::Command::new("netsh");
149        cmd.args(["wlan", "show", "interfaces"]);
150        match super::util::run_with_timeout(cmd, super::util::QUICK).await {
151            Some(output) => {
152                let text = String::from_utf8_lossy(&output.stdout);
153                let mut band = String::new();
154                let mut ssid = String::new();
155
156                for line in text.lines() {
157                    let line = line.trim();
158                    if line.starts_with("SSID")
159                        && !line.starts_with("SSID B")
160                        && !line.starts_with("SSID name")
161                    {
162                        if let Some(val) = line.split(':').nth(1) {
163                            ssid = val.trim().to_string();
164                        }
165                    }
166                    if line.starts_with("Radio type") || line.starts_with("Band") {
167                        if let Some(val) = line.split(':').nth(1) {
168                            let val = val.trim();
169                            if val.contains("6 GHz") || val.contains("6E") {
170                                band = "6 GHz".to_string();
171                            } else if val.contains("5 GHz")
172                                || val.contains("802.11a")
173                                || val.contains("802.11ac")
174                            {
175                                band = "5 GHz".to_string();
176                            } else if val.contains("802.11ax") {
177                                // 802.11ax can be 2.4/5/6 GHz; leave band empty to rely on channel
178                                band = String::new();
179                            } else {
180                                band = "2.4 GHz".to_string();
181                            }
182                        }
183                    }
184                    // Also check "Channel" for band detection fallback
185                    if line.starts_with("Channel") {
186                        if let Some(val) = line.split(':').nth(1) {
187                            if let Ok(ch) = val.trim().parse::<u32>() {
188                                if band.is_empty() {
189                                    if ch > 14 && ch <= 177 {
190                                        band = "5 GHz".to_string();
191                                    } else if ch <= 14 {
192                                        band = "2.4 GHz".to_string();
193                                    }
194                                }
195                            }
196                        }
197                    }
198                }
199
200                if !ssid.is_empty() {
201                    if !band.is_empty() {
202                        format!("Wi-Fi ({}) - {}", band, ssid)
203                    } else {
204                        format!("Wi-Fi - {}", ssid)
205                    }
206                } else {
207                    "Wi-Fi".to_string()
208                }
209            }
210            None => "Wi-Fi".to_string(),
211        }
212    }
213
214    #[cfg(target_os = "macos")]
215    {
216        let mut cmd = tokio::process::Command::new("/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport");
217        cmd.args(["-I"]);
218        match super::util::run_with_timeout(cmd, super::util::QUICK).await {
219            Some(output) => {
220                let text = String::from_utf8_lossy(&output.stdout);
221                let mut ssid = String::new();
222                let mut channel = 0u32;
223                for line in text.lines() {
224                    let line = line.trim();
225                    if line.starts_with("SSID:") {
226                        ssid = line
227                            .split(':')
228                            .nth(1)
229                            .map(|s| s.trim().to_string())
230                            .unwrap_or_default();
231                    }
232                    if line.starts_with("channel:") {
233                        if let Some(val) = line.split(':').nth(1) {
234                            channel = val
235                                .trim()
236                                .split(',')
237                                .next()
238                                .and_then(|s| s.parse().ok())
239                                .unwrap_or(0);
240                        }
241                    }
242                }
243                let band = if channel > 14 && channel <= 177 {
244                    "5 GHz"
245                } else if channel <= 14 && channel > 0 {
246                    "2.4 GHz"
247                } else {
248                    ""
249                };
250                if !ssid.is_empty() {
251                    if !band.is_empty() {
252                        format!("Wi-Fi ({}) - {}", band, ssid)
253                    } else {
254                        format!("Wi-Fi - {}", ssid)
255                    }
256                } else {
257                    "Wi-Fi".to_string()
258                }
259            }
260            None => "Wi-Fi".to_string(),
261        }
262    }
263
264    #[cfg(target_os = "linux")]
265    {
266        let mut cmd = tokio::process::Command::new("iwgetid");
267        cmd.args(["-r"]);
268        match super::util::run_with_timeout(cmd, super::util::QUICK).await {
269            Some(output) => {
270                let ssid = String::from_utf8_lossy(&output.stdout).trim().to_string();
271                // Try to get frequency
272                let mut freq_cmd = tokio::process::Command::new("iwgetid");
273                freq_cmd.args(["--freq", "-r"]);
274                let band = match super::util::run_with_timeout(freq_cmd, super::util::QUICK).await {
275                    Some(freq_out) => {
276                        let freq_str = String::from_utf8_lossy(&freq_out.stdout).trim().to_string();
277                        if let Ok(freq) = freq_str.parse::<f64>() {
278                            if freq > 5.0 {
279                                "5 GHz".to_string()
280                            } else if freq > 2.0 {
281                                "2.4 GHz".to_string()
282                            } else {
283                                String::new()
284                            }
285                        } else {
286                            String::new()
287                        }
288                    }
289                    None => String::new(),
290                };
291
292                if !ssid.is_empty() {
293                    if !band.is_empty() {
294                        format!("Wi-Fi ({}) - {}", band, ssid)
295                    } else {
296                        format!("Wi-Fi - {}", ssid)
297                    }
298                } else {
299                    "Wi-Fi".to_string()
300                }
301            }
302            None => "Wi-Fi".to_string(),
303        }
304    }
305}