Skip to main content

seer_core/status/
client.rs

1//! Domain health checking (HTTP status, SSL, expiration, DNS resolution).
2//!
3//! Retry boundary (deliberate): checks here are single-attempt, unlike the
4//! WHOIS/RDAP clients. This module's job is to OBSERVE a domain's health at a
5//! point in time — automatic retries would mask exactly the flakiness a
6//! health probe exists to surface. Callers that want tolerance to transient
7//! failures (e.g. watch mode) own that policy at their layer.
8
9use 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
22/// Default timeout for HTTP and TLS operations (10 seconds).
23/// Balances responsiveness with allowing slow servers to respond.
24const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
25
26static_regex! {
27    /// Pre-compiled regex for extracting HTML title.
28    TITLE_REGEX = r"(?i)<title[^>]*>([^<]+)</title>";
29}
30
31/// Client for checking domain status (HTTP, SSL, expiration)
32#[derive(Debug, Clone)]
33pub struct StatusClient {
34    timeout: Duration,
35    /// Cached DNS resolver reused across status checks.
36    dns_resolver: DnsResolver,
37    /// Reusable SmartLookup for domain expiration checks.
38    smart_lookup: SmartLookup,
39}
40
41impl Default for StatusClient {
42    fn default() -> Self {
43        Self::new()
44    }
45}
46
47impl StatusClient {
48    /// Creates a new StatusClient with default settings.
49    pub fn new() -> Self {
50        Self {
51            timeout: DEFAULT_TIMEOUT,
52            dns_resolver: DnsResolver::new(),
53            smart_lookup: SmartLookup::new(),
54        }
55    }
56
57    /// Builds a client honoring `~/.seer/config.toml` settings.
58    ///
59    /// Reads `timeouts.http_secs` (clamped to 1–120s by
60    /// [`crate::config::SeerConfig::load`]) for the HTTP/TLS probes, plus
61    /// `timeouts.dns_secs`, `timeouts.whois_secs`, and `timeouts.rdap_secs`
62    /// for the internal DNS resolver and expiration lookup — matching the
63    /// [`crate::availability::AvailabilityChecker::from_config`] precedent of
64    /// per-protocol timeouts on every sub-client.
65    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    /// Sets the timeout for HTTP and TLS operations.
74    pub fn with_timeout(mut self, timeout: Duration) -> Self {
75        self.timeout = timeout;
76        self
77    }
78
79    /// Checks the status of a domain (HTTP, SSL, expiration, DNS).
80    #[instrument(skip(self), fields(domain = %domain))]
81    pub async fn check(&self, domain: &str) -> Result<StatusResponse> {
82        // Normalize domain format (doesn't require DNS resolution). The exact
83        // host is kept — `www.example.com` can serve a different site and
84        // certificate than the apex; the expiration lookup reduces it to the
85        // registration on its own.
86        let domain = normalize_host(domain)?;
87        debug!("Checking status for domain: {}", domain);
88
89        let mut response = StatusResponse::new(domain.clone());
90
91        // Fetch HTTP status, SSL cert info, domain expiration, DNS
92        // resolution, and CAA policy concurrently. HTTP and SSL checks
93        // include SSRF protection internally; CAA never fails the request
94        // (a resolver error yields an empty policy).
95        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        // Apply HTTP info
104        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        // Apply certificate info and tag the CAA policy with the issuer
117        // comparison if a cert was retrieved.
118        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        // Expiration and DNS never add a sub-check error: a failed lookup
133        // folds into "unknown" (no expiration / no records).
134        response.domain_expiration = expiry_result;
135        response.dns_resolution = Some(dns_result);
136
137        Ok(response)
138    }
139
140    /// Fetches the HTTP status code and page title of `https://{domain}/`.
141    ///
142    /// Goes through [`GuardedFetcher`], the SSRF-guarded GET shared with
143    /// `headers`/`takeover`: redirects are followed manually with the guard
144    /// re-run and the validated IPs pinned at every hop (DNS-rebinding
145    /// defense), and the body read is capped and timeout-bounded.
146    ///
147    /// # Security Note
148    /// This path uses reqwest's default (validating) TLS configuration — a
149    /// bad certificate surfaces as a typed `SeerError::HttpError` and the
150    /// status check reports it as a failed "http" sub-check instead of
151    /// silently returning attacker-controlled body content as "successful".
152    /// The SSL inspection path in `ssl.rs` (and `fetch_certificate_info`
153    /// below) intentionally relaxes verification because inspecting an
154    /// invalid cert is the whole point of that code; this path MUST NOT.
155    ///
156    /// The page title is remote content and should be treated as untrusted.
157    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    /// Fetches the leaf certificate's details via the inspection handshake
163    /// ([`crate::tls::inspect`]).
164    ///
165    /// # Security Note
166    /// The handshake accepts any presented chain so invalid certificates can
167    /// be inspected. Chain trust is not verified, so the data retrieved
168    /// (issuer, subject, dates) may come from a MITM's own certificate.
169    async fn fetch_certificate_info(&self, domain: &str) -> Result<CertificateInfo> {
170        // SSRF protection: resolve and reject reserved IPs before connecting.
171        // Use crate::net::resolve_public_host so we get the Hickory fallback
172        // when the OS resolver is broken (corporate Macs, Tailscale split-DNS,
173        // etc.) — the same path every other outbound-connect uses. The
174        // handshake connects to exactly these addresses (no DNS rebinding).
175        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    /// Fetches domain expiration info using WHOIS/RDAP; `None` when the lookup
190    /// fails (which must not fail the whole status check) or has no date.
191    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    /// Fetches DNS root record resolution (A, AAAA, CNAME, NS). A failed
203    /// query contributes no records rather than an error.
204    async fn fetch_dns_resolution(&self, domain: &str) -> DnsResolution {
205        let resolver = &self.dns_resolver;
206
207        // Query all record types concurrently
208        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        // Each query answers only its own type, so the A|AAAA accessor splits
216        // the two lists exactly.
217        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        // Extract CNAME target (trim trailing dot)
228        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        // Extract NS records (trim trailing dots)
238        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        // Domain resolves if it has A/AAAA records or a CNAME
251        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
263/// GETs `url` and returns `(status code, reason phrase, page title)`.
264///
265/// The body is read only for a 2xx `text/html` response, where it feeds the
266/// title; any other final response reports its status straight from the
267/// headers, so a body that stalls or errors cannot fail the sub-check.
268async 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
285/// Extracts the title from HTML content.
286///
287/// Strips ASCII control characters (NUL, ESC, etc.) at extraction time so
288/// the value is safe for every downstream sink — JSON (which would happily
289/// encode `\u0000` and pass it to an LLM via the MCP server), the human
290/// formatter (which sanitises again at render time), and the bulk-CSV
291/// writer. Without the strip, a crafted `<title>Foo\x00Bar</title>` reaches
292/// the LLM context window.
293fn extract_title(html: &str) -> Option<String> {
294    TITLE_REGEX
295        .captures(html)
296        .and_then(|caps| caps.get(1))
297        .map(|m| {
298            // Strip ALL control characters. A raw `\n` or `\t` inside a
299            // `<title>` element is meaningless HTML whitespace (browsers
300            // collapse it to a single space); preserving them would
301            // produce multi-line JSON field values and break CSV column
302            // alignment downstream.
303            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
313/// Parses certificate information from DER-encoded certificate using x509-parser.
314fn 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    // Extract issuer — combine CN and O when both exist. Intermediate CA
321    // certs commonly have a short CN like "E7" or "R3"; without the
322    // organization the human-readable name is unhelpful and the CAA
323    // comparison cannot match the CA's well-known name.
324    let issuer = format_issuer_name(cert.issuer()).unwrap_or_else(|| "Unknown Issuer".to_string());
325
326    // Extract subject - prefer CN, fall back to O (Organization)
327    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    // Hostname verification is performed manually because the inspection
338    // handshake accepts any presented chain to allow cert inspection on
339    // mildly-broken sites. Without this check any cert — even one issued for
340    // an unrelated domain — would be accepted. The rule is shared with
341    // `ssl.rs`, so `seer status` and `seer ssl` cannot disagree.
342    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
355/// Builds a human-readable issuer label, combining Organization and Common
356/// Name when both exist. Used for the cert's issuer rather than the bare
357/// CN so users see "Let's Encrypt (E7)" rather than "E7".
358fn 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
370/// Pulls the first attribute matching `oid` out of an X.509 name.
371fn 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
387/// Extracts the Common Name, falling back to the Organization.
388fn 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
394/// Extracts a string from an ASN.1 attribute value, handling different encodings.
395fn extract_attr_string(value: &x509_parser::der_parser::asn1_rs::Any) -> Option<String> {
396    // Try as_str() first (handles PrintableString, IA5String, etc.)
397    if let Ok(s) = value.as_str() {
398        return Some(s.to_string());
399    }
400
401    // Try UTF8String explicitly
402    if let Ok(utf8) = value.as_utf8string() {
403        return Some(utf8.string().to_string());
404    }
405
406    // Try raw bytes as UTF-8
407    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    /// A/AAAA/CNAME/NS extraction over the full resolve path, against the
427    /// loopback DNS fixture.
428    #[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    /// The hostname verdict comes from the rule shared with `ssl.rs`
446    /// (`tls::cert_matches_host`, which carries the RFC 6125 cases).
447    #[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        // An IP-literal host now matches an iPAddress SAN, as in `seer ssl`.
459        assert!(verified(CN_VICTIM_SAN_IP, "203.0.113.7"));
460    }
461
462    // --- http_info (hermetic: wiremock on 127.0.0.1 via the test seam) ----
463
464    #[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        // Non-2xx reports the status without a title.
498        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}