1use std::net::SocketAddr;
19use std::path::PathBuf;
20use std::time::{Duration, Instant};
21
22use serde::{Deserialize, Serialize};
23use tokio::io::{AsyncReadExt, AsyncWriteExt};
24use tokio::net::TcpStream;
25use tokio::time::timeout;
26
27use crate::config::SeerConfig;
28use crate::dns::{DnsResolver, RecordType};
29use crate::http::{read_body_capped, BodyReadError, Overflow};
30use crate::rdap::MAX_BOOTSTRAP_SIZE;
31
32const IANA_BOOTSTRAP_DNS: &str = "https://data.iana.org/rdap/dns.json";
35
36const DEFAULT_WHOIS_ADDR: &str = "whois.iana.org:43";
40
41const DEFAULT_PROBE_DOMAIN: &str = "example.com";
44
45const DEFAULT_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
49
50const CHECK_CONFIG: &str = "config";
52const CHECK_DNS: &str = "dns";
53const CHECK_WHOIS: &str = "whois";
54const CHECK_RDAP_BOOTSTRAP: &str = "rdap-bootstrap";
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
61#[serde(rename_all = "lowercase")]
62pub enum CheckStatus {
63 Pass,
65 Warn,
67 Fail,
69}
70
71impl std::fmt::Display for CheckStatus {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 f.write_str(match self {
74 CheckStatus::Pass => "PASS",
75 CheckStatus::Warn => "WARN",
76 CheckStatus::Fail => "FAIL",
77 })
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct DoctorCheck {
84 pub name: String,
87 pub status: CheckStatus,
89 pub detail: String,
91 pub latency_ms: Option<u64>,
93}
94
95impl DoctorCheck {
96 fn new(
97 name: &str,
98 status: CheckStatus,
99 detail: impl Into<String>,
100 latency_ms: Option<u64>,
101 ) -> Self {
102 Self {
103 name: name.to_string(),
104 status,
105 detail: detail.into(),
106 latency_ms,
107 }
108 }
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct DoctorReport {
114 pub checks: Vec<DoctorCheck>,
117 pub overall: CheckStatus,
119}
120
121impl DoctorReport {
122 pub fn from_checks(checks: Vec<DoctorCheck>) -> Self {
125 let overall = checks
126 .iter()
127 .map(|check| check.status)
128 .max()
129 .unwrap_or(CheckStatus::Pass);
130 Self { checks, overall }
131 }
132}
133
134#[derive(Debug, Clone)]
141pub struct Doctor {
142 config: SeerConfig,
143 config_path: Option<PathBuf>,
146 resolver: DnsResolver,
148 probe_domain: String,
150 whois_addr: String,
152 bootstrap_url: String,
154 probe_timeout: Duration,
156 #[cfg(test)]
160 allow_reserved_probe_hosts: bool,
161}
162
163impl Doctor {
164 pub fn from_config(config: &SeerConfig) -> Self {
167 Self {
168 config: config.clone(),
169 config_path: SeerConfig::config_path(),
170 resolver: DnsResolver::from_config(config),
171 probe_domain: DEFAULT_PROBE_DOMAIN.to_string(),
172 whois_addr: DEFAULT_WHOIS_ADDR.to_string(),
173 bootstrap_url: IANA_BOOTSTRAP_DNS.to_string(),
174 probe_timeout: DEFAULT_PROBE_TIMEOUT,
175 #[cfg(test)]
176 allow_reserved_probe_hosts: false,
177 }
178 }
179
180 #[cfg(test)]
186 pub(crate) fn with_config_path(mut self, path: PathBuf) -> Self {
187 self.config_path = Some(path);
188 self
189 }
190
191 #[cfg(test)]
194 pub(crate) fn with_resolver(mut self, resolver: DnsResolver) -> Self {
195 self.resolver = resolver;
196 self
197 }
198
199 #[cfg(test)]
201 pub(crate) fn with_probe_domain(mut self, domain: &str) -> Self {
202 self.probe_domain = domain.to_string();
203 self
204 }
205
206 #[cfg(test)]
208 pub(crate) fn with_whois_addr(mut self, addr: String) -> Self {
209 self.whois_addr = addr;
210 self
211 }
212
213 #[cfg(test)]
215 pub(crate) fn with_bootstrap_url(mut self, url: String) -> Self {
216 self.bootstrap_url = url;
217 self
218 }
219
220 #[cfg(test)]
222 pub(crate) fn with_probe_timeout(mut self, timeout: Duration) -> Self {
223 self.probe_timeout = timeout;
224 self
225 }
226
227 #[cfg(test)]
231 pub(crate) fn allowing_reserved_for_tests(mut self) -> Self {
232 self.allow_reserved_probe_hosts = true;
233 self
234 }
235
236 pub async fn run(&self) -> DoctorReport {
254 let (config, dns, whois, rdap) = tokio::join!(
255 self.check_config(),
256 self.check_dns(),
257 self.check_whois(),
258 self.check_rdap_bootstrap(),
259 );
260 DoctorReport::from_checks(vec![config, dns, whois, rdap])
261 }
262
263 async fn check_config(&self) -> DoctorCheck {
270 let Some(path) = self.config_path.as_ref() else {
271 return DoctorCheck::new(
272 CHECK_CONFIG,
273 CheckStatus::Pass,
274 "no home directory found; using built-in defaults",
275 None,
276 );
277 };
278 if !path.exists() {
279 return DoctorCheck::new(
280 CHECK_CONFIG,
281 CheckStatus::Pass,
282 format!("{} not present; using built-in defaults", path.display()),
283 None,
284 );
285 }
286 match std::fs::read_to_string(path) {
289 Ok(content) => match toml::from_str::<SeerConfig>(&content) {
290 Ok(_) => DoctorCheck::new(
291 CHECK_CONFIG,
292 CheckStatus::Pass,
293 format!("{} parsed OK", path.display()),
294 None,
295 ),
296 Err(e) => DoctorCheck::new(
297 CHECK_CONFIG,
298 CheckStatus::Warn,
299 format!(
300 "{} is malformed TOML (seer runs on defaults): {}",
301 path.display(),
302 one_line(&e.to_string())
303 ),
304 None,
305 ),
306 },
307 Err(e) => DoctorCheck::new(
308 CHECK_CONFIG,
309 CheckStatus::Warn,
310 format!(
311 "could not read {} (seer runs on defaults): {}",
312 path.display(),
313 e
314 ),
315 None,
316 ),
317 }
318 }
319
320 async fn check_dns(&self) -> DoctorCheck {
323 let nameserver = self.config.nameserver.as_deref();
324 let ns_label = nameserver.unwrap_or("8.8.8.8 (default)");
325 let start = Instant::now();
326 let outcome = timeout(
327 self.probe_timeout,
328 self.resolver
329 .resolve(&self.probe_domain, RecordType::A, nameserver),
330 )
331 .await;
332 let latency = Some(latency_since(start));
333 match outcome {
334 Ok(Ok(records)) if !records.is_empty() => DoctorCheck::new(
335 CHECK_DNS,
336 CheckStatus::Pass,
337 format!(
338 "resolved {} A via {} ({} record{})",
339 self.probe_domain,
340 ns_label,
341 records.len(),
342 plural(records.len())
343 ),
344 latency,
345 ),
346 Ok(Ok(_)) => DoctorCheck::new(
351 CHECK_DNS,
352 CheckStatus::Warn,
353 format!(
354 "{} returned no A records via {} — resolver reachable, but a well-known name came back empty (possible filtering or captive portal)",
355 self.probe_domain, ns_label
356 ),
357 latency,
358 ),
359 Ok(Err(e)) => DoctorCheck::new(
360 CHECK_DNS,
361 CheckStatus::Fail,
362 format!("resolving {} via {} failed: {}", self.probe_domain, ns_label, e),
363 latency,
364 ),
365 Err(_) => DoctorCheck::new(
366 CHECK_DNS,
367 CheckStatus::Fail,
368 format!(
369 "resolving {} via {} timed out after {:?}",
370 self.probe_domain, ns_label, self.probe_timeout
371 ),
372 latency,
373 ),
374 }
375 }
376
377 async fn check_whois(&self) -> DoctorCheck {
381 let start = Instant::now();
382 let addrs = match self.resolve_whois_target().await {
390 Ok(addrs) => addrs,
391 Err(detail) => {
392 return DoctorCheck::new(
393 CHECK_WHOIS,
394 CheckStatus::Fail,
395 detail,
396 Some(latency_since(start)),
397 )
398 }
399 };
400 let outcome = timeout(self.probe_timeout, self.whois_exchange(&addrs)).await;
403 let latency = Some(latency_since(start));
404 match outcome {
405 Ok(Ok(detail)) => DoctorCheck::new(CHECK_WHOIS, CheckStatus::Pass, detail, latency),
406 Ok(Err(detail)) => DoctorCheck::new(CHECK_WHOIS, CheckStatus::Fail, detail, latency),
407 Err(_) => DoctorCheck::new(
408 CHECK_WHOIS,
409 CheckStatus::Fail,
410 format!(
411 "WHOIS probe to {} timed out after {:?} (port 43 may be blocked)",
412 self.whois_addr, self.probe_timeout
413 ),
414 latency,
415 ),
416 }
417 }
418
419 async fn resolve_whois_target(&self) -> Result<Vec<SocketAddr>, String> {
425 let (host, port) = self
426 .whois_addr
427 .rsplit_once(':')
428 .and_then(|(host, port)| port.parse::<u16>().ok().map(|port| (host, port)))
429 .ok_or_else(|| format!("invalid WHOIS probe address {}", self.whois_addr))?;
430 #[cfg(test)]
431 if self.allow_reserved_probe_hosts {
432 return tokio::net::lookup_host((host, port))
433 .await
434 .map(|iter| iter.collect())
435 .map_err(|e| format!("resolving {} failed: {}", host, e));
436 }
437 crate::net::resolve_public_host(host, port)
438 .await
439 .map_err(|e| format!("resolving {} failed: {}", host, e))
440 }
441
442 async fn whois_exchange(&self, addrs: &[SocketAddr]) -> Result<String, String> {
446 let mut stream = TcpStream::connect(addrs)
447 .await
448 .map_err(|e| format!("connect to {} failed: {}", self.whois_addr, e))?;
449 stream
450 .write_all(format!("{}\r\n", self.probe_domain).as_bytes())
451 .await
452 .map_err(|e| format!("write to {} failed: {}", self.whois_addr, e))?;
453 let mut buf = [0u8; 256];
454 let n = stream
455 .read(&mut buf)
456 .await
457 .map_err(|e| format!("read from {} failed: {}", self.whois_addr, e))?;
458 if n == 0 {
459 return Err(format!(
460 "{} closed the connection without sending data",
461 self.whois_addr
462 ));
463 }
464 Ok(format!(
465 "queried {} via {} ({} byte{} received)",
466 self.probe_domain,
467 self.whois_addr,
468 n,
469 plural(n)
470 ))
471 }
472
473 async fn check_rdap_bootstrap(&self) -> DoctorCheck {
476 let start = Instant::now();
477 let outcome = timeout(self.probe_timeout, self.rdap_bootstrap_probe()).await;
478 let latency = Some(latency_since(start));
479 match outcome {
480 Ok(Ok(detail)) => {
481 DoctorCheck::new(CHECK_RDAP_BOOTSTRAP, CheckStatus::Pass, detail, latency)
482 }
483 Ok(Err(detail)) => {
484 DoctorCheck::new(CHECK_RDAP_BOOTSTRAP, CheckStatus::Fail, detail, latency)
485 }
486 Err(_) => DoctorCheck::new(
487 CHECK_RDAP_BOOTSTRAP,
488 CheckStatus::Fail,
489 format!(
490 "GET {} timed out after {:?}",
491 self.bootstrap_url, self.probe_timeout
492 ),
493 latency,
494 ),
495 }
496 }
497
498 async fn rdap_bootstrap_probe(&self) -> Result<String, String> {
502 let client = crate::net::client_builder(self.probe_timeout)
507 .build()
508 .map_err(|e| format!("could not build HTTP client: {}", e))?;
509 let response = client
510 .get(&self.bootstrap_url)
511 .send()
512 .await
513 .map_err(|e| format!("GET {} failed: {}", self.bootstrap_url, e))?;
514 let status = response.status();
515 if status != reqwest::StatusCode::OK {
516 return Err(format!(
517 "GET {} returned HTTP {}",
518 self.bootstrap_url, status
519 ));
520 }
521 let body = read_body_capped(
522 response,
523 MAX_BOOTSTRAP_SIZE,
524 self.probe_timeout,
525 Overflow::Reject,
526 )
527 .await
528 .map_err(|e| match e {
529 BodyReadError::Chunk(e) => format!("reading {} body failed: {}", self.bootstrap_url, e),
530 BodyReadError::TooLarge => format!(
531 "GET {} returned a body over the {} byte cap",
532 self.bootstrap_url, MAX_BOOTSTRAP_SIZE
533 ),
534 BodyReadError::TimedOut => format!(
535 "reading {} body timed out after {:?}",
536 self.bootstrap_url, self.probe_timeout
537 ),
538 })?;
539 if body.is_empty() {
540 return Err(format!("GET {} returned an empty body", self.bootstrap_url));
541 }
542 Ok(format!(
543 "fetched {} ({} bytes)",
544 self.bootstrap_url,
545 body.len()
546 ))
547 }
548}
549
550fn latency_since(start: Instant) -> u64 {
552 u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX)
553}
554
555fn plural(n: usize) -> &'static str {
557 if n == 1 {
558 ""
559 } else {
560 "s"
561 }
562}
563
564fn one_line(s: &str) -> String {
567 s.split_whitespace().collect::<Vec<_>>().join(" ")
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573
574 use std::sync::atomic::{AtomicU32, Ordering};
575
576 use wiremock::matchers::{method, path};
577 use wiremock::{Mock, MockServer, ResponseTemplate};
578
579 use crate::dns::test_support::{mock_dns_resolver, spawn_mock_dns, MockMode};
580
581 struct TmpFile(PathBuf);
585
586 impl TmpFile {
587 fn unique(tag: &str) -> Self {
588 static COUNTER: AtomicU32 = AtomicU32::new(0);
589 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
590 Self(std::env::temp_dir().join(format!(
591 "seer-doctor-{}-{}-{}.toml",
592 tag,
593 std::process::id(),
594 n
595 )))
596 }
597
598 fn with_content(tag: &str, content: &str) -> Self {
599 let file = Self::unique(tag);
600 std::fs::write(&file.0, content).expect("write temp config");
601 file
602 }
603 }
604
605 impl Drop for TmpFile {
606 fn drop(&mut self) {
607 let _ = std::fs::remove_file(&self.0);
608 }
609 }
610
611 async fn spawn_whois_responder(response: &'static [u8]) -> u16 {
613 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
614 .await
615 .expect("bind mock whois");
616 let port = listener.local_addr().expect("local addr").port();
617 tokio::spawn(async move {
618 loop {
619 let Ok((mut sock, _)) = listener.accept().await else {
620 return;
621 };
622 let mut buf = [0u8; 128];
623 let _ = sock.read(&mut buf).await; let _ = sock.write_all(response).await;
625 }
626 });
627 port
628 }
629
630 async fn refused_port() -> u16 {
632 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
633 .await
634 .expect("bind for refused port");
635 let port = listener.local_addr().expect("local addr").port();
636 drop(listener);
637 port
638 }
639
640 fn base_doctor(config: &SeerConfig) -> Doctor {
641 Doctor::from_config(config)
647 .with_config_path(TmpFile::unique("unused").0.clone())
648 .with_probe_timeout(Duration::from_secs(2))
649 .allowing_reserved_for_tests()
650 }
651
652 async fn dns_doctor(mode: MockMode) -> Doctor {
654 let port = spawn_mock_dns(mode).await;
655 let config = SeerConfig {
656 nameserver: Some("127.0.0.1".to_string()),
657 ..SeerConfig::default()
658 };
659 base_doctor(&config)
660 .with_resolver(mock_dns_resolver(port))
661 .with_probe_domain("seer.test")
662 }
663
664 fn check(status: CheckStatus) -> DoctorCheck {
667 DoctorCheck::new("x", status, "detail", None)
668 }
669
670 #[test]
671 fn overall_is_worst_of_checks() {
672 use CheckStatus::{Fail, Pass, Warn};
673 let cases = [
674 (vec![], Pass),
675 (vec![check(Pass), check(Pass)], Pass),
676 (vec![check(Pass), check(Warn)], Warn),
677 (vec![check(Warn), check(Pass), check(Fail)], Fail),
678 (vec![check(Fail), check(Warn)], Fail),
679 ];
680 for (checks, expected) in cases {
681 assert_eq!(DoctorReport::from_checks(checks).overall, expected);
682 }
683 }
684
685 #[test]
686 fn report_serde_roundtrips_with_lowercase_statuses() {
687 let report = DoctorReport::from_checks(vec![DoctorCheck::new(
688 "dns",
689 CheckStatus::Fail,
690 "boom",
691 Some(12),
692 )]);
693 let json = serde_json::to_string(&report).expect("serialize report");
694 assert!(json.contains("\"fail\""), "got: {json}");
695 let back: DoctorReport = serde_json::from_str(&json).expect("deserialize report");
696 assert_eq!(back, report);
697 }
698
699 #[test]
700 fn doctor_defaults_target_real_endpoints() {
701 let doctor = Doctor::from_config(&SeerConfig::default());
704 assert_eq!(doctor.whois_addr, "whois.iana.org:43");
705 assert_eq!(doctor.bootstrap_url, "https://data.iana.org/rdap/dns.json");
706 assert_eq!(doctor.probe_domain, "example.com");
707 assert_eq!(doctor.probe_timeout, Duration::from_secs(5));
708 }
709
710 #[tokio::test]
713 async fn config_check_absent_file_passes() {
714 let missing = TmpFile::unique("absent");
715 let doctor =
716 Doctor::from_config(&SeerConfig::default()).with_config_path(missing.0.clone());
717 let check = doctor.check_config().await;
718 assert_eq!(check.status, CheckStatus::Pass);
719 assert!(check.detail.contains("defaults"), "got: {}", check.detail);
720 assert_eq!(check.latency_ms, None);
721 }
722
723 #[tokio::test]
724 async fn config_check_valid_file_passes() {
725 let file = TmpFile::with_content("valid", "output_format = \"json\"\n");
726 let doctor = Doctor::from_config(&SeerConfig::default()).with_config_path(file.0.clone());
727 let check = doctor.check_config().await;
728 assert_eq!(check.status, CheckStatus::Pass);
729 assert!(check.detail.contains("parsed OK"), "got: {}", check.detail);
730 }
731
732 #[tokio::test]
733 async fn config_check_malformed_file_warns() {
734 for content in ["output_format = [not toml", "output_format = 42"] {
737 let file = TmpFile::with_content("malformed", content);
738 let doctor =
739 Doctor::from_config(&SeerConfig::default()).with_config_path(file.0.clone());
740 let check = doctor.check_config().await;
741 assert_eq!(check.status, CheckStatus::Warn, "content: {content}");
742 assert!(check.detail.contains("defaults"), "got: {}", check.detail);
743 assert!(!check.detail.contains('\n'), "got: {}", check.detail);
745 }
746 }
747
748 #[tokio::test]
751 async fn whois_check_passes_against_responding_listener() {
752 let port = spawn_whois_responder(b"% IANA WHOIS server\r\n").await;
753 let doctor =
754 base_doctor(&SeerConfig::default()).with_whois_addr(format!("127.0.0.1:{port}"));
755 let check = doctor.check_whois().await;
756 assert_eq!(check.status, CheckStatus::Pass, "got: {}", check.detail);
757 assert!(check.latency_ms.is_some());
758 }
759
760 #[tokio::test]
761 async fn whois_check_fails_on_connection_refused() {
762 let port = refused_port().await;
763 let doctor =
764 base_doctor(&SeerConfig::default()).with_whois_addr(format!("127.0.0.1:{port}"));
765 let check = doctor.check_whois().await;
766 assert_eq!(check.status, CheckStatus::Fail);
767 assert!(
772 check.detail.contains(&format!("127.0.0.1:{port}")),
773 "got: {}",
774 check.detail
775 );
776 }
777
778 #[tokio::test]
779 async fn whois_check_fails_on_eof_without_data() {
780 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
783 .await
784 .expect("bind mock whois");
785 let port = listener.local_addr().expect("local addr").port();
786 tokio::spawn(async move {
787 let Ok((mut sock, _)) = listener.accept().await else {
788 return;
789 };
790 let mut buf = [0u8; 128];
791 let _ = sock.read(&mut buf).await;
792 });
794 let doctor =
795 base_doctor(&SeerConfig::default()).with_whois_addr(format!("127.0.0.1:{port}"));
796 let check = doctor.check_whois().await;
797 assert_eq!(check.status, CheckStatus::Fail, "got: {}", check.detail);
798 }
799
800 #[tokio::test]
801 async fn whois_check_vets_probe_target_through_resolve_public_host() {
802 let unused = TmpFile::unique("reserved");
808 let doctor = Doctor::from_config(&SeerConfig::default())
809 .with_config_path(unused.0.clone())
810 .with_whois_addr("10.255.255.1:43".to_string())
811 .with_probe_timeout(Duration::from_millis(300));
812 let check = doctor.check_whois().await;
813 assert_eq!(check.status, CheckStatus::Fail, "got: {}", check.detail);
814 assert!(check.detail.contains("reserved"), "got: {}", check.detail);
815 assert!(
816 !check.detail.contains("port 43 may be blocked"),
817 "got: {}",
818 check.detail
819 );
820 }
821
822 #[tokio::test]
823 async fn whois_check_fails_on_timeout() {
824 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
826 .await
827 .expect("bind mock whois");
828 let port = listener.local_addr().expect("local addr").port();
829 tokio::spawn(async move {
830 let Ok((_sock, _)) = listener.accept().await else {
831 return;
832 };
833 tokio::time::sleep(Duration::from_secs(30)).await;
834 });
835 let doctor = base_doctor(&SeerConfig::default())
836 .with_whois_addr(format!("127.0.0.1:{port}"))
837 .with_probe_timeout(Duration::from_millis(200));
838 let check = doctor.check_whois().await;
839 assert_eq!(check.status, CheckStatus::Fail);
840 assert!(check.detail.contains("timed out"), "got: {}", check.detail);
841 }
842
843 #[tokio::test]
846 async fn rdap_check_passes_on_200_with_body() {
847 let server = MockServer::start().await;
848 Mock::given(method("GET"))
849 .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"services":[]}"#))
850 .mount(&server)
851 .await;
852 let doctor = base_doctor(&SeerConfig::default())
853 .with_bootstrap_url(format!("{}/rdap/dns.json", server.uri()));
854 let check = doctor.check_rdap_bootstrap().await;
855 assert_eq!(check.status, CheckStatus::Pass, "got: {}", check.detail);
856 assert!(check.latency_ms.is_some());
857 }
858
859 #[tokio::test]
860 async fn rdap_check_fails_on_500() {
861 let server = MockServer::start().await;
862 Mock::given(method("GET"))
863 .respond_with(ResponseTemplate::new(500))
864 .mount(&server)
865 .await;
866 let doctor = base_doctor(&SeerConfig::default())
867 .with_bootstrap_url(format!("{}/rdap/dns.json", server.uri()));
868 let check = doctor.check_rdap_bootstrap().await;
869 assert_eq!(check.status, CheckStatus::Fail);
870 assert!(check.detail.contains("500"), "got: {}", check.detail);
871 }
872
873 #[tokio::test]
874 async fn rdap_check_fails_on_empty_body() {
875 let server = MockServer::start().await;
876 Mock::given(method("GET"))
877 .respond_with(ResponseTemplate::new(200))
878 .mount(&server)
879 .await;
880 let doctor = base_doctor(&SeerConfig::default())
881 .with_bootstrap_url(format!("{}/rdap/dns.json", server.uri()));
882 let check = doctor.check_rdap_bootstrap().await;
883 assert_eq!(check.status, CheckStatus::Fail);
884 assert!(check.detail.contains("empty body"), "got: {}", check.detail);
885 }
886
887 #[tokio::test]
890 async fn rdap_check_does_not_follow_redirects() {
891 let server = MockServer::start().await;
892 Mock::given(path("/rdap/dns.json"))
893 .respond_with(ResponseTemplate::new(302).insert_header("location", "/moved.json"))
894 .mount(&server)
895 .await;
896 Mock::given(path("/moved.json"))
897 .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"services":[]}"#))
898 .mount(&server)
899 .await;
900 let doctor = base_doctor(&SeerConfig::default())
901 .with_bootstrap_url(format!("{}/rdap/dns.json", server.uri()));
902 let check = doctor.check_rdap_bootstrap().await;
903 assert_eq!(check.status, CheckStatus::Fail, "got: {}", check.detail);
904 assert!(check.detail.contains("302"), "got: {}", check.detail);
905 }
906
907 #[tokio::test]
909 async fn rdap_check_fails_on_body_over_the_bootstrap_cap() {
910 let server = MockServer::start().await;
911 Mock::given(method("GET"))
912 .respond_with(
913 ResponseTemplate::new(200).set_body_bytes(vec![b' '; MAX_BOOTSTRAP_SIZE + 1]),
914 )
915 .mount(&server)
916 .await;
917 let doctor = base_doctor(&SeerConfig::default())
918 .with_bootstrap_url(format!("{}/rdap/dns.json", server.uri()));
919 let check = doctor.check_rdap_bootstrap().await;
920 assert_eq!(check.status, CheckStatus::Fail, "got: {}", check.detail);
921 assert!(check.detail.contains("byte cap"), "got: {}", check.detail);
922 }
923
924 #[tokio::test]
925 async fn rdap_check_fails_on_timeout() {
926 let server = MockServer::start().await;
927 Mock::given(method("GET"))
928 .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(30)))
929 .mount(&server)
930 .await;
931 let doctor = base_doctor(&SeerConfig::default())
932 .with_bootstrap_url(format!("{}/rdap/dns.json", server.uri()))
933 .with_probe_timeout(Duration::from_millis(200));
934 let check = doctor.check_rdap_bootstrap().await;
935 assert_eq!(check.status, CheckStatus::Fail, "got: {}", check.detail);
936 }
937
938 #[tokio::test]
941 async fn dns_check_passes_with_mock_zone() {
942 let doctor = dns_doctor(MockMode::Zone).await;
943 let check = doctor.check_dns().await;
944 assert_eq!(check.status, CheckStatus::Pass, "got: {}", check.detail);
945 assert!(check.detail.contains("127.0.0.1"), "got: {}", check.detail);
947 assert!(check.latency_ms.is_some());
948 }
949
950 #[tokio::test]
951 async fn dns_check_warns_on_nxdomain_for_probe_name() {
952 let doctor = dns_doctor(MockMode::Nxdomain).await;
953 let check = doctor.check_dns().await;
954 assert_eq!(check.status, CheckStatus::Warn, "got: {}", check.detail);
955 }
956
957 #[tokio::test]
958 async fn dns_check_fails_when_server_unresponsive() {
959 let doctor = dns_doctor(MockMode::Ignore)
962 .await
963 .with_probe_timeout(Duration::from_secs(1));
964 let check = doctor.check_dns().await;
965 assert_eq!(check.status, CheckStatus::Fail, "got: {}", check.detail);
966 }
967
968 #[tokio::test]
971 async fn run_reports_all_four_checks_in_order_and_passes() {
972 let config_file = TmpFile::with_content("run-valid", "output_format = \"yaml\"\n");
973 let whois_port = spawn_whois_responder(b"% ok\r\n").await;
974 let rdap = MockServer::start().await;
975 Mock::given(method("GET"))
976 .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"services":[]}"#))
977 .mount(&rdap)
978 .await;
979
980 let doctor = dns_doctor(MockMode::Zone)
981 .await
982 .with_config_path(config_file.0.clone())
983 .with_whois_addr(format!("127.0.0.1:{whois_port}"))
984 .with_bootstrap_url(format!("{}/rdap/dns.json", rdap.uri()));
985
986 let report = doctor.run().await;
987 let names: Vec<&str> = report.checks.iter().map(|c| c.name.as_str()).collect();
988 assert_eq!(names, ["config", "dns", "whois", "rdap-bootstrap"]);
989 for check in &report.checks {
990 assert_eq!(
991 check.status,
992 CheckStatus::Pass,
993 "{}: {}",
994 check.name,
995 check.detail
996 );
997 }
998 assert_eq!(report.overall, CheckStatus::Pass);
999 }
1000
1001 #[tokio::test]
1002 async fn run_isolates_probe_failures() {
1003 let missing_config = TmpFile::unique("run-absent");
1006 let whois_port = refused_port().await;
1007 let rdap = MockServer::start().await;
1008 Mock::given(method("GET"))
1009 .respond_with(ResponseTemplate::new(500))
1010 .mount(&rdap)
1011 .await;
1012
1013 let doctor = dns_doctor(MockMode::Zone)
1014 .await
1015 .with_config_path(missing_config.0.clone())
1016 .with_whois_addr(format!("127.0.0.1:{whois_port}"))
1017 .with_bootstrap_url(format!("{}/rdap/dns.json", rdap.uri()));
1018
1019 let report = doctor.run().await;
1020 let by_name = |name: &str| {
1021 report
1022 .checks
1023 .iter()
1024 .find(|c| c.name == name)
1025 .unwrap_or_else(|| panic!("missing check {name}"))
1026 };
1027 assert_eq!(by_name("config").status, CheckStatus::Pass);
1028 assert_eq!(by_name("dns").status, CheckStatus::Pass);
1029 assert_eq!(by_name("whois").status, CheckStatus::Fail);
1030 assert_eq!(by_name("rdap-bootstrap").status, CheckStatus::Fail);
1031 assert_eq!(report.overall, CheckStatus::Fail);
1032 }
1033
1034 #[tokio::test]
1035 async fn run_with_malformed_config_only_warns_overall() {
1036 let config_file = TmpFile::with_content("run-malformed", "output_format = [broken");
1039 let whois_port = spawn_whois_responder(b"% ok\r\n").await;
1040 let rdap = MockServer::start().await;
1041 Mock::given(method("GET"))
1042 .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"services":[]}"#))
1043 .mount(&rdap)
1044 .await;
1045
1046 let doctor = dns_doctor(MockMode::Zone)
1047 .await
1048 .with_config_path(config_file.0.clone())
1049 .with_whois_addr(format!("127.0.0.1:{whois_port}"))
1050 .with_bootstrap_url(format!("{}/rdap/dns.json", rdap.uri()));
1051
1052 let report = doctor.run().await;
1053 assert_eq!(report.overall, CheckStatus::Warn);
1054 }
1055}