Skip to main content

mailrs_shield/
dnsbl.rs

1use std::collections::HashMap;
2use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
3use std::sync::Mutex;
4use std::time::{Duration, Instant};
5
6use hickory_resolver::TokioResolver;
7
8/// reverse an IPv4 address for DNSBL lookup: 1.2.3.4 → "4.3.2.1"
9pub fn reverse_ipv4(ip: Ipv4Addr) -> String {
10    let o = ip.octets();
11    format!("{}.{}.{}.{}", o[3], o[2], o[1], o[0])
12}
13
14/// build DNSBL query hostname: reversed_ip.zone
15pub fn dnsbl_query(reversed: &str, zone: &str) -> String {
16    format!("{reversed}.{zone}")
17}
18
19/// spamhaus return codes (127.0.0.x)
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum DnsblResult {
22    /// not listed
23    Clean,
24    /// listed in SBL (spamhaus block list)
25    Sbl,
26    /// listed in CSS (combined spam sources)
27    Css,
28    /// listed in XBL (exploits block list)
29    Xbl,
30    /// listed in PBL (policy block list)
31    Pbl,
32    /// listed but unknown code
33    Listed(u8),
34}
35
36/// interpret a spamhaus-style A record response
37pub fn interpret_spamhaus(ip: Ipv4Addr) -> DnsblResult {
38    let octets = ip.octets();
39    if octets[0] != 127 || octets[1] != 0 || octets[2] != 0 {
40        return DnsblResult::Clean;
41    }
42    match octets[3] {
43        2 => DnsblResult::Sbl,
44        3 => DnsblResult::Css,
45        4..=7 => DnsblResult::Xbl,
46        10 | 11 => DnsblResult::Pbl,
47        0 => DnsblResult::Clean,
48        other => DnsblResult::Listed(other),
49    }
50}
51
52/// IPv6 is not supported for DNSBL (most lists don't support it)
53pub fn is_ipv6_dnsbl_supported(_ip: &Ipv6Addr) -> bool {
54    false
55}
56
57/// perform actual DNS query against DNSBL zones
58/// returns the first zone that lists the IP with its result
59pub async fn check_dnsbl(
60    resolver: &TokioResolver,
61    ip: IpAddr,
62    zones: &[String],
63) -> Option<(String, DnsblResult)> {
64    let ipv4 = match ip {
65        IpAddr::V4(v4) => v4,
66        IpAddr::V6(v6) => {
67            if !is_ipv6_dnsbl_supported(&v6) {
68                return None;
69            }
70            return None;
71        }
72    };
73
74    let reversed = reverse_ipv4(ipv4);
75
76    for zone in zones {
77        let query_host = dnsbl_query(&reversed, zone);
78        if let Ok(response) = resolver.ipv4_lookup(&query_host).await {
79            for record in response.answers() {
80                if let hickory_resolver::proto::rr::RData::A(addr) = &record.data {
81                    let result = interpret_spamhaus(addr.0);
82                    if result != DnsblResult::Clean {
83                        return Some((zone.clone(), result));
84                    }
85                }
86            }
87        }
88    }
89
90    None
91}
92
93/// cached DNSBL lookup to avoid repeated queries for known IPs
94/// caches both positive (listed) and negative (clean) results
95pub struct DnsblCache {
96    #[allow(clippy::type_complexity)]
97    cache: Mutex<HashMap<IpAddr, (Option<(String, DnsblResult)>, Instant)>>,
98    ttl: Duration,
99}
100
101impl DnsblCache {
102    /// Construct an empty cache with the given per-entry TTL.
103    pub fn new(ttl: Duration) -> Self {
104        Self {
105            cache: Mutex::new(HashMap::new()),
106            ttl,
107        }
108    }
109
110    /// check with cache: return cached result if fresh, otherwise query DNS
111    pub async fn check(
112        &self,
113        resolver: &TokioResolver,
114        ip: IpAddr,
115        zones: &[String],
116    ) -> Option<(String, DnsblResult)> {
117        // check cache
118        {
119            let cache = self.cache.lock().unwrap();
120            if let Some((result, inserted_at)) = cache.get(&ip)
121                && inserted_at.elapsed() < self.ttl {
122                    return result.clone();
123                }
124        }
125
126        // cache miss or expired — query
127        let result = check_dnsbl(resolver, ip, zones).await;
128
129        // store in cache (including None for negative caching)
130        {
131            let mut cache = self.cache.lock().unwrap();
132            cache.insert(ip, (result.clone(), Instant::now()));
133        }
134
135        result
136    }
137
138    /// remove expired entries
139    pub fn cleanup(&self) {
140        let mut cache = self.cache.lock().unwrap();
141        cache.retain(|_, (_, inserted_at)| inserted_at.elapsed() < self.ttl);
142    }
143
144    /// number of cached entries (for testing)
145    pub fn len(&self) -> usize {
146        self.cache.lock().unwrap().len()
147    }
148
149    /// check if cache is empty
150    pub fn is_empty(&self) -> bool {
151        self.cache.lock().unwrap().is_empty()
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn reverse_ipv4_standard() {
161        assert_eq!(reverse_ipv4(Ipv4Addr::new(1, 2, 3, 4)), "4.3.2.1");
162    }
163
164    #[test]
165    fn reverse_ipv4_loopback() {
166        assert_eq!(reverse_ipv4(Ipv4Addr::new(127, 0, 0, 1)), "1.0.0.127");
167    }
168
169    #[test]
170    fn dnsbl_query_format() {
171        let reversed = reverse_ipv4(Ipv4Addr::new(10, 20, 30, 40));
172        let query = dnsbl_query(&reversed, "zen.spamhaus.org");
173        assert_eq!(query, "40.30.20.10.zen.spamhaus.org");
174    }
175
176    #[test]
177    fn interpret_spamhaus_sbl() {
178        assert_eq!(
179            interpret_spamhaus(Ipv4Addr::new(127, 0, 0, 2)),
180            DnsblResult::Sbl
181        );
182    }
183
184    #[test]
185    fn interpret_spamhaus_xbl() {
186        assert_eq!(
187            interpret_spamhaus(Ipv4Addr::new(127, 0, 0, 4)),
188            DnsblResult::Xbl
189        );
190    }
191
192    #[test]
193    fn interpret_spamhaus_pbl() {
194        assert_eq!(
195            interpret_spamhaus(Ipv4Addr::new(127, 0, 0, 10)),
196            DnsblResult::Pbl
197        );
198    }
199
200    #[test]
201    fn interpret_spamhaus_clean() {
202        assert_eq!(
203            interpret_spamhaus(Ipv4Addr::new(127, 0, 0, 0)),
204            DnsblResult::Clean
205        );
206        // non-127.0.0.x should also be clean
207        assert_eq!(
208            interpret_spamhaus(Ipv4Addr::new(192, 168, 1, 1)),
209            DnsblResult::Clean
210        );
211    }
212
213    #[test]
214    fn ipv6_not_supported() {
215        assert!(!is_ipv6_dnsbl_supported(&Ipv6Addr::LOCALHOST));
216    }
217
218    #[test]
219    fn dnsbl_cache_negative() {
220        let cache = DnsblCache::new(Duration::from_secs(300));
221
222        // insert a negative (clean) result
223        let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1));
224        {
225            let mut c = cache.cache.lock().unwrap();
226            c.insert(ip, (None, Instant::now()));
227        }
228
229        // cache should have the entry
230        assert_eq!(cache.len(), 1);
231
232        // verify it's a negative entry
233        let c = cache.cache.lock().unwrap();
234        let (result, _) = c.get(&ip).unwrap();
235        assert!(result.is_none());
236    }
237
238    #[test]
239    fn dnsbl_cache_cleanup_expired() {
240        let cache = DnsblCache::new(Duration::from_millis(1));
241
242        let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1));
243        {
244            let mut c = cache.cache.lock().unwrap();
245            c.insert(
246                ip,
247                (
248                    Some(("zen.spamhaus.org".into(), DnsblResult::Sbl)),
249                    Instant::now() - Duration::from_secs(10),
250                ),
251            );
252        }
253
254        cache.cleanup();
255        assert!(cache.is_empty());
256    }
257}