Skip to main content

nd_300/diagnostics/
dns_benchmark.rs

1//! DNS resolver benchmark + integrity checks — deep diagnostic.
2//!
3//! Times A-record lookups against the system resolver and three public
4//! resolvers (concurrent chains, sequential queries within each), then runs
5//! two integrity probes:
6//!
7//! - **NXDOMAIN-wildcard hijack**: a random label under .com must NOT
8//!   resolve; if it does, the resolver (typically the ISP) is rewriting
9//!   NXDOMAIN responses to ad/search pages.
10//! - **DNSSEC validation**: `dnssec-failed.org` carries a deliberately bogus
11//!   signature. A validating resolver refuses to resolve it; a
12//!   non-validating one returns an address. Subprocess-free and identical on
13//!   all platforms.
14
15use serde::Serialize;
16use std::time::{Duration, Instant};
17
18use super::util;
19
20const TEST_DOMAINS: &[&str] = &["example.com", "wikipedia.org", "cloudflare.com"];
21
22const PUBLIC_RESOLVERS: &[(&str, &str)] = &[
23    ("Cloudflare", "1.1.1.1"),
24    ("Google", "8.8.8.8"),
25    ("Quad9", "9.9.9.9"),
26];
27
28/// Whole-module budget.
29const MODULE_BUDGET: Duration = Duration::from_secs(20);
30
31/// Deliberately-bogus DNSSEC zone maintained for validation testing.
32const DNSSEC_BOGUS_DOMAIN: &str = "dnssec-failed.org";
33
34#[derive(Debug, Clone, Serialize)]
35pub struct ResolverTiming {
36    pub name: String,
37    /// "system" for the OS resolver, else the server IP.
38    pub server: String,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub avg_ms: Option<f64>,
41    pub queries_ok: u8,
42    pub queries_total: u8,
43}
44
45#[derive(Debug, Clone, Serialize)]
46pub struct DnsBenchmark {
47    pub resolvers: Vec<ResolverTiming>,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub fastest: Option<String>,
50    /// How much slower the system resolver is than the fastest public one
51    /// (positive = system slower).
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub system_vs_fastest_ms: Option<f64>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub hijack_detected: Option<bool>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub dnssec_validating: Option<bool>,
58    pub assessment: String,
59    pub level: String,
60}
61
62pub async fn collect() -> Option<DnsBenchmark> {
63    tokio::time::timeout(MODULE_BUDGET, collect_inner())
64        .await
65        .unwrap_or_default()
66}
67
68async fn collect_inner() -> Option<DnsBenchmark> {
69    // Benchmark all resolver chains concurrently.
70    let mut chains = Vec::new();
71    chains.push(tokio::spawn(benchmark_system()));
72    for (name, server) in PUBLIC_RESOLVERS {
73        chains.push(tokio::spawn(benchmark_public(name, server)));
74    }
75    let resolvers: Vec<ResolverTiming> = futures_util::future::join_all(chains)
76        .await
77        .into_iter()
78        .flatten()
79        .collect();
80
81    // Control: did the system resolver resolve anything at all? If DNS is
82    // dead the core DNS check already failed — skip this section.
83    let system_ok = resolvers
84        .iter()
85        .any(|r| r.server == "system" && r.queries_ok > 0);
86    if !system_ok {
87        return None;
88    }
89
90    // Integrity probes (system resolver).
91    let hijack_detected = Some(hijack_probe().await);
92    let bogus_resolved =
93        util::lookup_host_timeout(format!("{}:443", DNSSEC_BOGUS_DOMAIN), util::RESOLVE)
94            .await
95            .is_some_and(|addrs| !addrs.is_empty());
96    let dnssec_validating = Some(!bogus_resolved);
97
98    Some(build_benchmark(
99        resolvers,
100        hijack_detected,
101        dnssec_validating,
102    ))
103}
104
105/// Pure assembly + verdict — unit-testable without a network.
106fn build_benchmark(
107    resolvers: Vec<ResolverTiming>,
108    hijack_detected: Option<bool>,
109    dnssec_validating: Option<bool>,
110) -> DnsBenchmark {
111    let fastest_public = resolvers
112        .iter()
113        .filter(|r| r.server != "system")
114        .filter_map(|r| r.avg_ms.map(|ms| (r, ms)))
115        .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
116
117    let system_ms = resolvers
118        .iter()
119        .find(|r| r.server == "system")
120        .and_then(|r| r.avg_ms);
121
122    let fastest = fastest_public.map(|(r, _)| r.name.clone());
123    let system_vs_fastest_ms = match (system_ms, fastest_public) {
124        (Some(sys), Some((_, fast))) => Some(sys - fast),
125        _ => None,
126    };
127
128    let system_much_slower = matches!(
129        (system_ms, fastest_public),
130        (Some(sys), Some((_, fast))) if sys > 2.0 * fast && (sys - fast) > 50.0
131    );
132
133    let (assessment, level) = if hijack_detected == Some(true) {
134        (
135            "DNS hijack detected: non-existent domains resolve — the resolver is rewriting NXDOMAIN (typically ISP ad redirection)",
136            "fail",
137        )
138    } else if dnssec_validating == Some(false) {
139        (
140            "Resolver does not validate DNSSEC — forged DNS answers would not be detected",
141            "warn",
142        )
143    } else if system_much_slower {
144        (
145            "System resolver is much slower than public alternatives — consider changing DNS servers",
146            "warn",
147        )
148    } else {
149        ("Resolver is fast and honest", "ok")
150    };
151
152    DnsBenchmark {
153        resolvers,
154        fastest,
155        system_vs_fastest_ms,
156        hijack_detected,
157        dnssec_validating,
158        assessment: assessment.to_string(),
159        level: level.to_string(),
160    }
161}
162
163/// Time the system resolver across the test domains.
164async fn benchmark_system() -> ResolverTiming {
165    let mut times = Vec::new();
166    let mut ok: u8 = 0;
167    for domain in TEST_DOMAINS {
168        let start = Instant::now();
169        if util::lookup_host_timeout(format!("{}:443", domain), util::RESOLVE)
170            .await
171            .is_some_and(|a| !a.is_empty())
172        {
173            times.push(start.elapsed().as_secs_f64() * 1000.0);
174            ok += 1;
175        }
176    }
177    ResolverTiming {
178        name: "System".to_string(),
179        server: "system".to_string(),
180        avg_ms: avg(&times),
181        queries_ok: ok,
182        queries_total: TEST_DOMAINS.len() as u8,
183    }
184}
185
186/// Time a public resolver across the test domains via the platform's DNS
187/// query tool.
188async fn benchmark_public(name: &str, server: &str) -> ResolverTiming {
189    let mut times = Vec::new();
190    let mut ok: u8 = 0;
191    for domain in TEST_DOMAINS {
192        if let Some(ms) = query_via_server(domain, server).await {
193            times.push(ms);
194            ok += 1;
195        }
196    }
197    ResolverTiming {
198        name: name.to_string(),
199        server: server.to_string(),
200        avg_ms: avg(&times),
201        queries_ok: ok,
202        queries_total: TEST_DOMAINS.len() as u8,
203    }
204}
205
206fn avg(times: &[f64]) -> Option<f64> {
207    if times.is_empty() {
208        None
209    } else {
210        Some(times.iter().sum::<f64>() / times.len() as f64)
211    }
212}
213
214/// Query `domain` against a specific `server`.
215///
216/// Unix: `dig` reports its own `Query time` (parse it — subprocess spawn
217/// overhead excluded). Windows: `nslookup` has no timing output, so the
218/// subprocess is wall-clocked; the constant spawn overhead affects every
219/// resolver equally, so comparisons stay fair (noted for absolute values).
220async fn query_via_server(domain: &str, server: &str) -> Option<f64> {
221    #[cfg(windows)]
222    {
223        let start = Instant::now();
224        let mut cmd = tokio::process::Command::new("nslookup");
225        cmd.args(["-timeout=2", domain, server]);
226        let output = util::run_with_timeout(cmd, util::SLOW).await?;
227        if !output.status.success() {
228            return None;
229        }
230        let text = String::from_utf8_lossy(&output.stdout);
231        // The answer section repeats "Address"; a failed lookup prints
232        // "can't find" instead.
233        if text.contains("can't find") || !text.contains("Address") {
234            return None;
235        }
236        Some(start.elapsed().as_secs_f64() * 1000.0)
237    }
238
239    #[cfg(unix)]
240    {
241        let mut cmd = tokio::process::Command::new("dig");
242        cmd.args([
243            &format!("@{}", server),
244            domain,
245            "+time=2",
246            "+tries=1",
247            "+noall",
248            "+answer",
249            "+stats",
250        ]);
251        let output = util::run_with_timeout(cmd, util::SLOW).await?;
252        if !output.status.success() {
253            return None;
254        }
255        let text = String::from_utf8_lossy(&output.stdout);
256        parse_dig_query_time(&text)
257    }
258}
259
260/// Parse `;; Query time: 23 msec` from dig output.
261#[cfg(any(unix, test))]
262fn parse_dig_query_time(text: &str) -> Option<f64> {
263    for line in text.lines() {
264        if let Some(rest) = line.strip_prefix(";; Query time:") {
265            let num: String = rest
266                .trim()
267                .chars()
268                .take_while(|c| c.is_ascii_digit() || *c == '.')
269                .collect();
270            return num.parse().ok();
271        }
272    }
273    None
274}
275
276/// Resolve a random label that must not exist. Resolving anyway = the
277/// resolver wildcards NXDOMAIN. The label is derived from time + pid (no
278/// rand dependency); collisions with a real domain are practically
279/// impossible.
280async fn hijack_probe() -> bool {
281    let nanos = std::time::SystemTime::now()
282        .duration_since(std::time::UNIX_EPOCH)
283        .map(|d| d.subsec_nanos() as u64 + d.as_secs())
284        .unwrap_or(0xdead_beef);
285    let mixed = nanos
286        .wrapping_mul(6364136223846793005)
287        .wrapping_add(std::process::id() as u64);
288    let label = format!("nd300-{:016x}.com:443", mixed);
289
290    util::lookup_host_timeout(label, util::RESOLVE)
291        .await
292        .is_some_and(|addrs| !addrs.is_empty())
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    fn timing(name: &str, server: &str, avg_ms: Option<f64>, ok: u8) -> ResolverTiming {
300        ResolverTiming {
301            name: name.to_string(),
302            server: server.to_string(),
303            avg_ms,
304            queries_ok: ok,
305            queries_total: 3,
306        }
307    }
308
309    #[test]
310    fn parse_dig_query_time_works() {
311        let out =
312            "example.com. 300 IN A 93.184.216.34\n;; Query time: 23 msec\n;; SERVER: 1.1.1.1#53";
313        assert_eq!(parse_dig_query_time(out), Some(23.0));
314        assert_eq!(parse_dig_query_time("no stats here"), None);
315    }
316
317    #[test]
318    fn hijack_is_fail_level() {
319        let b = build_benchmark(
320            vec![timing("System", "system", Some(20.0), 3)],
321            Some(true),
322            Some(true),
323        );
324        assert_eq!(b.level, "fail");
325        assert!(b.assessment.contains("hijack"));
326    }
327
328    #[test]
329    fn non_validating_dnssec_warns() {
330        let b = build_benchmark(
331            vec![timing("System", "system", Some(20.0), 3)],
332            Some(false),
333            Some(false),
334        );
335        assert_eq!(b.level, "warn");
336        assert!(b.assessment.contains("DNSSEC"));
337    }
338
339    #[test]
340    fn slow_system_resolver_warns() {
341        let b = build_benchmark(
342            vec![
343                timing("System", "system", Some(180.0), 3),
344                timing("Cloudflare", "1.1.1.1", Some(15.0), 3),
345            ],
346            Some(false),
347            Some(true),
348        );
349        assert_eq!(b.level, "warn");
350        assert_eq!(b.fastest.as_deref(), Some("Cloudflare"));
351        assert!(b.system_vs_fastest_ms.is_some_and(|d| d > 100.0));
352    }
353
354    #[test]
355    fn modestly_slower_system_is_ok() {
356        // 2x but under the 50ms absolute floor — not worth a warning.
357        let b = build_benchmark(
358            vec![
359                timing("System", "system", Some(30.0), 3),
360                timing("Cloudflare", "1.1.1.1", Some(12.0), 3),
361            ],
362            Some(false),
363            Some(true),
364        );
365        assert_eq!(b.level, "ok");
366    }
367
368    #[test]
369    fn healthy_resolver_is_ok() {
370        let b = build_benchmark(
371            vec![
372                timing("System", "system", Some(14.0), 3),
373                timing("Cloudflare", "1.1.1.1", Some(15.0), 3),
374                timing("Google", "8.8.8.8", Some(18.0), 3),
375            ],
376            Some(false),
377            Some(true),
378        );
379        assert_eq!(b.level, "ok");
380    }
381}