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