1use crate::config::Config;
2use crate::diagnostics::vpn::{self, VpnAdapter};
3use crate::render::progress::create_spinner;
4
5use super::cmd::{run_cmd, TIMEOUT_MEDIUM, TIMEOUT_SLOW};
6use super::{print_step_fail, print_step_ok, warn_icon};
7use crate::actions::{is_interactive, prompt_yes_no};
8
9#[derive(Debug, Clone)]
14pub struct DisabledVpn {
15 pub name: String,
16 pub method: DisableMethod,
17}
18
19#[derive(Debug, Clone)]
20pub enum DisableMethod {
21 VendorCli(String, Vec<String>), Netsh(String), #[cfg(target_os = "macos")]
24 Scutil(String), #[cfg(target_os = "linux")]
26 Nmcli(String), #[cfg(target_os = "linux")]
28 WgQuick(String), }
30
31fn is_enterprise_vpn(adapter: &VpnAdapter) -> bool {
33 let lower = adapter.name.to_lowercase();
34 let vendor_lower = adapter.vendor.as_deref().unwrap_or("").to_lowercase();
35 let type_lower = adapter.adapter_type.to_lowercase();
36
37 let enterprise_patterns = [
38 "cisco",
39 "anyconnect",
40 "globalprotect",
41 "palo alto",
42 "zscaler",
43 "forticlient",
44 "fortinet",
45 "pulse secure",
46 "juniper",
47 "f5 ",
48 "big-ip",
49 "checkpoint",
50 "corp",
51 "enterprise",
52 "mdm",
53 "company",
54 ];
55
56 enterprise_patterns
57 .iter()
58 .any(|p| lower.contains(p) || vendor_lower.contains(p) || type_lower.contains(p))
59}
60
61fn find_vendor_cli(adapter: &VpnAdapter) -> Option<(String, Vec<String>)> {
63 let lower = adapter.name.to_lowercase();
64 let vendor_lower = adapter.vendor.as_deref().unwrap_or("").to_lowercase();
65
66 if lower.contains("nord") || vendor_lower.contains("nord") {
68 return Some(("nordvpn".to_string(), vec!["disconnect".to_string()]));
69 }
70 if lower.contains("expressvpn") || vendor_lower.contains("expressvpn") {
72 return Some(("expressvpn".to_string(), vec!["disconnect".to_string()]));
73 }
74 if lower.contains("mullvad") || vendor_lower.contains("mullvad") {
76 return Some(("mullvad".to_string(), vec!["disconnect".to_string()]));
77 }
78 if lower.contains("tailscale") || vendor_lower.contains("tailscale") {
80 return Some(("tailscale".to_string(), vec!["down".to_string()]));
81 }
82 if adapter.adapter_type == "WireGuard" {
84 if let Some(ref iface) = adapter.interface_name {
85 return Some((
86 "wg-quick".to_string(),
87 vec!["down".to_string(), iface.clone()],
88 ));
89 }
90 }
91
92 if lower.contains("cisco")
94 || vendor_lower.contains("cisco")
95 || adapter.adapter_type.contains("Cisco")
96 {
97 #[cfg(windows)]
98 {
99 let paths = [
101 r"C:\Program Files (x86)\Cisco\Cisco AnyConnect Secure Mobility Client\vpncli.exe",
102 r"C:\Program Files\Cisco\Cisco AnyConnect Secure Mobility Client\vpncli.exe",
103 ];
104 for path in &paths {
105 if std::path::Path::new(path).exists() {
106 return Some((path.to_string(), vec!["disconnect".to_string()]));
107 }
108 }
109 }
110 #[cfg(unix)]
111 {
112 return Some((
113 "/opt/cisco/anyconnect/bin/vpn".to_string(),
114 vec!["disconnect".to_string()],
115 ));
116 }
117 }
118
119 None
120}
121
122pub async fn detect_and_disable(config: &Config) -> Vec<DisabledVpn> {
125 let mut disabled = Vec::new();
126
127 let spinner = create_spinner("Detecting VPN connections...");
128 let vpns = vpn::collect().await;
129 spinner.finish_and_clear();
130
131 let vpns = match vpns {
132 Some(v) => v,
133 None => return disabled,
134 };
135
136 let connected: Vec<&VpnAdapter> = vpns.iter().filter(|v| v.status == "Connected").collect();
137 if connected.is_empty() {
138 return disabled;
139 }
140
141 for adapter in connected {
142 if is_enterprise_vpn(adapter) {
143 if is_interactive(config) {
144 println!(
145 " {} Corporate VPN detected: {} — skipping (managed by your organization)",
146 warn_icon(config),
147 crate::render::color::cyan(&adapter.name, config),
148 );
149 }
150 continue;
151 }
152
153 let do_disable = if is_interactive(config) {
154 let prompt = format!(
155 " VPN detected: {} ({}). VPN connections can interfere with network fixes. Disable? (y/N): ",
156 adapter.name, adapter.adapter_type,
157 );
158 prompt_yes_no(&prompt)
159 } else {
160 false
162 };
163
164 if !do_disable {
165 continue;
166 }
167
168 if let Some((bin, args)) = find_vendor_cli(adapter) {
170 let spinner = create_spinner(&format!("Disabling {}...", adapter.name));
171 let mut cmd = tokio::process::Command::new(&bin);
172 cmd.args(&args);
173 let result = run_cmd(cmd, TIMEOUT_MEDIUM).await;
174 spinner.finish_and_clear();
175
176 if let Ok(output) = result {
177 if output.status.success() {
178 if is_interactive(config) {
179 print_step_ok(&format!("Disabled {}", adapter.name), config);
180 }
181 disabled.push(DisabledVpn {
182 name: adapter.name.clone(),
183 method: DisableMethod::VendorCli(
184 bin,
185 args.iter()
186 .map(|a| a.replace("disconnect", "connect").replace("down", "up"))
187 .collect(),
188 ),
189 });
190 continue;
191 }
192 }
193 }
194
195 let spinner = create_spinner(&format!("Disabling {}...", adapter.name));
197 let fallback_result = disable_adapter_fallback(adapter, config).await;
198 spinner.finish_and_clear();
199 match fallback_result {
200 Some(d) => disabled.push(d),
201 None => {
202 if is_interactive(config) {
203 print_step_fail(
204 &format!("Could not disable {}", adapter.name),
205 "Try disconnecting manually before running fix",
206 config,
207 );
208 }
209 }
210 }
211 }
212
213 if !disabled.is_empty() {
214 let spinner = create_spinner("Waiting for VPN disconnect...");
216 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
217 spinner.finish_and_clear();
218 }
219
220 disabled
221}
222
223async fn disable_adapter_fallback(adapter: &VpnAdapter, config: &Config) -> Option<DisabledVpn> {
224 #[cfg(windows)]
225 {
226 if let Some(ref iface) = adapter.interface_name {
228 let mut cmd = tokio::process::Command::new("netsh");
229 cmd.args(["interface", "set", "interface", iface, "disabled"]);
230 if let Ok(output) = run_cmd(cmd, TIMEOUT_SLOW).await {
231 if output.status.success() {
232 if is_interactive(config) {
233 print_step_ok(&format!("Disabled {}", adapter.name), config);
234 }
235 return Some(DisabledVpn {
236 name: adapter.name.clone(),
237 method: DisableMethod::Netsh(iface.clone()),
238 });
239 }
240 }
241 }
242 let _ = config;
243 None
244 }
245
246 #[cfg(target_os = "macos")]
247 {
248 let mut cmd = tokio::process::Command::new("scutil");
250 cmd.args(["--nc", "stop", &adapter.name]);
251 if let Ok(output) = run_cmd(cmd, TIMEOUT_MEDIUM).await {
252 if output.status.success() {
253 if is_interactive(config) {
254 print_step_ok(&format!("Disabled {}", adapter.name), config);
255 }
256 return Some(DisabledVpn {
257 name: adapter.name.clone(),
258 method: DisableMethod::Scutil(adapter.name.clone()),
259 });
260 }
261 }
262 None
263 }
264
265 #[cfg(target_os = "linux")]
266 {
267 let mut nmcli_cmd = tokio::process::Command::new("nmcli");
269 nmcli_cmd.args(["connection", "down", &adapter.name]);
270 if let Ok(output) = run_cmd(nmcli_cmd, TIMEOUT_MEDIUM).await {
271 if output.status.success() {
272 if is_interactive(config) {
273 print_step_ok(&format!("Disabled {}", adapter.name), config);
274 }
275 return Some(DisabledVpn {
276 name: adapter.name.clone(),
277 method: DisableMethod::Nmcli(adapter.name.clone()),
278 });
279 }
280 }
281 if let Some(ref iface) = adapter.interface_name {
283 let mut wg_cmd = tokio::process::Command::new("wg-quick");
284 wg_cmd.args(["down", iface]);
285 if let Ok(output) = run_cmd(wg_cmd, TIMEOUT_MEDIUM).await {
286 if output.status.success() {
287 if is_interactive(config) {
288 print_step_ok(&format!("Disabled {}", adapter.name), config);
289 }
290 return Some(DisabledVpn {
291 name: adapter.name.clone(),
292 method: DisableMethod::WgQuick(iface.clone()),
293 });
294 }
295 }
296 }
297 let _ = config;
298 None
299 }
300}
301
302pub async fn offer_reenable(disabled: &[DisabledVpn], config: &Config) {
304 if disabled.is_empty() {
305 return;
306 }
307
308 for vpn in disabled {
309 let do_reenable = if is_interactive(config) {
310 let prompt = format!(" Re-enable {}? (y/N): ", vpn.name);
311 prompt_yes_no(&prompt)
312 } else {
313 false
315 };
316
317 if !do_reenable {
318 continue;
319 }
320
321 let spinner = create_spinner(&format!("Re-enabling {}...", vpn.name));
322 let success = reenable_vpn(vpn).await;
323 spinner.finish_and_clear();
324
325 if success {
326 if is_interactive(config) {
327 print_step_ok(&format!("Re-enabled {}", vpn.name), config);
328 }
329 let spinner = create_spinner("Verifying connectivity...");
334 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
335 let mut connected = super::connectivity::check_connectivity().await;
336 if !connected {
337 spinner.set_message("Confirming connectivity loss...");
338 tokio::time::sleep(std::time::Duration::from_secs(3)).await;
339 connected = super::connectivity::check_connectivity().await;
340 }
341 spinner.finish_and_clear();
342
343 if !connected {
344 let spinner = create_spinner(&format!("Disabling {} again...", vpn.name));
346 let _ = redisable_vpn(vpn).await;
347 spinner.finish_and_clear();
348
349 if is_interactive(config) {
350 println!(
351 " {} Re-enabling {} broke connectivity. The VPN has been disabled again.",
352 warn_icon(config),
353 crate::render::color::cyan(&vpn.name, config),
354 );
355 println!(
356 " {}",
357 crate::render::color::dim(
358 "Check your VPN configuration or contact your VPN provider.",
359 config
360 ),
361 );
362 }
363 }
364 } else if is_interactive(config) {
365 print_step_fail(
366 &format!("Failed to re-enable {}", vpn.name),
367 "Try reconnecting manually",
368 config,
369 );
370 }
371 }
372}
373
374pub(super) async fn reenable_vpn(vpn: &DisabledVpn) -> bool {
375 match &vpn.method {
376 DisableMethod::VendorCli(bin, reconnect_args) => {
377 let mut cmd = tokio::process::Command::new(bin);
378 cmd.args(reconnect_args);
379 if let Ok(output) = run_cmd(cmd, TIMEOUT_MEDIUM).await {
380 return output.status.success();
381 }
382 false
383 }
384 DisableMethod::Netsh(iface) => {
385 let mut cmd = tokio::process::Command::new("netsh");
386 cmd.args(["interface", "set", "interface", iface, "enabled"]);
387 if let Ok(output) = run_cmd(cmd, TIMEOUT_SLOW).await {
388 return output.status.success();
389 }
390 false
391 }
392 #[cfg(target_os = "macos")]
393 DisableMethod::Scutil(service) => {
394 let mut cmd = tokio::process::Command::new("scutil");
395 cmd.args(["--nc", "start", service]);
396 if let Ok(output) = run_cmd(cmd, TIMEOUT_MEDIUM).await {
397 return output.status.success();
398 }
399 false
400 }
401 #[cfg(target_os = "linux")]
402 DisableMethod::Nmcli(conn) => {
403 let mut cmd = tokio::process::Command::new("nmcli");
404 cmd.args(["connection", "up", conn]);
405 if let Ok(output) = run_cmd(cmd, TIMEOUT_MEDIUM).await {
406 return output.status.success();
407 }
408 false
409 }
410 #[cfg(target_os = "linux")]
411 DisableMethod::WgQuick(iface) => {
412 let mut cmd = tokio::process::Command::new("wg-quick");
413 cmd.args(["up", iface]);
414 if let Ok(output) = run_cmd(cmd, TIMEOUT_MEDIUM).await {
415 return output.status.success();
416 }
417 false
418 }
419 }
420}
421
422pub(super) async fn redisable_vpn(vpn: &DisabledVpn) -> bool {
423 match &vpn.method {
424 DisableMethod::VendorCli(bin, reconnect_args) => {
425 let disconnect_args: Vec<String> = reconnect_args
426 .iter()
427 .map(|a| a.replace("connect", "disconnect").replace("up", "down"))
428 .collect();
429 let mut cmd = tokio::process::Command::new(bin);
430 cmd.args(&disconnect_args);
431 if let Ok(output) = run_cmd(cmd, TIMEOUT_MEDIUM).await {
432 return output.status.success();
433 }
434 false
435 }
436 DisableMethod::Netsh(iface) => {
437 let mut cmd = tokio::process::Command::new("netsh");
438 cmd.args(["interface", "set", "interface", iface, "disabled"]);
439 if let Ok(output) = run_cmd(cmd, TIMEOUT_SLOW).await {
440 return output.status.success();
441 }
442 false
443 }
444 #[cfg(target_os = "macos")]
445 DisableMethod::Scutil(service) => {
446 let mut cmd = tokio::process::Command::new("scutil");
447 cmd.args(["--nc", "stop", service]);
448 if let Ok(output) = run_cmd(cmd, TIMEOUT_MEDIUM).await {
449 return output.status.success();
450 }
451 false
452 }
453 #[cfg(target_os = "linux")]
454 DisableMethod::Nmcli(conn) => {
455 let mut cmd = tokio::process::Command::new("nmcli");
456 cmd.args(["connection", "down", conn]);
457 if let Ok(output) = run_cmd(cmd, TIMEOUT_MEDIUM).await {
458 return output.status.success();
459 }
460 false
461 }
462 #[cfg(target_os = "linux")]
463 DisableMethod::WgQuick(iface) => {
464 let mut cmd = tokio::process::Command::new("wg-quick");
465 cmd.args(["down", iface]);
466 if let Ok(output) = run_cmd(cmd, TIMEOUT_MEDIUM).await {
467 return output.status.success();
468 }
469 false
470 }
471 }
472}
473
474pub fn vpn_json(disabled: &[DisabledVpn]) -> serde_json::Value {
476 if disabled.is_empty() {
477 return serde_json::json!(null);
478 }
479 let items: Vec<serde_json::Value> = disabled
480 .iter()
481 .map(|v| {
482 serde_json::json!({
483 "name": v.name,
484 "disabled": true,
485 })
486 })
487 .collect();
488 serde_json::json!(items)
489}