Skip to main content

mailrs_dnsbl/
lib.rs

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
13/// Reverse an IPv4 address into the dotted-octets form used for DNSBL
14/// lookup per RFC 5782 §2.1: `1.2.3.4` → `"4.3.2.1"`.
15pub fn reverse_ipv4(ip: Ipv4Addr) -> String {
16    let o = ip.octets();
17    // Max length: 4 octets × 3 chars + 3 dots = 15 chars.
18    let mut out = String::with_capacity(15);
19    write!(&mut out, "{}.{}.{}.{}", o[3], o[2], o[1], o[0]).unwrap();
20    out
21}
22
23/// Build a DNSBL query hostname: `<reversed_ip>.<zone>` per RFC 5782 §2.1.
24pub 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/// Spamhaus return codes (127.0.0.x) per <https://www.spamhaus.org/zen/>.
33///
34/// `Clean` covers both "not listed" (NXDOMAIN) and "listed under
35/// 127.0.0.0" (test record). Real listings populate the `Sbl` / `Css`
36/// / `Xbl` / `Pbl` variants; non-Spamhaus DNSBLs that return other
37/// 127.0.0.x codes hit `Listed(other)`.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum DnsblResult {
40    /// Not listed (NXDOMAIN or 127.0.0.0).
41    Clean,
42    /// Listed in SBL (Spamhaus Block List).
43    Sbl,
44    /// Listed in CSS (Combined Spam Sources).
45    Css,
46    /// Listed in XBL (Exploits Block List).
47    Xbl,
48    /// Listed in PBL (Policy Block List).
49    Pbl,
50    /// Listed but the return code is outside the documented Spamhaus
51    /// range. Other DNSBLs (e.g. Barracuda) may use custom codes.
52    Listed(u8),
53}
54
55/// Interpret a Spamhaus-style A record response as a [`DnsblResult`].
56///
57/// Anything outside 127.0.0.0/24 (or the 0 / 1 sentinels) is treated
58/// as `Clean`. The codes follow Spamhaus's published ranges; other
59/// DNSBL operators that share the 127.0.0.x convention but with
60/// different code-to-list mapping will hit `DnsblResult::Listed(code)`.
61pub 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
76/// Stub: IPv6 isn't supported by most DNSBL operators (the reverse-IP
77/// scheme is impractical at IPv6 scale). Always returns `false`. Kept
78/// as an extension point in case a future operator adopts IPv6 lookups.
79pub fn is_ipv6_dnsbl_supported(_ip: &Ipv6Addr) -> bool {
80    false
81}
82
83/// Run the DNSBL lookup against each zone in order; return the first
84/// zone that lists `ip`, or `None` if no zone lists it.
85///
86/// IPv6 addresses are short-circuited (see [`is_ipv6_dnsbl_supported`]).
87/// DNS lookup failures (NXDOMAIN, SERVFAIL, timeout) are treated as
88/// "not listed" — the function returns `None`, not an error.
89pub 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
123/// TTL-cached DNSBL lookup. Avoids repeated DNS queries for IPs we've
124/// seen recently.
125///
126/// Caches **both** positive results (listed → known bad) and negative
127/// results (not listed → known good). The TTL applies uniformly; if
128/// you want different positive vs negative TTLs, run two caches.
129///
130/// Storage is `Mutex<HashMap>` — fine for sub-1k entries with moderate
131/// throughput. For higher contention, wrap your own `DashMap` and call
132/// [`check_dnsbl`] directly.
133pub struct DnsblCache {
134    #[allow(clippy::type_complexity)]
135    cache: Mutex<HashMap<IpAddr, (Option<(String, DnsblResult)>, Instant)>>,
136    ttl: Duration,
137}
138
139impl DnsblCache {
140    /// Construct an empty cache with the given per-entry TTL.
141    pub fn new(ttl: Duration) -> Self {
142        Self {
143            cache: Mutex::new(HashMap::new()),
144            ttl,
145        }
146    }
147
148    /// check with cache: return cached result if fresh, otherwise query DNS
149    pub async fn check(
150        &self,
151        resolver: &TokioResolver,
152        ip: IpAddr,
153        zones: &[String],
154    ) -> Option<(String, DnsblResult)> {
155        // check cache
156        {
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        // cache miss or expired — query
165        let result = check_dnsbl(resolver, ip, zones).await;
166
167        // store in cache (including None for negative caching)
168        {
169            let mut cache = self.cache.lock().unwrap();
170            cache.insert(ip, (result.clone(), Instant::now()));
171        }
172
173        result
174    }
175
176    /// remove expired entries
177    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    /// number of cached entries (for testing)
183    pub fn len(&self) -> usize {
184        self.cache.lock().unwrap().len()
185    }
186
187    /// check if cache is empty
188    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        // non-127.0.0.x should also be clean
245        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        // insert a negative (clean) result
261        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        // cache should have the entry
268        assert_eq!(cache.len(), 1);
269
270        // verify it's a negative entry
271        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    // ===== additional edge cases =====
297
298    #[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        // RFC-strict zones might be supplied with a trailing dot; we
311        // don't strip it. Documented behavior.
312        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        // Code 99 isn't documented — should yield Listed(99) not Clean.
351        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        // 127.0.1.x is OUTSIDE the documented Spamhaus range — Clean.
358        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        // pre-seed a positive entry
373        {
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        // Read it twice — both reads see the same value.
384        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())); // negative, fresh
405        }
406        cache.cleanup();
407        assert_eq!(cache.len(), 1); // still there
408    }
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        // Even a Spamhaus-style v6 still rejected — the function is
415        // a documented stub.
416        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        // Edge: empty zone. Documented behavior: returns "reversed."
424        let q = dnsbl_query("4.3.2.1", "");
425        assert_eq!(q, "4.3.2.1.");
426    }
427}