1use std::collections::HashSet;
10use std::net::SocketAddr;
11use std::time::Duration;
12
13use chrono::Utc;
14use native_tls::TlsConnector;
15use once_cell::sync::Lazy;
16use regex::Regex;
17use reqwest::{Client, Url};
18use tokio::net::TcpStream;
19use tracing::{debug, instrument};
20
21use super::types::{CertificateInfo, DnsResolution, DomainExpiration, StatusResponse};
22use crate::caa::{self, CaaPolicy};
23use crate::dns::{DnsResolver, RecordData, RecordType};
24use crate::error::{Result, SeerError};
25use crate::lookup::SmartLookup;
26use crate::validation::normalize_host;
27
28const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
31const MAX_REDIRECTS: usize = 5;
32
33static TITLE_REGEX: Lazy<Regex> = Lazy::new(|| {
35 Regex::new(r"(?i)<title[^>]*>([^<]+)</title>").expect("Invalid regex for HTML title extraction")
36});
37
38#[derive(Debug, Clone)]
40pub struct StatusClient {
41 timeout: Duration,
42 dns_resolver: DnsResolver,
44 smart_lookup: SmartLookup,
46}
47
48impl Default for StatusClient {
49 fn default() -> Self {
50 Self::new()
51 }
52}
53
54impl StatusClient {
55 pub fn new() -> Self {
57 Self {
58 timeout: DEFAULT_TIMEOUT,
59 dns_resolver: DnsResolver::new(),
60 smart_lookup: SmartLookup::new(),
61 }
62 }
63
64 pub fn from_config(config: &crate::config::SeerConfig) -> Self {
73 Self {
74 timeout: config.http_timeout(),
75 dns_resolver: DnsResolver::from_config(config),
76 smart_lookup: SmartLookup::from_config(config),
77 }
78 }
79
80 pub fn with_timeout(mut self, timeout: Duration) -> Self {
82 self.timeout = timeout;
83 self
84 }
85
86 #[instrument(skip(self), fields(domain = %domain))]
88 pub async fn check(&self, domain: &str) -> Result<StatusResponse> {
89 let domain = normalize_host(domain)?;
94 debug!("Checking status for domain: {}", domain);
95
96 let mut response = StatusResponse::new(domain.clone());
97
98 let (http_result, cert_result, expiry_result, dns_result, caa_policy) = tokio::join!(
103 self.fetch_http_info(&domain),
104 self.fetch_certificate_info(&domain),
105 self.fetch_domain_expiration(&domain),
106 self.fetch_dns_resolution(&domain),
107 caa::lookup_caa(&self.dns_resolver, &domain),
108 );
109
110 match http_result {
112 Ok((status, status_text, title)) => {
113 response.http_status = Some(status);
114 response.http_status_text = Some(status_text);
115 response.title = title;
116 }
117 Err(e) => response.errors.push(super::types::StatusError {
118 check: "http".to_string(),
119 message: e.to_string(),
120 }),
121 }
122
123 let mut caa_policy: CaaPolicy = caa_policy;
126 match cert_result {
127 Ok(cert_info) => {
128 caa_policy.issuer_match =
129 Some(caa::classify_issuer(&cert_info.issuer, &caa_policy));
130 response.certificate = Some(cert_info);
131 }
132 Err(e) => response.errors.push(super::types::StatusError {
133 check: "ssl".to_string(),
134 message: e.to_string(),
135 }),
136 }
137 response.caa = Some(caa_policy);
138
139 match expiry_result {
141 Ok(expiry_info) => response.domain_expiration = expiry_info,
142 Err(e) => response.errors.push(super::types::StatusError {
143 check: "expiration".to_string(),
144 message: e.to_string(),
145 }),
146 }
147
148 match dns_result {
150 Ok(dns_info) => response.dns_resolution = Some(dns_info),
151 Err(e) => response.errors.push(super::types::StatusError {
152 check: "dns".to_string(),
153 message: e.to_string(),
154 }),
155 }
156
157 Ok(response)
158 }
159
160 async fn fetch_http_info(&self, domain: &str) -> Result<(u16, String, Option<String>)> {
179 let mut url = Url::parse(&format!("https://{}", domain))
180 .map_err(|e| SeerError::HttpError(format!("invalid URL: {}", e)))?;
181 let mut visited = HashSet::new();
182
183 for _ in 0..=MAX_REDIRECTS {
184 let validated_addrs = validate_url_target(&url).await?;
185
186 if !visited.insert(url.clone()) {
187 return Err(SeerError::HttpError("redirect loop detected".to_string()));
188 }
189
190 let host = url
194 .host_str()
195 .ok_or_else(|| SeerError::HttpError("missing URL host".to_string()))?;
196 let client = Client::builder()
197 .redirect(reqwest::redirect::Policy::none())
198 .user_agent(concat!("Seer/", env!("CARGO_PKG_VERSION")))
199 .resolve_to_addrs(host, &validated_addrs)
200 .build()
201 .map_err(|e| SeerError::HttpError(format!("failed to build HTTP client: {}", e)))?;
202
203 let response = client
204 .get(url.clone())
205 .timeout(self.timeout)
206 .send()
207 .await
208 .map_err(|e| SeerError::HttpError(e.to_string()))?;
209
210 if response.status().is_redirection() {
211 let location = response.headers().get(reqwest::header::LOCATION);
212 let location = location.and_then(|v| v.to_str().ok()).ok_or_else(|| {
213 SeerError::HttpError("redirect missing location header".to_string())
214 })?;
215 let next_url = url
216 .join(location)
217 .or_else(|_| Url::parse(location))
218 .map_err(|e| SeerError::HttpError(format!("invalid redirect URL: {}", e)))?;
219 url = next_url;
220 continue;
221 }
222
223 let status = response.status();
224 let status_code = status.as_u16();
225 let status_text = status.canonical_reason().unwrap_or("Unknown").to_string();
226
227 let title = if status.is_success() {
229 let content_type = response
230 .headers()
231 .get("content-type")
232 .and_then(|v| v.to_str().ok())
233 .unwrap_or("");
234
235 if content_type.contains("text/html") {
236 const MAX_TITLE_BODY: usize = 64 * 1024;
241 use futures::StreamExt;
242 let mut buf: Vec<u8> = Vec::with_capacity(8 * 1024);
243 let mut stream = response.bytes_stream();
244 while let Some(chunk) = stream.next().await {
245 let chunk = chunk
246 .map_err(|e| SeerError::HttpError(format!("body chunk: {}", e)))?;
247 let remaining = MAX_TITLE_BODY.saturating_sub(buf.len());
248 if remaining == 0 {
249 break;
250 }
251 let take = remaining.min(chunk.len());
252 buf.extend_from_slice(&chunk[..take]);
253 if buf.len() >= MAX_TITLE_BODY {
254 break;
255 }
256 }
257 let body = String::from_utf8_lossy(&buf);
258 extract_title(&body)
259 } else {
260 None
261 }
262 } else {
263 None
264 };
265
266 return Ok((status_code, status_text, title));
267 }
268
269 Err(SeerError::HttpError("too many redirects".to_string()))
270 }
271
272 async fn fetch_certificate_info(&self, domain: &str) -> Result<CertificateInfo> {
279 let socket_addrs = crate::net::resolve_public_host(domain, 443)
284 .await
285 .map_err(|e| SeerError::CertificateError(e.to_string()))?;
286
287 let connector = TlsConnector::builder()
288 .danger_accept_invalid_certs(true) .build()
290 .map_err(|e| SeerError::CertificateError(e.to_string()))?;
291
292 let connector = tokio_native_tls::TlsConnector::from(connector);
293
294 let stream =
297 tokio::time::timeout(self.timeout, TcpStream::connect(socket_addrs.as_slice()))
298 .await
299 .map_err(|_| SeerError::Timeout(format!("connection to {} timed out", domain)))?
300 .map_err(|e| SeerError::CertificateError(e.to_string()))?;
301
302 let tls_stream = tokio::time::timeout(self.timeout, connector.connect(domain, stream))
304 .await
305 .map_err(|_| SeerError::Timeout(format!("TLS handshake with {} timed out", domain)))?
306 .map_err(|e| SeerError::CertificateError(e.to_string()))?;
307
308 let cert = tls_stream
310 .get_ref()
311 .peer_certificate()
312 .map_err(|e| SeerError::CertificateError(e.to_string()))?
313 .ok_or_else(|| SeerError::CertificateError("no certificate found".to_string()))?;
314
315 let der = cert
317 .to_der()
318 .map_err(|e| SeerError::CertificateError(e.to_string()))?;
319
320 parse_certificate_der(&der, domain)
321 }
322
323 async fn fetch_domain_expiration(&self, domain: &str) -> Result<Option<DomainExpiration>> {
325 match self.smart_lookup.lookup(domain).await {
326 Ok(result) => {
327 let (expiration_date, registrar) = result.expiration_info();
328
329 if let Some(exp_date) = expiration_date {
330 let days_until_expiry = crate::dates::days_until(exp_date, Utc::now());
331 Ok(Some(DomainExpiration {
332 expiration_date: exp_date,
333 days_until_expiry,
334 registrar,
335 }))
336 } else {
337 Ok(None)
338 }
339 }
340 Err(_) => Ok(None), }
342 }
343
344 async fn fetch_dns_resolution(&self, domain: &str) -> Result<DnsResolution> {
346 let resolver = &self.dns_resolver;
347
348 let (a_result, aaaa_result, cname_result, ns_result) = tokio::join!(
350 resolver.resolve(domain, RecordType::A, None),
351 resolver.resolve(domain, RecordType::AAAA, None),
352 resolver.resolve(domain, RecordType::CNAME, None),
353 resolver.resolve(domain, RecordType::NS, None)
354 );
355
356 let a_records: Vec<String> = a_result
358 .unwrap_or_default()
359 .into_iter()
360 .filter_map(|r| {
361 if let RecordData::A { address } = r.data {
362 Some(address)
363 } else {
364 None
365 }
366 })
367 .collect();
368
369 let aaaa_records: Vec<String> = aaaa_result
371 .unwrap_or_default()
372 .into_iter()
373 .filter_map(|r| {
374 if let RecordData::AAAA { address } = r.data {
375 Some(address)
376 } else {
377 None
378 }
379 })
380 .collect();
381
382 let cname_target: Option<String> =
384 cname_result.unwrap_or_default().into_iter().find_map(|r| {
385 if let RecordData::CNAME { target } = r.data {
386 Some(target.trim_end_matches('.').to_string())
387 } else {
388 None
389 }
390 });
391
392 let nameservers: Vec<String> = ns_result
394 .unwrap_or_default()
395 .into_iter()
396 .filter_map(|r| {
397 if let RecordData::NS { nameserver } = r.data {
398 Some(nameserver.trim_end_matches('.').to_string())
399 } else {
400 None
401 }
402 })
403 .collect();
404
405 let resolves = !a_records.is_empty() || !aaaa_records.is_empty() || cname_target.is_some();
407
408 Ok(DnsResolution {
409 a_records,
410 aaaa_records,
411 cname_target,
412 nameservers,
413 resolves,
414 })
415 }
416}
417
418fn extract_title(html: &str) -> Option<String> {
429 TITLE_REGEX
430 .captures(html)
431 .and_then(|caps| caps.get(1))
432 .map(|m| {
433 m.as_str()
439 .chars()
440 .filter(|c| !c.is_control())
441 .collect::<String>()
442 .trim()
443 .to_string()
444 })
445 .filter(|s| !s.is_empty())
446}
447
448async fn validate_url_target(url: &Url) -> Result<Vec<SocketAddr>> {
468 crate::net::validate_http_url(url).await
469}
470
471fn parse_certificate_der(der: &[u8], domain: &str) -> Result<CertificateInfo> {
473 use x509_parser::prelude::*;
474
475 let (_, cert) = X509Certificate::from_der(der)
476 .map_err(|e| SeerError::CertificateError(format!("failed to parse certificate: {}", e)))?;
477
478 let issuer = format_issuer_name(cert.issuer()).unwrap_or_else(|| "Unknown Issuer".to_string());
483
484 let subject =
486 extract_name_from_x509(cert.subject()).unwrap_or_else(|| "Unknown Subject".to_string());
487
488 let valid_from = asn1_time_to_chrono(cert.validity().not_before)?;
490 let valid_until = asn1_time_to_chrono(cert.validity().not_after)?;
491
492 let now = Utc::now();
493 let days_until_expiry = crate::dates::days_until(valid_until, now);
494 let is_valid = now >= valid_from && now <= valid_until;
495
496 let hostname_verified = cert_matches_hostname(&cert, domain);
501
502 Ok(CertificateInfo {
503 issuer,
504 subject,
505 valid_from,
506 valid_until,
507 days_until_expiry,
508 is_valid,
509 hostname_verified,
510 })
511}
512
513fn hostname_matches_pattern(host: &str, pattern: &str) -> bool {
519 let host = host.to_ascii_lowercase();
520 let pattern = pattern.to_ascii_lowercase();
521 if let Some(rest) = pattern.strip_prefix("*.") {
522 let Some(dot) = host.find('.') else {
524 return false;
525 };
526 let host_rest = &host[dot + 1..];
527 host_rest == rest
528 } else {
529 host == pattern
530 }
531}
532
533fn cert_matches_hostname(cert: &x509_parser::certificate::X509Certificate<'_>, host: &str) -> bool {
542 use x509_parser::prelude::*;
543
544 let mut has_dns_san = false;
546 if let Ok(Some(san_ext)) = cert.tbs_certificate.subject_alternative_name() {
547 for name in &san_ext.value.general_names {
548 if let GeneralName::DNSName(n) = name {
549 has_dns_san = true;
550 if hostname_matches_pattern(host, n) {
551 return true;
552 }
553 }
554 }
555 }
556 if has_dns_san {
557 return false;
558 }
559
560 for cn in cert.subject().iter_common_name() {
562 if let Ok(s) = cn.as_str() {
563 if hostname_matches_pattern(host, s) {
564 return true;
565 }
566 }
567 }
568
569 false
570}
571
572fn format_issuer_name(name: &x509_parser::prelude::X509Name) -> Option<String> {
576 use x509_parser::oid_registry;
577 let cn = extract_oid_value(name, &oid_registry::OID_X509_COMMON_NAME);
578 let org = extract_oid_value(name, &oid_registry::OID_X509_ORGANIZATION_NAME);
579 match (org, cn) {
580 (Some(o), Some(c)) if o != c => Some(format!("{} ({})", o, c)),
581 (Some(o), _) => Some(o),
582 (None, Some(c)) => Some(c),
583 (None, None) => None,
584 }
585}
586
587fn extract_oid_value(
589 name: &x509_parser::prelude::X509Name,
590 oid: &x509_parser::der_parser::oid::Oid<'static>,
591) -> Option<String> {
592 for rdn in name.iter() {
593 for attr in rdn.iter() {
594 if attr.attr_type() == oid {
595 if let Some(s) = extract_attr_string(attr.attr_value()) {
596 return Some(s);
597 }
598 }
599 }
600 }
601 None
602}
603
604fn extract_name_from_x509(name: &x509_parser::prelude::X509Name) -> Option<String> {
606 use x509_parser::prelude::*;
607
608 for rdn in name.iter() {
610 for attr in rdn.iter() {
611 if attr.attr_type() == &oid_registry::OID_X509_COMMON_NAME {
612 if let Some(s) = extract_attr_string(attr.attr_value()) {
613 return Some(s);
614 }
615 }
616 }
617 }
618
619 for rdn in name.iter() {
621 for attr in rdn.iter() {
622 if attr.attr_type() == &oid_registry::OID_X509_ORGANIZATION_NAME {
623 if let Some(s) = extract_attr_string(attr.attr_value()) {
624 return Some(s);
625 }
626 }
627 }
628 }
629
630 None
631}
632
633fn extract_attr_string(value: &x509_parser::der_parser::asn1_rs::Any) -> Option<String> {
635 if let Ok(s) = value.as_str() {
637 return Some(s.to_string());
638 }
639
640 if let Ok(utf8) = value.as_utf8string() {
642 return Some(utf8.string().to_string());
643 }
644
645 if let Ok(s) = std::str::from_utf8(value.data) {
647 return Some(s.to_string());
648 }
649
650 None
651}
652
653fn asn1_time_to_chrono(time: x509_parser::time::ASN1Time) -> Result<chrono::DateTime<Utc>> {
655 let timestamp = time.timestamp();
656 chrono::DateTime::from_timestamp(timestamp, 0)
657 .ok_or_else(|| SeerError::CertificateError("invalid certificate timestamp".to_string()))
658}
659
660#[cfg(test)]
661mod tests {
662 use super::*;
663
664 #[test]
665 fn from_config_applies_http_timeout() {
666 let mut config = crate::config::SeerConfig::default();
667 config.timeouts.http_secs = 55;
668 let client = StatusClient::from_config(&config);
669 assert_eq!(client.timeout, Duration::from_secs(55));
670 }
671
672 #[test]
673 fn hostname_matches_pattern_exact() {
674 assert!(hostname_matches_pattern("example.com", "example.com"));
675 assert!(hostname_matches_pattern("EXAMPLE.COM", "example.com"));
676 assert!(hostname_matches_pattern("example.com", "EXAMPLE.COM"));
677 assert!(!hostname_matches_pattern("evil.com", "example.com"));
678 assert!(!hostname_matches_pattern("example.com", "evil.com"));
679 }
680
681 #[test]
682 fn hostname_matches_pattern_wildcard() {
683 assert!(hostname_matches_pattern("a.example.com", "*.example.com"));
684 assert!(hostname_matches_pattern("A.EXAMPLE.COM", "*.example.com"));
685 assert!(!hostname_matches_pattern("example.com", "*.example.com"));
687 assert!(!hostname_matches_pattern(
689 "a.b.example.com",
690 "*.example.com"
691 ));
692 assert!(!hostname_matches_pattern("b.other.com", "*.example.com"));
693 }
694
695 #[test]
696 fn hostname_matches_pattern_wildcard_requires_dot() {
697 assert!(!hostname_matches_pattern("localhost", "*.example.com"));
699 }
700
701 const CERT_CN_VICTIM_SAN_OTHER: &str = "MIIBoDCCAUegAwIBAgIUdStRrtt0ycIGUV74700+xRrFcJ0wCgYIKoZIzj0EAwIwGTEXMBUGA1UEAwwOdmljdGltLmV4YW1wbGUwHhcNMjYwOTIyMTcwMzA4WhcNMzYwOTE5MTcwMzA4WjAZMRcwFQYDVQQDDA52aWN0aW0uZXhhbXBsZTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJPCvcjh/aeA2qb1taFaBCxI/ue4srU8jUNjjvQW9IKMqdsUluEGjW7fcYSa8w/79MWZ/naVmgZKQs/eSXCU/AWjbTBrMB0GA1UdDgQWBBSG5So71BSr3DZri66kQaPKzWbjNDAfBgNVHSMEGDAWgBSG5So71BSr3DZri66kQaPKzWbjNDAPBgNVHRMBAf8EBTADAQH/MBgGA1UdEQQRMA+CDW90aGVyLmV4YW1wbGUwCgYIKoZIzj0EAwIDRwAwRAIgEnAMNQMytsawL+CuV7N9z/ftwHVzdFunp+oG7QjIou4CIHsf9vyIXQUPs5iBrhprcRiwyuZQWy0mZyRdavp4Kgbh";
703 const CERT_CN_VICTIM_NO_SAN: &str = "MIIBhzCCAS2gAwIBAgIUeGkzmcc68l5FOH5NOBgS3Ybcg4gwCgYIKoZIzj0EAwIwGTEXMBUGA1UEAwwOdmljdGltLmV4YW1wbGUwHhcNMjYwOTIyMTcwMzA4WhcNMzYwOTE5MTcwMzA4WjAZMRcwFQYDVQQDDA52aWN0aW0uZXhhbXBsZTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABC6rgHiHBhd3vxpcRHm7VH2YgCybc0Bl4ewS1lMjdtM5+R+pX/STje36olq5IDx9AEJfxtdRMvtiWp9jfb5vdB6jUzBRMB0GA1UdDgQWBBS5JfZqENT0bfsAazBNLiAVb77UdzAfBgNVHSMEGDAWgBS5JfZqENT0bfsAazBNLiAVb77UdzAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0gAMEUCIQD5zMnpSHSVr3vSmZM0vh0R345Rg3wc+OgeZwmsDxDJQQIgBNJ0CS0bpChCAQls0oFZUPD6u7iX7uBOD/QRPZ2Ub1k=";
705
706 fn cert_info(b64: &str, host: &str) -> CertificateInfo {
707 use base64::{engine::general_purpose::STANDARD, Engine};
708 let der = STANDARD.decode(b64).unwrap();
709 parse_certificate_der(&der, host).unwrap()
710 }
711
712 #[test]
713 fn cn_is_ignored_when_the_cert_has_dns_sans() {
714 assert!(!cert_info(CERT_CN_VICTIM_SAN_OTHER, "victim.example").hostname_verified);
717 assert!(cert_info(CERT_CN_VICTIM_SAN_OTHER, "other.example").hostname_verified);
718 assert!(cert_info(CERT_CN_VICTIM_NO_SAN, "victim.example").hostname_verified);
720 assert!(!cert_info(CERT_CN_VICTIM_NO_SAN, "other.example").hostname_verified);
721 }
722
723 #[tokio::test]
726 async fn validate_url_target_rejects_unsupported_scheme() {
727 let url = Url::parse("ftp://example.com/").unwrap();
728 let err = validate_url_target(&url).await.unwrap_err();
729 assert!(
730 matches!(err, SeerError::HttpError(ref s) if s.contains("unsupported URL scheme")),
731 "got: {err:?}"
732 );
733 }
734
735 #[tokio::test]
736 async fn validate_url_target_rejects_credentials() {
737 let url = Url::parse("https://user:pass@example.com/").unwrap();
738 let err = validate_url_target(&url).await.unwrap_err();
739 assert!(
740 matches!(err, SeerError::HttpError(ref s) if s.contains("credentials")),
741 "got: {err:?}"
742 );
743 }
744
745 #[tokio::test]
746 async fn validate_url_target_rejects_non_standard_port() {
747 let url = Url::parse("https://8.8.8.8:8443/").unwrap();
748 let err = validate_url_target(&url).await.unwrap_err();
749 assert!(
750 matches!(err, SeerError::HttpError(ref s) if s.contains("non-standard port")),
751 "got: {err:?}"
752 );
753 }
754
755 #[tokio::test]
756 async fn validate_url_target_rejects_loopback_literal() {
757 let url = Url::parse("https://127.0.0.1/").unwrap();
758 let err = validate_url_target(&url).await.unwrap_err();
759 assert!(
760 matches!(err, SeerError::HttpError(ref s) if s.contains("reserved")),
761 "got: {err:?}"
762 );
763 }
764
765 #[tokio::test]
766 async fn validate_url_target_rejects_bracketed_ipv6_loopback_literal() {
767 let url = Url::parse("https://[::1]/").unwrap();
771 let err = validate_url_target(&url).await.unwrap_err();
772 assert!(
773 matches!(err, SeerError::HttpError(ref s) if s.contains("reserved")),
774 "got: {err:?}"
775 );
776 }
777
778 #[tokio::test]
779 async fn validate_url_target_allows_public_ip_literal() {
780 let url = Url::parse("https://8.8.8.8/").unwrap();
781 let addrs = validate_url_target(&url).await.unwrap();
782 assert_eq!(addrs.len(), 1);
783 assert_eq!(addrs[0].port(), 443);
784 }
785}