Skip to main content

web_analyzer/
domain_validator.rs

1use reqwest::Client;
2use serde::{Deserialize, Serialize};
3use std::net::IpAddr;
4use std::sync::atomic::{AtomicUsize, Ordering};
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7use tokio::process::Command;
8
9// ── Constants ───────────────────────────────────────────────────────────────
10
11const SKIP_PATTERNS: &[&str] = &[
12    "stun.l.google.com",
13    ".cloudapp.azure.com",
14    "clients6.google.com",
15    ".cdn.cloudflare.net",
16    "rr1.sn-",
17    "rr2.sn-",
18    "rr3.sn-",
19    "rr4.sn-",
20    "rr5.sn-",
21    "e-0014.e-msedge",
22    "s-part-",
23    ".t-msedge.net",
24    "perimeterx.map",
25    "i.ytimg.com",
26    "analytics-alv.google.com",
27    "signaler-pa.clients",
28    "westus-0.in.applicationinsights",
29];
30
31const INTERNAL_PATTERNS: &[&str] = &[
32    "localhost",
33    "127.0.0.1",
34    "0.0.0.0",
35    "192.168.",
36    "10.",
37    "172.16.",
38    "172.17.",
39    "172.18.",
40    "172.19.",
41    "172.20.",
42    "172.21.",
43    "172.22.",
44    "172.23.",
45    "172.24.",
46    "172.25.",
47    "172.26.",
48    "172.27.",
49    "172.28.",
50    "172.29.",
51    "172.30.",
52    "172.31.",
53];
54
55// ── Data Structures ─────────────────────────────────────────────────────────
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct ValidationResult {
59    pub domain: String,
60    pub valid: bool,
61    pub skip_reason: Option<String>,
62    pub dns_valid: bool,
63    pub http_valid: bool,
64    pub ssl_valid: bool,
65    pub dns_info: Option<DnsValidation>,
66    pub http_info: Option<HttpValidation>,
67    pub ssl_info: Option<SslValidation>,
68    pub errors: Vec<String>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct DnsValidation {
73    pub ip_addresses: Vec<String>,
74    pub mx_exists: bool,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct HttpValidation {
79    pub http_reachable: bool,
80    pub https_reachable: bool,
81    pub http_status: Option<u16>,
82    pub https_status: Option<u16>,
83    pub redirects_to_https: bool,
84    pub response_time_ms: u128,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct SslValidation {
89    pub ssl_available: bool,
90    pub protocol_version: String,
91    pub cipher_suite: String,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct ValidationStats {
96    pub total: usize,
97    pub valid: usize,
98    pub invalid: usize,
99    pub skipped: usize,
100    pub dns_failed: usize,
101    pub http_failed: usize,
102    pub ssl_failed: usize,
103    pub success_rate: f64,
104    pub processing_time_secs: f64,
105    pub domains_per_sec: f64,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct BulkValidationResult {
110    pub stats: ValidationStats,
111    pub valid_domains: Vec<String>,
112    pub results: Vec<ValidationResult>,
113}
114
115// ── Shared Counters ─────────────────────────────────────────────────────────
116
117struct AtomicStats {
118    valid: AtomicUsize,
119    invalid: AtomicUsize,
120    skipped: AtomicUsize,
121    dns_failed: AtomicUsize,
122    http_failed: AtomicUsize,
123    ssl_failed: AtomicUsize,
124}
125
126impl AtomicStats {
127    fn new() -> Self {
128        Self {
129            valid: AtomicUsize::new(0),
130            invalid: AtomicUsize::new(0),
131            skipped: AtomicUsize::new(0),
132            dns_failed: AtomicUsize::new(0),
133            http_failed: AtomicUsize::new(0),
134            ssl_failed: AtomicUsize::new(0),
135        }
136    }
137}
138
139// ── Public API ──────────────────────────────────────────────────────────────
140
141/// Validate a single domain comprehensively (DNS → HTTP → SSL)
142pub async fn validate_domain(domain: &str) -> ValidationResult {
143    let client = crate::http_client_builder()
144        .timeout(Duration::from_secs(10))
145        .danger_accept_invalid_certs(true)
146        .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
147        .redirect(reqwest::redirect::Policy::limited(5))
148        .build()
149        .unwrap_or_else(|_| Client::new());
150
151    validate_single(&client, domain).await
152}
153
154/// Validate multiple domains in parallel with configurable concurrency
155pub async fn validate_domains_bulk(
156    domains: &[String],
157    max_concurrency: usize,
158    progress_tx: Option<tokio::sync::mpsc::Sender<crate::ScanProgress>>,
159) -> BulkValidationResult {
160    let start = Instant::now();
161    let total = domains.len();
162    report_progress(
163        &progress_tx,
164        5.0,
165        format!("Starting validation for {} domain(s)", total),
166        "Info",
167    );
168
169    let client = crate::http_client_builder()
170        .timeout(Duration::from_secs(10))
171        .danger_accept_invalid_certs(true)
172        .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
173        .redirect(reqwest::redirect::Policy::limited(5))
174        .pool_max_idle_per_host(max_concurrency)
175        .build()
176        .unwrap_or_else(|_| Client::new());
177
178    let stats = Arc::new(AtomicStats::new());
179    let semaphore = Arc::new(tokio::sync::Semaphore::new(max_concurrency));
180    let completed = Arc::new(AtomicUsize::new(0));
181
182    let mut handles = Vec::with_capacity(total);
183
184    for domain in domains {
185        let client = client.clone();
186        let domain = domain.clone();
187        let stats = Arc::clone(&stats);
188        let sem = Arc::clone(&semaphore);
189        let completed = Arc::clone(&completed);
190        let progress_tx = progress_tx.clone();
191
192        handles.push(tokio::spawn(async move {
193            let _permit = sem.acquire().await.unwrap();
194            let result = validate_single(&client, &domain).await;
195
196            // Update counters
197            if result.skip_reason.is_some() {
198                stats.skipped.fetch_add(1, Ordering::Relaxed);
199            } else if result.valid {
200                stats.valid.fetch_add(1, Ordering::Relaxed);
201            } else {
202                stats.invalid.fetch_add(1, Ordering::Relaxed);
203                if !result.dns_valid {
204                    stats.dns_failed.fetch_add(1, Ordering::Relaxed);
205                }
206                if !result.http_valid && result.dns_valid {
207                    stats.http_failed.fetch_add(1, Ordering::Relaxed);
208                }
209                if !result.ssl_valid && result.dns_valid {
210                    stats.ssl_failed.fetch_add(1, Ordering::Relaxed);
211                }
212            }
213
214            let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
215            let percentage = if total > 0 {
216                5.0 + (90.0 * (done as f32 / total as f32))
217            } else {
218                95.0
219            };
220            let status = if result.skip_reason.is_some() {
221                "Info"
222            } else if result.valid {
223                "Success"
224            } else {
225                "Error"
226            };
227            let message = if let Some(reason) = &result.skip_reason {
228                format!("Skipped {}: {}", domain, reason)
229            } else if result.valid {
230                format!("Validated {}", domain)
231            } else {
232                format!("Validation failed for {}", domain)
233            };
234            report_progress(&progress_tx, percentage, message, status);
235
236            result
237        }));
238    }
239
240    // Collect results
241    let mut results = Vec::with_capacity(total);
242    for handle in handles {
243        if let Ok(result) = handle.await {
244            results.push(result);
245        }
246    }
247
248    let elapsed = start.elapsed().as_secs_f64();
249    let valid_count = stats.valid.load(Ordering::Relaxed);
250
251    let valid_domains: Vec<String> = results
252        .iter()
253        .filter(|r| r.valid)
254        .map(|r| r.domain.clone())
255        .collect();
256
257    report_progress(
258        &progress_tx,
259        100.0,
260        format!(
261            "Bulk validation complete: {} valid of {}",
262            valid_count, total
263        ),
264        "Success",
265    );
266
267    BulkValidationResult {
268        stats: ValidationStats {
269            total,
270            valid: valid_count,
271            invalid: stats.invalid.load(Ordering::Relaxed),
272            skipped: stats.skipped.load(Ordering::Relaxed),
273            dns_failed: stats.dns_failed.load(Ordering::Relaxed),
274            http_failed: stats.http_failed.load(Ordering::Relaxed),
275            ssl_failed: stats.ssl_failed.load(Ordering::Relaxed),
276            success_rate: if total > 0 {
277                (valid_count as f64 / total as f64) * 100.0
278            } else {
279                0.0
280            },
281            processing_time_secs: elapsed,
282            domains_per_sec: if elapsed > 0.0 {
283                total as f64 / elapsed
284            } else {
285                0.0
286            },
287        },
288        valid_domains,
289        results,
290    }
291}
292
293fn report_progress(
294    progress_tx: &Option<tokio::sync::mpsc::Sender<crate::ScanProgress>>,
295    percentage: f32,
296    message: impl Into<String>,
297    status: &str,
298) {
299    if let Some(tx) = progress_tx {
300        let _ = tx.try_send(crate::ScanProgress {
301            module: "Domain Validator".into(),
302            percentage,
303            message: message.into(),
304            status: status.into(),
305        });
306    }
307}
308
309// ── Single Domain Validation ────────────────────────────────────────────────
310
311async fn validate_single(client: &Client, domain: &str) -> ValidationResult {
312    let mut result = ValidationResult {
313        domain: domain.to_string(),
314        valid: false,
315        skip_reason: None,
316        dns_valid: false,
317        http_valid: false,
318        ssl_valid: false,
319        dns_info: None,
320        http_info: None,
321        ssl_info: None,
322        errors: vec![],
323    };
324
325    // 1. Skip check
326    if let Some(reason) = should_skip(domain) {
327        result.skip_reason = Some(reason);
328        return result;
329    }
330
331    // 2. DNS validation
332    match validate_dns(domain).await {
333        Ok(dns) => {
334            result.dns_valid = true;
335            result.dns_info = Some(dns);
336        }
337        Err(e) => {
338            result.errors.push(format!("DNS: {}", e));
339            return result; // Skip HTTP/SSL if DNS fails
340        }
341    }
342
343    // 3. HTTP validation
344    match validate_http(client, domain).await {
345        Ok(http) => {
346            result.http_valid = http.http_reachable || http.https_reachable;
347            if !result.http_valid {
348                result
349                    .errors
350                    .push("HTTP: No HTTP/HTTPS connectivity".into());
351            }
352            result.http_info = Some(http);
353        }
354        Err(e) => {
355            result.errors.push(format!("HTTP: {}", e));
356        }
357    }
358
359    // 4. SSL validation
360    match validate_ssl(domain).await {
361        Ok(ssl) => {
362            result.ssl_valid = ssl.ssl_available;
363            result.ssl_info = Some(ssl);
364        }
365        Err(e) => {
366            result.errors.push(format!("SSL: {}", e));
367        }
368    }
369
370    // Overall: valid if DNS + HTTP pass
371    result.valid = result.dns_valid && result.http_valid;
372    result
373}
374
375// ── Skip Check ──────────────────────────────────────────────────────────────
376
377fn should_skip(domain: &str) -> Option<String> {
378    let lower = domain.to_lowercase();
379
380    // Skip patterns
381    for &pattern in SKIP_PATTERNS {
382        if lower.contains(pattern) {
383            return Some(format!("Matches skip pattern: {}", pattern));
384        }
385    }
386
387    // IP address
388    if domain.parse::<IpAddr>().is_ok() {
389        return Some("IP address detected".into());
390    }
391
392    // Internal/localhost
393    for &internal in INTERNAL_PATTERNS {
394        if lower.contains(internal) {
395            return Some("Internal/localhost domain".into());
396        }
397    }
398
399    // Length
400    if domain.len() < 4 || domain.len() > 253 {
401        return Some("Invalid domain length".into());
402    }
403
404    // Must contain at least one dot
405    if !domain.contains('.') {
406        return Some("No TLD detected".into());
407    }
408
409    None
410}
411
412// ── DNS Validation ──────────────────────────────────────────────────────────
413
414async fn validate_dns(domain: &str) -> Result<DnsValidation, String> {
415    // Resolve A records via dig
416    let a_output = Command::new("dig")
417        .args(["+short", "A", domain])
418        .output()
419        .await
420        .map_err(|e| format!("dig failed: {}", e))?;
421
422    let a_records: Vec<String> = String::from_utf8_lossy(&a_output.stdout)
423        .lines()
424        .map(|s| s.trim().to_string())
425        .filter(|s| !s.is_empty() && s.parse::<IpAddr>().is_ok())
426        .collect();
427
428    if a_records.is_empty() {
429        return Err("No A records found (NXDOMAIN or empty)".into());
430    }
431
432    // Check MX records
433    let mx_output = Command::new("dig")
434        .args(["+short", "MX", domain])
435        .output()
436        .await
437        .unwrap_or_else(|_| std::process::Output {
438            status: std::process::ExitStatus::default(),
439            stdout: vec![],
440            stderr: vec![],
441        });
442
443    let mx_exists = !String::from_utf8_lossy(&mx_output.stdout).trim().is_empty();
444
445    Ok(DnsValidation {
446        ip_addresses: a_records,
447        mx_exists,
448    })
449}
450
451// ── HTTP Validation ─────────────────────────────────────────────────────────
452
453async fn validate_http(client: &Client, domain: &str) -> Result<HttpValidation, String> {
454    let mut info = HttpValidation {
455        http_reachable: false,
456        https_reachable: false,
457        http_status: None,
458        https_status: None,
459        redirects_to_https: false,
460        response_time_ms: 0,
461    };
462
463    let start = Instant::now();
464
465    // HTTPS first (more common)
466    if let Ok(resp) = client.head(format!("https://{}", domain)).send().await {
467        info.https_reachable = true;
468        info.https_status = Some(resp.status().as_u16());
469        info.response_time_ms = start.elapsed().as_millis();
470
471        if resp.status().as_u16() < 500 {
472            return Ok(info);
473        }
474    }
475
476    // HTTP fallback
477    // Build a separate client that doesn't follow redirects for HTTP check
478    let no_redirect_client = crate::http_client_builder()
479        .timeout(Duration::from_secs(8))
480        .redirect(reqwest::redirect::Policy::none())
481        .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
482        .build()
483        .unwrap_or_else(|_| Client::new());
484
485    if let Ok(resp) = no_redirect_client
486        .head(format!("http://{}", domain))
487        .send()
488        .await
489    {
490        info.http_reachable = true;
491        info.http_status = Some(resp.status().as_u16());
492
493        // Check for HTTPS redirect
494        let status = resp.status().as_u16();
495        if [301, 302, 307, 308].contains(&status) {
496            if let Some(location) = resp.headers().get("location") {
497                if let Ok(loc) = location.to_str() {
498                    if loc.starts_with("https://") {
499                        info.redirects_to_https = true;
500                    }
501                }
502            }
503        }
504    }
505
506    if info.response_time_ms == 0 {
507        info.response_time_ms = start.elapsed().as_millis();
508    }
509
510    Ok(info)
511}
512
513// ── SSL Validation ──────────────────────────────────────────────────────────
514
515async fn validate_ssl(domain: &str) -> Result<SslValidation, String> {
516    let output = Command::new("openssl")
517        .args([
518            "s_client",
519            "-connect",
520            &format!("{}:443", domain),
521            "-servername",
522            domain,
523            "-brief",
524        ])
525        .stdin(std::process::Stdio::null())
526        .stdout(std::process::Stdio::piped())
527        .stderr(std::process::Stdio::piped())
528        .output()
529        .await
530        .map_err(|e| format!("openssl failed: {}", e))?;
531
532    let stderr = String::from_utf8_lossy(&output.stderr);
533    let stdout = String::from_utf8_lossy(&output.stdout);
534    let combined = format!("{}\n{}", stdout, stderr);
535
536    if combined.contains("CONNECTION ESTABLISHED") || combined.contains("Protocol") {
537        // Extract protocol version
538        let protocol = combined
539            .lines()
540            .find(|l| l.contains("Protocol version:") || l.starts_with("Protocol"))
541            .and_then(|l| l.split(':').nth(1))
542            .map(|s| s.trim().to_string())
543            .unwrap_or_else(|| "Unknown".into());
544
545        // Extract cipher
546        let cipher = combined
547            .lines()
548            .find(|l| l.contains("Ciphersuite:") || l.contains("Cipher"))
549            .and_then(|l| l.split(':').nth(1))
550            .map(|s| s.trim().to_string())
551            .unwrap_or_else(|| "Unknown".into());
552
553        Ok(SslValidation {
554            ssl_available: true,
555            protocol_version: protocol,
556            cipher_suite: cipher,
557        })
558    } else if output.status.success() || combined.contains("Verify") {
559        // openssl connected even without -brief details
560        Ok(SslValidation {
561            ssl_available: true,
562            protocol_version: "TLS".into(),
563            cipher_suite: "Unknown".into(),
564        })
565    } else {
566        Err("SSL connection failed".to_string())
567    }
568}