Skip to main content

nd_300/diagnostics/
vpn.rs

1use serde::Serialize;
2
3use super::shared_cache::SharedCache;
4
5#[derive(Debug, Clone, Serialize)]
6pub struct VpnAdapter {
7    pub name: String,
8    pub adapter_type: String,
9    pub status: String,
10    pub ip_address: Option<String>,
11    pub vendor: Option<String>,
12    pub is_enterprise: bool,
13    pub interface_name: Option<String>,
14}
15
16pub async fn collect_with_cache(cache: &SharedCache) -> Option<Vec<VpnAdapter>> {
17    let mut vpns = Vec::new();
18
19    #[cfg(windows)]
20    {
21        if let Some(ref ic) = cache.ipconfig {
22            parse_vpn_from_ipconfig(&ic.raw, &mut vpns);
23        } else {
24            collect_windows_ipconfig(&mut vpns).await;
25        }
26        collect_windows_wmi(&mut vpns).await;
27    }
28
29    #[cfg(target_os = "macos")]
30    {
31        let _ = cache;
32        collect_macos_ifconfig(&mut vpns).await;
33        collect_macos_scutil(&mut vpns).await;
34    }
35
36    #[cfg(target_os = "linux")]
37    {
38        let _ = cache;
39        collect_linux_ip_link(&mut vpns).await;
40        collect_linux_nmcli(&mut vpns).await;
41        collect_linux_wireguard(&mut vpns).await;
42    }
43
44    vpns.dedup_by(|a, b| {
45        if let (Some(ref ai), Some(ref bi)) = (&a.interface_name, &b.interface_name) {
46            ai == bi
47        } else {
48            a.name == b.name
49        }
50    });
51
52    if vpns.is_empty() {
53        None
54    } else {
55        Some(vpns)
56    }
57}
58
59pub async fn collect() -> Option<Vec<VpnAdapter>> {
60    let mut vpns = Vec::new();
61
62    #[cfg(windows)]
63    {
64        collect_windows_ipconfig(&mut vpns).await;
65        collect_windows_wmi(&mut vpns).await;
66    }
67
68    #[cfg(target_os = "macos")]
69    {
70        collect_macos_ifconfig(&mut vpns).await;
71        collect_macos_scutil(&mut vpns).await;
72    }
73
74    #[cfg(target_os = "linux")]
75    {
76        collect_linux_ip_link(&mut vpns).await;
77        collect_linux_nmcli(&mut vpns).await;
78        collect_linux_wireguard(&mut vpns).await;
79    }
80
81    // Deduplicate by interface name
82    vpns.dedup_by(|a, b| {
83        if let (Some(ref ai), Some(ref bi)) = (&a.interface_name, &b.interface_name) {
84            ai == bi
85        } else {
86            a.name == b.name
87        }
88    });
89
90    if vpns.is_empty() {
91        None
92    } else {
93        Some(vpns)
94    }
95}
96
97// ── Windows ─────────────────────────────────────────────────────────────────
98
99#[cfg(windows)]
100fn parse_vpn_from_ipconfig(text: &str, vpns: &mut Vec<VpnAdapter>) {
101    let mut current_name = String::new();
102    let mut current_ip = None;
103
104    for line in text.lines() {
105        if !line.starts_with(' ') && !line.starts_with('\t') && line.contains("adapter") {
106            let name = line.trim().trim_end_matches(':');
107            let lower = name.to_lowercase();
108            if lower.contains("vpn")
109                || lower.contains("tap")
110                || lower.contains("tun")
111                || lower.contains("wireguard")
112                || lower.contains("wintun")
113                || lower.contains("fortinet")
114                || lower.contains("cisco")
115                || lower.contains("palo alto")
116                || lower.contains("global protect")
117                || lower.contains("nordlynx")
118                || lower.contains("expressvpn")
119                || lower.contains("mullvad")
120                || lower.contains("tailscale")
121                || lower.contains("zscaler")
122                || lower.contains("pulse")
123            {
124                if !current_name.is_empty() {
125                    let vendor = detect_vendor(&current_name);
126                    let is_enterprise = is_enterprise_vendor(&current_name, vendor.as_deref());
127                    vpns.push(VpnAdapter {
128                        name: current_name.clone(),
129                        adapter_type: detect_vpn_type(&current_name),
130                        status: if current_ip.is_some() {
131                            "Connected"
132                        } else {
133                            "Disconnected"
134                        }
135                        .to_string(),
136                        ip_address: current_ip.take(),
137                        vendor,
138                        is_enterprise,
139                        interface_name: None,
140                    });
141                }
142                current_name = name.to_string();
143                current_ip = None;
144            } else {
145                current_name.clear();
146            }
147        } else if !current_name.is_empty() {
148            let trimmed = line.trim();
149            if trimmed.contains("IPv4 Address")
150                || (trimmed.contains("IP Address") && !trimmed.contains("Autoconfiguration"))
151            {
152                current_ip = trimmed
153                    .split(':')
154                    .nth(1)
155                    .map(|s| s.trim().trim_end_matches("(Preferred)").trim().to_string());
156            }
157        }
158    }
159
160    if !current_name.is_empty() {
161        let vendor = detect_vendor(&current_name);
162        let is_enterprise = is_enterprise_vendor(&current_name, vendor.as_deref());
163        vpns.push(VpnAdapter {
164            name: current_name.clone(),
165            adapter_type: detect_vpn_type(&current_name),
166            status: if current_ip.is_some() {
167                "Connected"
168            } else {
169                "Disconnected"
170            }
171            .to_string(),
172            ip_address: current_ip,
173            vendor,
174            is_enterprise,
175            interface_name: None,
176        });
177    }
178}
179
180#[cfg(windows)]
181async fn collect_windows_ipconfig(vpns: &mut Vec<VpnAdapter>) {
182    let mut cmd = tokio::process::Command::new("ipconfig");
183    cmd.args(["/all"]);
184    if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
185        let text = String::from_utf8_lossy(&output.stdout);
186        parse_vpn_from_ipconfig(&text, vpns);
187    }
188}
189
190#[cfg(windows)]
191async fn collect_windows_wmi(vpns: &mut Vec<VpnAdapter>) {
192    use std::collections::HashMap;
193    use wmi::{COMLibrary, WMIConnection};
194
195    // Extract into Send-safe tuple inside spawn_blocking
196    let wmi_rows: Vec<(String, Option<String>, u16)> = tokio::task::spawn_blocking(|| {
197        let com = match COMLibrary::new() {
198            Ok(c) => c,
199            Err(_) => return Vec::new(),
200        };
201        let wmi = match WMIConnection::new(com) {
202            Ok(w) => w,
203            Err(_) => return Vec::new(),
204        };
205
206        let query = r#"SELECT Name, NetConnectionID, Description, NetConnectionStatus FROM Win32_NetworkAdapter WHERE Description LIKE '%TAP%' OR Description LIKE '%TUN%' OR Description LIKE '%Wintun%' OR Description LIKE '%WireGuard%' OR Description LIKE '%VPN%' OR Description LIKE '%NordLynx%' OR Description LIKE '%ExpressVPN%' OR Description LIKE '%Tailscale%'"#;
207        let results: Vec<HashMap<String, wmi::Variant>> = wmi.raw_query(query).unwrap_or_default();
208
209        results.into_iter().filter_map(|row| {
210            let description = match row.get("Description") {
211                Some(wmi::Variant::String(s)) => s.clone(),
212                _ => return None,
213            };
214            let net_id = match row.get("NetConnectionID") {
215                Some(wmi::Variant::String(s)) => Some(s.clone()),
216                _ => None,
217            };
218            let status_val = match row.get("NetConnectionStatus") {
219                Some(wmi::Variant::UI2(n)) => *n,
220                Some(wmi::Variant::I4(n)) => *n as u16,
221                _ => 0,
222            };
223            Some((description, net_id, status_val))
224        }).collect()
225    })
226    .await
227    .unwrap_or_default();
228
229    for (description, net_id, status_val) in wmi_rows {
230        // Skip if we already have this from ipconfig
231        let name_for_check = net_id.clone().unwrap_or_else(|| description.clone());
232        if vpns
233            .iter()
234            .any(|v| v.name == name_for_check || v.name == description)
235        {
236            continue;
237        }
238
239        let vendor = detect_vendor(&description);
240        let is_enterprise = is_enterprise_vendor(&description, vendor.as_deref());
241
242        vpns.push(VpnAdapter {
243            name: name_for_check,
244            adapter_type: detect_vpn_type(&description),
245            status: if status_val == 2 {
246                "Connected"
247            } else {
248                "Disconnected"
249            }
250            .to_string(),
251            ip_address: None,
252            vendor,
253            is_enterprise,
254            interface_name: net_id,
255        });
256    }
257}
258
259// ── macOS ───────────────────────────────────────────────────────────────────
260
261#[cfg(target_os = "macos")]
262async fn collect_macos_ifconfig(vpns: &mut Vec<VpnAdapter>) {
263    let cmd = tokio::process::Command::new("ifconfig");
264    if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
265        let text = String::from_utf8_lossy(&output.stdout);
266        let mut current_iface = String::new();
267        let mut current_ip = None;
268
269        for line in text.lines() {
270            if !line.starts_with('\t') && !line.starts_with(' ') {
271                if !current_iface.is_empty() && is_vpn_interface(&current_iface) {
272                    let vendor = detect_vendor(&current_iface);
273                    let is_enterprise = is_enterprise_vendor(&current_iface, vendor.as_deref());
274                    vpns.push(VpnAdapter {
275                        name: current_iface.clone(),
276                        adapter_type: detect_vpn_type(&current_iface),
277                        status: if current_ip.is_some() {
278                            "Connected"
279                        } else {
280                            "Disconnected"
281                        }
282                        .to_string(),
283                        ip_address: current_ip.take(),
284                        vendor,
285                        is_enterprise,
286                        interface_name: Some(current_iface.clone()),
287                    });
288                }
289                current_iface = line.split(':').next().unwrap_or("").to_string();
290                current_ip = None;
291            } else if line.contains("inet ") {
292                current_ip = line.split_whitespace().nth(1).map(|s| s.to_string());
293            }
294        }
295
296        if !current_iface.is_empty() && is_vpn_interface(&current_iface) {
297            let vendor = detect_vendor(&current_iface);
298            let is_enterprise = is_enterprise_vendor(&current_iface, vendor.as_deref());
299            vpns.push(VpnAdapter {
300                name: current_iface.clone(),
301                adapter_type: detect_vpn_type(&current_iface),
302                status: if current_ip.is_some() {
303                    "Connected"
304                } else {
305                    "Disconnected"
306                }
307                .to_string(),
308                ip_address: current_ip,
309                vendor,
310                is_enterprise,
311                interface_name: Some(current_iface),
312            });
313        }
314    }
315}
316
317#[cfg(target_os = "macos")]
318async fn collect_macos_scutil(vpns: &mut Vec<VpnAdapter>) {
319    let mut cmd = tokio::process::Command::new("scutil");
320    cmd.args(["--nc", "list"]);
321    if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
322        let text = String::from_utf8_lossy(&output.stdout);
323        for line in text.lines() {
324            // Lines like: * (Connected)      "VPN Name" [L2TP]
325            //   or:      - (Disconnected)   "VPN Name" [IPSec]
326            let trimmed = line.trim();
327            let status = if trimmed.contains("(Connected)") {
328                "Connected"
329            } else if trimmed.contains("(Disconnected)") {
330                "Disconnected"
331            } else {
332                continue;
333            };
334
335            // Extract name between quotes
336            if let (Some(start), Some(end)) = (trimmed.find('"'), trimmed.rfind('"')) {
337                if start < end {
338                    let name = &trimmed[start + 1..end];
339
340                    // Skip if already found via ifconfig
341                    if vpns.iter().any(|v| v.name == name) {
342                        continue;
343                    }
344
345                    // Extract type in brackets
346                    let vpn_type = if let Some(bracket_start) = trimmed.rfind('[') {
347                        if let Some(bracket_end) = trimmed.rfind(']') {
348                            trimmed[bracket_start + 1..bracket_end].to_string()
349                        } else {
350                            "VPN".to_string()
351                        }
352                    } else {
353                        "VPN".to_string()
354                    };
355
356                    let vendor = detect_vendor(name);
357                    let is_enterprise = is_enterprise_vendor(name, vendor.as_deref());
358
359                    vpns.push(VpnAdapter {
360                        name: name.to_string(),
361                        adapter_type: vpn_type,
362                        status: status.to_string(),
363                        ip_address: None,
364                        vendor,
365                        is_enterprise,
366                        interface_name: None,
367                    });
368                }
369            }
370        }
371    }
372}
373
374// ── Linux ───────────────────────────────────────────────────────────────────
375
376#[cfg(target_os = "linux")]
377async fn collect_linux_ip_link(vpns: &mut Vec<VpnAdapter>) {
378    let mut cmd = tokio::process::Command::new("ip");
379    cmd.args(["link", "show"]);
380    if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
381        let text = String::from_utf8_lossy(&output.stdout);
382        for line in text.lines() {
383            let parts: Vec<&str> = line.split_whitespace().collect();
384            if parts.len() >= 2 {
385                let name = parts[1].trim_end_matches(':');
386                if is_vpn_interface(name) {
387                    let is_up = line.contains("state UP");
388                    let vendor = detect_vendor(name);
389                    let is_enterprise = is_enterprise_vendor(name, vendor.as_deref());
390                    vpns.push(VpnAdapter {
391                        name: name.to_string(),
392                        adapter_type: detect_vpn_type(name),
393                        status: if is_up { "Connected" } else { "Disconnected" }.to_string(),
394                        ip_address: None,
395                        vendor,
396                        is_enterprise,
397                        interface_name: Some(name.to_string()),
398                    });
399                }
400            }
401        }
402    }
403}
404
405#[cfg(target_os = "linux")]
406async fn collect_linux_nmcli(vpns: &mut Vec<VpnAdapter>) {
407    let mut cmd = tokio::process::Command::new("nmcli");
408    cmd.args([
409        "-t",
410        "-f",
411        "TYPE,NAME,DEVICE",
412        "connection",
413        "show",
414        "--active",
415    ]);
416    if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
417        let text = String::from_utf8_lossy(&output.stdout);
418        for line in text.lines() {
419            let parts: Vec<&str> = line.splitn(3, ':').collect();
420            if parts.len() >= 2 {
421                let conn_type = parts[0];
422                let conn_name = parts[1];
423                let device = if parts.len() >= 3 {
424                    Some(parts[2])
425                } else {
426                    None
427                };
428
429                // NM VPN connection types
430                if conn_type.contains("vpn") || conn_type.contains("wireguard") {
431                    if vpns.iter().any(|v| v.name == conn_name) {
432                        continue;
433                    }
434                    let vendor = detect_vendor(conn_name);
435                    let is_enterprise = is_enterprise_vendor(conn_name, vendor.as_deref());
436                    vpns.push(VpnAdapter {
437                        name: conn_name.to_string(),
438                        adapter_type: conn_type.to_string(),
439                        status: "Connected".to_string(),
440                        ip_address: None,
441                        vendor,
442                        is_enterprise,
443                        interface_name: device.map(|d| d.to_string()),
444                    });
445                }
446            }
447        }
448    }
449}
450
451#[cfg(target_os = "linux")]
452async fn collect_linux_wireguard(vpns: &mut Vec<VpnAdapter>) {
453    let mut cmd = tokio::process::Command::new("wg");
454    cmd.args(["show", "interfaces"]);
455    if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
456        if output.status.success() {
457            let text = String::from_utf8_lossy(&output.stdout);
458            for iface in text.split_whitespace() {
459                if vpns
460                    .iter()
461                    .any(|v| v.interface_name.as_deref() == Some(iface))
462                {
463                    continue;
464                }
465                vpns.push(VpnAdapter {
466                    name: iface.to_string(),
467                    adapter_type: "WireGuard".to_string(),
468                    status: "Connected".to_string(),
469                    ip_address: None,
470                    vendor: Some("WireGuard".to_string()),
471                    is_enterprise: false,
472                    interface_name: Some(iface.to_string()),
473                });
474            }
475        }
476    }
477}
478
479// ── Shared helpers ──────────────────────────────────────────────────────────
480
481#[cfg(unix)]
482fn is_vpn_interface(name: &str) -> bool {
483    let lower = name.to_lowercase();
484    lower.starts_with("tun")
485        || lower.starts_with("tap")
486        || lower.starts_with("utun")
487        || lower.starts_with("wg")
488        || lower.starts_with("ppp")
489        || lower.contains("vpn")
490        || lower.contains("wireguard")
491        || lower.contains("wintun")
492}
493
494fn detect_vpn_type(name: &str) -> String {
495    let lower = name.to_lowercase();
496    if lower.contains("wireguard") || lower.starts_with("wg") || lower.contains("wintun") {
497        "WireGuard".to_string()
498    } else if lower.starts_with("tun") || lower.starts_with("utun") {
499        "TUN Tunnel".to_string()
500    } else if lower.starts_with("tap") {
501        "TAP Tunnel".to_string()
502    } else if lower.starts_with("ppp") {
503        "PPP".to_string()
504    } else if lower.contains("cisco") || lower.contains("anyconnect") {
505        "Cisco AnyConnect".to_string()
506    } else if lower.contains("fortinet") || lower.contains("forticlient") {
507        "FortiClient".to_string()
508    } else if lower.contains("global protect")
509        || lower.contains("globalprotect")
510        || lower.contains("palo alto")
511    {
512        "GlobalProtect".to_string()
513    } else if lower.contains("zscaler") {
514        "Zscaler".to_string()
515    } else if lower.contains("pulse") {
516        "Pulse Secure".to_string()
517    } else {
518        "VPN".to_string()
519    }
520}
521
522fn detect_vendor(name: &str) -> Option<String> {
523    let lower = name.to_lowercase();
524    if lower.contains("nord") || lower.contains("nordlynx") {
525        Some("NordVPN".to_string())
526    } else if lower.contains("expressvpn") {
527        Some("ExpressVPN".to_string())
528    } else if lower.contains("mullvad") {
529        Some("Mullvad".to_string())
530    } else if lower.contains("tailscale") {
531        Some("Tailscale".to_string())
532    } else if lower.contains("wireguard") || lower.starts_with("wg") {
533        Some("WireGuard".to_string())
534    } else if lower.contains("cisco") || lower.contains("anyconnect") {
535        Some("Cisco".to_string())
536    } else if lower.contains("globalprotect")
537        || lower.contains("global protect")
538        || lower.contains("palo alto")
539    {
540        Some("Palo Alto".to_string())
541    } else if lower.contains("fortinet") || lower.contains("forticlient") {
542        Some("Fortinet".to_string())
543    } else if lower.contains("zscaler") {
544        Some("Zscaler".to_string())
545    } else if lower.contains("pulse") {
546        Some("Pulse Secure".to_string())
547    } else {
548        None
549    }
550}
551
552fn is_enterprise_vendor(name: &str, vendor: Option<&str>) -> bool {
553    let lower = name.to_lowercase();
554    let vendor_lower = vendor.unwrap_or("").to_lowercase();
555
556    let enterprise_patterns = [
557        "cisco",
558        "anyconnect",
559        "globalprotect",
560        "palo alto",
561        "zscaler",
562        "forticlient",
563        "fortinet",
564        "pulse secure",
565        "juniper",
566        "f5 ",
567        "big-ip",
568        "checkpoint",
569        "corp",
570        "enterprise",
571        "mdm",
572        "company",
573    ];
574
575    enterprise_patterns
576        .iter()
577        .any(|p| lower.contains(p) || vendor_lower.contains(p))
578}