1use std::time::Duration;
8
9use crate::actions::flush_dns_platform;
10use crate::config::Config;
11
12use super::adapters;
13use super::arp;
14use super::cmd::CmdOutcome;
15use super::dhcp;
16use super::dns::{self, DnsProvider};
17use super::session::{RestoreOp, RestoreRegistry};
18use super::stages;
19use super::vpn;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum DiagnosticKey {
25 Adapters,
26 Interfaces,
27 Gateway,
28 Dns,
29 PublicIp,
30 Latency,
31 Ports,
32 Speed,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Cost {
37 Cheap,
38 Medium,
39 Expensive,
40}
41
42impl Cost {
43 pub fn rank(self) -> u8 {
44 match self {
45 Cost::Cheap => 0,
46 Cost::Medium => 1,
47 Cost::Expensive => 2,
48 }
49 }
50}
51
52#[derive(Debug, Clone)]
53pub enum Risk {
54 Low,
55 Medium,
56 High(RiskExplanation),
61}
62
63impl Risk {
64 pub fn is_high(&self) -> bool {
65 matches!(self, Risk::High(_))
66 }
67 pub fn rank(&self) -> u8 {
68 match self {
69 Risk::Low => 0,
70 Risk::Medium => 1,
71 Risk::High(_) => 2,
72 }
73 }
74}
75
76#[derive(Debug, Clone, Copy)]
77pub enum Reversibility {
78 FullyReversible,
80 RebootToFullyRevert,
82 NotReversible,
84}
85
86impl Reversibility {
87 pub fn label(self) -> &'static str {
88 match self {
89 Reversibility::FullyReversible => "fully reversible",
90 Reversibility::RebootToFullyRevert => "requires reboot to fully revert",
91 Reversibility::NotReversible => "not reversible",
92 }
93 }
94}
95
96#[derive(Debug, Clone)]
100pub struct RiskExplanation {
101 pub what: &'static str,
104 pub why: &'static str,
107 pub side_effects: &'static [&'static str],
109 pub reversible: Reversibility,
111 pub typical_duration: &'static str,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
116pub enum ActionId {
117 FlushDns,
118 SetDnsCloudflare,
119 SetDnsAutomatic,
120 FlushArp,
121 RestartNetworkServices,
122 RenewDhcp,
123 DisableConsumerVpns,
124 BounceInterface,
125 DeepStackReset,
130}
131
132#[derive(Debug, Clone)]
133pub struct Action {
134 pub id: ActionId,
135 pub label: &'static str,
138 pub one_line_why: &'static str,
140 pub targets: &'static [DiagnosticKey],
143 pub cost: Cost,
144 pub risk: Risk,
145 pub max_attempts: u8,
149 pub stabilization: Duration,
153}
154
155#[derive(Debug, Clone)]
156pub struct ActionOutcome {
157 pub ok: bool,
158 pub message: String,
159 pub cmd_outcomes: Vec<CmdOutcome>,
164 pub fatal_environment_change: bool,
168}
169
170impl ActionOutcome {
171 pub fn ok(msg: impl Into<String>) -> Self {
172 Self {
173 ok: true,
174 message: msg.into(),
175 cmd_outcomes: Vec::new(),
176 fatal_environment_change: false,
177 }
178 }
179 pub fn fail(msg: impl Into<String>) -> Self {
180 Self {
181 ok: false,
182 message: msg.into(),
183 cmd_outcomes: Vec::new(),
184 fatal_environment_change: false,
185 }
186 }
187 pub fn from_result(r: Result<String, String>) -> Self {
188 match r {
189 Ok(msg) => Self::ok(msg),
190 Err(msg) => Self::fail(msg),
191 }
192 }
193 pub fn with_fatal_env_change(mut self) -> Self {
194 self.fatal_environment_change = true;
195 self
196 }
197}
198
199impl Action {
200 pub async fn apply(&self, config: &Config, restore: &RestoreRegistry) -> ActionOutcome {
210 match self.id {
211 ActionId::FlushDns => apply_flush_dns().await,
212 ActionId::SetDnsCloudflare => apply_set_dns(DnsProvider::Cloudflare, restore).await,
213 ActionId::SetDnsAutomatic => apply_set_dns(DnsProvider::Automatic, restore).await,
214 ActionId::FlushArp => apply_flush_arp().await,
215 ActionId::RestartNetworkServices => apply_restart_services().await,
216 ActionId::RenewDhcp => apply_renew_dhcp().await,
217 ActionId::DisableConsumerVpns => apply_disable_consumer_vpns(config, restore).await,
218 ActionId::BounceInterface => apply_bounce_interface(restore).await,
219 ActionId::DeepStackReset => apply_deep_stack_reset(config, restore).await,
220 }
221 }
222}
223
224async fn apply_flush_dns() -> ActionOutcome {
227 ActionOutcome::from_result(flush_dns_platform().await)
228}
229
230async fn apply_set_dns(provider: DnsProvider, restore: &RestoreRegistry) -> ActionOutcome {
231 let iface = match adapters::detect_default_interface().await {
232 Some(i) => i,
233 None => return ActionOutcome::fail("Could not detect a default network interface"),
234 };
235 let service_name = service_name_for(&iface).await;
236 #[cfg(target_os = "macos")]
237 {
238 let snapshot = match dns::capture_macos_dns_snapshot(&service_name).await {
239 Ok(snapshot) => std::sync::Arc::new(snapshot),
240 Err(error) => {
241 return ActionOutcome::fail(format!(
242 "Refused to change DNS because the exact prior DNS/search-domain state could not be captured: {error}"
243 ));
244 }
245 };
246 let token = restore
247 .register(RestoreOp::RestoreMacosDns(std::sync::Arc::clone(&snapshot)))
248 .await;
249 let result = dns::set_dns_servers(&iface, &service_name, provider).await;
250 match result {
251 Ok(message) => {
252 restore.mark_resolved(token).await;
253 ActionOutcome::ok(message)
254 }
255 Err(error) => match dns::restore_macos_dns_snapshot(&snapshot).await {
256 Ok(()) => {
257 restore.mark_resolved(token).await;
258 ActionOutcome::fail(format!(
259 "{error}; exact prior DNS/search-domain state restored"
260 ))
261 }
262 Err(restore_error) => ActionOutcome::fail(format!(
263 "{error}; exact DNS/search-domain restoration also failed ({restore_error}); nd300 will retry during cleanup"
264 )),
265 },
266 }
267 }
268
269 #[cfg(not(target_os = "macos"))]
270 {
271 let _ = restore;
272 ActionOutcome::from_result(dns::set_dns_servers(&iface, &service_name, provider).await)
273 }
274}
275
276async fn apply_flush_arp() -> ActionOutcome {
277 ActionOutcome::from_result(arp::flush_arp().await)
278}
279
280async fn apply_restart_services() -> ActionOutcome {
281 ActionOutcome::from_result(stages::restart_services().await)
282}
283
284async fn apply_renew_dhcp() -> ActionOutcome {
285 if let Some(iface) = adapters::detect_default_interface().await {
286 ActionOutcome::from_result(adapters::renew_dhcp_on_interface(&iface).await)
287 } else {
288 ActionOutcome::from_result(dhcp::renew_dhcp().await)
289 }
290}
291
292async fn apply_disable_consumer_vpns(config: &Config, restore: &RestoreRegistry) -> ActionOutcome {
293 if !crate::actions::is_interactive(config) {
294 return ActionOutcome::fail(
295 "Skipped: disabling VPNs requires an interactive session so they can be re-enabled safely.",
296 );
297 }
298
299 let batch = vpn::detect_and_disable(config, restore).await;
300 if batch.disabled.is_empty() && batch.uncertain_attempts == 0 {
301 return ActionOutcome::ok("No consumer VPNs were active");
302 }
303 if batch.disabled.is_empty() {
304 let cleanup_failures = restore.drain().await;
305 let cleanup = if cleanup_failures.is_empty() {
306 "conservative reconnect cleanup completed".to_string()
307 } else {
308 format!(
309 "reconnect cleanup needs attention: {}",
310 cleanup_failures.join("; ")
311 )
312 };
313 return ActionOutcome::fail(format!(
314 "Could not confirm {} VPN disconnect attempt(s); {}",
315 batch.uncertain_attempts, cleanup
316 ))
317 .with_fatal_env_change();
318 }
319
320 let disabled: Vec<vpn::DisabledVpn> =
321 batch.disabled.iter().map(|item| item.vpn.clone()).collect();
322
323 let names: Vec<String> = disabled.iter().map(|v| v.name.clone()).collect();
324
325 let dispositions = vpn::offer_reenable(&disabled, config).await;
330 let mut reconnect_pending = 0_usize;
331 for (item, disposition) in batch.disabled.into_iter().zip(dispositions) {
332 if disposition.resolves_restore() {
333 restore.mark_resolved(item.restore_token).await;
334 } else {
335 reconnect_pending += 1;
336 }
337 }
338
339 let cleanup_pending = batch.uncertain_attempts + reconnect_pending;
340 let uncertain = if cleanup_pending == 0 {
341 String::new()
342 } else {
343 let cleanup_failures = restore.drain().await;
344 let cleanup = if cleanup_failures.is_empty() {
345 "reconnect cleanup completed".to_string()
346 } else {
347 format!(
348 "reconnect cleanup needs attention: {}",
349 cleanup_failures.join("; ")
350 )
351 };
352 format!(
353 "; {} uncertain/failed reconnect state(s), {}",
354 cleanup_pending, cleanup
355 )
356 };
357 ActionOutcome::ok(format!(
358 "Disabled consumer VPNs: {}{}",
359 names.join(", "),
360 uncertain
361 ))
362 .with_fatal_env_change()
363}
364
365async fn apply_bounce_interface(restore: &RestoreRegistry) -> ActionOutcome {
366 let iface = match adapters::detect_default_interface().await {
367 Some(i) => i,
368 None => return ActionOutcome::fail("Could not detect a default network interface"),
369 };
370
371 #[cfg(target_os = "macos")]
372 if stages::detect_macos_service(&iface).await.is_none() {
373 return ActionOutcome::fail(format!(
374 "Refused to bounce macOS interface {} because it is not an unambiguous physical network service. Tunnel/virtual interfaces such as utun are never bounced.",
375 iface
376 ));
377 }
378
379 let token = restore
382 .register(RestoreOp::ReEnableInterface {
383 iface: iface.clone(),
384 })
385 .await;
386
387 if let Err(e) = stages::disable_interface(&iface).await {
388 let cleanup_failures = restore.drain().await;
393 let cleanup = if cleanup_failures.is_empty() {
394 "conservative re-enable cleanup completed".to_string()
395 } else {
396 format!(
397 "re-enable cleanup needs attention: {}",
398 cleanup_failures.join("; ")
399 )
400 };
401 return ActionOutcome::fail(format!(
402 "Disable {} did not complete cleanly: {}. {}.",
403 iface, e, cleanup
404 ))
405 .with_fatal_env_change();
406 }
407
408 tokio::time::sleep(Duration::from_secs(3)).await;
409
410 if let Err(first_err) = stages::restore_interface(&iface).await {
413 tokio::time::sleep(Duration::from_secs(2)).await;
414 if let Err(retry_err) = stages::restore_interface(&iface).await {
415 let cleanup_failures = restore.drain().await;
418 let cleanup = if cleanup_failures.is_empty() {
419 "cleanup re-enabled the adapter".to_string()
420 } else {
421 format!(
422 "cleanup still needs attention: {}",
423 cleanup_failures.join("; ")
424 )
425 };
426 let cmd_hint = reenable_command_hint(&iface);
427 return ActionOutcome::fail(format!(
428 "Network adapter \"{}\" re-enable failed twice ({}; retry: {}); {}. \
429 If you still have no connection, run: {}",
430 iface, first_err, retry_err, cleanup, cmd_hint
431 ))
432 .with_fatal_env_change();
433 }
434 }
435
436 restore.mark_resolved(token).await;
438 ActionOutcome::ok(format!("{} bounced (disable → 3s wait → re-enable)", iface))
439 .with_fatal_env_change()
440}
441
442fn reenable_command_hint(iface: &str) -> String {
445 #[cfg(windows)]
446 {
447 format!("netsh interface set interface \"{}\" enabled", iface)
448 }
449 #[cfg(target_os = "macos")]
450 {
451 format!(
452 "networksetup -setairportpower {} on (Wi-Fi) or ifconfig {} up (wired)",
453 iface, iface
454 )
455 }
456 #[cfg(target_os = "linux")]
457 {
458 format!("sudo ip link set {} up", iface)
459 }
460}
461
462async fn apply_deep_stack_reset(config: &Config, restore: &RestoreRegistry) -> ActionOutcome {
463 #[cfg(target_os = "macos")]
464 let saved_ssid = None;
465 #[cfg(not(target_os = "macos"))]
466 let saved_ssid = super::wifi::capture_current_ssid().await;
467 match stages::platform_stage3(config, &saved_ssid, restore).await {
468 Ok(steps) => {
469 if steps.is_empty() {
470 ActionOutcome::fail("Stack reset attempted but no steps succeeded")
471 .with_fatal_env_change()
472 } else {
473 ActionOutcome::ok(format!("Stack reset: {}", steps.join("; ")))
474 .with_fatal_env_change()
475 }
476 }
477 Err(e) => ActionOutcome::fail(e).with_fatal_env_change(),
478 }
479}
480
481#[cfg(target_os = "macos")]
482async fn service_name_for(iface: &str) -> String {
483 if let Some(svc) = stages::detect_macos_service(iface).await {
486 svc
487 } else {
488 iface.to_string()
489 }
490}
491
492#[cfg(not(target_os = "macos"))]
493async fn service_name_for(iface: &str) -> String {
494 iface.to_string()
495}
496
497pub fn all_actions() -> Vec<Action> {
503 let mut actions = vec![
504 Action {
505 id: ActionId::FlushDns,
506 label: "Flush the DNS cache",
507 one_line_why: "Clears stale DNS records that often cause resolution failures.",
508 targets: &[DiagnosticKey::Dns],
509 cost: Cost::Cheap,
510 risk: Risk::Low,
511 max_attempts: 2,
512 stabilization: Duration::from_secs(1),
513 },
514 Action {
515 id: ActionId::SetDnsCloudflare,
516 label: "Switch DNS to Cloudflare (1.1.1.1)",
517 one_line_why: "Bypasses a broken or filtered DNS server provided by your network.",
518 targets: &[DiagnosticKey::Dns],
519 cost: Cost::Cheap,
520 risk: Risk::Low,
521 max_attempts: 1,
522 stabilization: Duration::from_secs(2),
523 },
524 Action {
525 id: ActionId::SetDnsAutomatic,
526 label: "Reset DNS to your router's defaults (DHCP)",
527 one_line_why: "Removes any custom DNS servers and lets your router choose.",
528 targets: &[DiagnosticKey::Dns],
529 cost: Cost::Cheap,
530 risk: Risk::Low,
531 max_attempts: 1,
532 stabilization: Duration::from_secs(2),
533 },
534 Action {
535 id: ActionId::FlushArp,
536 label: "Flush the ARP cache",
537 one_line_why: "Clears stale gateway entries that block traffic to your router.",
538 targets: &[DiagnosticKey::Gateway, DiagnosticKey::Latency],
539 cost: Cost::Cheap,
540 risk: Risk::Low,
541 max_attempts: 1,
542 stabilization: Duration::from_secs(1),
543 },
544 Action {
545 id: ActionId::RestartNetworkServices,
546 label: "Restart networking services",
547 one_line_why: "Brings the OS-level DNS / DHCP services back to a clean state.",
548 targets: &[
549 DiagnosticKey::Dns,
550 DiagnosticKey::Gateway,
551 DiagnosticKey::PublicIp,
552 ],
553 cost: Cost::Medium,
554 risk: Risk::Low,
555 max_attempts: 1,
556 stabilization: Duration::from_secs(3),
557 },
558 Action {
559 id: ActionId::RenewDhcp,
560 label: "Renew the DHCP lease",
561 one_line_why: "Asks your router for a fresh IP address and gateway.",
562 targets: &[
563 DiagnosticKey::Gateway,
564 DiagnosticKey::PublicIp,
565 DiagnosticKey::Adapters,
566 DiagnosticKey::Interfaces,
567 ],
568 cost: Cost::Medium,
569 risk: Risk::Low,
570 max_attempts: 1,
571 stabilization: Duration::from_secs(8),
572 },
573 Action {
574 id: ActionId::DisableConsumerVpns,
575 label: "Temporarily disable consumer VPNs",
576 one_line_why: "Some consumer VPNs (NordVPN, ExpressVPN, Tailscale, etc.) interfere with diagnostics. Enterprise VPNs are never auto-disabled.",
577 targets: &[
578 DiagnosticKey::PublicIp,
579 DiagnosticKey::Latency,
580 DiagnosticKey::Dns,
581 ],
582 cost: Cost::Medium,
583 risk: Risk::Medium,
584 max_attempts: 1,
585 stabilization: Duration::from_secs(2),
586 },
587 Action {
588 id: ActionId::BounceInterface,
589 label: "Restart your network adapter (disable → re-enable)",
590 one_line_why: "Forces the adapter to reset its link, re-associate Wi-Fi, and re-DHCP.",
591 targets: &[
592 DiagnosticKey::Adapters,
593 DiagnosticKey::Interfaces,
594 DiagnosticKey::Gateway,
595 DiagnosticKey::Dns,
596 DiagnosticKey::PublicIp,
597 DiagnosticKey::Latency,
598 ],
599 cost: Cost::Expensive,
600 risk: Risk::Medium,
601 max_attempts: 1,
602 stabilization: Duration::from_secs(10),
603 },
604 ];
605
606 let deep_reset_explanation = make_deep_reset_explanation();
608 actions.push(Action {
609 id: ActionId::DeepStackReset,
610 label: deep_reset_explanation.what,
611 one_line_why: "Last-resort recovery when nothing else worked.",
612 targets: &[
613 DiagnosticKey::Dns,
614 DiagnosticKey::Gateway,
615 DiagnosticKey::PublicIp,
616 DiagnosticKey::Adapters,
617 DiagnosticKey::Interfaces,
618 ],
619 cost: Cost::Expensive,
620 risk: Risk::High(deep_reset_explanation),
621 max_attempts: 1,
622 stabilization: Duration::from_secs(15),
623 });
624
625 actions
626}
627
628#[cfg(windows)]
629fn make_deep_reset_explanation() -> RiskExplanation {
630 RiskExplanation {
631 what: "Reset Windows networking stack",
632 why: "This rebuilds Windows' TCP/IP, Winsock, and IPv6 catalogs from scratch — the standard fix when simpler steps haven't recovered the connection.",
633 side_effects: &[
634 "You will lose internet for ~10–15 seconds.",
635 "Open VPN sessions and SSH connections will drop.",
636 "A reboot is recommended afterward; nd300 will remind you at the end.",
637 ],
638 reversible: Reversibility::RebootToFullyRevert,
639 typical_duration: "10–15 seconds",
640 }
641}
642
643#[cfg(target_os = "macos")]
644fn make_deep_reset_explanation() -> RiskExplanation {
645 RiskExplanation {
646 what: "Safely cycle the macOS network service",
647 why: "The former reset deleted and recreated the active network service and could damage Wi-Fi configuration. That implementation has been removed. Its replacement only disables and re-enables an exactly-mapped physical service, preserves resolver state, and remains unavailable until disposable-Mac validation is complete.",
648 side_effects: &[
649 "The service-deletion reset will never run.",
650 "This build refuses the replacement cycle until it passes destructive testing on a disposable Mac.",
651 "Use the lower-risk adapter restart or System Settings while this safeguard is active.",
652 ],
653 reversible: Reversibility::FullyReversible,
654 typical_duration: "Unavailable until disposable-Mac validation",
655 }
656}
657
658#[cfg(target_os = "linux")]
659fn make_deep_reset_explanation() -> RiskExplanation {
660 RiskExplanation {
661 what: "Attempt guarded Linux deep network recovery",
662 why: "ND300 no longer deletes and recreates NetworkManager profiles because an interruption could leave the connection permanently missing. NetworkManager-managed connections are refused; the remaining fallback only applies to legacy dhcpcd/wpa_supplicant systems.",
663 side_effects: &[
664 "NetworkManager-managed connections will not be altered.",
665 "On legacy dhcpcd systems, the network service may restart briefly.",
666 "A requested Wi-Fi reconnect may append a wpa_supplicant entry and is not automatically reversible.",
667 ],
668 reversible: Reversibility::NotReversible,
669 typical_duration: "Unavailable for NetworkManager; about 10–20 seconds on the legacy fallback",
670 }
671}
672
673#[cfg(test)]
674mod tests {
675 use crate::config::Config;
676
677 use super::*;
678
679 #[tokio::test]
680 async fn json_mode_does_not_disable_consumer_vpns() {
681 let outcome =
684 apply_disable_consumer_vpns(&Config::new().with_json(), &RestoreRegistry::new()).await;
685
686 assert!(!outcome.ok);
687 assert!(
688 outcome.message.contains("requires an interactive session"),
689 "unexpected outcome: {:?}",
690 outcome
691 );
692 }
693
694 #[test]
695 fn macos_action_sources_contain_no_service_deletion_or_credential_replay() {
696 let stages = include_str!("stages.rs");
697 let wifi = include_str!("wifi.rs");
698 let dns = include_str!("dns.rs");
699 let standalone_dns = include_str!("../dns.rs");
700 for forbidden in [
701 concat!("-remove", "networkservice"),
702 concat!("-create", "networkservice"),
703 concat!("find-generic", "-password"),
704 concat!("Apple80211", ".framework"),
705 concat!("-setairport", "network"),
706 ] {
707 assert!(
708 !stages.contains(forbidden) && !wifi.contains(forbidden),
709 "forbidden destructive macOS action dependency returned: {forbidden}"
710 );
711 }
712 for forbidden in [
713 "install_cmd",
714 "nextdns deactivate",
715 concat!("find-generic", "-password"),
716 ] {
717 assert!(
718 !dns.contains(forbidden) && !standalone_dns.contains(forbidden),
719 "forbidden non-transactional macOS DNS mutation returned: {forbidden}"
720 );
721 }
722 }
723
724 #[test]
725 fn linux_action_sources_contain_no_networkmanager_profile_deletion() {
726 let stages = include_str!("stages.rs");
727 let forbidden = concat!("\"connection\"", ", \"delete\"");
728 assert!(
729 !stages.contains(forbidden),
730 "unguarded NetworkManager profile deletion returned"
731 );
732 assert!(
733 stages.contains("Linux deep reset is safely unavailable while NetworkManager"),
734 "fail-closed NetworkManager recovery guidance is missing"
735 );
736 }
737}