Skip to main content

nd_300/diagnostics/
dns.rs

1use serde::Serialize;
2use std::time::Instant;
3
4use super::DiagnosticResult;
5
6#[derive(Debug, Clone, Serialize)]
7pub struct DnsInfo {
8    pub servers: Vec<DnsServer>,
9    /// The `dns.google` probe, kept for JSON backward compatibility.
10    pub resolution_test: Option<DnsResolutionTest>,
11    /// All probed domains (additive field, v3.4.0+).
12    #[serde(skip_serializing_if = "Vec::is_empty")]
13    pub resolution_tests: Vec<DnsResolutionTest>,
14}
15
16#[derive(Debug, Clone, Serialize)]
17pub struct DnsServer {
18    pub address: String,
19    pub reachable: bool,
20    pub latency_ms: Option<f64>,
21}
22
23#[derive(Debug, Clone, Serialize)]
24pub struct DnsResolutionTest {
25    pub domain: String,
26    pub resolved: bool,
27    pub resolution_time_ms: f64,
28    pub resolved_ips: Vec<String>,
29}
30
31pub async fn check() -> (DiagnosticResult, Option<DnsInfo>) {
32    let servers = get_dns_servers().await;
33
34    if servers.is_empty() {
35        return (
36            DiagnosticResult::fail("DNS", "No DNS servers configured"),
37            None,
38        );
39    }
40
41    // Test DNS resolution against several independent domains concurrently.
42    // The verdict uses the count resolved + the median time, so one slow or
43    // dropped lookup can no longer flip the verdict on a healthy network.
44    let tests: Vec<DnsResolutionTest> =
45        futures_util::future::join_all(TEST_DOMAINS.iter().map(|d| test_domain(d))).await;
46
47    let server_results: Vec<DnsServer> = servers
48        .iter()
49        .map(|s| DnsServer {
50            address: s.clone(),
51            reachable: true, // We know they're configured
52            latency_ms: None,
53        })
54        .collect();
55
56    let info = DnsInfo {
57        servers: server_results,
58        resolution_test: tests.iter().find(|t| t.domain == TEST_DOMAINS[0]).cloned(),
59        resolution_tests: tests.clone(),
60    };
61
62    let result = dns_verdict(&tests);
63
64    (result, Some(info))
65}
66
67/// Pure verdict over the resolution probes — unit-testable without a network.
68///
69/// - 0 resolved → Fail
70/// - some (but not all) resolved → Warn (partial resolution)
71/// - all resolved → verdict on the **median** resolution time
72///   (≤200ms Ok, 200–500ms Warn, >500ms Warn-slow)
73fn dns_verdict(tests: &[DnsResolutionTest]) -> DiagnosticResult {
74    let total = tests.len();
75    let mut resolved_times: Vec<f64> = tests
76        .iter()
77        .filter(|t| t.resolved)
78        .map(|t| t.resolution_time_ms)
79        .collect();
80
81    if resolved_times.is_empty() {
82        return DiagnosticResult::fail("DNS", "DNS resolution failed");
83    }
84
85    if resolved_times.len() < total {
86        return DiagnosticResult::warn(
87            "DNS",
88            format!(
89                "Partial DNS resolution ({}/{} domains)",
90                resolved_times.len(),
91                total
92            ),
93        );
94    }
95
96    resolved_times.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
97    let median = resolved_times[resolved_times.len() / 2];
98
99    if median > 500.0 {
100        DiagnosticResult::warn("DNS", format!("Resolving slowly ({:.0}ms median)", median))
101    } else if median > 200.0 {
102        DiagnosticResult::warn(
103            "DNS",
104            format!("Resolving with moderate latency ({:.0}ms median)", median),
105        )
106    } else {
107        DiagnosticResult::ok(
108            "DNS",
109            format!("Resolving normally ({:.0}ms median)", median),
110        )
111    }
112}
113
114async fn get_dns_servers() -> Vec<String> {
115    #[cfg(windows)]
116    {
117        get_dns_servers_windows().await
118    }
119
120    #[cfg(target_os = "macos")]
121    {
122        get_dns_servers_macos().await
123    }
124
125    #[cfg(target_os = "linux")]
126    {
127        get_dns_servers_linux().await
128    }
129}
130
131#[cfg(windows)]
132async fn get_dns_servers_windows() -> Vec<String> {
133    let mut servers = Vec::new();
134
135    let mut cmd = tokio::process::Command::new("netsh");
136    cmd.args(["interface", "ip", "show", "dns"]);
137    if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
138        let text = String::from_utf8_lossy(&output.stdout);
139        for line in text.lines() {
140            let line = line.trim();
141            // Look for IP addresses in the output
142            if let Some(ip) = extract_ip(line) {
143                if !servers.contains(&ip) {
144                    servers.push(ip);
145                }
146            }
147        }
148    }
149
150    if servers.is_empty() {
151        // Fallback: try ipconfig
152        let mut cmd = tokio::process::Command::new("ipconfig");
153        cmd.args(["/all"]);
154        if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
155            let text = String::from_utf8_lossy(&output.stdout);
156            let mut in_dns_section = false;
157            for line in text.lines() {
158                if line.contains("DNS Servers") {
159                    in_dns_section = true;
160                    if let Some(ip) = extract_ip(line) {
161                        servers.push(ip);
162                    }
163                } else if in_dns_section {
164                    let trimmed = line.trim();
165                    if trimmed.is_empty()
166                        || trimmed.contains(':') && !trimmed.starts_with(char::is_numeric)
167                    {
168                        in_dns_section = false;
169                    } else if let Some(ip) = extract_ip(trimmed) {
170                        servers.push(ip);
171                    }
172                }
173            }
174        }
175    }
176
177    servers
178}
179
180#[cfg(target_os = "macos")]
181async fn get_dns_servers_macos() -> Vec<String> {
182    let mut servers = Vec::new();
183
184    let mut cmd = tokio::process::Command::new("scutil");
185    cmd.args(["--dns"]);
186    if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
187        let text = String::from_utf8_lossy(&output.stdout);
188        for line in text.lines() {
189            if line.contains("nameserver") {
190                if let Some(ip) = extract_ip(line) {
191                    if !servers.contains(&ip) {
192                        servers.push(ip);
193                    }
194                }
195            }
196        }
197    }
198
199    servers
200}
201
202#[cfg(target_os = "linux")]
203async fn get_dns_servers_linux() -> Vec<String> {
204    let mut servers = Vec::new();
205
206    // Try /etc/resolv.conf first
207    if let Ok(content) = tokio::fs::read_to_string("/etc/resolv.conf").await {
208        for line in content.lines() {
209            if line.starts_with("nameserver") {
210                if let Some(ip) = line.split_whitespace().nth(1) {
211                    servers.push(ip.to_string());
212                }
213            }
214        }
215    }
216
217    // Fallback: try resolvectl
218    if servers.is_empty() || servers.iter().all(|s| s == "127.0.0.53") {
219        let mut cmd = tokio::process::Command::new("resolvectl");
220        cmd.args(["status"]);
221        if let Some(output) = super::util::run_with_timeout(cmd, super::util::SLOW).await {
222            let text = String::from_utf8_lossy(&output.stdout);
223            for line in text.lines() {
224                if line.contains("DNS Servers") || line.contains("Current DNS") {
225                    if let Some(ip) = extract_ip(line) {
226                        if ip != "127.0.0.53" && !servers.contains(&ip) {
227                            servers.push(ip);
228                        }
229                    }
230                }
231            }
232        }
233    }
234
235    servers
236}
237
238fn extract_ip(text: &str) -> Option<String> {
239    for word in text.split_whitespace() {
240        // Try IPv4 first
241        let cleaned = word.trim_matches(|c: char| !c.is_ascii_digit() && c != '.');
242        let parts: Vec<&str> = cleaned.split('.').collect();
243        if parts.len() == 4 && parts.iter().all(|p| p.parse::<u8>().is_ok()) {
244            return Some(cleaned.to_string());
245        }
246
247        // Try IPv6: must contain at least two colons and only hex digits/colons
248        let trimmed = word.trim_matches(|c: char| !c.is_ascii_hexdigit() && c != ':');
249        if trimmed.matches(':').count() >= 2
250            && !trimmed.is_empty()
251            && trimmed.chars().all(|c| c.is_ascii_hexdigit() || c == ':')
252        {
253            return Some(trimmed.to_string());
254        }
255    }
256    None
257}
258
259/// Domains probed for the resolution verdict. Three independent zones with
260/// distinct authoritative operators, so one slow/unlucky zone traversal can't
261/// flip the verdict.
262const TEST_DOMAINS: &[&str] = &["dns.google", "one.one.one.one", "example.com"];
263
264async fn test_domain(domain: &'static str) -> DnsResolutionTest {
265    let overall = Instant::now();
266
267    // Each lookup is retried once after a short pause — a single transient
268    // drop shouldn't count as a failed domain. The recorded time is the
269    // *successful attempt's* time only, so a retry doesn't masquerade as a
270    // slow resolver.
271    let outcome = super::util::retry_probe(2, std::time::Duration::from_millis(500), || async {
272        let start = Instant::now();
273        let addrs =
274            super::util::lookup_host_timeout(format!("{}:443", domain), super::util::RESOLVE)
275                .await?;
276        let elapsed = start.elapsed().as_secs_f64() * 1000.0;
277        let ips: Vec<String> = addrs.into_iter().map(|a| a.ip().to_string()).collect();
278        if ips.is_empty() {
279            None
280        } else {
281            Some((elapsed, ips))
282        }
283    })
284    .await;
285
286    match outcome {
287        Some((elapsed, ips)) => DnsResolutionTest {
288            domain: domain.to_string(),
289            resolved: true,
290            resolution_time_ms: elapsed,
291            resolved_ips: ips,
292        },
293        None => DnsResolutionTest {
294            domain: domain.to_string(),
295            resolved: false,
296            resolution_time_ms: overall.elapsed().as_secs_f64() * 1000.0,
297            resolved_ips: vec![],
298        },
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    fn test_result(domain: &str, resolved: bool, time_ms: f64) -> DnsResolutionTest {
307        DnsResolutionTest {
308            domain: domain.to_string(),
309            resolved,
310            resolution_time_ms: time_ms,
311            resolved_ips: if resolved {
312                vec!["192.0.2.1".to_string()]
313            } else {
314                vec![]
315            },
316        }
317    }
318
319    use super::super::DiagnosticStatus;
320
321    #[test]
322    fn all_fast_ok() {
323        let tests = [
324            test_result("dns.google", true, 80.0),
325            test_result("one.one.one.one", true, 90.0),
326            test_result("example.com", true, 110.0),
327        ];
328        let v = dns_verdict(&tests);
329        assert_eq!(v.status, DiagnosticStatus::Ok);
330        assert!(v.summary.contains("median"));
331    }
332
333    /// The de-flake regression test: one slow outlier among fast peers must
334    /// not flip the verdict (the old single-domain check would have warned).
335    #[test]
336    fn single_slow_outlier_median_still_ok() {
337        let tests = [
338            test_result("dns.google", true, 1200.0),
339            test_result("one.one.one.one", true, 80.0),
340            test_result("example.com", true, 90.0),
341        ];
342        let v = dns_verdict(&tests);
343        assert_eq!(v.status, DiagnosticStatus::Ok);
344    }
345
346    #[test]
347    fn median_moderate_warns() {
348        let tests = [
349            test_result("dns.google", true, 250.0),
350            test_result("one.one.one.one", true, 300.0),
351            test_result("example.com", true, 220.0),
352        ];
353        let v = dns_verdict(&tests);
354        assert_eq!(v.status, DiagnosticStatus::Warn);
355        assert!(v.summary.contains("moderate"));
356    }
357
358    #[test]
359    fn median_slow_warns() {
360        let tests = [
361            test_result("dns.google", true, 800.0),
362            test_result("one.one.one.one", true, 900.0),
363            test_result("example.com", true, 700.0),
364        ];
365        let v = dns_verdict(&tests);
366        assert_eq!(v.status, DiagnosticStatus::Warn);
367        assert!(v.summary.contains("slowly"));
368    }
369
370    #[test]
371    fn partial_resolution_warns() {
372        let tests = [
373            test_result("dns.google", true, 80.0),
374            test_result("one.one.one.one", false, 5000.0),
375            test_result("example.com", true, 90.0),
376        ];
377        let v = dns_verdict(&tests);
378        assert_eq!(v.status, DiagnosticStatus::Warn);
379        assert!(v.summary.contains("2/3"));
380    }
381
382    #[test]
383    fn all_failed_fails() {
384        let tests = [
385            test_result("dns.google", false, 5000.0),
386            test_result("one.one.one.one", false, 5000.0),
387            test_result("example.com", false, 5000.0),
388        ];
389        let v = dns_verdict(&tests);
390        assert_eq!(v.status, DiagnosticStatus::Fail);
391    }
392}