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        let active_interfaces = collect_macos_active_interfaces().await;
33        collect_macos_ifconfig(&mut vpns, &active_interfaces).await;
34        collect_macos_scutil(&mut vpns).await;
35    }
36
37    #[cfg(target_os = "linux")]
38    {
39        let _ = cache;
40        collect_linux_ip_link(&mut vpns).await;
41        collect_linux_nmcli(&mut vpns).await;
42        collect_linux_wireguard(&mut vpns).await;
43    }
44
45    vpns.dedup_by(|a, b| {
46        if let (Some(ref ai), Some(ref bi)) = (&a.interface_name, &b.interface_name) {
47            ai == bi
48        } else {
49            a.name == b.name
50        }
51    });
52
53    if vpns.is_empty() {
54        None
55    } else {
56        Some(vpns)
57    }
58}
59
60pub async fn collect() -> Option<Vec<VpnAdapter>> {
61    let mut vpns = Vec::new();
62
63    #[cfg(windows)]
64    {
65        collect_windows_ipconfig(&mut vpns).await;
66        collect_windows_wmi(&mut vpns).await;
67    }
68
69    #[cfg(target_os = "macos")]
70    {
71        let active_interfaces = collect_macos_active_interfaces().await;
72        collect_macos_ifconfig(&mut vpns, &active_interfaces).await;
73        collect_macos_scutil(&mut vpns).await;
74    }
75
76    #[cfg(target_os = "linux")]
77    {
78        collect_linux_ip_link(&mut vpns).await;
79        collect_linux_nmcli(&mut vpns).await;
80        collect_linux_wireguard(&mut vpns).await;
81    }
82
83    // Deduplicate by interface name
84    vpns.dedup_by(|a, b| {
85        if let (Some(ref ai), Some(ref bi)) = (&a.interface_name, &b.interface_name) {
86            ai == bi
87        } else {
88            a.name == b.name
89        }
90    });
91
92    if vpns.is_empty() {
93        None
94    } else {
95        Some(vpns)
96    }
97}
98
99// ── Windows ─────────────────────────────────────────────────────────────────
100
101#[cfg(windows)]
102fn parse_vpn_from_ipconfig(text: &str, vpns: &mut Vec<VpnAdapter>) {
103    let mut current_name = String::new();
104    let mut current_ip = None;
105
106    for line in text.lines() {
107        if !line.starts_with(' ') && !line.starts_with('\t') && line.contains("adapter") {
108            let name = line.trim().trim_end_matches(':');
109            let lower = name.to_lowercase();
110            if lower.contains("vpn")
111                || lower.contains("tap")
112                || lower.contains("tun")
113                || lower.contains("wireguard")
114                || lower.contains("wintun")
115                || lower.contains("fortinet")
116                || lower.contains("cisco")
117                || lower.contains("palo alto")
118                || lower.contains("global protect")
119                || lower.contains("nordlynx")
120                || lower.contains("expressvpn")
121                || lower.contains("mullvad")
122                || lower.contains("tailscale")
123                || lower.contains("zscaler")
124                || lower.contains("pulse")
125            {
126                if !current_name.is_empty() {
127                    let vendor = detect_vendor(&current_name);
128                    let is_enterprise = is_enterprise_vendor(&current_name, vendor.as_deref());
129                    vpns.push(VpnAdapter {
130                        name: current_name.clone(),
131                        adapter_type: detect_vpn_type(&current_name),
132                        status: if current_ip.is_some() {
133                            "Connected"
134                        } else {
135                            "Disconnected"
136                        }
137                        .to_string(),
138                        ip_address: current_ip.take(),
139                        vendor,
140                        is_enterprise,
141                        interface_name: None,
142                    });
143                }
144                current_name = name.to_string();
145                current_ip = None;
146            } else {
147                current_name.clear();
148            }
149        } else if !current_name.is_empty() {
150            let trimmed = line.trim();
151            if trimmed.contains("IPv4 Address")
152                || (trimmed.contains("IP Address") && !trimmed.contains("Autoconfiguration"))
153            {
154                current_ip = trimmed
155                    .split(':')
156                    .nth(1)
157                    .map(|s| s.trim().trim_end_matches("(Preferred)").trim().to_string());
158            }
159        }
160    }
161
162    if !current_name.is_empty() {
163        let vendor = detect_vendor(&current_name);
164        let is_enterprise = is_enterprise_vendor(&current_name, vendor.as_deref());
165        vpns.push(VpnAdapter {
166            name: current_name.clone(),
167            adapter_type: detect_vpn_type(&current_name),
168            status: if current_ip.is_some() {
169                "Connected"
170            } else {
171                "Disconnected"
172            }
173            .to_string(),
174            ip_address: current_ip,
175            vendor,
176            is_enterprise,
177            interface_name: None,
178        });
179    }
180}
181
182#[cfg(windows)]
183async fn collect_windows_ipconfig(vpns: &mut Vec<VpnAdapter>) {
184    let mut cmd = tokio::process::Command::new("ipconfig");
185    cmd.args(["/all"]);
186    if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
187        let text = String::from_utf8_lossy(&output.stdout);
188        parse_vpn_from_ipconfig(&text, vpns);
189    }
190}
191
192#[cfg(windows)]
193async fn collect_windows_wmi(vpns: &mut Vec<VpnAdapter>) {
194    use std::collections::HashMap;
195    use wmi::{COMLibrary, WMIConnection};
196
197    // Extract into Send-safe tuple inside spawn_blocking
198    let wmi_rows: Vec<(String, Option<String>, u16)> = tokio::task::spawn_blocking(|| {
199        let com = match COMLibrary::new() {
200            Ok(c) => c,
201            Err(_) => return Vec::new(),
202        };
203        let wmi = match WMIConnection::new(com) {
204            Ok(w) => w,
205            Err(_) => return Vec::new(),
206        };
207
208        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%'"#;
209        let results: Vec<HashMap<String, wmi::Variant>> = wmi.raw_query(query).unwrap_or_default();
210
211        results.into_iter().filter_map(|row| {
212            let description = match row.get("Description") {
213                Some(wmi::Variant::String(s)) => s.clone(),
214                _ => return None,
215            };
216            let net_id = match row.get("NetConnectionID") {
217                Some(wmi::Variant::String(s)) => Some(s.clone()),
218                _ => None,
219            };
220            let status_val = match row.get("NetConnectionStatus") {
221                Some(wmi::Variant::UI2(n)) => *n,
222                Some(wmi::Variant::I4(n)) => *n as u16,
223                _ => 0,
224            };
225            Some((description, net_id, status_val))
226        }).collect()
227    })
228    .await
229    .unwrap_or_default();
230
231    for (description, net_id, status_val) in wmi_rows {
232        // Skip if we already have this from ipconfig
233        let name_for_check = net_id.clone().unwrap_or_else(|| description.clone());
234        if vpns
235            .iter()
236            .any(|v| v.name == name_for_check || v.name == description)
237        {
238            continue;
239        }
240
241        let vendor = detect_vendor(&description);
242        let is_enterprise = is_enterprise_vendor(&description, vendor.as_deref());
243
244        vpns.push(VpnAdapter {
245            name: name_for_check,
246            adapter_type: detect_vpn_type(&description),
247            status: if status_val == 2 {
248                "Connected"
249            } else {
250                "Disconnected"
251            }
252            .to_string(),
253            ip_address: None,
254            vendor,
255            is_enterprise,
256            interface_name: net_id,
257        });
258    }
259}
260
261// ── macOS ───────────────────────────────────────────────────────────────────
262
263#[cfg(target_os = "macos")]
264async fn collect_macos_active_interfaces() -> std::collections::HashSet<String> {
265    let mut cmd = tokio::process::Command::new("/usr/sbin/scutil");
266    cmd.arg("--nwi");
267    let mut routes4 = tokio::process::Command::new("netstat");
268    routes4.args(["-rn", "-f", "inet"]);
269    let mut routes6 = tokio::process::Command::new("netstat");
270    routes6.args(["-rn", "-f", "inet6"]);
271    let (nwi, routes4, routes6) = tokio::join!(
272        super::util::run_with_timeout(cmd, super::util::QUICK),
273        super::util::run_with_timeout(routes4, super::util::QUICK),
274        super::util::run_with_timeout(routes6, super::util::QUICK),
275    );
276    let mut active = nwi
277        .map(|output| parse_macos_nwi(&String::from_utf8_lossy(&output.stdout)))
278        .unwrap_or_default();
279    for output in [routes4, routes6].into_iter().flatten() {
280        active.extend(parse_macos_route_interfaces(&String::from_utf8_lossy(
281            &output.stdout,
282        )));
283    }
284    active
285}
286
287#[cfg(any(target_os = "macos", test))]
288fn parse_macos_nwi(text: &str) -> std::collections::HashSet<String> {
289    let mut active = std::collections::HashSet::new();
290    for line in text.lines().map(str::trim) {
291        if let Some(interfaces) = line.strip_prefix("Network interfaces:") {
292            active.extend(interfaces.split_whitespace().map(str::to_string));
293        } else if line.contains(": flags") && line.contains("Reachable") {
294            if let Some((name, _)) = line.split_once(':') {
295                active.insert(name.trim().to_string());
296            }
297        }
298    }
299    active
300}
301
302#[cfg(any(target_os = "macos", test))]
303fn parse_macos_route_interfaces(text: &str) -> std::collections::HashSet<String> {
304    text.lines()
305        .filter_map(|line| {
306            let fields: Vec<&str> = line.split_whitespace().collect();
307            (fields.len() >= 4
308                && !matches!(
309                    fields[0],
310                    "Destination" | "Routing" | "Internet:" | "Internet6:"
311                )
312                && meaningful_macos_route(fields[0], fields[1], fields[2]))
313            .then(|| fields[3].to_string())
314        })
315        .collect()
316}
317
318#[cfg(any(target_os = "macos", test))]
319fn meaningful_macos_route(destination: &str, gateway: &str, _flags: &str) -> bool {
320    let destination = destination.to_ascii_lowercase();
321    let gateway = gateway.to_ascii_lowercase();
322
323    if destination == "default" {
324        // Modern macOS installs interface-scoped IPv6 defaults for every utun,
325        // including dormant system tunnels. A reachable VPN is also present in
326        // `scutil --nwi`, so a link-local gateway is not sufficient route
327        // evidence on its own.
328        return !gateway.starts_with("fe80::");
329    }
330
331    !(destination.starts_with("fe80")
332        || destination.starts_with("ff")
333        || destination.starts_with("169.254")
334        || destination.starts_with("::1")
335        || destination.starts_with("link#"))
336}
337
338#[cfg(target_os = "macos")]
339async fn collect_macos_ifconfig(
340    vpns: &mut Vec<VpnAdapter>,
341    active_interfaces: &std::collections::HashSet<String>,
342) {
343    let cmd = tokio::process::Command::new("ifconfig");
344    if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
345        let text = String::from_utf8_lossy(&output.stdout);
346        vpns.extend(parse_macos_ifconfig(&text, active_interfaces));
347    }
348}
349
350#[cfg(any(target_os = "macos", test))]
351fn parse_macos_ifconfig(
352    text: &str,
353    active_interfaces: &std::collections::HashSet<String>,
354) -> Vec<VpnAdapter> {
355    let mut rows = Vec::new();
356    let mut current_iface = String::new();
357    let mut current_ip: Option<String> = None;
358
359    let flush = |rows: &mut Vec<VpnAdapter>, iface: &str, ip: Option<String>| {
360        if !is_vpn_interface(iface) {
361            return;
362        }
363        // Darwin leaves many utun devices UP for system services, sometimes
364        // even with non-link-local addresses. Require reachability from
365        // `scutil --nwi` or ownership of an IPv4/IPv6 route; an address alone
366        // is not evidence of a currently active VPN.
367        if !active_interfaces.contains(iface) {
368            return;
369        }
370        let vendor = detect_vendor(iface);
371        let is_enterprise = is_enterprise_vendor(iface, vendor.as_deref());
372        rows.push(VpnAdapter {
373            name: iface.to_string(),
374            adapter_type: detect_vpn_type(iface),
375            status: "Connected".to_string(),
376            ip_address: ip,
377            vendor,
378            is_enterprise,
379            interface_name: Some(iface.to_string()),
380        });
381    };
382
383    for line in text.lines() {
384        if !line.starts_with(char::is_whitespace) {
385            flush(&mut rows, &current_iface, current_ip.take());
386            current_iface = line
387                .split_once(':')
388                .map(|(name, _)| name)
389                .unwrap_or("")
390                .to_string();
391        } else {
392            let trimmed = line.trim();
393            if let Some(value) = trimmed.strip_prefix("inet ") {
394                let address = value.split_whitespace().next().unwrap_or("");
395                if !address.starts_with("127.") && !address.is_empty() {
396                    current_ip = Some(address.to_string());
397                }
398            } else if let Some(value) = trimmed.strip_prefix("inet6 ") {
399                let address = value.split_whitespace().next().unwrap_or("");
400                if !address.starts_with("fe80:")
401                    && address != "::1"
402                    && !address.is_empty()
403                    && current_ip.is_none()
404                {
405                    current_ip = Some(address.to_string());
406                }
407            }
408        }
409    }
410    flush(&mut rows, &current_iface, current_ip);
411    rows
412}
413
414#[cfg(target_os = "macos")]
415async fn collect_macos_scutil(vpns: &mut Vec<VpnAdapter>) {
416    let mut cmd = tokio::process::Command::new("scutil");
417    cmd.args(["--nc", "list"]);
418    if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
419        let text = String::from_utf8_lossy(&output.stdout);
420        for row in parse_macos_scutil(&text) {
421            // Skip if already found via ifconfig.
422            if !vpns.iter().any(|vpn| vpn.name == row.name) {
423                vpns.push(row);
424            }
425        }
426    }
427}
428
429#[cfg(any(target_os = "macos", test))]
430fn parse_macos_scutil(text: &str) -> Vec<VpnAdapter> {
431    text.lines()
432        .filter_map(|line| {
433            // Current NetworkExtension services include an application ID
434            // before the quoted display name; older L2TP/IPSec rows do not.
435            let trimmed = line.trim();
436            let status = if trimmed.contains("(Connected)") {
437                "Connected"
438            } else if trimmed.contains("(Disconnected)") {
439                "Disconnected"
440            } else {
441                return None;
442            };
443
444            let (start, end) = (trimmed.find('"')?, trimmed.rfind('"')?);
445            (start < end).then(|| {
446                let name = &trimmed[start + 1..end];
447                let adapter_type = trimmed
448                    .rfind('[')
449                    .zip(trimmed.rfind(']'))
450                    .filter(|(open, close)| open < close)
451                    .map(|(open, close)| trimmed[open + 1..close].to_string())
452                    .unwrap_or_else(|| "VPN".to_string());
453                let vendor = detect_vendor(name);
454                let is_enterprise = is_enterprise_vendor(name, vendor.as_deref());
455                VpnAdapter {
456                    name: name.to_string(),
457                    adapter_type,
458                    status: status.to_string(),
459                    ip_address: None,
460                    vendor,
461                    is_enterprise,
462                    interface_name: None,
463                }
464            })
465        })
466        .collect()
467}
468
469// ── Linux ───────────────────────────────────────────────────────────────────
470
471#[cfg(target_os = "linux")]
472async fn collect_linux_ip_link(vpns: &mut Vec<VpnAdapter>) {
473    let mut cmd = tokio::process::Command::new("ip");
474    cmd.args(["link", "show"]);
475    if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
476        let text = String::from_utf8_lossy(&output.stdout);
477        for line in text.lines() {
478            let parts: Vec<&str> = line.split_whitespace().collect();
479            if parts.len() >= 2 {
480                let name = parts[1].trim_end_matches(':');
481                if is_vpn_interface(name) {
482                    let is_up = line.contains("state UP");
483                    let vendor = detect_vendor(name);
484                    let is_enterprise = is_enterprise_vendor(name, vendor.as_deref());
485                    vpns.push(VpnAdapter {
486                        name: name.to_string(),
487                        adapter_type: detect_vpn_type(name),
488                        status: if is_up { "Connected" } else { "Disconnected" }.to_string(),
489                        ip_address: None,
490                        vendor,
491                        is_enterprise,
492                        interface_name: Some(name.to_string()),
493                    });
494                }
495            }
496        }
497    }
498}
499
500#[cfg(target_os = "linux")]
501async fn collect_linux_nmcli(vpns: &mut Vec<VpnAdapter>) {
502    let mut cmd = tokio::process::Command::new("nmcli");
503    cmd.args([
504        "-t",
505        "-f",
506        "TYPE,NAME,DEVICE",
507        "connection",
508        "show",
509        "--active",
510    ]);
511    if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
512        let text = String::from_utf8_lossy(&output.stdout);
513        for line in text.lines() {
514            let parts: Vec<&str> = line.splitn(3, ':').collect();
515            if parts.len() >= 2 {
516                let conn_type = parts[0];
517                let conn_name = parts[1];
518                let device = if parts.len() >= 3 {
519                    Some(parts[2])
520                } else {
521                    None
522                };
523
524                // NM VPN connection types
525                if conn_type.contains("vpn") || conn_type.contains("wireguard") {
526                    if vpns.iter().any(|v| v.name == conn_name) {
527                        continue;
528                    }
529                    let vendor = detect_vendor(conn_name);
530                    let is_enterprise = is_enterprise_vendor(conn_name, vendor.as_deref());
531                    vpns.push(VpnAdapter {
532                        name: conn_name.to_string(),
533                        adapter_type: conn_type.to_string(),
534                        status: "Connected".to_string(),
535                        ip_address: None,
536                        vendor,
537                        is_enterprise,
538                        interface_name: device.map(|d| d.to_string()),
539                    });
540                }
541            }
542        }
543    }
544}
545
546#[cfg(target_os = "linux")]
547async fn collect_linux_wireguard(vpns: &mut Vec<VpnAdapter>) {
548    let mut cmd = tokio::process::Command::new("wg");
549    cmd.args(["show", "interfaces"]);
550    if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
551        if output.status.success() {
552            let text = String::from_utf8_lossy(&output.stdout);
553            for iface in text.split_whitespace() {
554                if vpns
555                    .iter()
556                    .any(|v| v.interface_name.as_deref() == Some(iface))
557                {
558                    continue;
559                }
560                vpns.push(VpnAdapter {
561                    name: iface.to_string(),
562                    adapter_type: "WireGuard".to_string(),
563                    status: "Connected".to_string(),
564                    ip_address: None,
565                    vendor: Some("WireGuard".to_string()),
566                    is_enterprise: false,
567                    interface_name: Some(iface.to_string()),
568                });
569            }
570        }
571    }
572}
573
574// ── Shared helpers ──────────────────────────────────────────────────────────
575
576#[cfg(any(unix, test))]
577fn is_vpn_interface(name: &str) -> bool {
578    let lower = name.to_lowercase();
579    lower.starts_with("tun")
580        || lower.starts_with("tap")
581        || lower.starts_with("utun")
582        || lower.starts_with("wg")
583        || lower.starts_with("ppp")
584        || lower.contains("vpn")
585        || lower.contains("wireguard")
586        || lower.contains("wintun")
587}
588
589fn detect_vpn_type(name: &str) -> String {
590    let lower = name.to_lowercase();
591    if lower.contains("wireguard") || lower.starts_with("wg") || lower.contains("wintun") {
592        "WireGuard".to_string()
593    } else if lower.starts_with("tun") || lower.starts_with("utun") {
594        "TUN Tunnel".to_string()
595    } else if lower.starts_with("tap") {
596        "TAP Tunnel".to_string()
597    } else if lower.starts_with("ppp") {
598        "PPP".to_string()
599    } else if lower.contains("cisco") || lower.contains("anyconnect") {
600        "Cisco AnyConnect".to_string()
601    } else if lower.contains("fortinet") || lower.contains("forticlient") {
602        "FortiClient".to_string()
603    } else if lower.contains("global protect")
604        || lower.contains("globalprotect")
605        || lower.contains("palo alto")
606    {
607        "GlobalProtect".to_string()
608    } else if lower.contains("zscaler") {
609        "Zscaler".to_string()
610    } else if lower.contains("pulse") {
611        "Pulse Secure".to_string()
612    } else {
613        "VPN".to_string()
614    }
615}
616
617fn detect_vendor(name: &str) -> Option<String> {
618    let lower = name.to_lowercase();
619    if lower.contains("nord") || lower.contains("nordlynx") {
620        Some("NordVPN".to_string())
621    } else if lower.contains("expressvpn") {
622        Some("ExpressVPN".to_string())
623    } else if lower.contains("mullvad") {
624        Some("Mullvad".to_string())
625    } else if lower.contains("tailscale") {
626        Some("Tailscale".to_string())
627    } else if lower.contains("wireguard") || lower.starts_with("wg") {
628        Some("WireGuard".to_string())
629    } else if lower.contains("cisco") || lower.contains("anyconnect") {
630        Some("Cisco".to_string())
631    } else if lower.contains("globalprotect")
632        || lower.contains("global protect")
633        || lower.contains("palo alto")
634    {
635        Some("Palo Alto".to_string())
636    } else if lower.contains("fortinet") || lower.contains("forticlient") {
637        Some("Fortinet".to_string())
638    } else if lower.contains("zscaler") {
639        Some("Zscaler".to_string())
640    } else if lower.contains("pulse") {
641        Some("Pulse Secure".to_string())
642    } else {
643        None
644    }
645}
646
647fn is_enterprise_vendor(name: &str, vendor: Option<&str>) -> bool {
648    let lower = name.to_lowercase();
649    let vendor_lower = vendor.unwrap_or("").to_lowercase();
650
651    let enterprise_patterns = [
652        "cisco",
653        "anyconnect",
654        "globalprotect",
655        "palo alto",
656        "zscaler",
657        "forticlient",
658        "fortinet",
659        "pulse secure",
660        "juniper",
661        "f5 ",
662        "big-ip",
663        "checkpoint",
664        "corp",
665        "enterprise",
666        "mdm",
667        "company",
668    ];
669
670    enterprise_patterns
671        .iter()
672        .any(|p| lower.contains(p) || vendor_lower.contains(p))
673}
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678
679    #[test]
680    fn macos_nwi_identifies_only_reachable_interfaces() {
681        let text = "IPv4 network interface information\n     en0 : flags : 0x5 (IPv4,DNS)\n           reach : 0x00000002 (Reachable)\nNetwork interfaces: en0 utun7\n";
682        let active = parse_macos_nwi(text);
683        assert!(active.contains("en0"));
684        assert!(active.contains("utun7"));
685        assert!(!active.contains("utun0"));
686    }
687
688    #[test]
689    fn macos_routes_identify_split_and_default_tunnels() {
690        let text = "Routing tables\n\nInternet:\nDestination Gateway Flags Netif Expire\ndefault link#20 UCS utun7\n100.64/10 link#21 UCS utun8 !\nInternet6:\nDestination Gateway Flags Netif Expire\ndefault fe80::%utun0 UGcIg utun0\nfe80::%utun0/64 fe80::1 UcI utun0\nff00::/8 ::1 UmCI utun1\n";
691        let routed = parse_macos_route_interfaces(text);
692        assert!(routed.contains("utun7"));
693        assert!(routed.contains("utun8"));
694        assert!(!routed.contains("!"));
695        assert!(!routed.contains("utun0"));
696        assert!(!routed.contains("utun1"));
697    }
698
699    #[test]
700    fn dormant_link_local_utuns_are_not_reported_as_vpns() {
701        let text = "utun0: flags=8051<UP,POINTOPOINT,RUNNING> mtu 1380\n\tinet6 fe80::1%utun0 prefixlen 64\nutun7: flags=8051<UP,POINTOPOINT,RUNNING> mtu 1380\n\tinet6 fe80::2%utun7 prefixlen 64\nutun8: flags=8051<UP,POINTOPOINT,RUNNING> mtu 1380\n\tinet 100.64.0.2 --> 100.64.0.1 netmask 0xffffffff\n";
702        let active = ["utun7".to_string()].into_iter().collect();
703        let rows = parse_macos_ifconfig(text, &active);
704        assert_eq!(rows.len(), 1);
705        assert_eq!(rows[0].interface_name.as_deref(), Some("utun7"));
706    }
707
708    #[test]
709    fn macos_scutil_parses_modern_network_extension_service() {
710        let text = "Available network connection services in the current set (*=enabled):\n* (Disconnected) 38349A36 VPN (io.tailscale.ipn.macos) \"Tailscale\" [VPN:io.tailscale.ipn.macos]\n";
711        let rows = parse_macos_scutil(text);
712        assert_eq!(rows.len(), 1);
713        assert_eq!(rows[0].name, "Tailscale");
714        assert_eq!(rows[0].status, "Disconnected");
715        assert_eq!(rows[0].vendor.as_deref(), Some("Tailscale"));
716    }
717}