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/// A broadly available, DNSSEC-signed control. A bogus-zone failure means
34/// nothing if this valid signed zone cannot resolve at the same time.
35const DNSSEC_VALID_DOMAIN: &str = "cloudflare.com";
36
37#[derive(Debug, Clone, Serialize)]
38pub struct ResolverTiming {
39    pub name: String,
40    /// "system" for the OS resolver, else the server IP.
41    pub server: String,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub avg_ms: Option<f64>,
44    pub queries_ok: u8,
45    pub queries_total: u8,
46}
47
48#[derive(Debug, Clone, Serialize)]
49pub struct DnsBenchmark {
50    pub resolvers: Vec<ResolverTiming>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub fastest: Option<String>,
53    /// How much slower the system resolver is than the fastest public one
54    /// (positive = system slower).
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub system_vs_fastest_ms: Option<f64>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub hijack_detected: Option<bool>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub dnssec_validating: Option<bool>,
61    pub assessment: String,
62    pub level: String,
63}
64
65pub async fn collect() -> Option<DnsBenchmark> {
66    collect_with_budget(MODULE_BUDGET).await
67}
68
69async fn collect_with_budget(budget: Duration) -> Option<DnsBenchmark> {
70    let deadline = tokio::time::Instant::now() + budget;
71
72    // Benchmark all resolver chains concurrently.
73    let mut chains = tokio::task::JoinSet::new();
74    chains.spawn(benchmark_system());
75    for (name, server) in PUBLIC_RESOLVERS {
76        chains.spawn(benchmark_public(name, server));
77    }
78    let resolvers = join_before_deadline(chains, deadline).await?;
79
80    // Control: did the system resolver resolve anything at all? If DNS is
81    // dead the core DNS check already failed — skip this section.
82    let system_ok = resolvers
83        .iter()
84        .any(|r| r.server == "system" && r.queries_ok > 0);
85    if !system_ok {
86        return None;
87    }
88
89    // Integrity probes (system resolver). The same valid signed control gates
90    // both claims: a timeout or resolver outage is inconclusive, not evidence
91    // that NXDOMAIN was preserved or that DNSSEC validation occurred.
92    let integrity = async {
93        let random_domain = random_hijack_domain();
94        let (valid_control, bogus_zone, random_label) = tokio::join!(
95            resolves(DNSSEC_VALID_DOMAIN),
96            resolves(DNSSEC_BOGUS_DOMAIN),
97            resolves(&random_domain),
98        );
99        (
100            hijack_evidence(valid_control, random_label),
101            dnssec_evidence(valid_control, bogus_zone),
102        )
103    };
104    let (hijack_detected, dnssec_validating) =
105        tokio::time::timeout_at(deadline, integrity).await.ok()?;
106
107    Some(build_benchmark(
108        resolvers,
109        hijack_detected,
110        dnssec_validating,
111    ))
112}
113
114async fn join_before_deadline<T: 'static>(
115    mut tasks: tokio::task::JoinSet<T>,
116    deadline: tokio::time::Instant,
117) -> Option<Vec<T>> {
118    let mut values = Vec::new();
119    while !tasks.is_empty() {
120        match tokio::time::timeout_at(deadline, tasks.join_next()).await {
121            Ok(Some(Ok(value))) => values.push(value),
122            // A single failed worker is equivalent to that resolver being
123            // unavailable. Preserve the other measurements.
124            Ok(Some(Err(_))) => {}
125            Ok(None) => break,
126            Err(_) => {
127                // JoinHandle/JoinSet drops detach work unless it is explicitly
128                // aborted. Abort and reap every resolver chain before the
129                // module returns so `dig`/`nslookup` cannot outlive the cap.
130                tasks.abort_all();
131                while tasks.join_next().await.is_some() {}
132                return None;
133            }
134        }
135    }
136    Some(values)
137}
138
139async fn resolves(domain: &str) -> bool {
140    util::lookup_host_timeout(format!("{domain}:443"), util::RESOLVE)
141        .await
142        .is_some_and(|addrs| !addrs.is_empty())
143}
144
145fn hijack_evidence(valid_control_resolved: bool, random_label_resolved: bool) -> Option<bool> {
146    valid_control_resolved.then_some(random_label_resolved)
147}
148
149fn dnssec_evidence(valid_signed_resolved: bool, bogus_signed_resolved: bool) -> Option<bool> {
150    if !valid_signed_resolved {
151        None
152    } else {
153        Some(!bogus_signed_resolved)
154    }
155}
156
157/// Pure assembly + verdict — unit-testable without a network.
158fn build_benchmark(
159    resolvers: Vec<ResolverTiming>,
160    hijack_detected: Option<bool>,
161    dnssec_validating: Option<bool>,
162) -> DnsBenchmark {
163    let fastest_public = resolvers
164        .iter()
165        .filter(|r| r.server != "system")
166        .filter_map(|r| r.avg_ms.map(|ms| (r, ms)))
167        .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
168
169    let system_ms = resolvers
170        .iter()
171        .find(|r| r.server == "system")
172        .and_then(|r| r.avg_ms);
173
174    let fastest = fastest_public.map(|(r, _)| r.name.clone());
175    let system_vs_fastest_ms = match (system_ms, fastest_public) {
176        (Some(sys), Some((_, fast))) => Some(sys - fast),
177        _ => None,
178    };
179
180    let system_much_slower = matches!(
181        (system_ms, fastest_public),
182        (Some(sys), Some((_, fast))) if sys > 2.0 * fast && (sys - fast) > 50.0
183    );
184
185    let integrity_inconclusive = hijack_detected.is_none() || dnssec_validating.is_none();
186    let (assessment, level) = if hijack_detected == Some(true) {
187        (
188            "DNS hijack detected: non-existent domains resolve — the resolver is rewriting NXDOMAIN (typically ISP ad redirection)",
189            "fail",
190        )
191    } else if dnssec_validating == Some(false) {
192        (
193            "Resolver does not validate DNSSEC — forged DNS answers would not be detected",
194            "warn",
195        )
196    } else if system_much_slower {
197        (
198            "System resolver is much slower than public alternatives — consider changing DNS servers",
199            "warn",
200        )
201    } else if integrity_inconclusive {
202        (
203            "Resolver performance was measured, but one or more DNS integrity checks were inconclusive",
204            "warn",
205        )
206    } else {
207        (
208            "Resolver integrity checks passed: NXDOMAIN was preserved and DNSSEC validation was confirmed",
209            "ok",
210        )
211    };
212
213    DnsBenchmark {
214        resolvers,
215        fastest,
216        system_vs_fastest_ms,
217        hijack_detected,
218        dnssec_validating,
219        assessment: assessment.to_string(),
220        level: level.to_string(),
221    }
222}
223
224/// Time the system resolver across the test domains.
225async fn benchmark_system() -> ResolverTiming {
226    let mut times = Vec::new();
227    let mut ok: u8 = 0;
228    for domain in TEST_DOMAINS {
229        let start = Instant::now();
230        if util::lookup_host_timeout(format!("{}:443", domain), util::RESOLVE)
231            .await
232            .is_some_and(|a| !a.is_empty())
233        {
234            times.push(start.elapsed().as_secs_f64() * 1000.0);
235            ok += 1;
236        }
237    }
238    ResolverTiming {
239        name: "System".to_string(),
240        server: "system".to_string(),
241        avg_ms: avg(&times),
242        queries_ok: ok,
243        queries_total: TEST_DOMAINS.len() as u8,
244    }
245}
246
247/// Time a public resolver across the test domains via the platform's DNS
248/// query tool.
249async fn benchmark_public(name: &str, server: &str) -> ResolverTiming {
250    let mut times = Vec::new();
251    let mut ok: u8 = 0;
252    for domain in TEST_DOMAINS {
253        if let Some(ms) = query_via_server(domain, server).await {
254            times.push(ms);
255            ok += 1;
256        }
257    }
258    ResolverTiming {
259        name: name.to_string(),
260        server: server.to_string(),
261        avg_ms: avg(&times),
262        queries_ok: ok,
263        queries_total: TEST_DOMAINS.len() as u8,
264    }
265}
266
267fn avg(times: &[f64]) -> Option<f64> {
268    if times.is_empty() {
269        None
270    } else {
271        Some(times.iter().sum::<f64>() / times.len() as f64)
272    }
273}
274
275/// Query `domain` against a specific `server`.
276///
277/// Unix: `dig` reports its own `Query time` (parse it — subprocess spawn
278/// overhead excluded). Windows: `nslookup` has no timing output, so the
279/// subprocess is wall-clocked; the constant spawn overhead affects every
280/// resolver equally, so comparisons stay fair (noted for absolute values).
281async fn query_via_server(domain: &str, server: &str) -> Option<f64> {
282    #[cfg(windows)]
283    {
284        let start = Instant::now();
285        let mut cmd = tokio::process::Command::new("nslookup");
286        cmd.args(["-timeout=2", domain, server]);
287        let output = util::run_with_timeout(cmd, util::SLOW).await?;
288        if !output.status.success() {
289            return None;
290        }
291        let text = String::from_utf8_lossy(&output.stdout);
292        // The answer section repeats "Address"; a failed lookup prints
293        // "can't find" instead.
294        if text.contains("can't find") || !text.contains("Address") {
295            return None;
296        }
297        Some(start.elapsed().as_secs_f64() * 1000.0)
298    }
299
300    #[cfg(unix)]
301    {
302        let mut cmd = tokio::process::Command::new("dig");
303        cmd.args([
304            &format!("@{}", server),
305            domain,
306            "+time=2",
307            "+tries=1",
308            "+noall",
309            "+answer",
310            "+stats",
311        ]);
312        let output = util::run_with_timeout(cmd, util::SLOW).await?;
313        if !output.status.success() {
314            return None;
315        }
316        let text = String::from_utf8_lossy(&output.stdout);
317        parse_dig_query_time(&text)
318    }
319}
320
321/// Parse `;; Query time: 23 msec` from dig output.
322#[cfg(any(unix, test))]
323fn parse_dig_query_time(text: &str) -> Option<f64> {
324    for line in text.lines() {
325        if let Some(rest) = line.strip_prefix(";; Query time:") {
326            let num: String = rest
327                .trim()
328                .chars()
329                .take_while(|c| c.is_ascii_digit() || *c == '.')
330                .collect();
331            return num.parse().ok();
332        }
333    }
334    None
335}
336
337/// Resolve a random label that must not exist. Resolving anyway = the
338/// resolver wildcards NXDOMAIN. The label is derived from time + pid (no
339/// rand dependency); collisions with a real domain are practically
340/// impossible.
341fn random_hijack_domain() -> String {
342    let nanos = std::time::SystemTime::now()
343        .duration_since(std::time::UNIX_EPOCH)
344        .map(|d| d.subsec_nanos() as u64 + d.as_secs())
345        .unwrap_or(0xdead_beef);
346    let mixed = nanos
347        .wrapping_mul(6364136223846793005)
348        .wrapping_add(std::process::id() as u64);
349    format!("nd300-{mixed:016x}.com")
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    fn timing(name: &str, server: &str, avg_ms: Option<f64>, ok: u8) -> ResolverTiming {
357        ResolverTiming {
358            name: name.to_string(),
359            server: server.to_string(),
360            avg_ms,
361            queries_ok: ok,
362            queries_total: 3,
363        }
364    }
365
366    #[test]
367    fn parse_dig_query_time_works() {
368        let out =
369            "example.com. 300 IN A 93.184.216.34\n;; Query time: 23 msec\n;; SERVER: 1.1.1.1#53";
370        assert_eq!(parse_dig_query_time(out), Some(23.0));
371        assert_eq!(parse_dig_query_time("no stats here"), None);
372    }
373
374    #[test]
375    fn dnssec_requires_a_good_signed_control() {
376        assert_eq!(dnssec_evidence(true, false), Some(true));
377        assert_eq!(dnssec_evidence(true, true), Some(false));
378        assert_eq!(dnssec_evidence(false, false), None);
379    }
380
381    #[test]
382    fn hijack_claim_requires_a_good_control() {
383        assert_eq!(hijack_evidence(true, false), Some(false));
384        assert_eq!(hijack_evidence(true, true), Some(true));
385        assert_eq!(hijack_evidence(false, false), None);
386        assert_eq!(hijack_evidence(false, true), None);
387    }
388
389    #[test]
390    fn inconclusive_integrity_never_claims_honesty() {
391        for (hijack, dnssec) in [(None, Some(true)), (Some(false), None), (None, None)] {
392            let b = build_benchmark(
393                vec![timing("System", "system", Some(20.0), 3)],
394                hijack,
395                dnssec,
396            );
397            assert_eq!(b.level, "warn");
398            assert!(b.assessment.contains("inconclusive"));
399            assert!(!b.assessment.to_ascii_lowercase().contains("honest"));
400        }
401    }
402
403    #[tokio::test]
404    async fn module_deadline_aborts_and_reaps_spawned_work() {
405        use std::sync::atomic::{AtomicUsize, Ordering};
406        use std::sync::Arc;
407
408        struct Dropped(Arc<AtomicUsize>);
409        impl Drop for Dropped {
410            fn drop(&mut self) {
411                self.0.fetch_add(1, Ordering::SeqCst);
412            }
413        }
414
415        let dropped = Arc::new(AtomicUsize::new(0));
416        let mut tasks = tokio::task::JoinSet::new();
417        for _ in 0..4 {
418            let dropped = dropped.clone();
419            tasks.spawn(async move {
420                let _guard = Dropped(dropped);
421                tokio::time::sleep(Duration::from_secs(60)).await;
422                1usize
423            });
424        }
425
426        let result = join_before_deadline(
427            tasks,
428            tokio::time::Instant::now() + Duration::from_millis(20),
429        )
430        .await;
431        assert!(result.is_none());
432        assert_eq!(dropped.load(Ordering::SeqCst), 4);
433    }
434
435    #[test]
436    fn hijack_is_fail_level() {
437        let b = build_benchmark(
438            vec![timing("System", "system", Some(20.0), 3)],
439            Some(true),
440            Some(true),
441        );
442        assert_eq!(b.level, "fail");
443        assert!(b.assessment.contains("hijack"));
444    }
445
446    #[test]
447    fn non_validating_dnssec_warns() {
448        let b = build_benchmark(
449            vec![timing("System", "system", Some(20.0), 3)],
450            Some(false),
451            Some(false),
452        );
453        assert_eq!(b.level, "warn");
454        assert!(b.assessment.contains("DNSSEC"));
455    }
456
457    #[test]
458    fn slow_system_resolver_warns() {
459        let b = build_benchmark(
460            vec![
461                timing("System", "system", Some(180.0), 3),
462                timing("Cloudflare", "1.1.1.1", Some(15.0), 3),
463            ],
464            Some(false),
465            Some(true),
466        );
467        assert_eq!(b.level, "warn");
468        assert_eq!(b.fastest.as_deref(), Some("Cloudflare"));
469        assert!(b.system_vs_fastest_ms.is_some_and(|d| d > 100.0));
470    }
471
472    #[test]
473    fn modestly_slower_system_is_ok() {
474        // 2x but under the 50ms absolute floor — not worth a warning.
475        let b = build_benchmark(
476            vec![
477                timing("System", "system", Some(30.0), 3),
478                timing("Cloudflare", "1.1.1.1", Some(12.0), 3),
479            ],
480            Some(false),
481            Some(true),
482        );
483        assert_eq!(b.level, "ok");
484    }
485
486    #[test]
487    fn healthy_resolver_is_ok() {
488        let b = build_benchmark(
489            vec![
490                timing("System", "system", Some(14.0), 3),
491                timing("Cloudflare", "1.1.1.1", Some(15.0), 3),
492                timing("Google", "8.8.8.8", Some(18.0), 3),
493            ],
494            Some(false),
495            Some(true),
496        );
497        assert_eq!(b.level, "ok");
498    }
499}