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" or "Jun  5 12:00:00 2025 GMT"
573                let clean_expiry = expiry_str.trim_end_matches(" GMT").trim_end_matches(" UTC");
574                
575                // Try parsing with space-padded day (%e) or zero-padded day (%d)
576                let parsed_date = chrono::NaiveDateTime::parse_from_str(clean_expiry, "%b %e %H:%M:%S %Y")
577                    .or_else(|_| chrono::NaiveDateTime::parse_from_str(clean_expiry, "%b %d %H:%M:%S %Y"));
578                    
579                if let Ok(expiry) = parsed_date {
580                    let now = chrono::Utc::now().naive_utc();
581                    ssl.days_until_expiry = Some((expiry - now).num_days());
582                }
583            }
584
585            // Extract SANs
586            if let Some(san_section) = cert_text.split("X509v3 Subject Alternative Name:").nth(1) {
587                let names: Vec<String> = Regex::new(r"DNS:([^,\s]+)")
588                    .ok()
589                    .map(|r| r.captures_iter(san_section).filter_map(|c| c.get(1).map(|m| m.as_str().to_string())).take(5).collect())
590                    .unwrap_or_default();
591                ssl.alternative_names = names;
592            }
593        }
594
595        ssl
596    } else {
597        SslInfo {
598            status: "HTTPS not available".into(),
599            issued_to: None,
600            issuer: None,
601            protocol_version: None,
602            expiry_date: None,
603            days_until_expiry: None,
604            alternative_names: vec![],
605        }
606    }
607}
608
609// ── DNS Records via dig ─────────────────────────────────────────────────────
610
611async fn dig_query(domain: &str, rtype: &str) -> Vec<String> {
612    tokio::process::Command::new("dig")
613        .args(["+short", rtype, domain])
614        .output()
615        .await
616        .ok()
617        .and_then(|o| String::from_utf8(o.stdout).ok())
618        .map(|t| {
619            t.lines()
620                .filter(|l| !l.trim().is_empty() && !l.starts_with(';'))
621                .map(|l| l.trim().to_string())
622                .collect()
623        })
624        .unwrap_or_default()
625}
626
627async fn get_dns_records(domain: &str) -> DnsInfo {
628    let (ns, mx, txt) = tokio::join!(
629        dig_query(domain, "NS"),
630        dig_query(domain, "MX"),
631        dig_query(domain, "TXT"),
632    );
633
634    let spf = txt.iter().find(|t| t.contains("v=spf1")).cloned();
635    let dmarc_records = dig_query(&format!("_dmarc.{}", domain), "TXT").await;
636    let dmarc = dmarc_records.into_iter().find(|t| t.contains("v=DMARC1"));
637
638    DnsInfo {
639        nameservers: ns,
640        mx_records: mx,
641        txt_records: txt,
642        spf,
643        dmarc,
644    }
645}
646
647// ── Port Scanning ───────────────────────────────────────────────────────────
648
649async fn scan_ports(ip: Option<&str>) -> Vec<String> {
650    let ip = match ip {
651        Some(ip) => ip,
652        None => return vec![],
653    };
654
655    let mut results = Vec::new();
656    let mut handles = Vec::new();
657
658    for &(port, service) in COMMON_PORTS {
659        let addr = format!("{}:{}", ip, port);
660        handles.push(tokio::spawn(async move {
661            match tokio::time::timeout(Duration::from_secs(1), TcpStream::connect(&addr)).await {
662                Ok(Ok(_)) => Some(format!("{}/{}", port, service)),
663                _ => None,
664            }
665        }));
666    }
667
668    for handle in handles {
669        if let Ok(Some(port_str)) = handle.await {
670            results.push(port_str);
671        }
672    }
673
674    results.sort();
675    results
676}
677
678// ── HTTP Status Check ───────────────────────────────────────────────────────
679
680async fn check_http_status(
681    client: &Client,
682    domain: &str,
683) -> (Option<String>, Option<String>, Option<f64>) {
684    for proto in &["https", "http"] {
685        let url = format!("{}://{}", proto, domain);
686        let start = Instant::now();
687        match client.get(&url).send().await {
688            Ok(resp) => {
689                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
690                let status_str = format!("{} - {}", resp.status().as_u16(), proto.to_uppercase());
691                let server = resp
692                    .headers()
693                    .get("server")
694                    .and_then(|v| v.to_str().ok())
695                    .map(|s| s.to_string());
696                return (
697                    Some(status_str),
698                    server,
699                    Some((elapsed * 100.0).round() / 100.0),
700                );
701            }
702            Err(_) => continue,
703        }
704    }
705    (None, None, None)
706}
707
708// ── Security Check ──────────────────────────────────────────────────────────
709
710async fn check_security(client: &Client, domain: &str) -> SecurityInfo {
711    let mut sec = SecurityInfo {
712        https_available: false,
713        https_redirect: false,
714        security_headers: HashMap::new(),
715        headers_count: 0,
716    };
717
718    // HTTPS + security headers
719    if let Ok(resp) = client.get(format!("https://{}", domain)).send().await {
720        sec.https_available = true;
721        for header in SECURITY_HEADERS {
722            if let Some(val) = resp.headers().get(*header) {
723                if let Ok(v) = val.to_str() {
724                    sec.security_headers
725                        .insert(header.to_string(), v.to_string());
726                    sec.headers_count += 1;
727                }
728            }
729        }
730    }
731
732    // HTTP → HTTPS redirect
733    if let Ok(resp) = client.get(format!("http://{}", domain)).send().await {
734        let final_url = resp.url().to_string();
735        if final_url.starts_with("https://") {
736            sec.https_redirect = true;
737        }
738    }
739
740    sec
741}
742
743// ── Security Score (0-100) ──────────────────────────────────────────────────
744
745fn calculate_security_score(ssl: &SslInfo, dns: &DnsInfo, security: &SecurityInfo) -> u32 {
746    let mut score: u32 = 0;
747
748    // HTTPS available (+30)
749    if security.https_available {
750        score += 30;
751    }
752
753    // HTTPS redirect (+10)
754    if security.https_redirect {
755        score += 10;
756    }
757
758    // SSL valid (+20)
759    if ssl.status == "Valid" {
760        score += 20;
761    }
762
763    // Security headers (up to +20, 4 points each)
764    score += (security.headers_count as u32 * 4).min(20);
765
766    // SPF record (+10)
767    if dns.spf.is_some() {
768        score += 10;
769    }
770
771    // DMARC record (+10)
772    if dns.dmarc.is_some() {
773        score += 10;
774    }
775
776    score
777}