1use std::time::Duration;
10
11use chrono::Utc;
12use tracing::{debug, instrument};
13
14use super::types::{CertificateInfo, DnsResolution, DomainExpiration, StatusResponse};
15use crate::caa::{self, CaaPolicy};
16use crate::dns::{DnsResolver, RecordData, RecordType};
17use crate::error::{Result, SeerError};
18use crate::http::GuardedFetcher;
19use crate::lookup::SmartLookup;
20use crate::validation::normalize_host;
21
22const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
25
26static_regex! {
27 TITLE_REGEX = r"(?i)<title[^>]*>([^<]+)</title>";
29}
30
31#[derive(Debug, Clone)]
33pub struct StatusClient {
34 timeout: Duration,
35 dns_resolver: DnsResolver,
37 smart_lookup: SmartLookup,
39}
40
41impl Default for StatusClient {
42 fn default() -> Self {
43 Self::new()
44 }
45}
46
47impl StatusClient {
48 pub fn new() -> Self {
50 Self {
51 timeout: DEFAULT_TIMEOUT,
52 dns_resolver: DnsResolver::new(),
53 smart_lookup: SmartLookup::new(),
54 }
55 }
56
57 pub fn from_config(config: &crate::config::SeerConfig) -> Self {
66 Self {
67 timeout: config.http_timeout(),
68 dns_resolver: DnsResolver::from_config(config),
69 smart_lookup: SmartLookup::from_config(config),
70 }
71 }
72
73 pub fn with_timeout(mut self, timeout: Duration) -> Self {
75 self.timeout = timeout;
76 self
77 }
78
79 #[instrument(skip(self), fields(domain = %domain))]
81 pub async fn check(&self, domain: &str) -> Result<StatusResponse> {
82 let domain = normalize_host(domain)?;
87 debug!("Checking status for domain: {}", domain);
88
89 let mut response = StatusResponse::new(domain.clone());
90
91 let (http_result, cert_result, expiry_result, dns_result, caa_policy) = tokio::join!(
96 self.fetch_http_info(&domain),
97 self.fetch_certificate_info(&domain),
98 self.fetch_domain_expiration(&domain),
99 self.fetch_dns_resolution(&domain),
100 caa::lookup_caa(&self.dns_resolver, &domain, None),
101 );
102
103 match http_result {
105 Ok((status, status_text, title)) => {
106 response.http_status = Some(status);
107 response.http_status_text = Some(status_text);
108 response.title = title;
109 }
110 Err(e) => response.errors.push(sub_check_error("http", &e)),
111 }
112
113 let mut caa_policy: CaaPolicy = caa_policy.unwrap_or_else(|_| CaaPolicy::empty());
117 match cert_result {
118 Ok((cert_info, wildcard)) => {
119 caa_policy.issuer_match = Some(caa::classify_issuer(
120 &cert_info.issuer,
121 &caa_policy,
122 wildcard,
123 ));
124 response.certificate = Some(cert_info);
125 }
126 Err(e) => response.errors.push(sub_check_error("ssl", &e)),
127 }
128 response.caa = Some(caa_policy);
129
130 response.domain_expiration = expiry_result;
133 response.dns_resolution = Some(dns_result);
134
135 Ok(response)
136 }
137
138 async fn fetch_http_info(&self, domain: &str) -> Result<(u16, String, Option<String>)> {
156 let fetcher = GuardedFetcher::new().with_timeout(self.timeout);
157 http_info(&fetcher, &format!("https://{domain}/")).await
158 }
159
160 async fn fetch_certificate_info(&self, domain: &str) -> Result<(CertificateInfo, bool)> {
169 let socket_addrs = crate::net::resolve_public_host(domain, 443)
175 .await
176 .map_err(|e| SeerError::CertificateError(e.to_string()))?;
177
178 let presented = crate::tls::inspect(
179 domain,
180 &socket_addrs,
181 self.timeout,
182 SeerError::CertificateError,
183 )
184 .await?;
185 parse_certificate_der(&presented.leaf, domain)
186 }
187
188 async fn fetch_domain_expiration(&self, domain: &str) -> Option<DomainExpiration> {
192 let result = self
193 .smart_lookup
194 .lookup(registration_name(domain)?)
195 .await
196 .ok()?;
197 let (expiration_date, registrar) = result.expiration_info();
198 let expiration_date = expiration_date?;
199 Some(DomainExpiration {
200 expiration_date,
201 days_until_expiry: crate::dates::days_until(expiration_date, Utc::now()),
202 registrar,
203 })
204 }
205
206 async fn fetch_dns_resolution(&self, domain: &str) -> DnsResolution {
209 let resolver = &self.dns_resolver;
210
211 let (a_result, aaaa_result, cname_result, ns_result) = tokio::join!(
213 resolver.resolve(domain, RecordType::A, None),
214 resolver.resolve(domain, RecordType::AAAA, None),
215 resolver.resolve(domain, RecordType::CNAME, None),
216 resolver.resolve(domain, RecordType::NS, None)
217 );
218
219 let addresses = |result: Result<Vec<crate::dns::DnsRecord>>| -> Vec<String> {
222 result
223 .unwrap_or_default()
224 .iter()
225 .filter_map(|r| r.data.address().map(str::to_string))
226 .collect()
227 };
228 let a_records = addresses(a_result);
229 let aaaa_records = addresses(aaaa_result);
230
231 let cname_target: Option<String> =
233 cname_result.unwrap_or_default().into_iter().find_map(|r| {
234 if let RecordData::CNAME { target } = r.data {
235 Some(target.trim_end_matches('.').to_string())
236 } else {
237 None
238 }
239 });
240
241 let nameservers: Vec<String> = ns_result
243 .unwrap_or_default()
244 .into_iter()
245 .filter_map(|r| {
246 if let RecordData::NS { nameserver } = r.data {
247 Some(nameserver.trim_end_matches('.').to_string())
248 } else {
249 None
250 }
251 })
252 .collect();
253
254 let resolves = !a_records.is_empty() || !aaaa_records.is_empty() || cname_target.is_some();
256
257 DnsResolution {
258 a_records,
259 aaaa_records,
260 cname_target,
261 nameservers,
262 resolves,
263 }
264 }
265}
266
267fn sub_check_error(check: &str, e: &SeerError) -> super::types::StatusError {
271 debug!(check, error = %e, "status sub-check failed");
272 super::types::StatusError {
273 check: check.to_string(),
274 message: e.sanitized_message(),
275 }
276}
277
278fn registration_name(host: &str) -> Option<&str> {
283 if host.parse::<std::net::IpAddr>().is_ok() {
284 return None;
285 }
286 crate::psl::registrable_domain(host)
287}
288
289async fn http_info(fetcher: &GuardedFetcher, url: &str) -> Result<(u16, String, Option<String>)> {
296 let (response, _) = fetcher.send(url).await?;
297 let status = response.status();
298 let is_html = response
299 .headers()
300 .get(reqwest::header::CONTENT_TYPE)
301 .and_then(|v| v.to_str().ok())
302 .is_some_and(|ct| ct.contains("text/html"));
303 let title = if status.is_success() && is_html {
304 match fetcher.read_body(response).await {
305 Ok(body) => extract_title(&body),
306 Err(e) => {
307 debug!(error = %e, "status page body unreadable; reporting no title");
308 None
309 }
310 }
311 } else {
312 None
313 };
314 let reason = status.canonical_reason().unwrap_or("Unknown").to_string();
315 Ok((status.as_u16(), reason, title))
316}
317
318fn extract_title(html: &str) -> Option<String> {
327 TITLE_REGEX
328 .captures(html)
329 .and_then(|caps| caps.get(1))
330 .map(|m| {
331 m.as_str()
337 .chars()
338 .filter(|c| !c.is_control())
339 .collect::<String>()
340 .trim()
341 .to_string()
342 })
343 .filter(|s| !s.is_empty())
344}
345
346fn parse_certificate_der(der: &[u8], domain: &str) -> Result<(CertificateInfo, bool)> {
349 use x509_parser::prelude::*;
350
351 let (_, cert) = X509Certificate::from_der(der)
352 .map_err(|e| SeerError::CertificateError(format!("failed to parse certificate: {}", e)))?;
353
354 let issuer = format_issuer_name(cert.issuer()).unwrap_or_else(|| "Unknown Issuer".to_string());
359
360 let subject =
362 extract_name_from_x509(cert.subject()).unwrap_or_else(|| "Unknown Subject".to_string());
363
364 let (valid_from, valid_until) = crate::tls::validity_window(&cert)
365 .ok_or_else(|| SeerError::CertificateError("invalid certificate timestamp".to_string()))?;
366
367 let now = Utc::now();
368 let days_until_expiry = crate::dates::days_until(valid_until, now);
369 let is_valid = now >= valid_from && now <= valid_until;
370
371 let hostname_verified = crate::tls::cert_matches_host(&cert, domain);
377
378 let info = CertificateInfo {
379 issuer,
380 subject,
381 valid_from,
382 valid_until,
383 days_until_expiry,
384 is_valid,
385 hostname_verified,
386 };
387 Ok((info, crate::tls::cert_names_wildcard(&cert)))
388}
389
390fn format_issuer_name(name: &x509_parser::prelude::X509Name) -> Option<String> {
394 use x509_parser::oid_registry;
395 let cn = extract_oid_value(name, &oid_registry::OID_X509_COMMON_NAME);
396 let org = extract_oid_value(name, &oid_registry::OID_X509_ORGANIZATION_NAME);
397 match (org, cn) {
398 (Some(o), Some(c)) if o != c => Some(format!("{} ({})", o, c)),
399 (Some(o), _) => Some(o),
400 (None, Some(c)) => Some(c),
401 (None, None) => None,
402 }
403}
404
405fn extract_oid_value(
407 name: &x509_parser::prelude::X509Name,
408 oid: &x509_parser::der_parser::oid::Oid<'static>,
409) -> Option<String> {
410 for rdn in name.iter() {
411 for attr in rdn.iter() {
412 if attr.attr_type() == oid {
413 if let Some(s) = extract_attr_string(attr.attr_value()) {
414 return Some(s);
415 }
416 }
417 }
418 }
419 None
420}
421
422fn extract_name_from_x509(name: &x509_parser::prelude::X509Name) -> Option<String> {
424 use x509_parser::oid_registry;
425 extract_oid_value(name, &oid_registry::OID_X509_COMMON_NAME)
426 .or_else(|| extract_oid_value(name, &oid_registry::OID_X509_ORGANIZATION_NAME))
427}
428
429fn extract_attr_string(value: &x509_parser::der_parser::asn1_rs::Any) -> Option<String> {
431 if let Ok(s) = value.as_str() {
433 return Some(s.to_string());
434 }
435
436 if let Ok(utf8) = value.as_utf8string() {
438 return Some(utf8.string().to_string());
439 }
440
441 if let Ok(s) = std::str::from_utf8(value.data) {
443 return Some(s.to_string());
444 }
445
446 None
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452
453 #[test]
454 fn from_config_applies_http_timeout() {
455 let mut config = crate::config::SeerConfig::default();
456 config.timeouts.http_secs = 55;
457 let client = StatusClient::from_config(&config);
458 assert_eq!(client.timeout, Duration::from_secs(55));
459 }
460
461 #[tokio::test]
464 async fn dns_resolution_splits_address_families() {
465 use crate::dns::test_support::{mock_dns_resolver_default, spawn_mock_dns, MockMode};
466
467 let port = spawn_mock_dns(MockMode::Zone).await;
468 let client = StatusClient {
469 dns_resolver: mock_dns_resolver_default(port),
470 ..StatusClient::new()
471 };
472 let dns = client.fetch_dns_resolution("seer.test").await;
473 assert_eq!(dns.a_records, ["192.0.2.1", "192.0.2.2"]);
474 assert_eq!(dns.aaaa_records, ["2001:db8::1"]);
475 assert_eq!(dns.cname_target, None);
476 assert_eq!(dns.nameservers, ["ns1.seer.test"]);
477 assert!(dns.resolves);
478 }
479
480 #[test]
483 fn certificate_hostname_uses_the_shared_rule() {
484 use crate::tls::test_support::{cert, CN_VICTIM_SAN_IP, CN_VICTIM_SAN_OTHER};
485
486 let verified = |b64, host| {
487 parse_certificate_der(&cert(b64), host)
488 .unwrap()
489 .0
490 .hostname_verified
491 };
492 assert!(!verified(CN_VICTIM_SAN_OTHER, "victim.example"));
493 assert!(verified(CN_VICTIM_SAN_IP, "victim.example"));
494 assert!(verified(CN_VICTIM_SAN_IP, "203.0.113.7"));
496 }
497
498 #[tokio::test]
501 async fn http_info_reads_title_only_for_html_success() {
502 use wiremock::matchers::path;
503 use wiremock::{Mock, MockServer, ResponseTemplate};
504
505 let server = MockServer::start().await;
506 Mock::given(path("/"))
507 .respond_with(ResponseTemplate::new(301).insert_header("location", "/home"))
508 .mount(&server)
509 .await;
510 Mock::given(path("/home"))
511 .respond_with(ResponseTemplate::new(200).set_body_raw(
512 "<html><title> Home\u{0}Page </title></html>",
513 "text/html; charset=utf-8",
514 ))
515 .mount(&server)
516 .await;
517 Mock::given(path("/json"))
518 .respond_with(ResponseTemplate::new(200).set_body_raw("<title>x</title>", "text/plain"))
519 .mount(&server)
520 .await;
521
522 let fetcher = GuardedFetcher::new().allowing_private_hosts();
523 let info = http_info(&fetcher, &format!("{}/", server.uri()))
524 .await
525 .unwrap();
526 assert_eq!(info, (200, "OK".to_string(), Some("HomePage".to_string())));
527
528 let info = http_info(&fetcher, &format!("{}/json", server.uri()))
529 .await
530 .unwrap();
531 assert_eq!(info, (200, "OK".to_string(), None));
532
533 let info = http_info(&fetcher, &format!("{}/missing", server.uri()))
535 .await
536 .unwrap();
537 assert_eq!(info, (404, "Not Found".to_string(), None));
538 }
539
540 #[tokio::test]
543 async fn an_unreadable_body_costs_only_the_title() {
544 use tokio::io::{AsyncReadExt, AsyncWriteExt};
545
546 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
547 let addr = listener.local_addr().unwrap();
548 tokio::spawn(async move {
549 let (mut tcp, _) = listener.accept().await.unwrap();
550 let _ = tcp.read(&mut [0u8; 1024]).await;
551 let _ = tcp
552 .write_all(
553 b"HTTP/1.1 200 OK\r\ncontent-type: text/html\r\ncontent-length: 100\r\n\r\n<title>",
554 )
555 .await;
556 std::future::pending::<()>().await;
557 });
558
559 let fetcher = GuardedFetcher::new()
560 .allowing_private_hosts()
561 .with_timeout(Duration::from_millis(300));
562 let info = http_info(&fetcher, &format!("http://{addr}/"))
563 .await
564 .unwrap();
565 assert_eq!(info, (200, "OK".to_string(), None));
566 }
567
568 #[test]
571 fn expiry_is_looked_up_for_the_registrable_domain() {
572 assert_eq!(registration_name("mail.google.com"), Some("google.com"));
573 assert_eq!(
574 registration_name("www.example.co.uk"),
575 Some("example.co.uk")
576 );
577 assert_eq!(registration_name("example.com"), Some("example.com"));
578 assert_eq!(registration_name("co.uk"), None);
579 assert_eq!(registration_name("192.0.2.1"), None);
580 }
581
582 #[test]
585 fn sub_check_errors_are_sanitized() {
586 let e = SeerError::CertificateError("failed to connect to internal.corp: refused".into());
587 let err = sub_check_error("ssl", &e);
588 assert_eq!(err.check, "ssl");
589 assert_eq!(err.message, e.sanitized_message());
590 assert!(!err.message.contains("internal.corp"));
591 }
592}