1use 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
28const MODULE_BUDGET: Duration = Duration::from_secs(20);
30
31const DNSSEC_BOGUS_DOMAIN: &str = "dnssec-failed.org";
33const DNSSEC_VALID_DOMAIN: &str = "cloudflare.com";
36
37#[derive(Debug, Clone, Serialize)]
38pub struct ResolverTiming {
39 pub name: String,
40 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 #[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 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 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 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 Ok(Some(Err(_))) => {}
125 Ok(None) => break,
126 Err(_) => {
127 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
157fn 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
224async 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(×),
242 queries_ok: ok,
243 queries_total: TEST_DOMAINS.len() as u8,
244 }
245}
246
247async 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(×),
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
275async 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 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#[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
337fn 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 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}