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#[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>), Netsh(String), #[cfg(target_os = "macos")]
26 Scutil(String), #[cfg(target_os = "linux")]
28 Nmcli(String), #[cfg(target_os = "linux")]
30 WgQuick(String), }
32
33const 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
47fn 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
77fn 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 if lower.contains("nord") || vendor_lower.contains("nord") {
84 return Some(("nordvpn".to_string(), vec!["disconnect".to_string()]));
85 }
86 if lower.contains("expressvpn") || vendor_lower.contains("expressvpn") {
88 return Some(("expressvpn".to_string(), vec!["disconnect".to_string()]));
89 }
90 if lower.contains("mullvad") || vendor_lower.contains("mullvad") {
92 return Some(("mullvad".to_string(), vec!["disconnect".to_string()]));
93 }
94 if lower.contains("tailscale") || vendor_lower.contains("tailscale") {
96 return Some(("tailscale".to_string(), vec!["down".to_string()]));
97 }
98 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 if lower.contains("cisco")
110 || vendor_lower.contains("cisco")
111 || adapter.adapter_type.contains("Cisco")
112 {
113 #[cfg(windows)]
114 {
115 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#[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 DefinitelyNotRun,
151 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 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 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 restore.mark_resolved(restore_token).await;
211 DisableAttempt::DefinitelyNotRun
212 }
213 DisconnectRun::Completed(_) | DisconnectRun::TimedOut => DisableAttempt::Uncertain,
217 }
218}
219
220pub 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 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
423pub 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 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 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
799pub 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 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}