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
8pub fn reverse_ipv4(ip: Ipv4Addr) -> String {
10 let o = ip.octets();
11 format!("{}.{}.{}.{}", o[3], o[2], o[1], o[0])
12}
13
14pub fn dnsbl_query(reversed: &str, zone: &str) -> String {
16 format!("{reversed}.{zone}")
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum DnsblResult {
22 Clean,
24 Sbl,
26 Css,
28 Xbl,
30 Pbl,
32 Listed(u8),
34}
35
36pub 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
52pub fn is_ipv6_dnsbl_supported(_ip: &Ipv6Addr) -> bool {
54 false
55}
56
57pub 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
93pub struct DnsblCache {
96 #[allow(clippy::type_complexity)]
97 cache: Mutex<HashMap<IpAddr, (Option<(String, DnsblResult)>, Instant)>>,
98 ttl: Duration,
99}
100
101impl DnsblCache {
102 pub fn new(ttl: Duration) -> Self {
104 Self {
105 cache: Mutex::new(HashMap::new()),
106 ttl,
107 }
108 }
109
110 pub async fn check(
112 &self,
113 resolver: &TokioResolver,
114 ip: IpAddr,
115 zones: &[String],
116 ) -> Option<(String, DnsblResult)> {
117 {
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 let result = check_dnsbl(resolver, ip, zones).await;
128
129 {
131 let mut cache = self.cache.lock().unwrap();
132 cache.insert(ip, (result.clone(), Instant::now()));
133 }
134
135 result
136 }
137
138 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 pub fn len(&self) -> usize {
146 self.cache.lock().unwrap().len()
147 }
148
149 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 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 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 assert_eq!(cache.len(), 1);
231
232 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}