seer_core/status/
client.rs

1use std::time::Duration;
2
3use chrono::Utc;
4use native_tls::TlsConnector;
5use regex::Regex;
6use tokio::net::TcpStream;
7use tracing::{debug, instrument};
8
9use super::types::{CertificateInfo, DomainExpiration, StatusResponse};
10use crate::error::{Result, SeerError};
11use crate::lookup::SmartLookup;
12use crate::validation::validate_domain_safe;
13
14const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
15
16/// Client for checking domain status (HTTP, SSL, expiration)
17#[derive(Debug, Clone)]
18pub struct StatusClient {
19    timeout: Duration,
20}
21
22impl Default for StatusClient {
23    fn default() -> Self {
24        Self::new()
25    }
26}
27
28impl StatusClient {
29    /// Create a new StatusClient with default settings
30    pub fn new() -> Self {
31        Self {
32            timeout: DEFAULT_TIMEOUT,
33        }
34    }
35
36    /// Set the timeout for HTTP and TLS operations
37    pub fn with_timeout(mut self, timeout: Duration) -> Self {
38        self.timeout = timeout;
39        self
40    }
41
42    /// Check the status of a domain
43    #[instrument(skip(self), fields(domain = %domain))]
44    pub async fn check(&self, domain: &str) -> Result<StatusResponse> {
45        // Validate domain and check for SSRF (prevents querying internal IPs)
46        let domain = validate_domain_safe(domain).await?;
47        debug!("Checking status for domain: {}", domain);
48
49        let mut response = StatusResponse::new(domain.clone());
50
51        // Fetch HTTP status and title concurrently with SSL cert info
52        let (http_result, cert_result, expiry_result) = tokio::join!(
53            self.fetch_http_info(&domain),
54            self.fetch_certificate_info(&domain),
55            self.fetch_domain_expiration(&domain)
56        );
57
58        // Apply HTTP info
59        if let Ok((status, status_text, title)) = http_result {
60            response.http_status = Some(status);
61            response.http_status_text = Some(status_text);
62            response.title = title;
63        }
64
65        // Apply certificate info
66        if let Ok(cert_info) = cert_result {
67            response.certificate = Some(cert_info);
68        }
69
70        // Apply domain expiration info
71        if let Ok(expiry_info) = expiry_result {
72            response.domain_expiration = expiry_info;
73        }
74
75        Ok(response)
76    }
77
78    /// Fetch HTTP status code and page title
79    async fn fetch_http_info(&self, domain: &str) -> Result<(u16, String, Option<String>)> {
80        let url = format!("https://{}", domain);
81
82        let client = reqwest::Client::builder()
83            .timeout(self.timeout)
84            .redirect(reqwest::redirect::Policy::limited(5))
85            .build()
86            .map_err(|e| SeerError::HttpError(e.to_string()))?;
87
88        let response = client
89            .get(&url)
90            .header("User-Agent", "Seer/0.1.0")
91            .send()
92            .await
93            .map_err(|e| SeerError::HttpError(e.to_string()))?;
94
95        let status = response.status();
96        let status_code = status.as_u16();
97        let status_text = status.canonical_reason().unwrap_or("Unknown").to_string();
98
99        // Only try to get title for successful HTML responses
100        let title = if status.is_success() {
101            let content_type = response
102                .headers()
103                .get("content-type")
104                .and_then(|v| v.to_str().ok())
105                .unwrap_or("");
106
107            if content_type.contains("text/html") {
108                let body = response
109                    .text()
110                    .await
111                    .map_err(|e| SeerError::HttpError(e.to_string()))?;
112                extract_title(&body)
113            } else {
114                None
115            }
116        } else {
117            None
118        };
119
120        Ok((status_code, status_text, title))
121    }
122
123    /// Fetch SSL certificate information using native-tls
124    async fn fetch_certificate_info(&self, domain: &str) -> Result<CertificateInfo> {
125        let connector = TlsConnector::builder()
126            .danger_accept_invalid_certs(true) // We want to see the cert even if invalid
127            .build()
128            .map_err(|e| SeerError::CertificateError(e.to_string()))?;
129
130        let connector = tokio_native_tls::TlsConnector::from(connector);
131
132        let addr = format!("{}:443", domain);
133        let stream = tokio::time::timeout(self.timeout, TcpStream::connect(&addr))
134            .await
135            .map_err(|_| SeerError::Timeout(format!("Connection to {} timed out", domain)))?
136            .map_err(|e| SeerError::CertificateError(e.to_string()))?;
137
138        let tls_stream = tokio::time::timeout(self.timeout, connector.connect(domain, stream))
139            .await
140            .map_err(|_| SeerError::Timeout(format!("TLS handshake with {} timed out", domain)))?
141            .map_err(|e| SeerError::CertificateError(e.to_string()))?;
142
143        // Get the peer certificate
144        let cert = tls_stream
145            .get_ref()
146            .peer_certificate()
147            .map_err(|e| SeerError::CertificateError(e.to_string()))?
148            .ok_or_else(|| SeerError::CertificateError("No certificate found".to_string()))?;
149
150        // Parse certificate info
151        let der = cert
152            .to_der()
153            .map_err(|e| SeerError::CertificateError(e.to_string()))?;
154
155        parse_certificate_der(&der, domain)
156    }
157
158    /// Fetch domain expiration info using WHOIS/RDAP
159    async fn fetch_domain_expiration(&self, domain: &str) -> Result<Option<DomainExpiration>> {
160        let lookup = SmartLookup::new();
161
162        match lookup.lookup(domain).await {
163            Ok(result) => {
164                let (expiration_date, registrar) = result.expiration_info();
165
166                if let Some(exp_date) = expiration_date {
167                    let days_until_expiry = (exp_date - Utc::now()).num_days();
168                    Ok(Some(DomainExpiration {
169                        expiration_date: exp_date,
170                        days_until_expiry,
171                        registrar,
172                    }))
173                } else {
174                    Ok(None)
175                }
176            }
177            Err(_) => Ok(None), // Don't fail the whole status check if WHOIS fails
178        }
179    }
180}
181
182// Domain normalization and validation is now handled by the validation module
183
184/// Extract the title from HTML content
185fn extract_title(html: &str) -> Option<String> {
186    let re = Regex::new(r"(?i)<title[^>]*>([^<]+)</title>").ok()?;
187    re.captures(html)
188        .and_then(|caps| caps.get(1))
189        .map(|m| m.as_str().trim().to_string())
190        .filter(|s| !s.is_empty())
191}
192
193/// Parse certificate information from DER-encoded certificate
194fn parse_certificate_der(der: &[u8], _domain: &str) -> Result<CertificateInfo> {
195    // Validate the DER is a proper certificate
196    let _ = native_tls::Certificate::from_der(der)
197        .map_err(|e| SeerError::CertificateError(e.to_string()))?;
198
199    // Parse the DER manually to extract dates and names
200    // This is a simplified parser for X.509 certificates
201    let (issuer, subject, valid_from, valid_until) = parse_x509_basic(der)?;
202
203    let now = Utc::now();
204    let days_until_expiry = (valid_until - now).num_days();
205    let is_valid = now >= valid_from && now <= valid_until;
206
207    Ok(CertificateInfo {
208        issuer,
209        subject,
210        valid_from,
211        valid_until,
212        days_until_expiry,
213        is_valid,
214    })
215}
216
217/// Basic X.509 certificate parser to extract issuer, subject, and validity dates
218fn parse_x509_basic(
219    der: &[u8],
220) -> Result<(String, String, chrono::DateTime<Utc>, chrono::DateTime<Utc>)> {
221    // This is a simplified parser that extracts common certificate fields
222    // by looking for known ASN.1 patterns
223
224    let issuer = extract_cn_from_der(der, true).unwrap_or_else(|| "Unknown Issuer".to_string());
225    let subject = extract_cn_from_der(der, false).unwrap_or_else(|| "Unknown Subject".to_string());
226
227    // Extract validity dates
228    let (valid_from, valid_until) = extract_validity_from_der(der)?;
229
230    Ok((issuer, subject, valid_from, valid_until))
231}
232
233/// Extract Common Name from DER certificate (simplified)
234fn extract_cn_from_der(der: &[u8], is_issuer: bool) -> Option<String> {
235    // Look for the OID 2.5.4.3 (Common Name) followed by the value
236    // OID encoding: 55 04 03 (2.5.4.3)
237    let cn_oid = [0x55, 0x04, 0x03];
238
239    let mut found_first = false;
240    for i in 0..der.len().saturating_sub(10) {
241        if der[i..].starts_with(&cn_oid) {
242            if is_issuer && found_first {
243                // Skip to subject's CN
244                continue;
245            }
246            if !is_issuer && !found_first {
247                found_first = true;
248                continue;
249            }
250
251            // The CN value follows the OID
252            // Skip OID (3 bytes) + type tag (1 byte) + length (1 byte)
253            let start = i + 5;
254            if start < der.len() {
255                let len = der[i + 4] as usize;
256                let end = (start + len).min(der.len());
257                if let Ok(s) = std::str::from_utf8(&der[start..end]) {
258                    return Some(s.to_string());
259                }
260            }
261        }
262    }
263
264    // Fallback: look for Organization name if CN not found
265    let org_oid = [0x55, 0x04, 0x0a]; // 2.5.4.10 (Organization)
266    for i in 0..der.len().saturating_sub(10) {
267        if der[i..].starts_with(&org_oid) {
268            let start = i + 5;
269            if start < der.len() {
270                let len = der[i + 4] as usize;
271                let end = (start + len).min(der.len());
272                if let Ok(s) = std::str::from_utf8(&der[start..end]) {
273                    return Some(s.to_string());
274                }
275            }
276            break;
277        }
278    }
279
280    None
281}
282
283/// Extract validity dates from DER certificate
284fn extract_validity_from_der(
285    der: &[u8],
286) -> Result<(chrono::DateTime<Utc>, chrono::DateTime<Utc>)> {
287    // Look for UTCTime (tag 0x17) or GeneralizedTime (tag 0x18) patterns
288    // Validity is typically a SEQUENCE containing two time values
289
290    let mut times: Vec<chrono::DateTime<Utc>> = Vec::new();
291
292    let mut i = 0;
293    while i < der.len().saturating_sub(15) && times.len() < 2 {
294        // UTCTime: tag 0x17, typically 13 bytes (YYMMDDHHMMSSZ)
295        if der[i] == 0x17 && i + 1 < der.len() {
296            let len = der[i + 1] as usize;
297            if len >= 13 && i + 2 + len <= der.len() {
298                if let Ok(s) = std::str::from_utf8(&der[i + 2..i + 2 + len]) {
299                    if let Some(dt) = parse_utc_time(s) {
300                        times.push(dt);
301                    }
302                }
303            }
304        }
305        // GeneralizedTime: tag 0x18, typically 15 bytes (YYYYMMDDHHMMSSZ)
306        else if der[i] == 0x18 && i + 1 < der.len() {
307            let len = der[i + 1] as usize;
308            if len >= 15 && i + 2 + len <= der.len() {
309                if let Ok(s) = std::str::from_utf8(&der[i + 2..i + 2 + len]) {
310                    if let Some(dt) = parse_generalized_time(s) {
311                        times.push(dt);
312                    }
313                }
314            }
315        }
316        i += 1;
317    }
318
319    if times.len() >= 2 {
320        Ok((times[0], times[1]))
321    } else {
322        Err(SeerError::CertificateError(
323            "Could not parse certificate validity dates".to_string(),
324        ))
325    }
326}
327
328/// Parse UTCTime format (YYMMDDHHMMSSZ)
329fn parse_utc_time(s: &str) -> Option<chrono::DateTime<Utc>> {
330    use chrono::NaiveDateTime;
331
332    let s = s.trim_end_matches('Z');
333    if s.len() < 12 {
334        return None;
335    }
336
337    let year: i32 = s[0..2].parse().ok()?;
338    let year = if year >= 50 { 1900 + year } else { 2000 + year };
339    let month: u32 = s[2..4].parse().ok()?;
340    let day: u32 = s[4..6].parse().ok()?;
341    let hour: u32 = s[6..8].parse().ok()?;
342    let min: u32 = s[8..10].parse().ok()?;
343    let sec: u32 = s[10..12].parse().ok()?;
344
345    NaiveDateTime::parse_from_str(
346        &format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", year, month, day, hour, min, sec),
347        "%Y-%m-%d %H:%M:%S",
348    )
349    .ok()
350    .map(|dt| dt.and_utc())
351}
352
353/// Parse GeneralizedTime format (YYYYMMDDHHMMSSZ)
354fn parse_generalized_time(s: &str) -> Option<chrono::DateTime<Utc>> {
355    use chrono::NaiveDateTime;
356
357    let s = s.trim_end_matches('Z');
358    if s.len() < 14 {
359        return None;
360    }
361
362    let year: i32 = s[0..4].parse().ok()?;
363    let month: u32 = s[4..6].parse().ok()?;
364    let day: u32 = s[6..8].parse().ok()?;
365    let hour: u32 = s[8..10].parse().ok()?;
366    let min: u32 = s[10..12].parse().ok()?;
367    let sec: u32 = s[12..14].parse().ok()?;
368
369    NaiveDateTime::parse_from_str(
370        &format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", year, month, day, hour, min, sec),
371        "%Y-%m-%d %H:%M:%S",
372    )
373    .ok()
374    .map(|dt| dt.and_utc())
375}