Skip to main content

nd_300/actions/fix/
vpn.rs

1use crate::config::Config;
2use crate::diagnostics::vpn::{self, VpnAdapter};
3use crate::render::progress::create_spinner;
4
5#[cfg(windows)]
6use super::cmd::TIMEOUT_SLOW;
7use super::cmd::{run_cmd, run_owned_mutation, TIMEOUT_MEDIUM};
8use super::{print_step_fail, print_step_ok, warn_icon};
9use crate::actions::{is_interactive, prompt_yes_no};
10
11/// Info about a VPN that was disabled during the fix, for potential re-enable.
12///
13/// `Clone` so a copy can be stowed in the restore registry (wrapped in `Arc`)
14/// while the original list is still passed to [`offer_reenable`].
15#[derive(Debug, Clone)]
16pub struct DisabledVpn {
17    pub name: String,
18    pub method: DisableMethod,
19}
20
21#[derive(Debug, Clone)]
22pub enum DisableMethod {
23    VendorCli(String, Vec<String>), // (binary, args)
24    Netsh(String),                  // adapter name (Windows)
25    #[cfg(target_os = "macos")]
26    Scutil(String), // VPN service name
27    #[cfg(target_os = "linux")]
28    Nmcli(String), // connection name
29    #[cfg(target_os = "linux")]
30    WgQuick(String), // interface name
31}
32
33/// Reconnect commands commonly return before the VPN has finished changing
34/// state. Keep the mutation itself bounded, then require a separate bounded
35/// readback before treating a restore as complete.
36const VPN_MUTATION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
37const VPN_STATE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
38const VPN_STATE_ATTEMPTS: usize = 6;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41enum VpnConnectionState {
42    Connected,
43    Disconnected,
44    Unknown,
45}
46
47/// Known enterprise VPN vendors — we never touch these automatically.
48fn is_enterprise_vpn(adapter: &VpnAdapter) -> bool {
49    let lower = adapter.name.to_lowercase();
50    let vendor_lower = adapter.vendor.as_deref().unwrap_or("").to_lowercase();
51    let type_lower = adapter.adapter_type.to_lowercase();
52
53    let enterprise_patterns = [
54        "cisco",
55        "anyconnect",
56        "globalprotect",
57        "palo alto",
58        "zscaler",
59        "forticlient",
60        "fortinet",
61        "pulse secure",
62        "juniper",
63        "f5 ",
64        "big-ip",
65        "checkpoint",
66        "corp",
67        "enterprise",
68        "mdm",
69        "company",
70    ];
71
72    enterprise_patterns
73        .iter()
74        .any(|p| lower.contains(p) || vendor_lower.contains(p) || type_lower.contains(p))
75}
76
77/// Try to find a vendor-specific CLI to disconnect this VPN.
78fn find_vendor_cli(adapter: &VpnAdapter) -> Option<(String, Vec<String>)> {
79    let lower = adapter.name.to_lowercase();
80    let vendor_lower = adapter.vendor.as_deref().unwrap_or("").to_lowercase();
81
82    // NordVPN
83    if lower.contains("nord") || vendor_lower.contains("nord") {
84        return Some(("nordvpn".to_string(), vec!["disconnect".to_string()]));
85    }
86    // ExpressVPN
87    if lower.contains("expressvpn") || vendor_lower.contains("expressvpn") {
88        return Some(("expressvpn".to_string(), vec!["disconnect".to_string()]));
89    }
90    // Mullvad
91    if lower.contains("mullvad") || vendor_lower.contains("mullvad") {
92        return Some(("mullvad".to_string(), vec!["disconnect".to_string()]));
93    }
94    // Tailscale
95    if lower.contains("tailscale") || vendor_lower.contains("tailscale") {
96        return Some(("tailscale".to_string(), vec!["down".to_string()]));
97    }
98    // WireGuard (wg-quick)
99    if adapter.adapter_type == "WireGuard" {
100        if let Some(ref iface) = adapter.interface_name {
101            return Some((
102                "wg-quick".to_string(),
103                vec!["down".to_string(), iface.clone()],
104            ));
105        }
106    }
107
108    // Cisco AnyConnect
109    if lower.contains("cisco")
110        || vendor_lower.contains("cisco")
111        || adapter.adapter_type.contains("Cisco")
112    {
113        #[cfg(windows)]
114        {
115            // Try common install paths
116            let paths = [
117                r"C:\Program Files (x86)\Cisco\Cisco AnyConnect Secure Mobility Client\vpncli.exe",
118                r"C:\Program Files\Cisco\Cisco AnyConnect Secure Mobility Client\vpncli.exe",
119            ];
120            for path in &paths {
121                if std::path::Path::new(path).exists() {
122                    return Some((path.to_string(), vec!["disconnect".to_string()]));
123                }
124            }
125        }
126        #[cfg(unix)]
127        {
128            return Some((
129                "/opt/cisco/anyconnect/bin/vpn".to_string(),
130                vec!["disconnect".to_string()],
131            ));
132        }
133    }
134
135    None
136}
137
138/// A VPN successfully disabled only after its inverse operation was registered.
139#[derive(Debug)]
140pub struct GuardedDisabledVpn {
141    pub vpn: DisabledVpn,
142    pub restore_token: super::session::RestoreToken,
143}
144
145#[derive(Debug)]
146enum DisableAttempt {
147    Disabled(GuardedDisabledVpn),
148    /// The process could not be spawned, so it definitely made no change and
149    /// its inverse was safely resolved.
150    DefinitelyNotRun,
151    /// The command timed out or exited non-zero after spawning. It may still
152    /// have disconnected the VPN, so its inverse remains registered.
153    Uncertain,
154}
155
156enum DisconnectRun {
157    Completed(std::process::ExitStatus),
158    SpawnFailed,
159    TimedOut,
160}
161
162async fn run_disconnect_command(
163    command: tokio::process::Command,
164    timeout: std::time::Duration,
165) -> DisconnectRun {
166    // Use the same serialized child-owning runner as reconnect. If this future
167    // is cancelled, its supervisor keeps the mutation lock until the spawned
168    // disconnect has been killed and reaped; cleanup reconnect cannot race it.
169    match run_owned_mutation(command, timeout).await {
170        Ok(output) => DisconnectRun::Completed(output.status),
171        Err(error) if error.contains(" spawn failed:") => DisconnectRun::SpawnFailed,
172        Err(_) => DisconnectRun::TimedOut,
173    }
174}
175
176#[derive(Debug, Default)]
177pub struct VpnDisableBatch {
178    pub disabled: Vec<GuardedDisabledVpn>,
179    pub uncertain_attempts: usize,
180}
181
182async fn run_registered_disable(
183    vpn: DisabledVpn,
184    command: tokio::process::Command,
185    timeout: std::time::Duration,
186    config: &Config,
187    restore: &super::session::RestoreRegistry,
188) -> DisableAttempt {
189    use super::session::RestoreOp;
190    use std::sync::Arc;
191
192    // Register the inverse before spawning the disconnect command. Cancellation,
193    // timeout, panic, SIGINT, SIGTERM, or SIGHUP can never land in a gap where a
194    // VPN has been disconnected but no reconnect operation exists.
195    let restore_token = restore
196        .register(RestoreOp::ReEnableVpn(Arc::new(vpn.clone())))
197        .await;
198    let result = run_disconnect_command(command, timeout).await;
199    match result {
200        DisconnectRun::Completed(status) if status.success() => {
201            if is_interactive(config) {
202                print_step_ok(&format!("Disabled {}", vpn.name), config);
203            }
204            DisableAttempt::Disabled(GuardedDisabledVpn { vpn, restore_token })
205        }
206        DisconnectRun::SpawnFailed => {
207            // A spawn failure means no child existed and no disconnect could
208            // have happened. Only this case is safe to resolve before trying
209            // another strategy.
210            restore.mark_resolved(restore_token).await;
211            DisableAttempt::DefinitelyNotRun
212        }
213        // A spawned command can perform the disconnect and still exit nonzero,
214        // or outlive our timeout. Keep the inverse pending; idempotent reconnect
215        // during cleanup is safer than stranding the VPN.
216        DisconnectRun::Completed(_) | DisconnectRun::TimedOut => DisableAttempt::Uncertain,
217    }
218}
219
220/// Detect connected VPNs and prompt user to disable them before fix stages.
221/// Every returned VPN had its reconnect operation registered before the
222/// disconnect subprocess was spawned.
223pub async fn detect_and_disable(
224    config: &Config,
225    restore: &super::session::RestoreRegistry,
226) -> VpnDisableBatch {
227    let mut batch = VpnDisableBatch::default();
228
229    let spinner = create_spinner("Detecting VPN connections...");
230    let vpns = vpn::collect().await;
231    spinner.finish_and_clear();
232
233    let vpns = match vpns {
234        Some(v) => v,
235        None => return batch,
236    };
237
238    let connected: Vec<&VpnAdapter> = vpns.iter().filter(|v| v.status == "Connected").collect();
239    if connected.is_empty() {
240        return batch;
241    }
242
243    for adapter in connected {
244        if is_enterprise_vpn(adapter) {
245            if is_interactive(config) {
246                println!(
247                    "  {} Corporate VPN detected: {} — skipping (managed by your organization)",
248                    warn_icon(config),
249                    crate::render::color::cyan(&adapter.name, config),
250                );
251            }
252            continue;
253        }
254
255        let do_disable = if is_interactive(config) {
256            let prompt = format!(
257                "  VPN detected: {} ({}). VPN connections can interfere with network fixes. Disable? (y/N): ",
258                adapter.name, adapter.adapter_type,
259            );
260            prompt_yes_no(&prompt)
261        } else {
262            false
263        };
264
265        if !do_disable {
266            continue;
267        }
268
269        // Try a vendor CLI first, but guard it before spawning.
270        if let Some((bin, args)) = find_vendor_cli(adapter) {
271            let guarded = DisabledVpn {
272                name: adapter.name.clone(),
273                method: DisableMethod::VendorCli(
274                    bin.clone(),
275                    args.iter()
276                        .map(|arg| match arg.as_str() {
277                            "disconnect" => "connect".to_string(),
278                            "down" => "up".to_string(),
279                            _ => arg.clone(),
280                        })
281                        .collect(),
282                ),
283            };
284            let mut command = tokio::process::Command::new(&bin);
285            command.args(&args);
286            let spinner = create_spinner(&format!("Disabling {}...", adapter.name));
287            let result =
288                run_registered_disable(guarded, command, TIMEOUT_MEDIUM, config, restore).await;
289            spinner.finish_and_clear();
290            match result {
291                DisableAttempt::Disabled(result) => {
292                    batch.disabled.push(result);
293                    continue;
294                }
295                DisableAttempt::Uncertain => {
296                    batch.uncertain_attempts += 1;
297                    if is_interactive(config) {
298                        print_step_fail(
299                            &format!("Could not confirm {} disconnected", adapter.name),
300                            "ND300 will conservatively reconnect it during cleanup",
301                            config,
302                        );
303                    }
304                    continue;
305                }
306                DisableAttempt::DefinitelyNotRun => {}
307            }
308        }
309
310        let spinner = create_spinner(&format!("Disabling {}...", adapter.name));
311        let fallback_result = disable_adapter_fallback(adapter, config, restore).await;
312        spinner.finish_and_clear();
313        match fallback_result {
314            DisableAttempt::Disabled(disabled_vpn) => batch.disabled.push(disabled_vpn),
315            DisableAttempt::Uncertain => {
316                batch.uncertain_attempts += 1;
317                if is_interactive(config) {
318                    print_step_fail(
319                        &format!("Could not confirm {} disconnected", adapter.name),
320                        "ND300 will conservatively reconnect it during cleanup",
321                        config,
322                    );
323                }
324            }
325            DisableAttempt::DefinitelyNotRun => {
326                if is_interactive(config) {
327                    print_step_fail(
328                        &format!("Could not disable {}", adapter.name),
329                        "Try disconnecting manually before running fix",
330                        config,
331                    );
332                }
333            }
334        }
335    }
336
337    if !batch.disabled.is_empty() || batch.uncertain_attempts > 0 {
338        let spinner = create_spinner("Waiting for VPN disconnect...");
339        tokio::time::sleep(std::time::Duration::from_secs(2)).await;
340        spinner.finish_and_clear();
341    }
342
343    batch
344}
345
346async fn disable_adapter_fallback(
347    adapter: &VpnAdapter,
348    config: &Config,
349    restore: &super::session::RestoreRegistry,
350) -> DisableAttempt {
351    #[cfg(windows)]
352    {
353        if let Some(ref iface) = adapter.interface_name {
354            let vpn = DisabledVpn {
355                name: adapter.name.clone(),
356                method: DisableMethod::Netsh(iface.clone()),
357            };
358            let mut command = tokio::process::Command::new("netsh");
359            command.args(["interface", "set", "interface", iface, "disabled"]);
360            return run_registered_disable(vpn, command, TIMEOUT_SLOW, config, restore).await;
361        }
362        DisableAttempt::DefinitelyNotRun
363    }
364
365    #[cfg(target_os = "macos")]
366    {
367        let vpn = DisabledVpn {
368            name: adapter.name.clone(),
369            method: DisableMethod::Scutil(adapter.name.clone()),
370        };
371        let mut command = tokio::process::Command::new("scutil");
372        command.args(["--nc", "stop", &adapter.name]);
373        run_registered_disable(vpn, command, TIMEOUT_MEDIUM, config, restore).await
374    }
375
376    #[cfg(target_os = "linux")]
377    {
378        let nmcli_vpn = DisabledVpn {
379            name: adapter.name.clone(),
380            method: DisableMethod::Nmcli(adapter.name.clone()),
381        };
382        let mut nmcli = tokio::process::Command::new("nmcli");
383        nmcli.args(["connection", "down", &adapter.name]);
384        match run_registered_disable(nmcli_vpn, nmcli, TIMEOUT_MEDIUM, config, restore).await {
385            result @ DisableAttempt::Disabled(_) | result @ DisableAttempt::Uncertain => {
386                return result;
387            }
388            DisableAttempt::DefinitelyNotRun => {}
389        }
390
391        if let Some(ref iface) = adapter.interface_name {
392            let wireguard_vpn = DisabledVpn {
393                name: adapter.name.clone(),
394                method: DisableMethod::WgQuick(iface.clone()),
395            };
396            let mut wg = tokio::process::Command::new("wg-quick");
397            wg.args(["down", iface]);
398            return run_registered_disable(wireguard_vpn, wg, TIMEOUT_MEDIUM, config, restore)
399                .await;
400        }
401        DisableAttempt::DefinitelyNotRun
402    }
403}
404
405#[derive(Debug, Clone, Copy, PartialEq, Eq)]
406pub enum ReenableDisposition {
407    IntentionallyLeftDisabled,
408    Reenabled,
409    IntentionallyRedisabled,
410    ReconnectFailed,
411    RedisableFailed,
412}
413
414impl ReenableDisposition {
415    pub fn resolves_restore(self) -> bool {
416        matches!(
417            self,
418            Self::IntentionallyLeftDisabled | Self::Reenabled | Self::IntentionallyRedisabled
419        )
420    }
421}
422
423/// Offer to re-enable VPNs that were disabled during the fix. The returned
424/// disposition for each input VPN determines whether its restore token can be
425/// resolved; failed/uncertain reconnects remain pending for the cleanup drain.
426pub async fn offer_reenable(disabled: &[DisabledVpn], config: &Config) -> Vec<ReenableDisposition> {
427    let mut dispositions = Vec::with_capacity(disabled.len());
428
429    for vpn in disabled {
430        let do_reenable = if is_interactive(config) {
431            let prompt = format!("  Re-enable {}? (y/N): ", vpn.name);
432            prompt_yes_no(&prompt)
433        } else {
434            false
435        };
436
437        if !do_reenable {
438            dispositions.push(ReenableDisposition::IntentionallyLeftDisabled);
439            continue;
440        }
441
442        let spinner = create_spinner(&format!("Re-enabling {}...", vpn.name));
443        let success = reenable_vpn(vpn).await;
444        spinner.finish_and_clear();
445
446        if success {
447            if is_interactive(config) {
448                print_step_ok(&format!("Re-enabled {}", vpn.name), config);
449            }
450            // Verify connectivity after re-enable. Re-disabling the user's
451            // VPN is destructive, so require two failed samples.
452            let spinner = create_spinner("Verifying connectivity...");
453            tokio::time::sleep(std::time::Duration::from_secs(5)).await;
454            let mut connected = super::connectivity::check_connectivity().await;
455            if !connected {
456                spinner.set_message("Confirming connectivity loss...");
457                tokio::time::sleep(std::time::Duration::from_secs(3)).await;
458                connected = super::connectivity::check_connectivity().await;
459            }
460            spinner.finish_and_clear();
461
462            if connected {
463                dispositions.push(ReenableDisposition::Reenabled);
464            } else {
465                let spinner = create_spinner(&format!("Disabling {} again...", vpn.name));
466                let redisabled = redisable_vpn(vpn).await;
467                spinner.finish_and_clear();
468
469                if is_interactive(config) {
470                    if redisabled {
471                        println!(
472                            "  {} Re-enabling {} broke connectivity. The VPN has been disabled again.",
473                            warn_icon(config),
474                            crate::render::color::cyan(&vpn.name, config),
475                        );
476                    } else {
477                        print_step_fail(
478                            &format!("Could not confirm {} was disabled again", vpn.name),
479                            "ND300 will keep reconnect cleanup registered",
480                            config,
481                        );
482                    }
483                }
484                dispositions.push(if redisabled {
485                    ReenableDisposition::IntentionallyRedisabled
486                } else {
487                    ReenableDisposition::RedisableFailed
488                });
489            }
490        } else {
491            if is_interactive(config) {
492                print_step_fail(
493                    &format!("Failed to re-enable {}", vpn.name),
494                    "ND300 will retry during cleanup",
495                    config,
496                );
497            }
498            dispositions.push(ReenableDisposition::ReconnectFailed);
499        }
500    }
501
502    dispositions
503}
504
505fn state_retry_delay() -> std::time::Duration {
506    if cfg!(test) {
507        std::time::Duration::from_millis(10)
508    } else {
509        std::time::Duration::from_secs(2)
510    }
511}
512
513#[cfg(any(target_os = "macos", test))]
514fn parse_scutil_state(text: &str) -> VpnConnectionState {
515    match text.lines().map(str::trim).find(|line| !line.is_empty()) {
516        Some(line) if line.eq_ignore_ascii_case("connected") => VpnConnectionState::Connected,
517        Some(line) if line.eq_ignore_ascii_case("disconnected") => VpnConnectionState::Disconnected,
518        _ => VpnConnectionState::Unknown,
519    }
520}
521
522fn parse_nordvpn_state(text: &str) -> VpnConnectionState {
523    for line in text.lines().map(str::trim) {
524        if line.eq_ignore_ascii_case("status: connected") {
525            return VpnConnectionState::Connected;
526        }
527        if line.eq_ignore_ascii_case("status: disconnected") {
528            return VpnConnectionState::Disconnected;
529        }
530    }
531    VpnConnectionState::Unknown
532}
533
534fn parse_expressvpn_state(text: &str) -> VpnConnectionState {
535    for line in text.lines().map(str::trim) {
536        let line = line.to_ascii_lowercase();
537        if line == "not connected" || line == "disconnected" {
538            return VpnConnectionState::Disconnected;
539        }
540        if line == "connected" || line.starts_with("connected to ") {
541            return VpnConnectionState::Connected;
542        }
543    }
544    VpnConnectionState::Unknown
545}
546
547fn parse_mullvad_state(text: &str) -> VpnConnectionState {
548    for line in text.lines().map(str::trim) {
549        let line = line.to_ascii_lowercase();
550        if line == "disconnected" {
551            return VpnConnectionState::Disconnected;
552        }
553        if line == "connected" || line.starts_with("connected to ") {
554            return VpnConnectionState::Connected;
555        }
556    }
557    VpnConnectionState::Unknown
558}
559
560fn parse_tailscale_state(text: &str) -> VpnConnectionState {
561    let Ok(value) = serde_json::from_str::<serde_json::Value>(text) else {
562        return VpnConnectionState::Unknown;
563    };
564    let backend = value.get("BackendState").and_then(|value| value.as_str());
565    let online = value
566        .get("Self")
567        .and_then(|value| value.get("Online"))
568        .and_then(|value| value.as_bool());
569    match (backend, online) {
570        (Some("Running"), Some(true)) => VpnConnectionState::Connected,
571        (Some("Stopped" | "NeedsLogin" | "NeedsMachineAuth"), _) | (_, Some(false)) => {
572            VpnConnectionState::Disconnected
573        }
574        _ => VpnConnectionState::Unknown,
575    }
576}
577
578fn parse_wireguard_interfaces(text: &str, expected_iface: &str) -> VpnConnectionState {
579    if text
580        .split_whitespace()
581        .any(|interface| interface == expected_iface)
582    {
583        VpnConnectionState::Connected
584    } else {
585        // `wg show interfaces` exits successfully and lists every live
586        // WireGuard interface. A successful empty/non-matching result is
587        // positive evidence that this particular interface is down.
588        VpnConnectionState::Disconnected
589    }
590}
591
592fn parse_netsh_state(text: &str) -> VpnConnectionState {
593    for line in text.lines() {
594        let mut fields = line.split_whitespace();
595        let (Some(admin), Some(state)) = (fields.next(), fields.next()) else {
596            continue;
597        };
598        let admin = admin.to_ascii_lowercase();
599        let state = state.to_ascii_lowercase();
600        if admin == "enabled" && state == "connected" {
601            return VpnConnectionState::Connected;
602        }
603        if matches!(admin.as_str(), "enabled" | "disabled")
604            && matches!(state.as_str(), "disconnected" | "connected")
605        {
606            return VpnConnectionState::Disconnected;
607        }
608    }
609    VpnConnectionState::Unknown
610}
611
612#[cfg(any(target_os = "linux", test))]
613fn parse_nmcli_state(text: &str) -> VpnConnectionState {
614    let state = text.trim().to_ascii_lowercase();
615    if state == "activated" || state.starts_with("100 (activated)") {
616        VpnConnectionState::Connected
617    } else if state == "deactivated" || state.starts_with("30 (disconnected)") {
618        VpnConnectionState::Disconnected
619    } else {
620        VpnConnectionState::Unknown
621    }
622}
623
624async fn read_state_command(
625    command: tokio::process::Command,
626    parser: fn(&str) -> VpnConnectionState,
627) -> VpnConnectionState {
628    read_state_text(command)
629        .await
630        .map_or(VpnConnectionState::Unknown, |text| parser(&text))
631}
632
633async fn read_state_text(command: tokio::process::Command) -> Option<String> {
634    let output = run_cmd(command, VPN_STATE_TIMEOUT).await.ok()?;
635    if !output.status.success() {
636        return None;
637    }
638    let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
639    if !output.stderr.is_empty() {
640        text.push('\n');
641        text.push_str(&String::from_utf8_lossy(&output.stderr));
642    }
643    Some(text)
644}
645
646async fn read_wireguard_state(iface: &str) -> VpnConnectionState {
647    let mut command = tokio::process::Command::new("wg");
648    command.args(["show", "interfaces"]);
649    read_state_text(command)
650        .await
651        .map_or(VpnConnectionState::Unknown, |text| {
652            parse_wireguard_interfaces(&text, iface)
653        })
654}
655
656fn vendor_program_name(program: &str) -> String {
657    std::path::Path::new(program)
658        .file_name()
659        .and_then(|name| name.to_str())
660        .unwrap_or(program)
661        .to_ascii_lowercase()
662}
663
664async fn read_vpn_state(vpn: &DisabledVpn) -> VpnConnectionState {
665    match &vpn.method {
666        DisableMethod::VendorCli(bin, reconnect_args) => {
667            let program = vendor_program_name(bin);
668            let (command, parser): (tokio::process::Command, fn(&str) -> VpnConnectionState) =
669                match program.as_str() {
670                    "nordvpn" | "nordvpn.exe" => {
671                        let mut command = tokio::process::Command::new(bin);
672                        command.arg("status");
673                        (command, parse_nordvpn_state)
674                    }
675                    "expressvpn" | "expressvpn.exe" => {
676                        let mut command = tokio::process::Command::new(bin);
677                        command.arg("status");
678                        (command, parse_expressvpn_state)
679                    }
680                    "mullvad" | "mullvad.exe" => {
681                        let mut command = tokio::process::Command::new(bin);
682                        command.arg("status");
683                        (command, parse_mullvad_state)
684                    }
685                    "tailscale" | "tailscale.exe" => {
686                        let mut command = tokio::process::Command::new(bin);
687                        command.args(["status", "--json"]);
688                        (command, parse_tailscale_state)
689                    }
690                    "wg-quick" | "wg-quick.exe" => {
691                        let Some(iface) = reconnect_args.last() else {
692                            return VpnConnectionState::Unknown;
693                        };
694                        return read_wireguard_state(iface).await;
695                    }
696                    _ => return VpnConnectionState::Unknown,
697                };
698            read_state_command(command, parser).await
699        }
700        DisableMethod::Netsh(iface) => {
701            let mut command = tokio::process::Command::new("netsh");
702            command.args(["interface", "show", "interface", &format!("name={iface}")]);
703            read_state_command(command, parse_netsh_state).await
704        }
705        #[cfg(target_os = "macos")]
706        DisableMethod::Scutil(service) => {
707            let mut command = tokio::process::Command::new("/usr/sbin/scutil");
708            command.args(["--nc", "status", service]);
709            read_state_command(command, parse_scutil_state).await
710        }
711        #[cfg(target_os = "linux")]
712        DisableMethod::Nmcli(conn) => {
713            let mut command = tokio::process::Command::new("nmcli");
714            command.args(["-g", "GENERAL.STATE", "connection", "show", conn]);
715            read_state_command(command, parse_nmcli_state).await
716        }
717        #[cfg(target_os = "linux")]
718        DisableMethod::WgQuick(iface) => read_wireguard_state(iface).await,
719    }
720}
721
722async fn verify_vpn_state(vpn: &DisabledVpn, expected: VpnConnectionState) -> bool {
723    for attempt in 0..VPN_STATE_ATTEMPTS {
724        if read_vpn_state(vpn).await == expected {
725            return true;
726        }
727        if attempt + 1 < VPN_STATE_ATTEMPTS {
728            tokio::time::sleep(state_retry_delay()).await;
729        }
730    }
731    false
732}
733
734async fn run_vpn_mutation(vpn: &DisabledVpn, reconnect: bool) -> bool {
735    let command = match &vpn.method {
736        DisableMethod::VendorCli(bin, reconnect_args) => {
737            let args: Vec<String> = if reconnect {
738                reconnect_args.clone()
739            } else {
740                reconnect_args
741                    .iter()
742                    .map(|arg| match arg.as_str() {
743                        "connect" => "disconnect".to_string(),
744                        "up" => "down".to_string(),
745                        _ => arg.clone(),
746                    })
747                    .collect()
748            };
749            let mut command = tokio::process::Command::new(bin);
750            command.args(args);
751            command
752        }
753        DisableMethod::Netsh(iface) => {
754            let mut command = tokio::process::Command::new("netsh");
755            command.args([
756                "interface",
757                "set",
758                "interface",
759                iface,
760                if reconnect { "enabled" } else { "disabled" },
761            ]);
762            command
763        }
764        #[cfg(target_os = "macos")]
765        DisableMethod::Scutil(service) => {
766            let mut command = tokio::process::Command::new("/usr/sbin/scutil");
767            command.args(["--nc", if reconnect { "start" } else { "stop" }, service]);
768            command
769        }
770        #[cfg(target_os = "linux")]
771        DisableMethod::Nmcli(conn) => {
772            let mut command = tokio::process::Command::new("nmcli");
773            command.args(["connection", if reconnect { "up" } else { "down" }, conn]);
774            command
775        }
776        #[cfg(target_os = "linux")]
777        DisableMethod::WgQuick(iface) => {
778            let mut command = tokio::process::Command::new("wg-quick");
779            command.args([if reconnect { "up" } else { "down" }, iface]);
780            command
781        }
782    };
783
784    matches!(
785        run_owned_mutation(command, VPN_MUTATION_TIMEOUT).await,
786        Ok(output) if output.status.success()
787    )
788}
789
790pub(super) async fn reenable_vpn(vpn: &DisabledVpn) -> bool {
791    run_vpn_mutation(vpn, true).await && verify_vpn_state(vpn, VpnConnectionState::Connected).await
792}
793
794pub(super) async fn redisable_vpn(vpn: &DisabledVpn) -> bool {
795    run_vpn_mutation(vpn, false).await
796        && verify_vpn_state(vpn, VpnConnectionState::Disconnected).await
797}
798
799/// Serialize VPN state for JSON output.
800pub fn vpn_json(disabled: &[DisabledVpn]) -> serde_json::Value {
801    if disabled.is_empty() {
802        return serde_json::json!(null);
803    }
804    let items: Vec<serde_json::Value> = disabled
805        .iter()
806        .map(|v| {
807            serde_json::json!({
808                "name": v.name,
809                "disabled": true,
810            })
811        })
812        .collect();
813    serde_json::json!(items)
814}
815
816#[cfg(test)]
817mod reconnect_verification_tests {
818    use super::*;
819
820    #[test]
821    fn scutil_requires_an_exact_connected_status_line() {
822        assert_eq!(
823            parse_scutil_state("Connected\nExtended Status <dictionary> {}"),
824            VpnConnectionState::Connected
825        );
826        assert_eq!(
827            parse_scutil_state("Disconnected\nExtended Status <dictionary> {}"),
828            VpnConnectionState::Disconnected
829        );
830        assert_eq!(
831            parse_scutil_state("Connecting\nExtended Status <dictionary> {}"),
832            VpnConnectionState::Unknown
833        );
834    }
835
836    #[test]
837    fn consumer_provider_parsers_do_not_confuse_disconnected_with_connected() {
838        assert_eq!(
839            parse_nordvpn_state("Status: Connected\nHostname: us1234"),
840            VpnConnectionState::Connected
841        );
842        assert_eq!(
843            parse_nordvpn_state("Status: Disconnected"),
844            VpnConnectionState::Disconnected
845        );
846        assert_eq!(
847            parse_expressvpn_state("Connected to USA - New York"),
848            VpnConnectionState::Connected
849        );
850        assert_eq!(
851            parse_expressvpn_state("Not connected"),
852            VpnConnectionState::Disconnected
853        );
854        assert_eq!(
855            parse_mullvad_state("Disconnected"),
856            VpnConnectionState::Disconnected
857        );
858    }
859
860    #[test]
861    fn tailscale_requires_running_and_online() {
862        assert_eq!(
863            parse_tailscale_state(r#"{"BackendState":"Running","Self":{"Online":true}}"#),
864            VpnConnectionState::Connected
865        );
866        assert_eq!(
867            parse_tailscale_state(r#"{"BackendState":"Running","Self":{"Online":false}}"#),
868            VpnConnectionState::Disconnected
869        );
870        assert_eq!(
871            parse_tailscale_state(r#"{"BackendState":"Running","Self":{}}"#),
872            VpnConnectionState::Unknown
873        );
874    }
875
876    #[test]
877    fn platform_state_parsers_are_fail_closed() {
878        let netsh = "Admin State    State          Type             Interface Name\n\
879                     Enabled        Connected      Dedicated        Test VPN";
880        assert_eq!(parse_netsh_state(netsh), VpnConnectionState::Connected);
881        assert_eq!(
882            parse_netsh_state(&netsh.replace("Connected", "Disconnected")),
883            VpnConnectionState::Disconnected
884        );
885        assert_eq!(
886            parse_nmcli_state("100 (activated)"),
887            VpnConnectionState::Connected
888        );
889        assert_eq!(
890            parse_nmcli_state("30 (disconnected)"),
891            VpnConnectionState::Disconnected
892        );
893        assert_eq!(
894            parse_nmcli_state("0 (unknown)"),
895            VpnConnectionState::Unknown
896        );
897        assert_eq!(
898            parse_wireguard_interfaces("wg0 wg-office", "wg0"),
899            VpnConnectionState::Connected
900        );
901        assert_eq!(
902            parse_wireguard_interfaces("wg-office", "wg0"),
903            VpnConnectionState::Disconnected
904        );
905    }
906
907    #[tokio::test]
908    async fn exit_zero_without_supported_state_readback_never_confirms_reconnect() {
909        let vpn = DisabledVpn {
910            name: "Unknown test provider".to_string(),
911            method: DisableMethod::VendorCli("rustc".to_string(), vec!["--version".to_string()]),
912        };
913
914        assert!(!reenable_vpn(&vpn).await);
915    }
916
917    #[cfg(unix)]
918    #[tokio::test]
919    async fn supported_provider_reconnect_requires_and_accepts_positive_readback() {
920        use std::os::unix::fs::PermissionsExt;
921        use std::sync::atomic::{AtomicU64, Ordering};
922
923        static NEXT_ID: AtomicU64 = AtomicU64::new(0);
924        let root = std::env::temp_dir().join(format!(
925            "nd300-vpn-readback-{}-{}",
926            std::process::id(),
927            NEXT_ID.fetch_add(1, Ordering::Relaxed)
928        ));
929        std::fs::create_dir_all(&root).expect("create fake provider directory");
930        let provider = root.join("nordvpn");
931        std::fs::write(
932            &provider,
933            "#!/bin/sh\nif [ \"$1\" = status ]; then printf 'Status: Connected\\n'; fi\n",
934        )
935        .expect("write fake provider");
936        std::fs::set_permissions(&provider, std::fs::Permissions::from_mode(0o700))
937            .expect("make fake provider executable");
938
939        let vpn = DisabledVpn {
940            name: "Fake NordVPN".to_string(),
941            method: DisableMethod::VendorCli(
942                provider.to_string_lossy().into_owned(),
943                vec!["connect".to_string()],
944            ),
945        };
946
947        assert!(reenable_vpn(&vpn).await);
948        std::fs::remove_dir_all(root).expect("remove fake provider directory");
949    }
950}
951
952#[cfg(test)]
953mod restore_registration_tests {
954    use super::*;
955    use crate::actions::fix::session::RestoreRegistry;
956
957    fn test_vpn() -> DisabledVpn {
958        DisabledVpn {
959            name: "Test VPN".to_string(),
960            method: DisableMethod::VendorCli("rustc".to_string(), vec!["--version".to_string()]),
961        }
962    }
963
964    #[tokio::test]
965    async fn spawned_nonzero_disconnect_keeps_reconnect_pending() {
966        let restore = RestoreRegistry::new();
967        let mut command = tokio::process::Command::new("rustc");
968        command.arg("--definitely-not-a-rustc-option");
969
970        let result = run_registered_disable(
971            test_vpn(),
972            command,
973            TIMEOUT_MEDIUM,
974            &Config::new().with_json(),
975            &restore,
976        )
977        .await;
978
979        assert!(matches!(result, DisableAttempt::Uncertain));
980        assert_eq!(restore.pending_count().await, 1);
981    }
982
983    #[tokio::test]
984    async fn spawn_failure_is_the_only_failure_that_resolves_reconnect() {
985        let restore = RestoreRegistry::new();
986        let command = tokio::process::Command::new("nd300-nonexistent-vpn-disconnector");
987
988        let result = run_registered_disable(
989            test_vpn(),
990            command,
991            TIMEOUT_MEDIUM,
992            &Config::new().with_json(),
993            &restore,
994        )
995        .await;
996
997        assert!(matches!(result, DisableAttempt::DefinitelyNotRun));
998        assert_eq!(restore.pending_count().await, 0);
999    }
1000
1001    #[tokio::test]
1002    async fn exit_zero_with_unknown_reconnect_state_stays_pending_after_drain() {
1003        use crate::actions::fix::session::RestoreOp;
1004        use std::sync::Arc;
1005
1006        let restore = RestoreRegistry::new();
1007        let vpn = DisabledVpn {
1008            name: "Unknown test provider".to_string(),
1009            method: DisableMethod::VendorCli("rustc".to_string(), vec!["--version".to_string()]),
1010        };
1011        restore
1012            .register(RestoreOp::ReEnableVpn(Arc::new(vpn)))
1013            .await;
1014
1015        let failures = restore.drain().await;
1016
1017        assert_eq!(failures.len(), 1, "failures: {failures:?}");
1018        assert_eq!(restore.pending_count().await, 1);
1019    }
1020
1021    #[cfg(unix)]
1022    #[tokio::test]
1023    async fn timed_out_disconnect_is_killed_reaped_and_keeps_reconnect_pending() {
1024        let restore = RestoreRegistry::new();
1025        let mut command = tokio::process::Command::new("sh");
1026        command.args(["-c", "sleep 30"]);
1027
1028        let result = run_registered_disable(
1029            test_vpn(),
1030            command,
1031            std::time::Duration::from_millis(1),
1032            &Config::new().with_json(),
1033            &restore,
1034        )
1035        .await;
1036
1037        assert!(matches!(result, DisableAttempt::Uncertain));
1038        assert_eq!(restore.pending_count().await, 1);
1039    }
1040
1041    #[cfg(unix)]
1042    #[tokio::test]
1043    async fn cancelled_disconnect_is_terminated_before_restore_cleanup_can_race_it() {
1044        let root =
1045            std::env::temp_dir().join(format!("nd300-vpn-cancel-test-{}", std::process::id()));
1046        let started = root.with_extension("started");
1047        let late = root.with_extension("late");
1048        let _ = std::fs::remove_file(&started);
1049        let _ = std::fs::remove_file(&late);
1050
1051        let restore = RestoreRegistry::new();
1052        let task_restore = restore.clone();
1053        let started_arg = started.to_string_lossy().into_owned();
1054        let late_arg = late.to_string_lossy().into_owned();
1055        let task = tokio::spawn(async move {
1056            let mut command = tokio::process::Command::new("sh");
1057            command.args([
1058                "-c",
1059                "printf started > \"$1\"; sleep 1; printf late > \"$2\"",
1060                "nd300-vpn-cancel-test",
1061                &started_arg,
1062                &late_arg,
1063            ]);
1064            run_registered_disable(
1065                test_vpn(),
1066                command,
1067                TIMEOUT_MEDIUM,
1068                &Config::new().with_json(),
1069                &task_restore,
1070            )
1071            .await
1072        });
1073
1074        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
1075        while !started.exists() && tokio::time::Instant::now() < deadline {
1076            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1077        }
1078        assert!(
1079            started.exists(),
1080            "disconnect child never reached its test body"
1081        );
1082        task.abort();
1083        let _ = task.await;
1084
1085        // Start cleanup immediately. The reconnect mutation uses the same
1086        // serialized owner, so it cannot overtake the cancelled disconnect's
1087        // kill-and-reap supervisor.
1088        let failures = restore.drain().await;
1089        assert_eq!(failures.len(), 1, "failures: {failures:?}");
1090        tokio::time::sleep(std::time::Duration::from_millis(1200)).await;
1091
1092        assert!(
1093            !late.exists(),
1094            "cancelled disconnect child survived long enough to mutate state after cleanup"
1095        );
1096        assert_eq!(restore.pending_count().await, 1);
1097        let _ = std::fs::remove_file(started);
1098        let _ = std::fs::remove_file(late);
1099    }
1100
1101    #[test]
1102    fn only_confirmed_or_intentional_reenable_dispositions_resolve_restore() {
1103        assert!(ReenableDisposition::IntentionallyLeftDisabled.resolves_restore());
1104        assert!(ReenableDisposition::Reenabled.resolves_restore());
1105        assert!(ReenableDisposition::IntentionallyRedisabled.resolves_restore());
1106        assert!(!ReenableDisposition::ReconnectFailed.resolves_restore());
1107        assert!(!ReenableDisposition::RedisableFailed.resolves_restore());
1108    }
1109}