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),
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(super::types::StatusError {
111 check: "http".to_string(),
112 message: e.to_string(),
113 }),
114 }
115
116 let mut caa_policy: CaaPolicy = caa_policy;
119 match cert_result {
120 Ok(cert_info) => {
121 caa_policy.issuer_match =
122 Some(caa::classify_issuer(&cert_info.issuer, &caa_policy));
123 response.certificate = Some(cert_info);
124 }
125 Err(e) => response.errors.push(super::types::StatusError {
126 check: "ssl".to_string(),
127 message: e.to_string(),
128 }),
129 }
130 response.caa = Some(caa_policy);
131
132 response.domain_expiration = expiry_result;
135 response.dns_resolution = Some(dns_result);
136
137 Ok(response)
138 }
139
140 async fn fetch_http_info(&self, domain: &str) -> Result<(u16, String, Option<String>)> {
158 let fetcher = GuardedFetcher::new().with_timeout(self.timeout);
159 http_info(&fetcher, &format!("https://{domain}/")).await
160 }
161
162 async fn fetch_certificate_info(&self, domain: &str) -> Result<CertificateInfo> {
170 let socket_addrs = crate::net::resolve_public_host(domain, 443)
176 .await
177 .map_err(|e| SeerError::CertificateError(e.to_string()))?;
178
179 let presented = crate::tls::inspect(
180 domain,
181 &socket_addrs,
182 self.timeout,
183 SeerError::CertificateError,
184 )
185 .await?;
186 parse_certificate_der(&presented.leaf, domain)
187 }
188
189 async fn fetch_domain_expiration(&self, domain: &str) -> Option<DomainExpiration> {
192 let result = self.smart_lookup.lookup(domain).await.ok()?;
193 let (expiration_date, registrar) = result.expiration_info();
194 let expiration_date = expiration_date?;
195 Some(DomainExpiration {
196 expiration_date,
197 days_until_expiry: crate::dates::days_until(expiration_date, Utc::now()),
198 registrar,
199 })
200 }
201
202 async fn fetch_dns_resolution(&self, domain: &str) -> DnsResolution {
205 let resolver = &self.dns_resolver;
206
207 let (a_result, aaaa_result, cname_result, ns_result) = tokio::join!(
209 resolver.resolve(domain, RecordType::A, None),
210 resolver.resolve(domain, RecordType::AAAA, None),
211 resolver.resolve(domain, RecordType::CNAME, None),
212 resolver.resolve(domain, RecordType::NS, None)
213 );
214
215 let addresses = |result: Result<Vec<crate::dns::DnsRecord>>| -> Vec<String> {
218 result
219 .unwrap_or_default()
220 .iter()
221 .filter_map(|r| r.data.address().map(str::to_string))
222 .collect()
223 };
224 let a_records = addresses(a_result);
225 let aaaa_records = addresses(aaaa_result);
226
227 let cname_target: Option<String> =
229 cname_result.unwrap_or_default().into_iter().find_map(|r| {
230 if let RecordData::CNAME { target } = r.data {
231 Some(target.trim_end_matches('.').to_string())
232 } else {
233 None
234 }
235 });
236
237 let nameservers: Vec<String> = ns_result
239 .unwrap_or_default()
240 .into_iter()
241 .filter_map(|r| {
242 if let RecordData::NS { nameserver } = r.data {
243 Some(nameserver.trim_end_matches('.').to_string())
244 } else {
245 None
246 }
247 })
248 .collect();
249
250 let resolves = !a_records.is_empty() || !aaaa_records.is_empty() || cname_target.is_some();
252
253 DnsResolution {
254 a_records,
255 aaaa_records,
256 cname_target,
257 nameservers,
258 resolves,
259 }
260 }
261}
262
263async fn http_info(fetcher: &GuardedFetcher, url: &str) -> Result<(u16, String, Option<String>)> {
269 let (response, _) = fetcher.send(url).await?;
270 let status = response.status();
271 let is_html = response
272 .headers()
273 .get(reqwest::header::CONTENT_TYPE)
274 .and_then(|v| v.to_str().ok())
275 .is_some_and(|ct| ct.contains("text/html"));
276 let title = if status.is_success() && is_html {
277 extract_title(&fetcher.read_body(response).await?)
278 } else {
279 None
280 };
281 let reason = status.canonical_reason().unwrap_or("Unknown").to_string();
282 Ok((status.as_u16(), reason, title))
283}
284
285fn extract_title(html: &str) -> Option<String> {
294 TITLE_REGEX
295 .captures(html)
296 .and_then(|caps| caps.get(1))
297 .map(|m| {
298 m.as_str()
304 .chars()
305 .filter(|c| !c.is_control())
306 .collect::<String>()
307 .trim()
308 .to_string()
309 })
310 .filter(|s| !s.is_empty())
311}
312
313fn parse_certificate_der(der: &[u8], domain: &str) -> Result<CertificateInfo> {
315 use x509_parser::prelude::*;
316
317 let (_, cert) = X509Certificate::from_der(der)
318 .map_err(|e| SeerError::CertificateError(format!("failed to parse certificate: {}", e)))?;
319
320 let issuer = format_issuer_name(cert.issuer()).unwrap_or_else(|| "Unknown Issuer".to_string());
325
326 let subject =
328 extract_name_from_x509(cert.subject()).unwrap_or_else(|| "Unknown Subject".to_string());
329
330 let (valid_from, valid_until) = crate::tls::validity_window(&cert)
331 .ok_or_else(|| SeerError::CertificateError("invalid certificate timestamp".to_string()))?;
332
333 let now = Utc::now();
334 let days_until_expiry = crate::dates::days_until(valid_until, now);
335 let is_valid = now >= valid_from && now <= valid_until;
336
337 let hostname_verified = crate::tls::cert_matches_host(&cert, domain);
343
344 Ok(CertificateInfo {
345 issuer,
346 subject,
347 valid_from,
348 valid_until,
349 days_until_expiry,
350 is_valid,
351 hostname_verified,
352 })
353}
354
355fn format_issuer_name(name: &x509_parser::prelude::X509Name) -> Option<String> {
359 use x509_parser::oid_registry;
360 let cn = extract_oid_value(name, &oid_registry::OID_X509_COMMON_NAME);
361 let org = extract_oid_value(name, &oid_registry::OID_X509_ORGANIZATION_NAME);
362 match (org, cn) {
363 (Some(o), Some(c)) if o != c => Some(format!("{} ({})", o, c)),
364 (Some(o), _) => Some(o),
365 (None, Some(c)) => Some(c),
366 (None, None) => None,
367 }
368}
369
370fn extract_oid_value(
372 name: &x509_parser::prelude::X509Name,
373 oid: &x509_parser::der_parser::oid::Oid<'static>,
374) -> Option<String> {
375 for rdn in name.iter() {
376 for attr in rdn.iter() {
377 if attr.attr_type() == oid {
378 if let Some(s) = extract_attr_string(attr.attr_value()) {
379 return Some(s);
380 }
381 }
382 }
383 }
384 None
385}
386
387fn extract_name_from_x509(name: &x509_parser::prelude::X509Name) -> Option<String> {
389 use x509_parser::oid_registry;
390 extract_oid_value(name, &oid_registry::OID_X509_COMMON_NAME)
391 .or_else(|| extract_oid_value(name, &oid_registry::OID_X509_ORGANIZATION_NAME))
392}
393
394fn extract_attr_string(value: &x509_parser::der_parser::asn1_rs::Any) -> Option<String> {
396 if let Ok(s) = value.as_str() {
398 return Some(s.to_string());
399 }
400
401 if let Ok(utf8) = value.as_utf8string() {
403 return Some(utf8.string().to_string());
404 }
405
406 if let Ok(s) = std::str::from_utf8(value.data) {
408 return Some(s.to_string());
409 }
410
411 None
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 #[test]
419 fn from_config_applies_http_timeout() {
420 let mut config = crate::config::SeerConfig::default();
421 config.timeouts.http_secs = 55;
422 let client = StatusClient::from_config(&config);
423 assert_eq!(client.timeout, Duration::from_secs(55));
424 }
425
426 #[tokio::test]
429 async fn dns_resolution_splits_address_families() {
430 use crate::dns::test_support::{mock_dns_resolver_default, spawn_mock_dns, MockMode};
431
432 let port = spawn_mock_dns(MockMode::Zone).await;
433 let client = StatusClient {
434 dns_resolver: mock_dns_resolver_default(port),
435 ..StatusClient::new()
436 };
437 let dns = client.fetch_dns_resolution("seer.test").await;
438 assert_eq!(dns.a_records, ["192.0.2.1", "192.0.2.2"]);
439 assert_eq!(dns.aaaa_records, ["2001:db8::1"]);
440 assert_eq!(dns.cname_target, None);
441 assert_eq!(dns.nameservers, ["ns1.seer.test"]);
442 assert!(dns.resolves);
443 }
444
445 #[test]
448 fn certificate_hostname_uses_the_shared_rule() {
449 use crate::tls::test_support::{cert, CN_VICTIM_SAN_IP, CN_VICTIM_SAN_OTHER};
450
451 let verified = |b64, host| {
452 parse_certificate_der(&cert(b64), host)
453 .unwrap()
454 .hostname_verified
455 };
456 assert!(!verified(CN_VICTIM_SAN_OTHER, "victim.example"));
457 assert!(verified(CN_VICTIM_SAN_IP, "victim.example"));
458 assert!(verified(CN_VICTIM_SAN_IP, "203.0.113.7"));
460 }
461
462 #[tokio::test]
465 async fn http_info_reads_title_only_for_html_success() {
466 use wiremock::matchers::path;
467 use wiremock::{Mock, MockServer, ResponseTemplate};
468
469 let server = MockServer::start().await;
470 Mock::given(path("/"))
471 .respond_with(ResponseTemplate::new(301).insert_header("location", "/home"))
472 .mount(&server)
473 .await;
474 Mock::given(path("/home"))
475 .respond_with(ResponseTemplate::new(200).set_body_raw(
476 "<html><title> Home\u{0}Page </title></html>",
477 "text/html; charset=utf-8",
478 ))
479 .mount(&server)
480 .await;
481 Mock::given(path("/json"))
482 .respond_with(ResponseTemplate::new(200).set_body_raw("<title>x</title>", "text/plain"))
483 .mount(&server)
484 .await;
485
486 let fetcher = GuardedFetcher::new().allowing_private_hosts();
487 let info = http_info(&fetcher, &format!("{}/", server.uri()))
488 .await
489 .unwrap();
490 assert_eq!(info, (200, "OK".to_string(), Some("HomePage".to_string())));
491
492 let info = http_info(&fetcher, &format!("{}/json", server.uri()))
493 .await
494 .unwrap();
495 assert_eq!(info, (200, "OK".to_string(), None));
496
497 let info = http_info(&fetcher, &format!("{}/missing", server.uri()))
499 .await
500 .unwrap();
501 assert_eq!(info, (404, "Not Found".to_string(), None));
502 }
503}