Skip to main content

web_analyzer/
domain_info.rs

1use regex::Regex;
2use reqwest::Client;
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::time::{Duration, Instant};
6use tokio::io::{AsyncReadExt, AsyncWriteExt};
7use tokio::net::TcpStream;
8
9// ── WHOIS server database ───────────────────────────────────────────────────
10
11const WHOIS_SERVERS: &[(&str, &str)] = &[
12    ("com", "whois.verisign-grs.com"),
13    ("net", "whois.verisign-grs.com"),
14    ("org", "whois.pir.org"),
15    ("info", "whois.afilias.net"),
16    ("biz", "whois.biz"),
17    ("us", "whois.nic.us"),
18    ("uk", "whois.nic.uk"),
19    ("de", "whois.denic.de"),
20    ("fr", "whois.nic.fr"),
21    ("it", "whois.nic.it"),
22    ("nl", "whois.domain-registry.nl"),
23    ("eu", "whois.eu"),
24    ("ru", "whois.tcinet.ru"),
25    ("cn", "whois.cnnic.cn"),
26    ("jp", "whois.jprs.jp"),
27    ("br", "whois.registro.br"),
28    ("au", "whois.auda.org.au"),
29    ("ca", "whois.cira.ca"),
30    ("in", "whois.registry.in"),
31    ("tr", "whois.nic.tr"),
32    ("co", "whois.nic.co"),
33    ("io", "whois.nic.io"),
34    ("me", "whois.nic.me"),
35    ("tv", "whois.nic.tv"),
36    ("cc", "whois.nic.cc"),
37];
38
39/// Common ports for scanning
40const COMMON_PORTS: &[(u16, &str)] = &[
41    (21, "FTP"),
42    (22, "SSH"),
43    (25, "SMTP"),
44    (80, "HTTP"),
45    (443, "HTTPS"),
46    (3306, "MySQL"),
47    (5432, "PostgreSQL"),
48    (8080, "HTTP-Alt"),
49    (8443, "HTTPS-Alt"),
50];
51
52/// Security headers to check
53const SECURITY_HEADERS: &[&str] = &[
54    "strict-transport-security",
55    "x-frame-options",
56    "x-content-type-options",
57    "x-xss-protection",
58    "content-security-policy",
59];
60
61/// Privacy keywords in WHOIS output
62const PRIVACY_KEYWORDS: &[&str] = &[
63    "redacted",
64    "privacy",
65    "gdpr",
66    "protected",
67    "proxy",
68    "private",
69];
70
71// ── Data Structures ─────────────────────────────────────────────────────────
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct DomainInfoResult {
75    pub domain: String,
76    pub ipv4: Option<String>,
77    pub ipv6: Vec<String>,
78    pub all_ipv4: Vec<String>,
79    pub reverse_dns: Option<String>,
80    pub whois: WhoisInfo,
81    pub ssl: SslInfo,
82    pub dns: DnsInfo,
83    pub open_ports: Vec<String>,
84    pub http_status: Option<String>,
85    pub web_server: Option<String>,
86    pub response_time_ms: Option<f64>,
87    pub security: SecurityInfo,
88    pub security_score: u32,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct WhoisInfo {
93    pub registrar: String,
94    pub creation_date: String,
95    pub expiry_date: String,
96    pub last_updated: String,
97    pub domain_status: Vec<String>,
98    pub registrant: String,
99    pub privacy_protection: String,
100    #[serde(skip_serializing_if = "Vec::is_empty")]
101    pub name_servers: Vec<String>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct SslInfo {
106    pub status: String,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub issued_to: Option<String>,
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub issuer: Option<String>,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub protocol_version: Option<String>,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub expiry_date: Option<String>,
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub days_until_expiry: Option<i64>,
117    #[serde(skip_serializing_if = "Vec::is_empty")]
118    pub alternative_names: Vec<String>,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct DnsInfo {
123    pub nameservers: Vec<String>,
124    pub mx_records: Vec<String>,
125    pub txt_records: Vec<String>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub spf: Option<String>,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub dmarc: Option<String>,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct SecurityInfo {
134    pub https_available: bool,
135    pub https_redirect: bool,
136    pub security_headers: HashMap<String, String>,
137    pub headers_count: usize,
138}
139
140// ── Main function ───────────────────────────────────────────────────────────
141
142pub async fn get_domain_info(
143    domain: &str,
144    progress_tx: Option<tokio::sync::mpsc::Sender<crate::ScanProgress>>,
145) -> Result<DomainInfoResult, Box<dyn std::error::Error + Send + Sync>> {
146    let clean = clean_domain(domain);
147
148    let client = Client::builder()
149        .timeout(Duration::from_secs(5))
150        .danger_accept_invalid_certs(true)
151        .redirect(reqwest::redirect::Policy::limited(3))
152        .user_agent("Mozilla/5.0")
153        .build()?;
154
155    if let Some(t) = &progress_tx { let _ = t.send(crate::ScanProgress { module: "Domain Info".into(), percentage: 5.0, message: format!("Initializing scan for {}", clean), status: "Info".into() }).await; }
156
157    // ── IP Resolution ───────────────────────────────────────────────────
158    let (mut ipv4, mut all_ipv4, mut ipv6) = (None, vec![], vec![]);
159    if let Some(t) = &progress_tx { let _ = t.send(crate::ScanProgress { module: "IP Resolution".into(), percentage: 10.0, message: "Resolving IP addresses...".into(), status: "Info".into() }).await; }
160
161    if let Ok(addrs) = tokio::net::lookup_host(format!("{}:80", clean)).await {
162        for addr in addrs {
163            match addr.ip() {
164                std::net::IpAddr::V4(ip) => {
165                    all_ipv4.push(ip.to_string());
166                }
167                std::net::IpAddr::V6(ip) => {
168                    ipv6.push(ip.to_string());
169                }
170            }
171        }
172    }
173    if !all_ipv4.is_empty() {
174        ipv4 = Some(all_ipv4[0].clone());
175    }
176    if let Some(t) = &progress_tx { let _ = t.send(crate::ScanProgress { module: "IP Resolution".into(), percentage: 15.0, message: "IP Resolution completed".into(), status: "Success".into() }).await; }
177
178    // ── Reverse DNS ─────────────────────────────────────────────────────
179    if let Some(t) = &progress_tx { let _ = t.send(crate::ScanProgress { module: "Reverse DNS".into(), percentage: 18.0, message: "Looking up reverse DNS...".into(), status: "Info".into() }).await; }
180    let reverse_dns = if let Some(ref ip) = ipv4 {
181        reverse_dns_lookup(ip).await
182    } else {
183        None
184    };
185    if let Some(t) = &progress_tx { let _ = t.send(crate::ScanProgress { module: "Reverse DNS".into(), percentage: 20.0, message: "Reverse DNS completed".into(), status: "Success".into() }).await; }
186
187    // ── Run concurrent tasks ────────────────────────────────────────────
188    let whois_fut = async {
189        if let Some(t) = &progress_tx { let _ = t.send(crate::ScanProgress { module: "WHOIS".into(), percentage: 25.0, message: "Querying WHOIS registries...".into(), status: "Info".into() }).await; }
190        let res = query_whois(&clean).await;
191        if let Some(t) = &progress_tx { let _ = t.send(crate::ScanProgress { module: "WHOIS".into(), percentage: 40.0, message: "WHOIS data retrieved".into(), status: "Success".into() }).await; }
192        res
193    };
194    let ssl_fut = async {
195        if let Some(t) = &progress_tx { let _ = t.send(crate::ScanProgress { module: "SSL".into(), percentage: 30.0, message: "Verifying SSL certificates...".into(), status: "Info".into() }).await; }
196        let res = check_ssl(&clean).await;
197        if let Some(t) = &progress_tx { let _ = t.send(crate::ScanProgress { module: "SSL".into(), percentage: 50.0, message: "SSL certificate validated".into(), status: "Success".into() }).await; }
198        res
199    };
200    let dns_fut = async {
201        if let Some(t) = &progress_tx { let _ = t.send(crate::ScanProgress { module: "DNS".into(), percentage: 35.0, message: "Fetching DNS records...".into(), status: "Info".into() }).await; }
202        let res = get_dns_records(&clean).await;
203        if let Some(t) = &progress_tx { let _ = t.send(crate::ScanProgress { module: "DNS".into(), percentage: 60.0, message: "DNS records retrieved".into(), status: "Success".into() }).await; }
204        res
205    };
206    let ports_fut = async {
207        if let Some(t) = &progress_tx { let _ = t.send(crate::ScanProgress { module: "Ports".into(), percentage: 40.0, message: "Scanning common ports...".into(), status: "Info".into() }).await; }
208        let res = scan_ports(ipv4.as_deref()).await;
209        if let Some(t) = &progress_tx { let _ = t.send(crate::ScanProgress { module: "Ports".into(), percentage: 70.0, message: "Port scanning complete".into(), status: "Success".into() }).await; }
210        res
211    };
212    let http_fut = async {
213        if let Some(t) = &progress_tx { let _ = t.send(crate::ScanProgress { module: "HTTP".into(), percentage: 45.0, message: "Checking HTTP status...".into(), status: "Info".into() }).await; }
214        let res = check_http_status(&client, &clean).await;
215        if let Some(t) = &progress_tx { let _ = t.send(crate::ScanProgress { module: "HTTP".into(), percentage: 80.0, message: "HTTP check complete".into(), status: "Success".into() }).await; }
216        res
217    };
218    let security_fut = async {
219        if let Some(t) = &progress_tx { let _ = t.send(crate::ScanProgress { module: "Security".into(), percentage: 50.0, message: "Analyzing security headers...".into(), status: "Info".into() }).await; }
220        let res = check_security(&client, &clean).await;
221        if let Some(t) = &progress_tx { let _ = t.send(crate::ScanProgress { module: "Security".into(), percentage: 90.0, message: "Security analysis complete".into(), status: "Success".into() }).await; }
222        res
223    };
224
225    let (whois, ssl, dns, open_ports, http_info, security) = tokio::join!(
226        whois_fut,
227        ssl_fut,
228        dns_fut,
229        ports_fut,
230        http_fut,
231        security_fut
232    );
233
234    // ── Security Score ──────────────────────────────────────────────────
235    let score = calculate_security_score(&ssl, &dns, &security);
236
237    Ok(DomainInfoResult {
238        domain: clean,
239        ipv4,
240        ipv6,
241        all_ipv4,
242        reverse_dns,
243        whois,
244        ssl,
245        dns,
246        open_ports,
247        http_status: http_info.0,
248        web_server: http_info.1,
249        response_time_ms: http_info.2,
250        security,
251        security_score: score,
252    })
253}
254
255// ── Domain cleaning ─────────────────────────────────────────────────────────
256
257fn clean_domain(domain: &str) -> String {
258    let d = domain
259        .trim_start_matches("https://")
260        .trim_start_matches("http://")
261        .replace("www.", "");
262    d.split('/')
263        .next()
264        .unwrap_or(&d)
265        .split(':')
266        .next()
267        .unwrap_or(&d)
268        .to_string()
269}
270
271// ── Reverse DNS ─────────────────────────────────────────────────────────────
272
273async fn reverse_dns_lookup(ip: &str) -> Option<String> {
274    let output = tokio::process::Command::new("dig")
275        .args(["+short", "-x", ip])
276        .output()
277        .await
278        .ok()?;
279    let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
280    if text.is_empty() {
281        None
282    } else {
283        Some(text.trim_end_matches('.').to_string())
284    }
285}
286
287// ── WHOIS via TCP socket ────────────────────────────────────────────────────
288
289fn get_whois_server(domain: &str) -> &'static str {
290    let tld = domain.split('.').next_back().unwrap_or("");
291    WHOIS_SERVERS
292        .iter()
293        .find(|(t, _)| *t == tld)
294        .map(|(_, s)| *s)
295        .unwrap_or("whois.iana.org")
296}
297
298async fn query_whois_tcp(domain: &str, server: &str) -> Option<String> {
299    let addr = format!("{}:43", server);
300    let mut stream = tokio::time::timeout(Duration::from_secs(10), TcpStream::connect(&addr))
301        .await
302        .ok()?
303        .ok()?;
304
305    stream
306        .write_all(format!("{}\r\n", domain).as_bytes())
307        .await
308        .ok()?;
309
310    let mut buf = Vec::new();
311    let _ = tokio::time::timeout(Duration::from_secs(10), stream.read_to_end(&mut buf)).await;
312
313    Some(String::from_utf8_lossy(&buf).to_string())
314}
315
316async fn query_whois(domain: &str) -> WhoisInfo {
317    let mut info = WhoisInfo {
318        registrar: "Unknown".into(),
319        creation_date: "Unknown".into(),
320        expiry_date: "Unknown".into(),
321        last_updated: "Unknown".into(),
322        domain_status: vec![],
323        registrant: "Unknown".into(),
324        privacy_protection: "Unknown".into(),
325        name_servers: vec![],
326    };
327
328    let server = get_whois_server(domain);
329    let output = match query_whois_tcp(domain, server).await {
330        Some(o) if !o.is_empty() => o,
331        _ => return info,
332    };
333
334    // Follow referral
335    let final_output = if let Some(caps) = Regex::new(r"(?i)Registrar WHOIS Server:\s*(.+)")
336        .ok()
337        .and_then(|r| r.captures(&output))
338    {
339        let referral = caps
340            .get(1)
341            .unwrap()
342            .as_str()
343            .trim()
344            .replace("whois://", "")
345            .replace("http://", "")
346            .replace("https://", "");
347        query_whois_tcp(domain, &referral).await.unwrap_or(output)
348    } else {
349        output
350    };
351
352    // Parse registrar
353    for pat in &[
354        r"(?i)Registrar:\s*(.+)",
355        r"(?i)Registrar Name:\s*(.+)",
356        r"(?i)Registrar Organization:\s*(.+)",
357    ] {
358        if let Some(m) = Regex::new(pat).ok().and_then(|r| r.captures(&final_output)) {
359            info.registrar = m.get(1).unwrap().as_str().trim().to_string();
360            break;
361        }
362    }
363
364    // Parse creation date
365    for pat in &[
366        r"(?i)Creation Date:\s*(.+)",
367        r"(?i)Created Date:\s*(.+)",
368        r"(?i)Created:\s*(.+)",
369        r"(?i)Registration Time:\s*(.+)",
370    ] {
371        if let Some(m) = Regex::new(pat).ok().and_then(|r| r.captures(&final_output)) {
372            info.creation_date = m
373                .get(1)
374                .unwrap()
375                .as_str()
376                .trim()
377                .split('\n')
378                .next()
379                .unwrap_or("")
380                .to_string();
381            break;
382        }
383    }
384
385    // Parse expiry date
386    for pat in &[
387        r"(?i)Registry Expiry Date:\s*(.+)",
388        r"(?i)Registrar Registration Expiration Date:\s*(.+)",
389        r"(?i)Expir(?:y|ation) Date:\s*(.+)",
390        r"(?i)expires:\s*(.+)",
391        r"(?i)Expiration Time:\s*(.+)",
392    ] {
393        if let Some(m) = Regex::new(pat).ok().and_then(|r| r.captures(&final_output)) {
394            info.expiry_date = m
395                .get(1)
396                .unwrap()
397                .as_str()
398                .trim()
399                .split('\n')
400                .next()
401                .unwrap_or("")
402                .to_string();
403            break;
404        }
405    }
406
407    // Parse updated date
408    for pat in &[
409        r"(?i)Updated Date:\s*(.+)",
410        r"(?i)Last Updated:\s*(.+)",
411        r"(?i)last-update:\s*(.+)",
412        r"(?i)Modified Date:\s*(.+)",
413    ] {
414        if let Some(m) = Regex::new(pat).ok().and_then(|r| r.captures(&final_output)) {
415            info.last_updated = m
416                .get(1)
417                .unwrap()
418                .as_str()
419                .trim()
420                .split('\n')
421                .next()
422                .unwrap_or("")
423                .to_string();
424            break;
425        }
426    }
427
428    // Parse domain status
429    if let Ok(rx) = Regex::new(r"(?i)(?:Domain )?Status:\s*(.+)") {
430        info.domain_status = rx
431            .captures_iter(&final_output)
432            .filter_map(|c| {
433                c.get(1).map(|m| {
434                    m.as_str()
435                        .split_whitespace()
436                        .next()
437                        .unwrap_or("")
438                        .to_string()
439                })
440            })
441            .filter(|s| !s.is_empty())
442            .take(3)
443            .collect();
444    }
445    if info.domain_status.is_empty() {
446        info.domain_status.push("Unknown".into());
447    }
448
449    // Parse registrant
450    for pat in &[
451        r"(?i)Registrant Name:\s*(.+)",
452        r"(?i)Registrant:\s*(.+)",
453        r"(?i)Registrant Organization:\s*(.+)",
454    ] {
455        if let Some(m) = Regex::new(pat).ok().and_then(|r| r.captures(&final_output)) {
456            let val = m
457                .get(1)
458                .unwrap()
459                .as_str()
460                .trim()
461                .split('\n')
462                .next()
463                .unwrap_or("")
464                .to_string();
465            if !val.is_empty() {
466                info.registrant = val;
467                break;
468            }
469        }
470    }
471
472    // Privacy protection
473    let lower = final_output.to_lowercase();
474    info.privacy_protection = if PRIVACY_KEYWORDS.iter().any(|k| lower.contains(k)) {
475        "Active".into()
476    } else {
477        "Inactive".into()
478    };
479
480    // Name servers
481    if let Ok(rx) = Regex::new(r"(?i)Name Server:\s*(.+)") {
482        info.name_servers = rx
483            .captures_iter(&final_output)
484            .filter_map(|c| c.get(1).map(|m| m.as_str().trim().to_lowercase()))
485            .take(4)
486            .collect();
487    }
488
489    info
490}
491
492// ── SSL Certificate ─────────────────────────────────────────────────────────
493
494async fn check_ssl(domain: &str) -> SslInfo {
495    // Use openssl s_client to get certificate info
496    let output = match tokio::process::Command::new("openssl")
497        .args([
498            "s_client",
499            "-connect",
500            &format!("{}:443", domain),
501            "-servername",
502            domain,
503        ])
504        .stdin(std::process::Stdio::null())
505        .stdout(std::process::Stdio::piped())
506        .stderr(std::process::Stdio::piped())
507        .output()
508        .await
509    {
510        Ok(o) => String::from_utf8_lossy(&o.stdout).to_string(),
511        Err(_) => {
512            return SslInfo {
513                status: "Error".into(),
514                issued_to: None,
515                issuer: None,
516                protocol_version: None,
517                expiry_date: None,
518                days_until_expiry: None,
519                alternative_names: vec![],
520            }
521        }
522    };
523
524    if output.contains("CONNECTED") {
525        let mut ssl = SslInfo {
526            status: "Valid".into(),
527            issued_to: None,
528            issuer: None,
529            protocol_version: None,
530            expiry_date: None,
531            days_until_expiry: None,
532            alternative_names: vec![],
533        };
534
535        // Extract subject CN
536        if let Some(m) = Regex::new(r"subject=.*?CN\s*=\s*([^\n/,]+)")
537            .ok()
538            .and_then(|r| r.captures(&output))
539        {
540            ssl.issued_to = Some(m.get(1).unwrap().as_str().trim().to_string());
541        }
542
543        // Extract issuer CN
544        if let Some(m) = Regex::new(r"issuer=.*?CN\s*=\s*([^\n/,]+)")
545            .ok()
546            .and_then(|r| r.captures(&output))
547        {
548            ssl.issuer = Some(m.get(1).unwrap().as_str().trim().to_string());
549        }
550
551        // Extract protocol
552        if let Some(m) = Regex::new(r"Protocol\s*:\s*(.+)")
553            .ok()
554            .and_then(|r| r.captures(&output))
555        {
556            ssl.protocol_version = Some(m.get(1).unwrap().as_str().trim().to_string());
557        }
558
559        // Get dates via openssl x509
560        if let Ok(cert_output) = tokio::process::Command::new("sh")
561            .args(["-c", &format!("echo | openssl s_client -connect {}:443 -servername {} 2>/dev/null | openssl x509 -noout -dates -subject -ext subjectAltName 2>/dev/null", domain, domain)])
562            .output()
563            .await
564        {
565            let cert_text = String::from_utf8_lossy(&cert_output.stdout);
566
567            if let Some(m) = Regex::new(r"notAfter=(.+)").ok().and_then(|r| r.captures(&cert_text)) {
568                let expiry_str = m.get(1).unwrap().as_str().trim().to_string();
569                ssl.expiry_date = Some(expiry_str.clone());
570
571                // Compute days_until_expiry from parsed date
572                // OpenSSL format: "Jun 15 12:00:00 2025 GMT"
573                if let Ok(expiry) = chrono::NaiveDateTime::parse_from_str(
574                    expiry_str.trim_end_matches(" GMT").trim_end_matches(" UTC"),
575                    "%b %d %H:%M:%S %Y",
576                ) {
577                    let now = chrono::Utc::now().naive_utc();
578                    ssl.days_until_expiry = Some((expiry - now).num_days());
579                }
580            }
581
582            // Extract SANs
583            if let Some(san_section) = cert_text.split("X509v3 Subject Alternative Name:").nth(1) {
584                let names: Vec<String> = Regex::new(r"DNS:([^,\s]+)")
585                    .ok()
586                    .map(|r| r.captures_iter(san_section).filter_map(|c| c.get(1).map(|m| m.as_str().to_string())).take(5).collect())
587                    .unwrap_or_default();
588                ssl.alternative_names = names;
589            }
590        }
591
592        ssl
593    } else {
594        SslInfo {
595            status: "HTTPS not available".into(),
596            issued_to: None,
597            issuer: None,
598            protocol_version: None,
599            expiry_date: None,
600            days_until_expiry: None,
601            alternative_names: vec![],
602        }
603    }
604}
605
606// ── DNS Records via dig ─────────────────────────────────────────────────────
607
608async fn dig_query(domain: &str, rtype: &str) -> Vec<String> {
609    tokio::process::Command::new("dig")
610        .args(["+short", rtype, domain])
611        .output()
612        .await
613        .ok()
614        .and_then(|o| String::from_utf8(o.stdout).ok())
615        .map(|t| {
616            t.lines()
617                .filter(|l| !l.trim().is_empty() && !l.starts_with(';'))
618                .map(|l| l.trim().to_string())
619                .collect()
620        })
621        .unwrap_or_default()
622}
623
624async fn get_dns_records(domain: &str) -> DnsInfo {
625    let (ns, mx, txt) = tokio::join!(
626        dig_query(domain, "NS"),
627        dig_query(domain, "MX"),
628        dig_query(domain, "TXT"),
629    );
630
631    let spf = txt.iter().find(|t| t.contains("v=spf1")).cloned();
632    let dmarc_records = dig_query(&format!("_dmarc.{}", domain), "TXT").await;
633    let dmarc = dmarc_records.into_iter().find(|t| t.contains("v=DMARC1"));
634
635    DnsInfo {
636        nameservers: ns,
637        mx_records: mx,
638        txt_records: txt,
639        spf,
640        dmarc,
641    }
642}
643
644// ── Port Scanning ───────────────────────────────────────────────────────────
645
646async fn scan_ports(ip: Option<&str>) -> Vec<String> {
647    let ip = match ip {
648        Some(ip) => ip,
649        None => return vec![],
650    };
651
652    let mut results = Vec::new();
653    let mut handles = Vec::new();
654
655    for &(port, service) in COMMON_PORTS {
656        let addr = format!("{}:{}", ip, port);
657        handles.push(tokio::spawn(async move {
658            match tokio::time::timeout(Duration::from_secs(1), TcpStream::connect(&addr)).await {
659                Ok(Ok(_)) => Some(format!("{}/{}", port, service)),
660                _ => None,
661            }
662        }));
663    }
664
665    for handle in handles {
666        if let Ok(Some(port_str)) = handle.await {
667            results.push(port_str);
668        }
669    }
670
671    results.sort();
672    results
673}
674
675// ── HTTP Status Check ───────────────────────────────────────────────────────
676
677async fn check_http_status(
678    client: &Client,
679    domain: &str,
680) -> (Option<String>, Option<String>, Option<f64>) {
681    for proto in &["https", "http"] {
682        let url = format!("{}://{}", proto, domain);
683        let start = Instant::now();
684        match client.get(&url).send().await {
685            Ok(resp) => {
686                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
687                let status_str = format!("{} - {}", resp.status().as_u16(), proto.to_uppercase());
688                let server = resp
689                    .headers()
690                    .get("server")
691                    .and_then(|v| v.to_str().ok())
692                    .map(|s| s.to_string());
693                return (
694                    Some(status_str),
695                    server,
696                    Some((elapsed * 100.0).round() / 100.0),
697                );
698            }
699            Err(_) => continue,
700        }
701    }
702    (None, None, None)
703}
704
705// ── Security Check ──────────────────────────────────────────────────────────
706
707async fn check_security(client: &Client, domain: &str) -> SecurityInfo {
708    let mut sec = SecurityInfo {
709        https_available: false,
710        https_redirect: false,
711        security_headers: HashMap::new(),
712        headers_count: 0,
713    };
714
715    // HTTPS + security headers
716    if let Ok(resp) = client.get(format!("https://{}", domain)).send().await {
717        sec.https_available = true;
718        for header in SECURITY_HEADERS {
719            if let Some(val) = resp.headers().get(*header) {
720                if let Ok(v) = val.to_str() {
721                    sec.security_headers
722                        .insert(header.to_string(), v.to_string());
723                    sec.headers_count += 1;
724                }
725            }
726        }
727    }
728
729    // HTTP → HTTPS redirect
730    if let Ok(resp) = client.get(format!("http://{}", domain)).send().await {
731        let final_url = resp.url().to_string();
732        if final_url.starts_with("https://") {
733            sec.https_redirect = true;
734        }
735    }
736
737    sec
738}
739
740// ── Security Score (0-100) ──────────────────────────────────────────────────
741
742fn calculate_security_score(ssl: &SslInfo, dns: &DnsInfo, security: &SecurityInfo) -> u32 {
743    let mut score: u32 = 0;
744
745    // HTTPS available (+30)
746    if security.https_available {
747        score += 30;
748    }
749
750    // HTTPS redirect (+10)
751    if security.https_redirect {
752        score += 10;
753    }
754
755    // SSL valid (+20)
756    if ssl.status == "Valid" {
757        score += 20;
758    }
759
760    // Security headers (up to +20, 4 points each)
761    score += (security.headers_count as u32 * 4).min(20);
762
763    // SPF record (+10)
764    if dns.spf.is_some() {
765        score += 10;
766    }
767
768    // DMARC record (+10)
769    if dns.dmarc.is_some() {
770        score += 10;
771    }
772
773    score
774}