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 pub resolution_test: Option<DnsResolutionTest>,
11 #[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 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, 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
67fn 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 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 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 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 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 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 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
259const 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 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 #[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}