seer_core/status/
client.rs1use 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#[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 pub fn new() -> Self {
31 Self {
32 timeout: DEFAULT_TIMEOUT,
33 }
34 }
35
36 pub fn with_timeout(mut self, timeout: Duration) -> Self {
38 self.timeout = timeout;
39 self
40 }
41
42 #[instrument(skip(self), fields(domain = %domain))]
44 pub async fn check(&self, domain: &str) -> Result<StatusResponse> {
45 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 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 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 if let Ok(cert_info) = cert_result {
67 response.certificate = Some(cert_info);
68 }
69
70 if let Ok(expiry_info) = expiry_result {
72 response.domain_expiration = expiry_info;
73 }
74
75 Ok(response)
76 }
77
78 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 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 async fn fetch_certificate_info(&self, domain: &str) -> Result<CertificateInfo> {
125 let connector = TlsConnector::builder()
126 .danger_accept_invalid_certs(true) .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 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 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 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), }
179 }
180}
181
182fn 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
193fn parse_certificate_der(der: &[u8], _domain: &str) -> Result<CertificateInfo> {
195 let _ = native_tls::Certificate::from_der(der)
197 .map_err(|e| SeerError::CertificateError(e.to_string()))?;
198
199 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
217fn parse_x509_basic(
219 der: &[u8],
220) -> Result<(String, String, chrono::DateTime<Utc>, chrono::DateTime<Utc>)> {
221 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 let (valid_from, valid_until) = extract_validity_from_der(der)?;
229
230 Ok((issuer, subject, valid_from, valid_until))
231}
232
233fn extract_cn_from_der(der: &[u8], is_issuer: bool) -> Option<String> {
235 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 continue;
245 }
246 if !is_issuer && !found_first {
247 found_first = true;
248 continue;
249 }
250
251 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 let org_oid = [0x55, 0x04, 0x0a]; 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
283fn extract_validity_from_der(
285 der: &[u8],
286) -> Result<(chrono::DateTime<Utc>, chrono::DateTime<Utc>)> {
287 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 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 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
328fn 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
353fn 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}