//! WHOIS domain expiry checking — raw TCP to WHOIS servers. use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; use tracing::instrument; use crate::config::WhoisConfig; use crate::types::WhoisResult; /// Query WHOIS for domain registration info. #[instrument(skip_all)] pub async fn check_whois(target: &str, config: &WhoisConfig) -> WhoisResult { let now = chrono::Utc::now().to_rfc3339(); let Some(server) = whois_server_for_tld(&config.domain) else { return WhoisResult { target: target.to_string(), domain: config.domain.clone(), registrar: None, expiry_date: None, days_remaining: None, nameservers: vec![], checked_at: now, error: Some(format!("no WHOIS server known for TLD of {}", config.domain)), }; }; match query_whois(server, &config.domain).await { Ok(response) => { let parsed = parse_whois_response(&response); let days_remaining = parsed.expiry_date.as_deref().and_then(compute_days_remaining); WhoisResult { target: target.to_string(), domain: config.domain.clone(), registrar: parsed.registrar, expiry_date: parsed.expiry_date, days_remaining, nameservers: parsed.nameservers, checked_at: now, error: None, } } Err(e) => WhoisResult { target: target.to_string(), domain: config.domain.clone(), registrar: None, expiry_date: None, days_remaining: None, nameservers: vec![], checked_at: now, error: Some(e), }, } } /// Determine the WHOIS server for a domain based on its TLD. pub fn whois_server_for_tld(domain: &str) -> Option<&'static str> { let tld = domain.rsplit('.').next()?; match tld { "com" => Some("whois.verisign-grs.com"), "net" => Some("whois.verisign-grs.com"), "org" => Some("whois.pir.org"), "work" => Some("whois.nic.work"), "app" => Some("whois.nic.google"), "dev" => Some("whois.nic.google"), "io" => Some("whois.nic.io"), "me" => Some("whois.nic.me"), "info" => Some("whois.afilias.net"), _ => None, } } /// Send a WHOIS query over TCP and return the raw response. async fn query_whois(server: &str, domain: &str) -> Result { let addr = format!("{server}:43"); let mut stream = tokio::time::timeout( std::time::Duration::from_secs(10), TcpStream::connect(&addr), ) .await .map_err(|_| format!("WHOIS connection to {server} timed out"))? .map_err(|e| format!("WHOIS connection to {server} failed: {e}"))?; stream .write_all(format!("{domain}\r\n").as_bytes()) .await .map_err(|e| format!("WHOIS write failed: {e}"))?; let mut response = Vec::with_capacity(4096); let mut buf = [0u8; 4096]; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); loop { let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); if remaining.is_zero() { break; } match tokio::time::timeout(remaining, stream.read(&mut buf)).await { Ok(Ok(0)) => break, Ok(Ok(n)) => { response.extend_from_slice(&buf[..n]); if response.len() > 65536 { break; } } Ok(Err(e)) => return Err(format!("WHOIS read error: {e}")), Err(_) => break, } } String::from_utf8(response).map_err(|e| format!("WHOIS response not UTF-8: {e}")) } /// Parse key fields from a raw WHOIS response. pub fn parse_whois_response(response: &str) -> ParsedWhoisResult { let mut registrar = None; let mut expiry_date = None; let mut nameservers = Vec::new(); for line in response.lines() { let line = line.trim(); let lower = line.to_lowercase(); // Registrar if registrar.is_none() && lower.starts_with("registrar:") { registrar = extract_value(line); } // Expiry date — multiple possible field names if expiry_date.is_none() && (lower.starts_with("registry expiry date:") || lower.starts_with("registrar registration expiration date:") || lower.starts_with("expiration date:") || lower.starts_with("paid-till:")) { expiry_date = extract_value(line); } // Name servers if (lower.starts_with("name server:") || lower.starts_with("nserver:")) && let Some(ns) = extract_value(line) { let ns = ns.trim_end_matches('.').to_lowercase(); if !nameservers.contains(&ns) { nameservers.push(ns); } } } ParsedWhoisResult { registrar, expiry_date, nameservers, } } pub struct ParsedWhoisResult { pub registrar: Option, pub expiry_date: Option, pub nameservers: Vec, } fn extract_value(line: &str) -> Option { let value = line.split_once(':')?.1.trim(); if value.is_empty() { None } else { Some(value.to_string()) } } /// Compute days remaining from an expiry date string. /// Tries RFC 3339 first, then common date-only formats. pub fn compute_days_remaining(expiry_str: &str) -> Option { let expiry = chrono::DateTime::parse_from_rfc3339(expiry_str) .map(|dt| dt.with_timezone(&chrono::Utc)) .or_else(|_| { chrono::NaiveDate::parse_from_str(expiry_str.trim(), "%Y-%m-%d") .map(|d| d.and_hms_opt(0, 0, 0).unwrap().and_utc()) }) .ok()?; let now = chrono::Utc::now(); Some(expiry.signed_duration_since(now).num_days()) } #[cfg(test)] mod tests { use super::*; #[test] fn whois_server_known_tlds() { assert_eq!(whois_server_for_tld("example.work"), Some("whois.nic.work")); assert_eq!(whois_server_for_tld("example.app"), Some("whois.nic.google")); assert_eq!(whois_server_for_tld("example.com"), Some("whois.verisign-grs.com")); assert_eq!(whois_server_for_tld("example.net"), Some("whois.verisign-grs.com")); assert_eq!(whois_server_for_tld("example.org"), Some("whois.pir.org")); } #[test] fn whois_server_unknown_tld() { assert_eq!(whois_server_for_tld("example.xyz"), None); } #[test] fn parse_whois_verisign_response() { let response = r#" Domain Name: EXAMPLE.COM Registry Domain ID: 2336799_DOMAIN_COM-VRSN Registrar WHOIS Server: whois.registrar.com Registrar URL: http://www.registrar.com Updated Date: 2024-08-14T07:01:44Z Creation Date: 1995-08-14T04:00:00Z Registry Expiry Date: 2025-08-13T04:00:00Z Registrar: Example Registrar, Inc. Name Server: A.IANA-SERVERS.NET Name Server: B.IANA-SERVERS.NET "#; let parsed = parse_whois_response(response); assert_eq!(parsed.registrar.as_deref(), Some("Example Registrar, Inc.")); assert_eq!(parsed.expiry_date.as_deref(), Some("2025-08-13T04:00:00Z")); assert_eq!(parsed.nameservers.len(), 2); assert!(parsed.nameservers.contains(&"a.iana-servers.net".to_string())); assert!(parsed.nameservers.contains(&"b.iana-servers.net".to_string())); } #[test] fn parse_whois_nic_work_response() { let response = r#" Domain Name: makenot.work Registry Domain ID: abc123 Registrar WHOIS Server: whois.namecheap.com Registrar URL: http://www.namecheap.com Updated Date: 2025-03-01T12:00:00Z Creation Date: 2024-03-01T12:00:00Z Registry Expiry Date: 2026-12-01T12:00:00Z Registrar: Namecheap, Inc. Name Server: dns1.registrar-servers.com Name Server: dns2.registrar-servers.com "#; let parsed = parse_whois_response(response); assert_eq!(parsed.registrar.as_deref(), Some("Namecheap, Inc.")); assert_eq!(parsed.expiry_date.as_deref(), Some("2026-12-01T12:00:00Z")); assert_eq!(parsed.nameservers.len(), 2); } #[test] fn parse_whois_empty_response() { let parsed = parse_whois_response(""); assert!(parsed.registrar.is_none()); assert!(parsed.expiry_date.is_none()); assert!(parsed.nameservers.is_empty()); } #[test] fn parse_whois_no_matching_fields() { let parsed = parse_whois_response("Some random text\nAnother line\n"); assert!(parsed.registrar.is_none()); assert!(parsed.expiry_date.is_none()); assert!(parsed.nameservers.is_empty()); } #[test] fn compute_days_remaining_rfc3339() { let future = (chrono::Utc::now() + chrono::Duration::days(30)).to_rfc3339(); let days = compute_days_remaining(&future).unwrap(); assert!((29..=30).contains(&days)); } #[test] fn compute_days_remaining_date_only() { let future = (chrono::Utc::now() + chrono::Duration::days(60)) .format("%Y-%m-%d") .to_string(); let days = compute_days_remaining(&future).unwrap(); assert!((59..=60).contains(&days)); } #[test] fn compute_days_remaining_expired() { let past = (chrono::Utc::now() - chrono::Duration::days(10)).to_rfc3339(); let days = compute_days_remaining(&past).unwrap(); assert!(days < 0); } #[test] fn compute_days_remaining_invalid() { assert!(compute_days_remaining("not-a-date").is_none()); } #[test] fn parse_whois_alternative_expiry_field() { let response = "Expiration Date: 2027-06-15T00:00:00Z\nName Server: ns1.example.com\n"; let parsed = parse_whois_response(response); assert_eq!(parsed.expiry_date.as_deref(), Some("2027-06-15T00:00:00Z")); } #[test] fn parse_whois_deduplicates_nameservers() { let response = "Name Server: ns1.example.com\nName Server: ns1.example.com\n"; let parsed = parse_whois_response(response); assert_eq!(parsed.nameservers.len(), 1); } // ── extended TLD coverage ── #[test] fn whois_server_all_known_tlds() { // Pins every match arm — a mutation swapping or removing an arm would // surface as Some→None or wrong host. assert_eq!(whois_server_for_tld("x.dev"), Some("whois.nic.google")); assert_eq!(whois_server_for_tld("x.io"), Some("whois.nic.io")); assert_eq!(whois_server_for_tld("x.me"), Some("whois.nic.me")); assert_eq!(whois_server_for_tld("x.info"), Some("whois.afilias.net")); } #[test] fn whois_server_empty_domain_is_none() { // `rsplit('.').next()` on "" returns Some(""), which falls through to None. assert_eq!(whois_server_for_tld(""), None); } // ── first-match semantics (`is_none()` guards) ── #[test] fn parse_whois_takes_first_registrar_only() { // Pins `if registrar.is_none() && ...` — a duplicate Registrar line // must NOT overwrite the first value. let response = "Registrar: First, Inc.\nRegistrar: Second, LLC.\n"; let parsed = parse_whois_response(response); assert_eq!(parsed.registrar.as_deref(), Some("First, Inc.")); } #[test] fn parse_whois_takes_first_expiry_only() { let response = "Registry Expiry Date: 2025-01-01T00:00:00Z\n\ Registry Expiry Date: 2099-12-31T00:00:00Z\n"; let parsed = parse_whois_response(response); assert_eq!(parsed.expiry_date.as_deref(), Some("2025-01-01T00:00:00Z")); } // ── remaining alternative field names ── #[test] fn parse_whois_registrar_registration_expiration_date() { let response = "Registrar Registration Expiration Date: 2028-01-15T12:00:00Z\n"; let parsed = parse_whois_response(response); assert_eq!(parsed.expiry_date.as_deref(), Some("2028-01-15T12:00:00Z")); } #[test] fn parse_whois_paid_till() { // Russian/Eastern European registry format let response = "paid-till: 2029-03-20T00:00:00Z\n"; let parsed = parse_whois_response(response); assert_eq!(parsed.expiry_date.as_deref(), Some("2029-03-20T00:00:00Z")); } #[test] fn parse_whois_nserver_alias() { // Some registries use `nserver:` instead of `Name Server:`. let response = "nserver: ns1.example.org\nnserver: ns2.example.org\n"; let parsed = parse_whois_response(response); assert_eq!(parsed.nameservers.len(), 2); assert!(parsed.nameservers.contains(&"ns1.example.org".to_string())); } // ── normalization on nameservers ── #[test] fn parse_whois_strips_trailing_dot_from_nameservers() { let response = "Name Server: NS1.EXAMPLE.COM.\n"; let parsed = parse_whois_response(response); assert_eq!(parsed.nameservers, vec!["ns1.example.com".to_string()]); } #[test] fn parse_whois_lowercases_nameservers() { // Catches a mutation removing `.to_lowercase()`. let response = "Name Server: NS1.EXAMPLE.COM\nNAME SERVER: ns1.example.com\n"; let parsed = parse_whois_response(response); // Both should normalise to the same value; dedup leaves 1. assert_eq!(parsed.nameservers, vec!["ns1.example.com".to_string()]); } // ── compute_days_remaining timezone/format edges ── #[test] fn compute_days_remaining_trims_whitespace_around_date_only() { // Pins the `.trim()` call inside the date-only fallback. let future = (chrono::Utc::now() + chrono::Duration::days(45)) .format(" %Y-%m-%d ") .to_string(); let days = compute_days_remaining(&future).unwrap(); assert!((44..=45).contains(&days), "expected 44 or 45, got {days}"); } #[test] fn compute_days_remaining_garbage_after_rfc3339_is_none() { // Neither format parses; falls through to None. assert!(compute_days_remaining("2027-06-15 NOT A TIME").is_none()); } }