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::collections::HashSet;
10use std::net::SocketAddr;
11use std::time::Duration;
12
13use chrono::Utc;
14use native_tls::TlsConnector;
15use once_cell::sync::Lazy;
16use regex::Regex;
17use reqwest::{Client, Url};
18use tokio::net::TcpStream;
19use tracing::{debug, instrument};
20
21use super::types::{CertificateInfo, DnsResolution, DomainExpiration, StatusResponse};
22use crate::caa::{self, CaaPolicy};
23use crate::dns::{DnsResolver, RecordData, RecordType};
24use crate::error::{Result, SeerError};
25use crate::lookup::SmartLookup;
26use crate::validation::normalize_host;
27
28/// Default timeout for HTTP and TLS operations (10 seconds).
29/// Balances responsiveness with allowing slow servers to respond.
30const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
31const MAX_REDIRECTS: usize = 5;
32
33/// Pre-compiled regex for extracting HTML title.
34static TITLE_REGEX: Lazy<Regex> = Lazy::new(|| {
35    Regex::new(r"(?i)<title[^>]*>([^<]+)</title>").expect("Invalid regex for HTML title extraction")
36});
37
38/// Client for checking domain status (HTTP, SSL, expiration)
39#[derive(Debug, Clone)]
40pub struct StatusClient {
41    timeout: Duration,
42    /// Cached DNS resolver reused across status checks.
43    dns_resolver: DnsResolver,
44    /// Reusable SmartLookup for domain expiration checks.
45    smart_lookup: SmartLookup,
46}
47
48impl Default for StatusClient {
49    fn default() -> Self {
50        Self::new()
51    }
52}
53
54impl StatusClient {
55    /// Creates a new StatusClient with default settings.
56    pub fn new() -> Self {
57        Self {
58            timeout: DEFAULT_TIMEOUT,
59            dns_resolver: DnsResolver::new(),
60            smart_lookup: SmartLookup::new(),
61        }
62    }
63
64    /// Builds a client honoring `~/.seer/config.toml` settings.
65    ///
66    /// Reads `timeouts.http_secs` (clamped to 1–120s by
67    /// [`crate::config::SeerConfig::load`]) for the HTTP/TLS probes, plus
68    /// `timeouts.dns_secs`, `timeouts.whois_secs`, and `timeouts.rdap_secs`
69    /// for the internal DNS resolver and expiration lookup — matching the
70    /// [`crate::availability::AvailabilityChecker::from_config`] precedent of
71    /// per-protocol timeouts on every sub-client.
72    pub fn from_config(config: &crate::config::SeerConfig) -> Self {
73        Self {
74            timeout: config.http_timeout(),
75            dns_resolver: DnsResolver::from_config(config),
76            smart_lookup: SmartLookup::from_config(config),
77        }
78    }
79
80    /// Sets the timeout for HTTP and TLS operations.
81    pub fn with_timeout(mut self, timeout: Duration) -> Self {
82        self.timeout = timeout;
83        self
84    }
85
86    /// Checks the status of a domain (HTTP, SSL, expiration, DNS).
87    #[instrument(skip(self), fields(domain = %domain))]
88    pub async fn check(&self, domain: &str) -> Result<StatusResponse> {
89        // Normalize domain format (doesn't require DNS resolution). The exact
90        // host is kept — `www.example.com` can serve a different site and
91        // certificate than the apex; the expiration lookup reduces it to the
92        // registration on its own.
93        let domain = normalize_host(domain)?;
94        debug!("Checking status for domain: {}", domain);
95
96        let mut response = StatusResponse::new(domain.clone());
97
98        // Fetch HTTP status, SSL cert info, domain expiration, DNS
99        // resolution, and CAA policy concurrently. HTTP and SSL checks
100        // include SSRF protection internally; CAA never fails the request
101        // (a resolver error yields an empty policy).
102        let (http_result, cert_result, expiry_result, dns_result, caa_policy) = tokio::join!(
103            self.fetch_http_info(&domain),
104            self.fetch_certificate_info(&domain),
105            self.fetch_domain_expiration(&domain),
106            self.fetch_dns_resolution(&domain),
107            caa::lookup_caa(&self.dns_resolver, &domain),
108        );
109
110        // Apply HTTP info
111        match http_result {
112            Ok((status, status_text, title)) => {
113                response.http_status = Some(status);
114                response.http_status_text = Some(status_text);
115                response.title = title;
116            }
117            Err(e) => response.errors.push(super::types::StatusError {
118                check: "http".to_string(),
119                message: e.to_string(),
120            }),
121        }
122
123        // Apply certificate info and tag the CAA policy with the issuer
124        // comparison if a cert was retrieved.
125        let mut caa_policy: CaaPolicy = caa_policy;
126        match cert_result {
127            Ok(cert_info) => {
128                caa_policy.issuer_match =
129                    Some(caa::classify_issuer(&cert_info.issuer, &caa_policy));
130                response.certificate = Some(cert_info);
131            }
132            Err(e) => response.errors.push(super::types::StatusError {
133                check: "ssl".to_string(),
134                message: e.to_string(),
135            }),
136        }
137        response.caa = Some(caa_policy);
138
139        // Apply domain expiration info
140        match expiry_result {
141            Ok(expiry_info) => response.domain_expiration = expiry_info,
142            Err(e) => response.errors.push(super::types::StatusError {
143                check: "expiration".to_string(),
144                message: e.to_string(),
145            }),
146        }
147
148        // Apply DNS resolution info
149        match dns_result {
150            Ok(dns_info) => response.dns_resolution = Some(dns_info),
151            Err(e) => response.errors.push(super::types::StatusError {
152                check: "dns".to_string(),
153                message: e.to_string(),
154            }),
155        }
156
157        Ok(response)
158    }
159
160    /// Fetches the HTTP status code and page title.
161    ///
162    /// Redirects are followed manually with IP validation at each hop.
163    /// Resolved IPs are pinned on the HTTP client via `resolve_to_addrs` to
164    /// prevent DNS rebinding attacks (TOCTOU between validation and connect).
165    ///
166    /// # Security Note
167    /// This path uses reqwest's default (validating) TLS configuration — a
168    /// bad certificate surfaces as a typed `SeerError::HttpError` and the
169    /// status check reports it as a failed "http" sub-check instead of
170    /// silently returning attacker-controlled body content as "successful".
171    /// The SSL inspection path in `ssl.rs` (and `fetch_certificate_info`
172    /// below) intentionally relaxes verification because inspecting an
173    /// invalid cert is the whole point of that code; this path MUST NOT.
174    ///
175    /// Redirect targets are validated for SSRF but the HTTP response body
176    /// (page title) comes from an unauthenticated connection and should be
177    /// treated as untrusted.
178    async fn fetch_http_info(&self, domain: &str) -> Result<(u16, String, Option<String>)> {
179        let mut url = Url::parse(&format!("https://{}", domain))
180            .map_err(|e| SeerError::HttpError(format!("invalid URL: {}", e)))?;
181        let mut visited = HashSet::new();
182
183        for _ in 0..=MAX_REDIRECTS {
184            let validated_addrs = validate_url_target(&url).await?;
185
186            if !visited.insert(url.clone()) {
187                return Err(SeerError::HttpError("redirect loop detected".to_string()));
188            }
189
190            // Build a per-hop client that pins the validated IPs so reqwest
191            // cannot re-resolve the hostname to a different (potentially
192            // private) address (DNS rebinding protection).
193            let host = url
194                .host_str()
195                .ok_or_else(|| SeerError::HttpError("missing URL host".to_string()))?;
196            let client = Client::builder()
197                .redirect(reqwest::redirect::Policy::none())
198                .user_agent(concat!("Seer/", env!("CARGO_PKG_VERSION")))
199                .resolve_to_addrs(host, &validated_addrs)
200                .build()
201                .map_err(|e| SeerError::HttpError(format!("failed to build HTTP client: {}", e)))?;
202
203            let response = client
204                .get(url.clone())
205                .timeout(self.timeout)
206                .send()
207                .await
208                .map_err(|e| SeerError::HttpError(e.to_string()))?;
209
210            if response.status().is_redirection() {
211                let location = response.headers().get(reqwest::header::LOCATION);
212                let location = location.and_then(|v| v.to_str().ok()).ok_or_else(|| {
213                    SeerError::HttpError("redirect missing location header".to_string())
214                })?;
215                let next_url = url
216                    .join(location)
217                    .or_else(|_| Url::parse(location))
218                    .map_err(|e| SeerError::HttpError(format!("invalid redirect URL: {}", e)))?;
219                url = next_url;
220                continue;
221            }
222
223            let status = response.status();
224            let status_code = status.as_u16();
225            let status_text = status.canonical_reason().unwrap_or("Unknown").to_string();
226
227            // Only try to get title for successful HTML responses
228            let title = if status.is_success() {
229                let content_type = response
230                    .headers()
231                    .get("content-type")
232                    .and_then(|v| v.to_str().ok())
233                    .unwrap_or("");
234
235                if content_type.contains("text/html") {
236                    // Stream at most 64 KB for title extraction. Streaming
237                    // (rather than `response.bytes().await`) prevents a
238                    // malicious server from forcing us to buffer a huge
239                    // body before the cap is applied.
240                    const MAX_TITLE_BODY: usize = 64 * 1024;
241                    use futures::StreamExt;
242                    let mut buf: Vec<u8> = Vec::with_capacity(8 * 1024);
243                    let mut stream = response.bytes_stream();
244                    while let Some(chunk) = stream.next().await {
245                        let chunk = chunk
246                            .map_err(|e| SeerError::HttpError(format!("body chunk: {}", e)))?;
247                        let remaining = MAX_TITLE_BODY.saturating_sub(buf.len());
248                        if remaining == 0 {
249                            break;
250                        }
251                        let take = remaining.min(chunk.len());
252                        buf.extend_from_slice(&chunk[..take]);
253                        if buf.len() >= MAX_TITLE_BODY {
254                            break;
255                        }
256                    }
257                    let body = String::from_utf8_lossy(&buf);
258                    extract_title(&body)
259                } else {
260                    None
261                }
262            } else {
263                None
264            };
265
266            return Ok((status_code, status_text, title));
267        }
268
269        Err(SeerError::HttpError("too many redirects".to_string()))
270    }
271
272    /// Fetches SSL certificate information using native-tls.
273    ///
274    /// # Security Note
275    /// This connection uses `danger_accept_invalid_certs(true)` to inspect certificates
276    /// even when invalid. Data retrieved (issuer, subject, dates) comes from an
277    /// unauthenticated TLS connection and may have been tampered with by a MITM.
278    async fn fetch_certificate_info(&self, domain: &str) -> Result<CertificateInfo> {
279        // SSRF protection: resolve and reject reserved IPs before connecting.
280        // Use crate::net::resolve_public_host so we get the Hickory fallback
281        // when the OS resolver is broken (corporate Macs, Tailscale split-DNS,
282        // etc.) — the same path every other outbound-connect uses.
283        let socket_addrs = crate::net::resolve_public_host(domain, 443)
284            .await
285            .map_err(|e| SeerError::CertificateError(e.to_string()))?;
286
287        let connector = TlsConnector::builder()
288            .danger_accept_invalid_certs(true) // We want to see the cert even if invalid
289            .build()
290            .map_err(|e| SeerError::CertificateError(e.to_string()))?;
291
292        let connector = tokio_native_tls::TlsConnector::from(connector);
293
294        // Connect directly to the validated socket address to prevent DNS
295        // rebinding (TOCTOU) between validation and connect.
296        let stream =
297            tokio::time::timeout(self.timeout, TcpStream::connect(socket_addrs.as_slice()))
298                .await
299                .map_err(|_| SeerError::Timeout(format!("connection to {} timed out", domain)))?
300                .map_err(|e| SeerError::CertificateError(e.to_string()))?;
301
302        // Use the domain as SNI hostname for the TLS handshake.
303        let tls_stream = tokio::time::timeout(self.timeout, connector.connect(domain, stream))
304            .await
305            .map_err(|_| SeerError::Timeout(format!("TLS handshake with {} timed out", domain)))?
306            .map_err(|e| SeerError::CertificateError(e.to_string()))?;
307
308        // Get the peer certificate
309        let cert = tls_stream
310            .get_ref()
311            .peer_certificate()
312            .map_err(|e| SeerError::CertificateError(e.to_string()))?
313            .ok_or_else(|| SeerError::CertificateError("no certificate found".to_string()))?;
314
315        // Parse certificate info
316        let der = cert
317            .to_der()
318            .map_err(|e| SeerError::CertificateError(e.to_string()))?;
319
320        parse_certificate_der(&der, domain)
321    }
322
323    /// Fetches domain expiration info using WHOIS/RDAP.
324    async fn fetch_domain_expiration(&self, domain: &str) -> Result<Option<DomainExpiration>> {
325        match self.smart_lookup.lookup(domain).await {
326            Ok(result) => {
327                let (expiration_date, registrar) = result.expiration_info();
328
329                if let Some(exp_date) = expiration_date {
330                    let days_until_expiry = crate::dates::days_until(exp_date, Utc::now());
331                    Ok(Some(DomainExpiration {
332                        expiration_date: exp_date,
333                        days_until_expiry,
334                        registrar,
335                    }))
336                } else {
337                    Ok(None)
338                }
339            }
340            Err(_) => Ok(None), // Don't fail the whole status check if WHOIS fails
341        }
342    }
343
344    /// Fetches DNS root record resolution (A, AAAA, CNAME, NS).
345    async fn fetch_dns_resolution(&self, domain: &str) -> Result<DnsResolution> {
346        let resolver = &self.dns_resolver;
347
348        // Query all record types concurrently
349        let (a_result, aaaa_result, cname_result, ns_result) = tokio::join!(
350            resolver.resolve(domain, RecordType::A, None),
351            resolver.resolve(domain, RecordType::AAAA, None),
352            resolver.resolve(domain, RecordType::CNAME, None),
353            resolver.resolve(domain, RecordType::NS, None)
354        );
355
356        // Extract A records
357        let a_records: Vec<String> = a_result
358            .unwrap_or_default()
359            .into_iter()
360            .filter_map(|r| {
361                if let RecordData::A { address } = r.data {
362                    Some(address)
363                } else {
364                    None
365                }
366            })
367            .collect();
368
369        // Extract AAAA records
370        let aaaa_records: Vec<String> = aaaa_result
371            .unwrap_or_default()
372            .into_iter()
373            .filter_map(|r| {
374                if let RecordData::AAAA { address } = r.data {
375                    Some(address)
376                } else {
377                    None
378                }
379            })
380            .collect();
381
382        // Extract CNAME target (trim trailing dot)
383        let cname_target: Option<String> =
384            cname_result.unwrap_or_default().into_iter().find_map(|r| {
385                if let RecordData::CNAME { target } = r.data {
386                    Some(target.trim_end_matches('.').to_string())
387                } else {
388                    None
389                }
390            });
391
392        // Extract NS records (trim trailing dots)
393        let nameservers: Vec<String> = ns_result
394            .unwrap_or_default()
395            .into_iter()
396            .filter_map(|r| {
397                if let RecordData::NS { nameserver } = r.data {
398                    Some(nameserver.trim_end_matches('.').to_string())
399                } else {
400                    None
401                }
402            })
403            .collect();
404
405        // Domain resolves if it has A/AAAA records or a CNAME
406        let resolves = !a_records.is_empty() || !aaaa_records.is_empty() || cname_target.is_some();
407
408        Ok(DnsResolution {
409            a_records,
410            aaaa_records,
411            cname_target,
412            nameservers,
413            resolves,
414        })
415    }
416}
417
418// Domain normalization and validation is now handled by the validation module
419
420/// Extracts the title from HTML content.
421///
422/// Strips ASCII control characters (NUL, ESC, etc.) at extraction time so
423/// the value is safe for every downstream sink — JSON (which would happily
424/// encode `\u0000` and pass it to an LLM via the MCP server), the human
425/// formatter (which sanitises again at render time), and the bulk-CSV
426/// writer. Without the strip, a crafted `<title>Foo\x00Bar</title>` reaches
427/// the LLM context window.
428fn extract_title(html: &str) -> Option<String> {
429    TITLE_REGEX
430        .captures(html)
431        .and_then(|caps| caps.get(1))
432        .map(|m| {
433            // Strip ALL control characters. A raw `\n` or `\t` inside a
434            // `<title>` element is meaningless HTML whitespace (browsers
435            // collapse it to a single space); preserving them would
436            // produce multi-line JSON field values and break CSV column
437            // alignment downstream.
438            m.as_str()
439                .chars()
440                .filter(|c| !c.is_control())
441                .collect::<String>()
442                .trim()
443                .to_string()
444        })
445        .filter(|s| !s.is_empty())
446}
447
448/// Validates that a URL target is safe (no private/reserved IPs, no credentials,
449/// supported scheme) and returns the resolved socket addresses.
450///
451/// The caller should pin these addresses on the HTTP client to prevent DNS
452/// rebinding between validation and the actual connection.
453///
454/// Resolution goes through [`crate::net::resolve_public_host`] — the shared
455/// SSRF guard used by every other outbound leg — which bounds the OS-resolver
456/// lookup (`getaddrinfo` has no deadline, and redirect targets are
457/// attacker-influenceable, so a black-holed hostname could otherwise pin this
458/// task indefinitely) and falls back to hickory when the system resolver is
459/// broken. The reserved-range policy is unchanged (the previous local check
460/// delegated to the same `net::is_reserved_ip`), and the guard's error already
461/// omits the resolved IP (internal-DNS-oracle hardening, issue #49).
462///
463/// The policy itself lives in [`crate::net::validate_http_url`], shared with
464/// the other HTTP fetch paths (`headers`, `takeover`) so the scheme,
465/// credential, port, and reserved-range rules cannot drift between them. This
466/// wrapper is kept as the status module's named entry point.
467async fn validate_url_target(url: &Url) -> Result<Vec<SocketAddr>> {
468    crate::net::validate_http_url(url).await
469}
470
471/// Parses certificate information from DER-encoded certificate using x509-parser.
472fn parse_certificate_der(der: &[u8], domain: &str) -> Result<CertificateInfo> {
473    use x509_parser::prelude::*;
474
475    let (_, cert) = X509Certificate::from_der(der)
476        .map_err(|e| SeerError::CertificateError(format!("failed to parse certificate: {}", e)))?;
477
478    // Extract issuer — combine CN and O when both exist. Intermediate CA
479    // certs commonly have a short CN like "E7" or "R3"; without the
480    // organization the human-readable name is unhelpful and the CAA
481    // comparison cannot match the CA's well-known name.
482    let issuer = format_issuer_name(cert.issuer()).unwrap_or_else(|| "Unknown Issuer".to_string());
483
484    // Extract subject - prefer CN, fall back to O (Organization)
485    let subject =
486        extract_name_from_x509(cert.subject()).unwrap_or_else(|| "Unknown Subject".to_string());
487
488    // Extract validity dates
489    let valid_from = asn1_time_to_chrono(cert.validity().not_before)?;
490    let valid_until = asn1_time_to_chrono(cert.validity().not_after)?;
491
492    let now = Utc::now();
493    let days_until_expiry = crate::dates::days_until(valid_until, now);
494    let is_valid = now >= valid_from && now <= valid_until;
495
496    // Hostname verification is performed manually because the TLS connector
497    // was configured with danger_accept_invalid_certs(true) to allow cert
498    // inspection on mildly-broken sites. Without this check any cert — even
499    // one issued for an unrelated domain — would be accepted.
500    let hostname_verified = cert_matches_hostname(&cert, domain);
501
502    Ok(CertificateInfo {
503        issuer,
504        subject,
505        valid_from,
506        valid_until,
507        days_until_expiry,
508        is_valid,
509        hostname_verified,
510    })
511}
512
513/// Matches a hostname against a certificate name pattern.
514///
515/// Supports exact matches (case-insensitive) and single-label wildcards
516/// per RFC 6125 — `*.example.com` matches `a.example.com` but not
517/// `example.com` or `a.b.example.com`.
518fn hostname_matches_pattern(host: &str, pattern: &str) -> bool {
519    let host = host.to_ascii_lowercase();
520    let pattern = pattern.to_ascii_lowercase();
521    if let Some(rest) = pattern.strip_prefix("*.") {
522        // Wildcard: must match exactly one label, and must contain a dot
523        let Some(dot) = host.find('.') else {
524            return false;
525        };
526        let host_rest = &host[dot + 1..];
527        host_rest == rest
528    } else {
529        host == pattern
530    }
531}
532
533/// Checks whether a certificate's SAN dNSName entries (or CN as fallback)
534/// match the queried hostname.
535///
536/// Per RFC 6125 §6.4.4, SAN dNSName is the authoritative source and the CN
537/// is consulted ONLY when the certificate carries no dNSName SAN at all —
538/// otherwise a cert whose SANs cover other hosts but whose CN happens to
539/// name this one would falsely verify. Mirrors `ssl.rs`, so `seer status`
540/// and `seer ssl` cannot disagree about the same certificate.
541fn cert_matches_hostname(cert: &x509_parser::certificate::X509Certificate<'_>, host: &str) -> bool {
542    use x509_parser::prelude::*;
543
544    // SAN dNSName entries (preferred per RFC 6125)
545    let mut has_dns_san = false;
546    if let Ok(Some(san_ext)) = cert.tbs_certificate.subject_alternative_name() {
547        for name in &san_ext.value.general_names {
548            if let GeneralName::DNSName(n) = name {
549                has_dns_san = true;
550                if hostname_matches_pattern(host, n) {
551                    return true;
552                }
553            }
554        }
555    }
556    if has_dns_san {
557        return false;
558    }
559
560    // CN fallback (legacy) — only for certificates without dNSName SANs.
561    for cn in cert.subject().iter_common_name() {
562        if let Ok(s) = cn.as_str() {
563            if hostname_matches_pattern(host, s) {
564                return true;
565            }
566        }
567    }
568
569    false
570}
571
572/// Builds a human-readable issuer label, combining Organization and Common
573/// Name when both exist. Used for the cert's issuer rather than the bare
574/// CN so users see "Let's Encrypt (E7)" rather than "E7".
575fn format_issuer_name(name: &x509_parser::prelude::X509Name) -> Option<String> {
576    use x509_parser::oid_registry;
577    let cn = extract_oid_value(name, &oid_registry::OID_X509_COMMON_NAME);
578    let org = extract_oid_value(name, &oid_registry::OID_X509_ORGANIZATION_NAME);
579    match (org, cn) {
580        (Some(o), Some(c)) if o != c => Some(format!("{} ({})", o, c)),
581        (Some(o), _) => Some(o),
582        (None, Some(c)) => Some(c),
583        (None, None) => None,
584    }
585}
586
587/// Pulls the first attribute matching `oid` out of an X.509 name.
588fn extract_oid_value(
589    name: &x509_parser::prelude::X509Name,
590    oid: &x509_parser::der_parser::oid::Oid<'static>,
591) -> Option<String> {
592    for rdn in name.iter() {
593        for attr in rdn.iter() {
594            if attr.attr_type() == oid {
595                if let Some(s) = extract_attr_string(attr.attr_value()) {
596                    return Some(s);
597                }
598            }
599        }
600    }
601    None
602}
603
604/// Extracts the Common Name or Organization from an X.509 name.
605fn extract_name_from_x509(name: &x509_parser::prelude::X509Name) -> Option<String> {
606    use x509_parser::prelude::*;
607
608    // Try Common Name first (OID 2.5.4.3)
609    for rdn in name.iter() {
610        for attr in rdn.iter() {
611            if attr.attr_type() == &oid_registry::OID_X509_COMMON_NAME {
612                if let Some(s) = extract_attr_string(attr.attr_value()) {
613                    return Some(s);
614                }
615            }
616        }
617    }
618
619    // Fall back to Organization (OID 2.5.4.10)
620    for rdn in name.iter() {
621        for attr in rdn.iter() {
622            if attr.attr_type() == &oid_registry::OID_X509_ORGANIZATION_NAME {
623                if let Some(s) = extract_attr_string(attr.attr_value()) {
624                    return Some(s);
625                }
626            }
627        }
628    }
629
630    None
631}
632
633/// Extracts a string from an ASN.1 attribute value, handling different encodings.
634fn extract_attr_string(value: &x509_parser::der_parser::asn1_rs::Any) -> Option<String> {
635    // Try as_str() first (handles PrintableString, IA5String, etc.)
636    if let Ok(s) = value.as_str() {
637        return Some(s.to_string());
638    }
639
640    // Try UTF8String explicitly
641    if let Ok(utf8) = value.as_utf8string() {
642        return Some(utf8.string().to_string());
643    }
644
645    // Try raw bytes as UTF-8
646    if let Ok(s) = std::str::from_utf8(value.data) {
647        return Some(s.to_string());
648    }
649
650    None
651}
652
653/// Converts an x509-parser ASN1Time to a chrono DateTime.
654fn asn1_time_to_chrono(time: x509_parser::time::ASN1Time) -> Result<chrono::DateTime<Utc>> {
655    let timestamp = time.timestamp();
656    chrono::DateTime::from_timestamp(timestamp, 0)
657        .ok_or_else(|| SeerError::CertificateError("invalid certificate timestamp".to_string()))
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663
664    #[test]
665    fn from_config_applies_http_timeout() {
666        let mut config = crate::config::SeerConfig::default();
667        config.timeouts.http_secs = 55;
668        let client = StatusClient::from_config(&config);
669        assert_eq!(client.timeout, Duration::from_secs(55));
670    }
671
672    #[test]
673    fn hostname_matches_pattern_exact() {
674        assert!(hostname_matches_pattern("example.com", "example.com"));
675        assert!(hostname_matches_pattern("EXAMPLE.COM", "example.com"));
676        assert!(hostname_matches_pattern("example.com", "EXAMPLE.COM"));
677        assert!(!hostname_matches_pattern("evil.com", "example.com"));
678        assert!(!hostname_matches_pattern("example.com", "evil.com"));
679    }
680
681    #[test]
682    fn hostname_matches_pattern_wildcard() {
683        assert!(hostname_matches_pattern("a.example.com", "*.example.com"));
684        assert!(hostname_matches_pattern("A.EXAMPLE.COM", "*.example.com"));
685        // Apex must not match wildcard (RFC 6125)
686        assert!(!hostname_matches_pattern("example.com", "*.example.com"));
687        // Wildcard only covers a single label
688        assert!(!hostname_matches_pattern(
689            "a.b.example.com",
690            "*.example.com"
691        ));
692        assert!(!hostname_matches_pattern("b.other.com", "*.example.com"));
693    }
694
695    #[test]
696    fn hostname_matches_pattern_wildcard_requires_dot() {
697        // A bare host with no dot cannot match a wildcard pattern
698        assert!(!hostname_matches_pattern("localhost", "*.example.com"));
699    }
700
701    /// Self-signed P-256 cert: CN=victim.example, SAN=DNS:other.example.
702    const CERT_CN_VICTIM_SAN_OTHER: &str = "MIIBoDCCAUegAwIBAgIUdStRrtt0ycIGUV74700+xRrFcJ0wCgYIKoZIzj0EAwIwGTEXMBUGA1UEAwwOdmljdGltLmV4YW1wbGUwHhcNMjYwOTIyMTcwMzA4WhcNMzYwOTE5MTcwMzA4WjAZMRcwFQYDVQQDDA52aWN0aW0uZXhhbXBsZTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJPCvcjh/aeA2qb1taFaBCxI/ue4srU8jUNjjvQW9IKMqdsUluEGjW7fcYSa8w/79MWZ/naVmgZKQs/eSXCU/AWjbTBrMB0GA1UdDgQWBBSG5So71BSr3DZri66kQaPKzWbjNDAfBgNVHSMEGDAWgBSG5So71BSr3DZri66kQaPKzWbjNDAPBgNVHRMBAf8EBTADAQH/MBgGA1UdEQQRMA+CDW90aGVyLmV4YW1wbGUwCgYIKoZIzj0EAwIDRwAwRAIgEnAMNQMytsawL+CuV7N9z/ftwHVzdFunp+oG7QjIou4CIHsf9vyIXQUPs5iBrhprcRiwyuZQWy0mZyRdavp4Kgbh";
703    /// Self-signed P-256 cert: CN=victim.example, no SAN extension.
704    const CERT_CN_VICTIM_NO_SAN: &str = "MIIBhzCCAS2gAwIBAgIUeGkzmcc68l5FOH5NOBgS3Ybcg4gwCgYIKoZIzj0EAwIwGTEXMBUGA1UEAwwOdmljdGltLmV4YW1wbGUwHhcNMjYwOTIyMTcwMzA4WhcNMzYwOTE5MTcwMzA4WjAZMRcwFQYDVQQDDA52aWN0aW0uZXhhbXBsZTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABC6rgHiHBhd3vxpcRHm7VH2YgCybc0Bl4ewS1lMjdtM5+R+pX/STje36olq5IDx9AEJfxtdRMvtiWp9jfb5vdB6jUzBRMB0GA1UdDgQWBBS5JfZqENT0bfsAazBNLiAVb77UdzAfBgNVHSMEGDAWgBS5JfZqENT0bfsAazBNLiAVb77UdzAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0gAMEUCIQD5zMnpSHSVr3vSmZM0vh0R345Rg3wc+OgeZwmsDxDJQQIgBNJ0CS0bpChCAQls0oFZUPD6u7iX7uBOD/QRPZ2Ub1k=";
705
706    fn cert_info(b64: &str, host: &str) -> CertificateInfo {
707        use base64::{engine::general_purpose::STANDARD, Engine};
708        let der = STANDARD.decode(b64).unwrap();
709        parse_certificate_der(&der, host).unwrap()
710    }
711
712    #[test]
713    fn cn_is_ignored_when_the_cert_has_dns_sans() {
714        // RFC 6125 §6.4.4: a matching CN must not rescue a cert whose SANs
715        // name other hosts. `seer ssl` already applied this; status did not.
716        assert!(!cert_info(CERT_CN_VICTIM_SAN_OTHER, "victim.example").hostname_verified);
717        assert!(cert_info(CERT_CN_VICTIM_SAN_OTHER, "other.example").hostname_verified);
718        // Legacy cert with no SAN at all still falls back to the CN.
719        assert!(cert_info(CERT_CN_VICTIM_NO_SAN, "victim.example").hostname_verified);
720        assert!(!cert_info(CERT_CN_VICTIM_NO_SAN, "other.example").hostname_verified);
721    }
722
723    // --- validate_url_target tests (hermetic: IP literals, no DNS) -------
724
725    #[tokio::test]
726    async fn validate_url_target_rejects_unsupported_scheme() {
727        let url = Url::parse("ftp://example.com/").unwrap();
728        let err = validate_url_target(&url).await.unwrap_err();
729        assert!(
730            matches!(err, SeerError::HttpError(ref s) if s.contains("unsupported URL scheme")),
731            "got: {err:?}"
732        );
733    }
734
735    #[tokio::test]
736    async fn validate_url_target_rejects_credentials() {
737        let url = Url::parse("https://user:pass@example.com/").unwrap();
738        let err = validate_url_target(&url).await.unwrap_err();
739        assert!(
740            matches!(err, SeerError::HttpError(ref s) if s.contains("credentials")),
741            "got: {err:?}"
742        );
743    }
744
745    #[tokio::test]
746    async fn validate_url_target_rejects_non_standard_port() {
747        let url = Url::parse("https://8.8.8.8:8443/").unwrap();
748        let err = validate_url_target(&url).await.unwrap_err();
749        assert!(
750            matches!(err, SeerError::HttpError(ref s) if s.contains("non-standard port")),
751            "got: {err:?}"
752        );
753    }
754
755    #[tokio::test]
756    async fn validate_url_target_rejects_loopback_literal() {
757        let url = Url::parse("https://127.0.0.1/").unwrap();
758        let err = validate_url_target(&url).await.unwrap_err();
759        assert!(
760            matches!(err, SeerError::HttpError(ref s) if s.contains("reserved")),
761            "got: {err:?}"
762        );
763    }
764
765    #[tokio::test]
766    async fn validate_url_target_rejects_bracketed_ipv6_loopback_literal() {
767        // `Url::host_str()` keeps the brackets on an IPv6 literal; the
768        // `Url::host()` extraction must unbracket it so the shared guard's
769        // IP-literal short-circuit catches it (no DNS involved).
770        let url = Url::parse("https://[::1]/").unwrap();
771        let err = validate_url_target(&url).await.unwrap_err();
772        assert!(
773            matches!(err, SeerError::HttpError(ref s) if s.contains("reserved")),
774            "got: {err:?}"
775        );
776    }
777
778    #[tokio::test]
779    async fn validate_url_target_allows_public_ip_literal() {
780        let url = Url::parse("https://8.8.8.8/").unwrap();
781        let addrs = validate_url_target(&url).await.unwrap();
782        assert_eq!(addrs.len(), 1);
783        assert_eq!(addrs[0].port(), 443);
784    }
785}