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 its
85        // registrable domain (see `registration_name`).
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, None),
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(sub_check_error("http", &e)),
111        }
112
113        // Apply certificate info and tag the CAA policy with the issuer
114        // comparison if a cert was retrieved.
115        // `domain` is already normalized, so `lookup_caa` cannot fail here.
116        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        // Expiration and DNS never add a sub-check error: a failed lookup
131        // folds into "unknown" (no expiration / no records).
132        response.domain_expiration = expiry_result;
133        response.dns_resolution = Some(dns_result);
134
135        Ok(response)
136    }
137
138    /// Fetches the HTTP status code and page title of `https://{domain}/`.
139    ///
140    /// Goes through [`GuardedFetcher`], the SSRF-guarded GET shared with
141    /// `headers`/`takeover`: redirects are followed manually with the guard
142    /// re-run and the validated IPs pinned at every hop (DNS-rebinding
143    /// defense), and the body read is capped and timeout-bounded.
144    ///
145    /// # Security Note
146    /// This path uses reqwest's default (validating) TLS configuration — a
147    /// bad certificate surfaces as a typed `SeerError::HttpError` and the
148    /// status check reports it as a failed "http" sub-check instead of
149    /// silently returning attacker-controlled body content as "successful".
150    /// The SSL inspection path in `ssl.rs` (and `fetch_certificate_info`
151    /// below) intentionally relaxes verification because inspecting an
152    /// invalid cert is the whole point of that code; this path MUST NOT.
153    ///
154    /// The page title is remote content and should be treated as untrusted.
155    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    /// Fetches the leaf certificate's details via the inspection handshake
161    /// ([`crate::tls::inspect`]), and whether it names a wildcard (for the
162    /// CAA comparison).
163    ///
164    /// # Security Note
165    /// The handshake accepts any presented chain so invalid certificates can
166    /// be inspected. Chain trust is not verified, so the data retrieved
167    /// (issuer, subject, dates) may come from a MITM's own certificate.
168    async fn fetch_certificate_info(&self, domain: &str) -> Result<(CertificateInfo, bool)> {
169        // SSRF protection: resolve and reject reserved IPs before connecting.
170        // Use crate::net::resolve_public_host so we get the Hickory fallback
171        // when the OS resolver is broken (corporate Macs, Tailscale split-DNS,
172        // etc.) — the same path every other outbound-connect uses. The
173        // handshake connects to exactly these addresses (no DNS rebinding).
174        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    /// Fetches the registration's expiration via WHOIS/RDAP; `None` when the
189    /// host has no registrable domain, or the lookup fails (which must not
190    /// fail the whole status check) or has no date.
191    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    /// Fetches DNS root record resolution (A, AAAA, CNAME, NS). A failed
207    /// query contributes no records rather than an error.
208    async fn fetch_dns_resolution(&self, domain: &str) -> DnsResolution {
209        let resolver = &self.dns_resolver;
210
211        // Query all record types concurrently
212        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        // Each query answers only its own type, so the A|AAAA accessor splits
220        // the two lists exactly.
221        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        // Extract CNAME target (trim trailing dot)
232        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        // Extract NS records (trim trailing dots)
242        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        // Domain resolves if it has A/AAAA records or a CNAME
255        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
267/// A failed sub-check as the response reports it: the sanitized message
268/// (the response reaches REST/MCP/Python consumers), with the full error
269/// logged instead.
270fn 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
278/// The name whose registration expiry a status check reports: the
279/// registrable domain of `host` (`google.com` for `mail.google.com`) — a
280/// registry holds no object for a host below it. `None` for an IP literal
281/// or a bare public suffix.
282fn 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
289/// GETs `url` and returns `(status code, reason phrase, page title)`.
290///
291/// The body is read only for a 2xx `text/html` response, where it feeds the
292/// title; any other final response reports its status straight from the
293/// headers. A body that stalls or errors only costs the title, so it cannot
294/// fail the sub-check.
295async 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
318/// Extracts the title from HTML content.
319///
320/// Strips ASCII control characters (NUL, ESC, etc.) at extraction time so
321/// the value is safe for every downstream sink — JSON (which would happily
322/// encode `\u0000` and pass it to an LLM via the MCP server), the human
323/// formatter (which sanitises again at render time), and the bulk-CSV
324/// writer. Without the strip, a crafted `<title>Foo\x00Bar</title>` reaches
325/// the LLM context window.
326fn extract_title(html: &str) -> Option<String> {
327    TITLE_REGEX
328        .captures(html)
329        .and_then(|caps| caps.get(1))
330        .map(|m| {
331            // Strip ALL control characters. A raw `\n` or `\t` inside a
332            // `<title>` element is meaningless HTML whitespace (browsers
333            // collapse it to a single space); preserving them would
334            // produce multi-line JSON field values and break CSV column
335            // alignment downstream.
336            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
346/// Parses certificate information from DER-encoded certificate using
347/// x509-parser, plus whether the certificate names a wildcard.
348fn 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    // Extract issuer — combine CN and O when both exist. Intermediate CA
355    // certs commonly have a short CN like "E7" or "R3"; without the
356    // organization the human-readable name is unhelpful and the CAA
357    // comparison cannot match the CA's well-known name.
358    let issuer = format_issuer_name(cert.issuer()).unwrap_or_else(|| "Unknown Issuer".to_string());
359
360    // Extract subject - prefer CN, fall back to O (Organization)
361    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    // Hostname verification is performed manually because the inspection
372    // handshake accepts any presented chain to allow cert inspection on
373    // mildly-broken sites. Without this check any cert — even one issued for
374    // an unrelated domain — would be accepted. The rule is shared with
375    // `ssl.rs`, so `seer status` and `seer ssl` cannot disagree.
376    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
390/// Builds a human-readable issuer label, combining Organization and Common
391/// Name when both exist. Used for the cert's issuer rather than the bare
392/// CN so users see "Let's Encrypt (E7)" rather than "E7".
393fn 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
405/// Pulls the first attribute matching `oid` out of an X.509 name.
406fn 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
422/// Extracts the Common Name, falling back to the Organization.
423fn 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
429/// Extracts a string from an ASN.1 attribute value, handling different encodings.
430fn extract_attr_string(value: &x509_parser::der_parser::asn1_rs::Any) -> Option<String> {
431    // Try as_str() first (handles PrintableString, IA5String, etc.)
432    if let Ok(s) = value.as_str() {
433        return Some(s.to_string());
434    }
435
436    // Try UTF8String explicitly
437    if let Ok(utf8) = value.as_utf8string() {
438        return Some(utf8.string().to_string());
439    }
440
441    // Try raw bytes as UTF-8
442    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    /// A/AAAA/CNAME/NS extraction over the full resolve path, against the
462    /// loopback DNS fixture.
463    #[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    /// The hostname verdict comes from the rule shared with `ssl.rs`
481    /// (`tls::cert_matches_host`, which carries the RFC 6125 cases).
482    #[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        // An IP-literal host now matches an iPAddress SAN, as in `seer ssl`.
495        assert!(verified(CN_VICTIM_SAN_IP, "203.0.113.7"));
496    }
497
498    // --- http_info (hermetic: wiremock on 127.0.0.1 via the test seam) ----
499
500    #[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        // Non-2xx reports the status without a title.
534        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    /// Regression: a 2xx HTML page whose body stalled failed the whole
541    /// "http" sub-check instead of just losing its title.
542    #[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    /// Regression: expiry was looked up for the full host, which no registry
569    /// holds (`mail.google.com`), so it was always missing.
570    #[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    /// Sub-check errors reach REST/MCP/Python, so they carry the sanitized
583    /// message, never the upstream detail.
584    #[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}