1#![doc = include_str!("../README.md")]
2#![deny(missing_docs)]
3#![deny(rustdoc::broken_intra_doc_links)]
4
5use std::collections::HashMap;
6use std::fmt::Write;
7use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
8use std::sync::Mutex;
9use std::time::{Duration, Instant};
10
11use hickory_resolver::TokioResolver;
12
13pub fn reverse_ipv4(ip: Ipv4Addr) -> String {
16 let o = ip.octets();
17 let mut out = String::with_capacity(15);
19 write!(&mut out, "{}.{}.{}.{}", o[3], o[2], o[1], o[0]).unwrap();
20 out
21}
22
23pub fn dnsbl_query(reversed: &str, zone: &str) -> String {
25 let mut out = String::with_capacity(reversed.len() + 1 + zone.len());
26 out.push_str(reversed);
27 out.push('.');
28 out.push_str(zone);
29 out
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum DnsblResult {
40 Clean,
42 Sbl,
44 Css,
46 Xbl,
48 Pbl,
50 Listed(u8),
53}
54
55pub fn interpret_spamhaus(ip: Ipv4Addr) -> DnsblResult {
62 let octets = ip.octets();
63 if octets[0] != 127 || octets[1] != 0 || octets[2] != 0 {
64 return DnsblResult::Clean;
65 }
66 match octets[3] {
67 2 => DnsblResult::Sbl,
68 3 => DnsblResult::Css,
69 4..=7 => DnsblResult::Xbl,
70 10 | 11 => DnsblResult::Pbl,
71 0 => DnsblResult::Clean,
72 other => DnsblResult::Listed(other),
73 }
74}
75
76pub fn is_ipv6_dnsbl_supported(_ip: &Ipv6Addr) -> bool {
80 false
81}
82
83pub async fn check_dnsbl(
90 resolver: &TokioResolver,
91 ip: IpAddr,
92 zones: &[String],
93) -> Option<(String, DnsblResult)> {
94 let ipv4 = match ip {
95 IpAddr::V4(v4) => v4,
96 IpAddr::V6(v6) => {
97 if !is_ipv6_dnsbl_supported(&v6) {
98 return None;
99 }
100 return None;
101 }
102 };
103
104 let reversed = reverse_ipv4(ipv4);
105
106 for zone in zones {
107 let query_host = dnsbl_query(&reversed, zone);
108 if let Ok(response) = resolver.ipv4_lookup(&query_host).await {
109 for record in response.answers() {
110 if let hickory_resolver::proto::rr::RData::A(addr) = &record.data {
111 let result = interpret_spamhaus(addr.0);
112 if result != DnsblResult::Clean {
113 return Some((zone.clone(), result));
114 }
115 }
116 }
117 }
118 }
119
120 None
121}
122
123pub struct DnsblCache {
134 #[allow(clippy::type_complexity)]
135 cache: Mutex<HashMap<IpAddr, (Option<(String, DnsblResult)>, Instant)>>,
136 ttl: Duration,
137}
138
139impl DnsblCache {
140 pub fn new(ttl: Duration) -> Self {
142 Self {
143 cache: Mutex::new(HashMap::new()),
144 ttl,
145 }
146 }
147
148 pub async fn check(
150 &self,
151 resolver: &TokioResolver,
152 ip: IpAddr,
153 zones: &[String],
154 ) -> Option<(String, DnsblResult)> {
155 {
157 let cache = self.cache.lock().unwrap();
158 if let Some((result, inserted_at)) = cache.get(&ip)
159 && inserted_at.elapsed() < self.ttl {
160 return result.clone();
161 }
162 }
163
164 let result = check_dnsbl(resolver, ip, zones).await;
166
167 {
169 let mut cache = self.cache.lock().unwrap();
170 cache.insert(ip, (result.clone(), Instant::now()));
171 }
172
173 result
174 }
175
176 pub fn cleanup(&self) {
178 let mut cache = self.cache.lock().unwrap();
179 cache.retain(|_, (_, inserted_at)| inserted_at.elapsed() < self.ttl);
180 }
181
182 pub fn len(&self) -> usize {
184 self.cache.lock().unwrap().len()
185 }
186
187 pub fn is_empty(&self) -> bool {
189 self.cache.lock().unwrap().is_empty()
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 #[test]
198 fn reverse_ipv4_standard() {
199 assert_eq!(reverse_ipv4(Ipv4Addr::new(1, 2, 3, 4)), "4.3.2.1");
200 }
201
202 #[test]
203 fn reverse_ipv4_loopback() {
204 assert_eq!(reverse_ipv4(Ipv4Addr::new(127, 0, 0, 1)), "1.0.0.127");
205 }
206
207 #[test]
208 fn dnsbl_query_format() {
209 let reversed = reverse_ipv4(Ipv4Addr::new(10, 20, 30, 40));
210 let query = dnsbl_query(&reversed, "zen.spamhaus.org");
211 assert_eq!(query, "40.30.20.10.zen.spamhaus.org");
212 }
213
214 #[test]
215 fn interpret_spamhaus_sbl() {
216 assert_eq!(
217 interpret_spamhaus(Ipv4Addr::new(127, 0, 0, 2)),
218 DnsblResult::Sbl
219 );
220 }
221
222 #[test]
223 fn interpret_spamhaus_xbl() {
224 assert_eq!(
225 interpret_spamhaus(Ipv4Addr::new(127, 0, 0, 4)),
226 DnsblResult::Xbl
227 );
228 }
229
230 #[test]
231 fn interpret_spamhaus_pbl() {
232 assert_eq!(
233 interpret_spamhaus(Ipv4Addr::new(127, 0, 0, 10)),
234 DnsblResult::Pbl
235 );
236 }
237
238 #[test]
239 fn interpret_spamhaus_clean() {
240 assert_eq!(
241 interpret_spamhaus(Ipv4Addr::new(127, 0, 0, 0)),
242 DnsblResult::Clean
243 );
244 assert_eq!(
246 interpret_spamhaus(Ipv4Addr::new(192, 168, 1, 1)),
247 DnsblResult::Clean
248 );
249 }
250
251 #[test]
252 fn ipv6_not_supported() {
253 assert!(!is_ipv6_dnsbl_supported(&Ipv6Addr::LOCALHOST));
254 }
255
256 #[test]
257 fn dnsbl_cache_negative() {
258 let cache = DnsblCache::new(Duration::from_secs(300));
259
260 let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1));
262 {
263 let mut c = cache.cache.lock().unwrap();
264 c.insert(ip, (None, Instant::now()));
265 }
266
267 assert_eq!(cache.len(), 1);
269
270 let c = cache.cache.lock().unwrap();
272 let (result, _) = c.get(&ip).unwrap();
273 assert!(result.is_none());
274 }
275
276 #[test]
277 fn dnsbl_cache_cleanup_expired() {
278 let cache = DnsblCache::new(Duration::from_millis(1));
279
280 let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1));
281 {
282 let mut c = cache.cache.lock().unwrap();
283 c.insert(
284 ip,
285 (
286 Some(("zen.spamhaus.org".into(), DnsblResult::Sbl)),
287 Instant::now() - Duration::from_secs(10),
288 ),
289 );
290 }
291
292 cache.cleanup();
293 assert!(cache.is_empty());
294 }
295
296 #[test]
299 fn reverse_ipv4_zero_address() {
300 assert_eq!(reverse_ipv4(Ipv4Addr::UNSPECIFIED), "0.0.0.0");
301 }
302
303 #[test]
304 fn reverse_ipv4_broadcast() {
305 assert_eq!(reverse_ipv4(Ipv4Addr::BROADCAST), "255.255.255.255");
306 }
307
308 #[test]
309 fn dnsbl_query_handles_trailing_dot_in_zone() {
310 let r = reverse_ipv4(Ipv4Addr::new(1, 2, 3, 4));
313 let q = dnsbl_query(&r, "spamhaus.org.");
314 assert_eq!(q, "4.3.2.1.spamhaus.org.");
315 }
316
317 #[test]
318 fn interpret_spamhaus_css_code() {
319 assert_eq!(
320 interpret_spamhaus(Ipv4Addr::new(127, 0, 0, 3)),
321 DnsblResult::Css
322 );
323 }
324
325 #[test]
326 fn interpret_spamhaus_xbl_range_all_codes() {
327 for code in 4..=7u8 {
328 assert_eq!(
329 interpret_spamhaus(Ipv4Addr::new(127, 0, 0, code)),
330 DnsblResult::Xbl,
331 "code {code} should be XBL"
332 );
333 }
334 }
335
336 #[test]
337 fn interpret_spamhaus_pbl_both_codes() {
338 assert_eq!(
339 interpret_spamhaus(Ipv4Addr::new(127, 0, 0, 10)),
340 DnsblResult::Pbl
341 );
342 assert_eq!(
343 interpret_spamhaus(Ipv4Addr::new(127, 0, 0, 11)),
344 DnsblResult::Pbl
345 );
346 }
347
348 #[test]
349 fn interpret_spamhaus_unknown_code_falls_through() {
350 let r = interpret_spamhaus(Ipv4Addr::new(127, 0, 0, 99));
352 assert_eq!(r, DnsblResult::Listed(99));
353 }
354
355 #[test]
356 fn interpret_spamhaus_almost_127_but_not_quite() {
357 assert_eq!(
359 interpret_spamhaus(Ipv4Addr::new(127, 0, 1, 2)),
360 DnsblResult::Clean
361 );
362 assert_eq!(
363 interpret_spamhaus(Ipv4Addr::new(127, 1, 0, 2)),
364 DnsblResult::Clean
365 );
366 }
367
368 #[test]
369 fn dnsbl_cache_double_lookup_returns_same() {
370 let cache = DnsblCache::new(Duration::from_secs(300));
371 let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5));
372 {
374 let mut c = cache.cache.lock().unwrap();
375 c.insert(
376 ip,
377 (
378 Some(("zen.spamhaus.org".into(), DnsblResult::Sbl)),
379 Instant::now(),
380 ),
381 );
382 }
383 let c = cache.cache.lock().unwrap();
385 let (r1, _) = c.get(&ip).unwrap();
386 let (r2, _) = c.get(&ip).unwrap();
387 assert_eq!(r1, r2);
388 assert!(r1.is_some());
389 }
390
391 #[test]
392 fn dnsbl_cache_is_empty_on_fresh() {
393 let cache = DnsblCache::new(Duration::from_secs(60));
394 assert!(cache.is_empty());
395 assert_eq!(cache.len(), 0);
396 }
397
398 #[test]
399 fn dnsbl_cache_cleanup_preserves_fresh() {
400 let cache = DnsblCache::new(Duration::from_secs(300));
401 let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 99));
402 {
403 let mut c = cache.cache.lock().unwrap();
404 c.insert(ip, (None, Instant::now())); }
406 cache.cleanup();
407 assert_eq!(cache.len(), 1); }
409
410 #[test]
411 fn is_ipv6_dnsbl_supported_always_false() {
412 assert!(!is_ipv6_dnsbl_supported(&Ipv6Addr::LOCALHOST));
413 assert!(!is_ipv6_dnsbl_supported(&Ipv6Addr::UNSPECIFIED));
414 assert!(!is_ipv6_dnsbl_supported(
417 &"2001:db8::1".parse::<Ipv6Addr>().unwrap()
418 ));
419 }
420
421 #[test]
422 fn dnsbl_query_with_empty_zone() {
423 let q = dnsbl_query("4.3.2.1", "");
425 assert_eq!(q, "4.3.2.1.");
426 }
427}