use hickory_resolver::TokioResolver; use serde::{Deserialize, Serialize}; use std::net::{IpAddr, Ipv4Addr}; use crate::error::Result; /// Default DNSBL servers to check, same as the TypeScript IPReputationChecker. pub const DEFAULT_DNSBL_SERVERS: &[&str] = &[ "zen.spamhaus.org", "bl.spamcop.net", "b.barracudacentral.org", "spam.dnsbl.sorbs.net", "dnsbl.sorbs.net", "cbl.abuseat.org", "xbl.spamhaus.org", "pbl.spamhaus.org", "dnsbl-1.uceprotect.net", "psbl.surriel.com", ]; /// Result of a DNSBL check. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DnsblResult { /// IP address that was checked. pub ip: String, /// Number of DNSBL servers that list this IP. pub listed_count: usize, /// Names of DNSBL servers that list this IP. pub listed_on: Vec, /// Total number of DNSBL servers checked. pub total_checked: usize, } /// Result of a full IP reputation check. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ReputationResult { /// Reputation score: 0 (worst) to 100 (best). pub score: u8, /// Whether the IP is considered spam source. pub is_spam: bool, /// IP address that was checked. pub ip: String, /// DNSBL results. pub dnsbl: DnsblResult, /// Heuristic IP type classification. pub ip_type: IpType, } /// Heuristic IP type classification. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum IpType { Residential, Datacenter, Proxy, Tor, Vpn, Unknown, } /// Risk level based on reputation score. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RiskLevel { /// Score < 20 High, /// Score 20-49 Medium, /// Score 50-79 Low, /// Score >= 80 Trusted, } /// Get the risk level for a reputation score. pub fn risk_level(score: u8) -> RiskLevel { match score { 0..=19 => RiskLevel::High, 20..=49 => RiskLevel::Medium, 50..=79 => RiskLevel::Low, _ => RiskLevel::Trusted, } } /// Check an IP against DNSBL servers. /// /// * `ip` - The IP address to check (must be IPv4) /// * `dnsbl_servers` - DNSBL servers to query (use `DEFAULT_DNSBL_SERVERS` for defaults) /// * `resolver` - DNS resolver to use pub async fn check_dnsbl( ip: IpAddr, dnsbl_servers: &[&str], resolver: &TokioResolver, ) -> Result { let ipv4 = match ip { IpAddr::V4(v4) => v4, IpAddr::V6(_) => { // IPv6 DNSBL is less common; return clean result return Ok(DnsblResult { ip: ip.to_string(), listed_count: 0, listed_on: Vec::new(), total_checked: 0, }); } }; let reversed = reverse_ipv4(ipv4); let total = dnsbl_servers.len(); // Query all DNSBL servers in parallel let mut handles = Vec::with_capacity(total); for &server in dnsbl_servers { let query = format!("{}.{}", reversed, server); let resolver = resolver.clone(); let server_name = server.to_string(); handles.push(tokio::spawn(async move { match resolver.lookup_ip(&query).await { Ok(_) => Some(server_name), // IP is listed Err(_) => None, // IP is not listed (NXDOMAIN) } })); } let mut listed_on = Vec::new(); for handle in handles { match handle.await { Ok(Some(server)) => listed_on.push(server), _ => {} } } Ok(DnsblResult { ip: ip.to_string(), listed_count: listed_on.len(), listed_on, total_checked: total, }) } /// Full IP reputation check: DNSBL + heuristic classification + scoring. pub async fn check_reputation( ip: IpAddr, dnsbl_servers: &[&str], resolver: &TokioResolver, ) -> Result { let dnsbl = check_dnsbl(ip, dnsbl_servers, resolver).await?; let ip_type = classify_ip(ip); // Scoring: start at 100 let mut score: i16 = 100; // Subtract 10 per DNSBL listing score -= (dnsbl.listed_count as i16) * 10; // Subtract 30 for suspicious IP types match ip_type { IpType::Proxy | IpType::Tor | IpType::Vpn => { score -= 30; } _ => {} } let score = score.clamp(0, 100) as u8; let is_spam = score < 50; Ok(ReputationResult { score, is_spam, ip: ip.to_string(), dnsbl, ip_type, }) } /// Reverse IPv4 octets for DNSBL queries: "1.2.3.4" -> "4.3.2.1". fn reverse_ipv4(ip: Ipv4Addr) -> String { let octets = ip.octets(); format!("{}.{}.{}.{}", octets[3], octets[2], octets[1], octets[0]) } /// Heuristic IP type classification based on well-known prefix ranges. /// Same heuristics as the TypeScript IPReputationChecker. fn classify_ip(ip: IpAddr) -> IpType { let ip_str = ip.to_string(); // Known Tor exit node prefixes if ip_str.starts_with("171.25.") || ip_str.starts_with("185.220.") || ip_str.starts_with("95.216.") { return IpType::Tor; } // Known VPN provider prefixes if ip_str.starts_with("185.156.") || ip_str.starts_with("37.120.") { return IpType::Vpn; } // Known proxy prefixes if ip_str.starts_with("34.92.") || ip_str.starts_with("34.206.") { return IpType::Proxy; } // Major cloud provider prefixes (datacenter) if ip_str.starts_with("13.") || ip_str.starts_with("35.") || ip_str.starts_with("52.") || ip_str.starts_with("34.") || ip_str.starts_with("104.") { return IpType::Datacenter; } IpType::Residential } /// Validate an IPv4 address string. pub fn is_valid_ipv4(ip: &str) -> bool { ip.parse::().is_ok() } #[cfg(test)] mod tests { use super::*; #[test] fn test_reverse_ipv4() { let ip: Ipv4Addr = "1.2.3.4".parse().unwrap(); assert_eq!(reverse_ipv4(ip), "4.3.2.1"); let ip: Ipv4Addr = "192.168.1.100".parse().unwrap(); assert_eq!(reverse_ipv4(ip), "100.1.168.192"); } #[test] fn test_classify_ip() { assert_eq!( classify_ip("171.25.193.20".parse().unwrap()), IpType::Tor ); assert_eq!( classify_ip("185.156.73.1".parse().unwrap()), IpType::Vpn ); assert_eq!( classify_ip("34.92.1.1".parse().unwrap()), IpType::Proxy ); assert_eq!( classify_ip("52.0.0.1".parse().unwrap()), IpType::Datacenter ); assert_eq!( classify_ip("203.0.113.1".parse().unwrap()), IpType::Residential ); } #[test] fn test_risk_level() { assert_eq!(risk_level(10), RiskLevel::High); assert_eq!(risk_level(30), RiskLevel::Medium); assert_eq!(risk_level(60), RiskLevel::Low); assert_eq!(risk_level(90), RiskLevel::Trusted); } #[test] fn test_is_valid_ipv4() { assert!(is_valid_ipv4("1.2.3.4")); assert!(is_valid_ipv4("255.255.255.255")); assert!(!is_valid_ipv4("999.999.999.999")); assert!(!is_valid_ipv4("not-an-ip")); } #[test] fn test_default_dnsbl_servers() { assert_eq!(DEFAULT_DNSBL_SERVERS.len(), 10); assert!(DEFAULT_DNSBL_SERVERS.contains(&"zen.spamhaus.org")); } }