use std::time::Duration;
use chrono::Utc;
use tracing::{debug, instrument};
use super::types::{CertificateInfo, DnsResolution, DomainExpiration, StatusResponse};
use crate::caa::{self, CaaPolicy};
use crate::dns::{DnsResolver, RecordData, RecordType};
use crate::error::{Result, SeerError};
use crate::http::GuardedFetcher;
use crate::lookup::SmartLookup;
use crate::validation::normalize_host;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
static_regex! {
TITLE_REGEX = r"(?i)<title[^>]*>([^<]+)</title>";
}
#[derive(Debug, Clone)]
pub struct StatusClient {
timeout: Duration,
dns_resolver: DnsResolver,
smart_lookup: SmartLookup,
}
impl Default for StatusClient {
fn default() -> Self {
Self::new()
}
}
impl StatusClient {
pub fn new() -> Self {
Self {
timeout: DEFAULT_TIMEOUT,
dns_resolver: DnsResolver::new(),
smart_lookup: SmartLookup::new(),
}
}
pub fn from_config(config: &crate::config::SeerConfig) -> Self {
Self {
timeout: config.http_timeout(),
dns_resolver: DnsResolver::from_config(config),
smart_lookup: SmartLookup::from_config(config),
}
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[instrument(skip(self), fields(domain = %domain))]
pub async fn check(&self, domain: &str) -> Result<StatusResponse> {
let domain = normalize_host(domain)?;
debug!("Checking status for domain: {}", domain);
let mut response = StatusResponse::new(domain.clone());
let (http_result, cert_result, expiry_result, dns_result, caa_policy) = tokio::join!(
self.fetch_http_info(&domain),
self.fetch_certificate_info(&domain),
self.fetch_domain_expiration(&domain),
self.fetch_dns_resolution(&domain),
caa::lookup_caa(&self.dns_resolver, &domain),
);
match http_result {
Ok((status, status_text, title)) => {
response.http_status = Some(status);
response.http_status_text = Some(status_text);
response.title = title;
}
Err(e) => response.errors.push(super::types::StatusError {
check: "http".to_string(),
message: e.to_string(),
}),
}
let mut caa_policy: CaaPolicy = caa_policy;
match cert_result {
Ok(cert_info) => {
caa_policy.issuer_match =
Some(caa::classify_issuer(&cert_info.issuer, &caa_policy));
response.certificate = Some(cert_info);
}
Err(e) => response.errors.push(super::types::StatusError {
check: "ssl".to_string(),
message: e.to_string(),
}),
}
response.caa = Some(caa_policy);
response.domain_expiration = expiry_result;
response.dns_resolution = Some(dns_result);
Ok(response)
}
async fn fetch_http_info(&self, domain: &str) -> Result<(u16, String, Option<String>)> {
let fetcher = GuardedFetcher::new().with_timeout(self.timeout);
http_info(&fetcher, &format!("https://{domain}/")).await
}
async fn fetch_certificate_info(&self, domain: &str) -> Result<CertificateInfo> {
let socket_addrs = crate::net::resolve_public_host(domain, 443)
.await
.map_err(|e| SeerError::CertificateError(e.to_string()))?;
let presented = crate::tls::inspect(
domain,
&socket_addrs,
self.timeout,
SeerError::CertificateError,
)
.await?;
parse_certificate_der(&presented.leaf, domain)
}
async fn fetch_domain_expiration(&self, domain: &str) -> Option<DomainExpiration> {
let result = self.smart_lookup.lookup(domain).await.ok()?;
let (expiration_date, registrar) = result.expiration_info();
let expiration_date = expiration_date?;
Some(DomainExpiration {
expiration_date,
days_until_expiry: crate::dates::days_until(expiration_date, Utc::now()),
registrar,
})
}
async fn fetch_dns_resolution(&self, domain: &str) -> DnsResolution {
let resolver = &self.dns_resolver;
let (a_result, aaaa_result, cname_result, ns_result) = tokio::join!(
resolver.resolve(domain, RecordType::A, None),
resolver.resolve(domain, RecordType::AAAA, None),
resolver.resolve(domain, RecordType::CNAME, None),
resolver.resolve(domain, RecordType::NS, None)
);
let addresses = |result: Result<Vec<crate::dns::DnsRecord>>| -> Vec<String> {
result
.unwrap_or_default()
.iter()
.filter_map(|r| r.data.address().map(str::to_string))
.collect()
};
let a_records = addresses(a_result);
let aaaa_records = addresses(aaaa_result);
let cname_target: Option<String> =
cname_result.unwrap_or_default().into_iter().find_map(|r| {
if let RecordData::CNAME { target } = r.data {
Some(target.trim_end_matches('.').to_string())
} else {
None
}
});
let nameservers: Vec<String> = ns_result
.unwrap_or_default()
.into_iter()
.filter_map(|r| {
if let RecordData::NS { nameserver } = r.data {
Some(nameserver.trim_end_matches('.').to_string())
} else {
None
}
})
.collect();
let resolves = !a_records.is_empty() || !aaaa_records.is_empty() || cname_target.is_some();
DnsResolution {
a_records,
aaaa_records,
cname_target,
nameservers,
resolves,
}
}
}
async fn http_info(fetcher: &GuardedFetcher, url: &str) -> Result<(u16, String, Option<String>)> {
let (response, _) = fetcher.send(url).await?;
let status = response.status();
let is_html = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.is_some_and(|ct| ct.contains("text/html"));
let title = if status.is_success() && is_html {
extract_title(&fetcher.read_body(response).await?)
} else {
None
};
let reason = status.canonical_reason().unwrap_or("Unknown").to_string();
Ok((status.as_u16(), reason, title))
}
fn extract_title(html: &str) -> Option<String> {
TITLE_REGEX
.captures(html)
.and_then(|caps| caps.get(1))
.map(|m| {
m.as_str()
.chars()
.filter(|c| !c.is_control())
.collect::<String>()
.trim()
.to_string()
})
.filter(|s| !s.is_empty())
}
fn parse_certificate_der(der: &[u8], domain: &str) -> Result<CertificateInfo> {
use x509_parser::prelude::*;
let (_, cert) = X509Certificate::from_der(der)
.map_err(|e| SeerError::CertificateError(format!("failed to parse certificate: {}", e)))?;
let issuer = format_issuer_name(cert.issuer()).unwrap_or_else(|| "Unknown Issuer".to_string());
let subject =
extract_name_from_x509(cert.subject()).unwrap_or_else(|| "Unknown Subject".to_string());
let (valid_from, valid_until) = crate::tls::validity_window(&cert)
.ok_or_else(|| SeerError::CertificateError("invalid certificate timestamp".to_string()))?;
let now = Utc::now();
let days_until_expiry = crate::dates::days_until(valid_until, now);
let is_valid = now >= valid_from && now <= valid_until;
let hostname_verified = crate::tls::cert_matches_host(&cert, domain);
Ok(CertificateInfo {
issuer,
subject,
valid_from,
valid_until,
days_until_expiry,
is_valid,
hostname_verified,
})
}
fn format_issuer_name(name: &x509_parser::prelude::X509Name) -> Option<String> {
use x509_parser::oid_registry;
let cn = extract_oid_value(name, &oid_registry::OID_X509_COMMON_NAME);
let org = extract_oid_value(name, &oid_registry::OID_X509_ORGANIZATION_NAME);
match (org, cn) {
(Some(o), Some(c)) if o != c => Some(format!("{} ({})", o, c)),
(Some(o), _) => Some(o),
(None, Some(c)) => Some(c),
(None, None) => None,
}
}
fn extract_oid_value(
name: &x509_parser::prelude::X509Name,
oid: &x509_parser::der_parser::oid::Oid<'static>,
) -> Option<String> {
for rdn in name.iter() {
for attr in rdn.iter() {
if attr.attr_type() == oid {
if let Some(s) = extract_attr_string(attr.attr_value()) {
return Some(s);
}
}
}
}
None
}
fn extract_name_from_x509(name: &x509_parser::prelude::X509Name) -> Option<String> {
use x509_parser::oid_registry;
extract_oid_value(name, &oid_registry::OID_X509_COMMON_NAME)
.or_else(|| extract_oid_value(name, &oid_registry::OID_X509_ORGANIZATION_NAME))
}
fn extract_attr_string(value: &x509_parser::der_parser::asn1_rs::Any) -> Option<String> {
if let Ok(s) = value.as_str() {
return Some(s.to_string());
}
if let Ok(utf8) = value.as_utf8string() {
return Some(utf8.string().to_string());
}
if let Ok(s) = std::str::from_utf8(value.data) {
return Some(s.to_string());
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_config_applies_http_timeout() {
let mut config = crate::config::SeerConfig::default();
config.timeouts.http_secs = 55;
let client = StatusClient::from_config(&config);
assert_eq!(client.timeout, Duration::from_secs(55));
}
#[tokio::test]
async fn dns_resolution_splits_address_families() {
use crate::dns::test_support::{mock_dns_resolver_default, spawn_mock_dns, MockMode};
let port = spawn_mock_dns(MockMode::Zone).await;
let client = StatusClient {
dns_resolver: mock_dns_resolver_default(port),
..StatusClient::new()
};
let dns = client.fetch_dns_resolution("seer.test").await;
assert_eq!(dns.a_records, ["192.0.2.1", "192.0.2.2"]);
assert_eq!(dns.aaaa_records, ["2001:db8::1"]);
assert_eq!(dns.cname_target, None);
assert_eq!(dns.nameservers, ["ns1.seer.test"]);
assert!(dns.resolves);
}
#[test]
fn certificate_hostname_uses_the_shared_rule() {
use crate::tls::test_support::{cert, CN_VICTIM_SAN_IP, CN_VICTIM_SAN_OTHER};
let verified = |b64, host| {
parse_certificate_der(&cert(b64), host)
.unwrap()
.hostname_verified
};
assert!(!verified(CN_VICTIM_SAN_OTHER, "victim.example"));
assert!(verified(CN_VICTIM_SAN_IP, "victim.example"));
assert!(verified(CN_VICTIM_SAN_IP, "203.0.113.7"));
}
#[tokio::test]
async fn http_info_reads_title_only_for_html_success() {
use wiremock::matchers::path;
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(path("/"))
.respond_with(ResponseTemplate::new(301).insert_header("location", "/home"))
.mount(&server)
.await;
Mock::given(path("/home"))
.respond_with(ResponseTemplate::new(200).set_body_raw(
"<html><title> Home\u{0}Page </title></html>",
"text/html; charset=utf-8",
))
.mount(&server)
.await;
Mock::given(path("/json"))
.respond_with(ResponseTemplate::new(200).set_body_raw("<title>x</title>", "text/plain"))
.mount(&server)
.await;
let fetcher = GuardedFetcher::new().allowing_private_hosts();
let info = http_info(&fetcher, &format!("{}/", server.uri()))
.await
.unwrap();
assert_eq!(info, (200, "OK".to_string(), Some("HomePage".to_string())));
let info = http_info(&fetcher, &format!("{}/json", server.uri()))
.await
.unwrap();
assert_eq!(info, (200, "OK".to_string(), None));
let info = http_info(&fetcher, &format!("{}/missing", server.uri()))
.await
.unwrap();
assert_eq!(info, (404, "Not Found".to_string(), None));
}
}