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