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_domain;
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_domain(domain)?;
91 debug!("Checking status for domain: {}", domain);
92
93 let mut response = StatusResponse::new(domain.clone());
94
95 let (http_result, cert_result, expiry_result, dns_result, caa_policy) = tokio::join!(
100 self.fetch_http_info(&domain),
101 self.fetch_certificate_info(&domain),
102 self.fetch_domain_expiration(&domain),
103 self.fetch_dns_resolution(&domain),
104 caa::lookup_caa(&self.dns_resolver, &domain),
105 );
106
107 match http_result {
109 Ok((status, status_text, title)) => {
110 response.http_status = Some(status);
111 response.http_status_text = Some(status_text);
112 response.title = title;
113 }
114 Err(e) => response.errors.push(super::types::StatusError {
115 check: "http".to_string(),
116 message: e.to_string(),
117 }),
118 }
119
120 let mut caa_policy: CaaPolicy = caa_policy;
123 match cert_result {
124 Ok(cert_info) => {
125 caa_policy.issuer_match =
126 Some(caa::classify_issuer(&cert_info.issuer, &caa_policy));
127 response.certificate = Some(cert_info);
128 }
129 Err(e) => response.errors.push(super::types::StatusError {
130 check: "ssl".to_string(),
131 message: e.to_string(),
132 }),
133 }
134 response.caa = Some(caa_policy);
135
136 match expiry_result {
138 Ok(expiry_info) => response.domain_expiration = expiry_info,
139 Err(e) => response.errors.push(super::types::StatusError {
140 check: "expiration".to_string(),
141 message: e.to_string(),
142 }),
143 }
144
145 match dns_result {
147 Ok(dns_info) => response.dns_resolution = Some(dns_info),
148 Err(e) => response.errors.push(super::types::StatusError {
149 check: "dns".to_string(),
150 message: e.to_string(),
151 }),
152 }
153
154 Ok(response)
155 }
156
157 async fn fetch_http_info(&self, domain: &str) -> Result<(u16, String, Option<String>)> {
176 let mut url = Url::parse(&format!("https://{}", domain))
177 .map_err(|e| SeerError::HttpError(format!("invalid URL: {}", e)))?;
178 let mut visited = HashSet::new();
179
180 for _ in 0..=MAX_REDIRECTS {
181 let validated_addrs = validate_url_target(&url).await?;
182
183 if !visited.insert(url.clone()) {
184 return Err(SeerError::HttpError("redirect loop detected".to_string()));
185 }
186
187 let host = url
191 .host_str()
192 .ok_or_else(|| SeerError::HttpError("missing URL host".to_string()))?;
193 let client = Client::builder()
194 .redirect(reqwest::redirect::Policy::none())
195 .user_agent(concat!("Seer/", env!("CARGO_PKG_VERSION")))
196 .resolve_to_addrs(host, &validated_addrs)
197 .build()
198 .map_err(|e| SeerError::HttpError(format!("failed to build HTTP client: {}", e)))?;
199
200 let response = client
201 .get(url.clone())
202 .timeout(self.timeout)
203 .send()
204 .await
205 .map_err(|e| SeerError::HttpError(e.to_string()))?;
206
207 if response.status().is_redirection() {
208 let location = response.headers().get(reqwest::header::LOCATION);
209 let location = location.and_then(|v| v.to_str().ok()).ok_or_else(|| {
210 SeerError::HttpError("redirect missing location header".to_string())
211 })?;
212 let next_url = url
213 .join(location)
214 .or_else(|_| Url::parse(location))
215 .map_err(|e| SeerError::HttpError(format!("invalid redirect URL: {}", e)))?;
216 url = next_url;
217 continue;
218 }
219
220 let status = response.status();
221 let status_code = status.as_u16();
222 let status_text = status.canonical_reason().unwrap_or("Unknown").to_string();
223
224 let title = if status.is_success() {
226 let content_type = response
227 .headers()
228 .get("content-type")
229 .and_then(|v| v.to_str().ok())
230 .unwrap_or("");
231
232 if content_type.contains("text/html") {
233 const MAX_TITLE_BODY: usize = 64 * 1024;
238 use futures::StreamExt;
239 let mut buf: Vec<u8> = Vec::with_capacity(8 * 1024);
240 let mut stream = response.bytes_stream();
241 while let Some(chunk) = stream.next().await {
242 let chunk = chunk
243 .map_err(|e| SeerError::HttpError(format!("body chunk: {}", e)))?;
244 let remaining = MAX_TITLE_BODY.saturating_sub(buf.len());
245 if remaining == 0 {
246 break;
247 }
248 let take = remaining.min(chunk.len());
249 buf.extend_from_slice(&chunk[..take]);
250 if buf.len() >= MAX_TITLE_BODY {
251 break;
252 }
253 }
254 let body = String::from_utf8_lossy(&buf);
255 extract_title(&body)
256 } else {
257 None
258 }
259 } else {
260 None
261 };
262
263 return Ok((status_code, status_text, title));
264 }
265
266 Err(SeerError::HttpError("too many redirects".to_string()))
267 }
268
269 async fn fetch_certificate_info(&self, domain: &str) -> Result<CertificateInfo> {
276 let socket_addrs = crate::net::resolve_public_host(domain, 443)
281 .await
282 .map_err(|e| SeerError::CertificateError(e.to_string()))?;
283
284 let connector = TlsConnector::builder()
285 .danger_accept_invalid_certs(true) .build()
287 .map_err(|e| SeerError::CertificateError(e.to_string()))?;
288
289 let connector = tokio_native_tls::TlsConnector::from(connector);
290
291 let stream =
294 tokio::time::timeout(self.timeout, TcpStream::connect(socket_addrs.as_slice()))
295 .await
296 .map_err(|_| SeerError::Timeout(format!("connection to {} timed out", domain)))?
297 .map_err(|e| SeerError::CertificateError(e.to_string()))?;
298
299 let tls_stream = tokio::time::timeout(self.timeout, connector.connect(domain, stream))
301 .await
302 .map_err(|_| SeerError::Timeout(format!("TLS handshake with {} timed out", domain)))?
303 .map_err(|e| SeerError::CertificateError(e.to_string()))?;
304
305 let cert = tls_stream
307 .get_ref()
308 .peer_certificate()
309 .map_err(|e| SeerError::CertificateError(e.to_string()))?
310 .ok_or_else(|| SeerError::CertificateError("no certificate found".to_string()))?;
311
312 let der = cert
314 .to_der()
315 .map_err(|e| SeerError::CertificateError(e.to_string()))?;
316
317 parse_certificate_der(&der, domain)
318 }
319
320 async fn fetch_domain_expiration(&self, domain: &str) -> Result<Option<DomainExpiration>> {
322 match self.smart_lookup.lookup(domain).await {
323 Ok(result) => {
324 let (expiration_date, registrar) = result.expiration_info();
325
326 if let Some(exp_date) = expiration_date {
327 let days_until_expiry = (exp_date - Utc::now()).num_days();
328 Ok(Some(DomainExpiration {
329 expiration_date: exp_date,
330 days_until_expiry,
331 registrar,
332 }))
333 } else {
334 Ok(None)
335 }
336 }
337 Err(_) => Ok(None), }
339 }
340
341 async fn fetch_dns_resolution(&self, domain: &str) -> Result<DnsResolution> {
343 let resolver = &self.dns_resolver;
344
345 let (a_result, aaaa_result, cname_result, ns_result) = tokio::join!(
347 resolver.resolve(domain, RecordType::A, None),
348 resolver.resolve(domain, RecordType::AAAA, None),
349 resolver.resolve(domain, RecordType::CNAME, None),
350 resolver.resolve(domain, RecordType::NS, None)
351 );
352
353 let a_records: Vec<String> = a_result
355 .unwrap_or_default()
356 .into_iter()
357 .filter_map(|r| {
358 if let RecordData::A { address } = r.data {
359 Some(address)
360 } else {
361 None
362 }
363 })
364 .collect();
365
366 let aaaa_records: Vec<String> = aaaa_result
368 .unwrap_or_default()
369 .into_iter()
370 .filter_map(|r| {
371 if let RecordData::AAAA { address } = r.data {
372 Some(address)
373 } else {
374 None
375 }
376 })
377 .collect();
378
379 let cname_target: Option<String> =
381 cname_result.unwrap_or_default().into_iter().find_map(|r| {
382 if let RecordData::CNAME { target } = r.data {
383 Some(target.trim_end_matches('.').to_string())
384 } else {
385 None
386 }
387 });
388
389 let nameservers: Vec<String> = ns_result
391 .unwrap_or_default()
392 .into_iter()
393 .filter_map(|r| {
394 if let RecordData::NS { nameserver } = r.data {
395 Some(nameserver.trim_end_matches('.').to_string())
396 } else {
397 None
398 }
399 })
400 .collect();
401
402 let resolves = !a_records.is_empty() || !aaaa_records.is_empty() || cname_target.is_some();
404
405 Ok(DnsResolution {
406 a_records,
407 aaaa_records,
408 cname_target,
409 nameservers,
410 resolves,
411 })
412 }
413}
414
415fn extract_title(html: &str) -> Option<String> {
426 TITLE_REGEX
427 .captures(html)
428 .and_then(|caps| caps.get(1))
429 .map(|m| {
430 m.as_str()
436 .chars()
437 .filter(|c| !c.is_control())
438 .collect::<String>()
439 .trim()
440 .to_string()
441 })
442 .filter(|s| !s.is_empty())
443}
444
445async fn validate_url_target(url: &Url) -> Result<Vec<SocketAddr>> {
460 let scheme = url.scheme();
461 if scheme != "https" && scheme != "http" {
462 return Err(SeerError::HttpError(format!(
463 "unsupported URL scheme: {}",
464 scheme
465 )));
466 }
467
468 if !url.username().is_empty() || url.password().is_some() {
469 return Err(SeerError::HttpError(
470 "URL credentials are not allowed".to_string(),
471 ));
472 }
473
474 let host = match url.host() {
477 Some(url::Host::Domain(d)) => d.to_string(),
478 Some(url::Host::Ipv4(ip)) => ip.to_string(),
479 Some(url::Host::Ipv6(ip)) => ip.to_string(),
480 None => return Err(SeerError::HttpError("missing URL host".to_string())),
481 };
482 let port = url.port_or_known_default().unwrap_or(443);
483
484 if port != 80 && port != 443 {
486 return Err(SeerError::HttpError(format!(
487 "non-standard port {} is not allowed in redirects",
488 port
489 )));
490 }
491
492 crate::net::resolve_public_host(&host, port)
493 .await
494 .map_err(|e| SeerError::HttpError(e.to_string()))
495}
496
497fn parse_certificate_der(der: &[u8], domain: &str) -> Result<CertificateInfo> {
499 use x509_parser::prelude::*;
500
501 let (_, cert) = X509Certificate::from_der(der)
502 .map_err(|e| SeerError::CertificateError(format!("failed to parse certificate: {}", e)))?;
503
504 let issuer = format_issuer_name(cert.issuer()).unwrap_or_else(|| "Unknown Issuer".to_string());
509
510 let subject =
512 extract_name_from_x509(cert.subject()).unwrap_or_else(|| "Unknown Subject".to_string());
513
514 let valid_from = asn1_time_to_chrono(cert.validity().not_before)?;
516 let valid_until = asn1_time_to_chrono(cert.validity().not_after)?;
517
518 let now = Utc::now();
519 let days_until_expiry = (valid_until - now).num_days();
520 let is_valid = now >= valid_from && now <= valid_until;
521
522 let hostname_verified = cert_matches_hostname(&cert, domain);
527
528 Ok(CertificateInfo {
529 issuer,
530 subject,
531 valid_from,
532 valid_until,
533 days_until_expiry,
534 is_valid,
535 hostname_verified,
536 })
537}
538
539fn hostname_matches_pattern(host: &str, pattern: &str) -> bool {
545 let host = host.to_ascii_lowercase();
546 let pattern = pattern.to_ascii_lowercase();
547 if let Some(rest) = pattern.strip_prefix("*.") {
548 let Some(dot) = host.find('.') else {
550 return false;
551 };
552 let host_rest = &host[dot + 1..];
553 host_rest == rest
554 } else {
555 host == pattern
556 }
557}
558
559fn cert_matches_hostname(cert: &x509_parser::certificate::X509Certificate<'_>, host: &str) -> bool {
565 use x509_parser::prelude::*;
566
567 if let Ok(Some(san_ext)) = cert.tbs_certificate.subject_alternative_name() {
569 for name in &san_ext.value.general_names {
570 if let GeneralName::DNSName(n) = name {
571 if hostname_matches_pattern(host, n) {
572 return true;
573 }
574 }
575 }
576 }
577
578 for cn in cert.subject().iter_common_name() {
580 if let Ok(s) = cn.as_str() {
581 if hostname_matches_pattern(host, s) {
582 return true;
583 }
584 }
585 }
586
587 false
588}
589
590fn format_issuer_name(name: &x509_parser::prelude::X509Name) -> Option<String> {
594 use x509_parser::oid_registry;
595 let cn = extract_oid_value(name, &oid_registry::OID_X509_COMMON_NAME);
596 let org = extract_oid_value(name, &oid_registry::OID_X509_ORGANIZATION_NAME);
597 match (org, cn) {
598 (Some(o), Some(c)) if o != c => Some(format!("{} ({})", o, c)),
599 (Some(o), _) => Some(o),
600 (None, Some(c)) => Some(c),
601 (None, None) => None,
602 }
603}
604
605fn extract_oid_value(
607 name: &x509_parser::prelude::X509Name,
608 oid: &x509_parser::der_parser::oid::Oid<'static>,
609) -> Option<String> {
610 for rdn in name.iter() {
611 for attr in rdn.iter() {
612 if attr.attr_type() == oid {
613 if let Some(s) = extract_attr_string(attr.attr_value()) {
614 return Some(s);
615 }
616 }
617 }
618 }
619 None
620}
621
622fn extract_name_from_x509(name: &x509_parser::prelude::X509Name) -> Option<String> {
624 use x509_parser::prelude::*;
625
626 for rdn in name.iter() {
628 for attr in rdn.iter() {
629 if attr.attr_type() == &oid_registry::OID_X509_COMMON_NAME {
630 if let Some(s) = extract_attr_string(attr.attr_value()) {
631 return Some(s);
632 }
633 }
634 }
635 }
636
637 for rdn in name.iter() {
639 for attr in rdn.iter() {
640 if attr.attr_type() == &oid_registry::OID_X509_ORGANIZATION_NAME {
641 if let Some(s) = extract_attr_string(attr.attr_value()) {
642 return Some(s);
643 }
644 }
645 }
646 }
647
648 None
649}
650
651fn extract_attr_string(value: &x509_parser::der_parser::asn1_rs::Any) -> Option<String> {
653 if let Ok(s) = value.as_str() {
655 return Some(s.to_string());
656 }
657
658 if let Ok(utf8) = value.as_utf8string() {
660 return Some(utf8.string().to_string());
661 }
662
663 if let Ok(s) = std::str::from_utf8(value.data) {
665 return Some(s.to_string());
666 }
667
668 None
669}
670
671fn asn1_time_to_chrono(time: x509_parser::time::ASN1Time) -> Result<chrono::DateTime<Utc>> {
673 let timestamp = time.timestamp();
674 chrono::DateTime::from_timestamp(timestamp, 0)
675 .ok_or_else(|| SeerError::CertificateError("invalid certificate timestamp".to_string()))
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681
682 #[test]
683 fn from_config_applies_http_timeout() {
684 let mut config = crate::config::SeerConfig::default();
685 config.timeouts.http_secs = 55;
686 let client = StatusClient::from_config(&config);
687 assert_eq!(client.timeout, Duration::from_secs(55));
688 }
689
690 #[test]
691 fn hostname_matches_pattern_exact() {
692 assert!(hostname_matches_pattern("example.com", "example.com"));
693 assert!(hostname_matches_pattern("EXAMPLE.COM", "example.com"));
694 assert!(hostname_matches_pattern("example.com", "EXAMPLE.COM"));
695 assert!(!hostname_matches_pattern("evil.com", "example.com"));
696 assert!(!hostname_matches_pattern("example.com", "evil.com"));
697 }
698
699 #[test]
700 fn hostname_matches_pattern_wildcard() {
701 assert!(hostname_matches_pattern("a.example.com", "*.example.com"));
702 assert!(hostname_matches_pattern("A.EXAMPLE.COM", "*.example.com"));
703 assert!(!hostname_matches_pattern("example.com", "*.example.com"));
705 assert!(!hostname_matches_pattern(
707 "a.b.example.com",
708 "*.example.com"
709 ));
710 assert!(!hostname_matches_pattern("b.other.com", "*.example.com"));
711 }
712
713 #[test]
714 fn hostname_matches_pattern_wildcard_requires_dot() {
715 assert!(!hostname_matches_pattern("localhost", "*.example.com"));
717 }
718
719 #[tokio::test]
722 async fn validate_url_target_rejects_unsupported_scheme() {
723 let url = Url::parse("ftp://example.com/").unwrap();
724 let err = validate_url_target(&url).await.unwrap_err();
725 assert!(
726 matches!(err, SeerError::HttpError(ref s) if s.contains("unsupported URL scheme")),
727 "got: {err:?}"
728 );
729 }
730
731 #[tokio::test]
732 async fn validate_url_target_rejects_credentials() {
733 let url = Url::parse("https://user:pass@example.com/").unwrap();
734 let err = validate_url_target(&url).await.unwrap_err();
735 assert!(
736 matches!(err, SeerError::HttpError(ref s) if s.contains("credentials")),
737 "got: {err:?}"
738 );
739 }
740
741 #[tokio::test]
742 async fn validate_url_target_rejects_non_standard_port() {
743 let url = Url::parse("https://8.8.8.8:8443/").unwrap();
744 let err = validate_url_target(&url).await.unwrap_err();
745 assert!(
746 matches!(err, SeerError::HttpError(ref s) if s.contains("non-standard port")),
747 "got: {err:?}"
748 );
749 }
750
751 #[tokio::test]
752 async fn validate_url_target_rejects_loopback_literal() {
753 let url = Url::parse("https://127.0.0.1/").unwrap();
754 let err = validate_url_target(&url).await.unwrap_err();
755 assert!(
756 matches!(err, SeerError::HttpError(ref s) if s.contains("reserved")),
757 "got: {err:?}"
758 );
759 }
760
761 #[tokio::test]
762 async fn validate_url_target_rejects_bracketed_ipv6_loopback_literal() {
763 let url = Url::parse("https://[::1]/").unwrap();
767 let err = validate_url_target(&url).await.unwrap_err();
768 assert!(
769 matches!(err, SeerError::HttpError(ref s) if s.contains("reserved")),
770 "got: {err:?}"
771 );
772 }
773
774 #[tokio::test]
775 async fn validate_url_target_allows_public_ip_literal() {
776 let url = Url::parse("https://8.8.8.8/").unwrap();
777 let addrs = validate_url_target(&url).await.unwrap();
778 assert_eq!(addrs.len(), 1);
779 assert_eq!(addrs[0].port(), 443);
780 }
781}