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";
33
34#[derive(Debug, Clone, Serialize)]
35pub struct ResolverTiming {
36 pub name: String,
37 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 #[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 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 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 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
105fn 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
163async 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(×),
181 queries_ok: ok,
182 queries_total: TEST_DOMAINS.len() as u8,
183 }
184}
185
186async 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(×),
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
214async 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 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#[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
276async 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 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}