Skip to main content

hickory_server/store/
blocklist.rs

1// Copyright 2015-2022 Benjamin Fry <benjaminfry@me.com>
2//
3// Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
4// https://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
5// https://opensource.org/licenses/MIT>, at your option. This file may not be
6// copied, modified, or distributed except according to those terms.
7
8//! Blocklist resolver related types
9
10#![cfg(feature = "blocklist")]
11
12use std::{
13    collections::HashMap,
14    fs::File,
15    io::{self, Read},
16    net::{Ipv4Addr, Ipv6Addr},
17    path::Path,
18    str::FromStr,
19    time::{Duration, Instant},
20};
21
22use serde::Deserialize;
23use tracing::{info, trace, warn};
24
25#[cfg(feature = "metrics")]
26use crate::metrics::blocklist::BlocklistMetrics;
27#[cfg(feature = "__dnssec")]
28use crate::{dnssec::NxProofKind, zone_handler::Nsec3QueryInfo};
29use crate::{
30    proto::{
31        op::Query,
32        rr::{
33            LowerName, Name, RData, Record, RecordType, TSigResponseContext,
34            rdata::{A, AAAA, TXT},
35        },
36    },
37    resolver::lookup::Lookup,
38    server::{Request, RequestInfo},
39    zone_handler::{
40        AuthLookup, AxfrPolicy, LookupControlFlow, LookupError, LookupOptions, ZoneHandler,
41        ZoneTransfer, ZoneType,
42    },
43};
44
45// TODO:
46//  * Add query-type specific results for non-address queries
47//  * Add support for per-blocklist sinkhole IPs, block messages, actions
48//  * Add support for an exclusion list: allow the user to configure a list of patterns that
49//    will never be insert into the in-memory blocklist (such as their own domain)
50//  * Add support for regex matching
51
52/// A conditional zone handler that will resolve queries against one or more block lists and return
53/// a forged response.  The typical use case will be to use this in a chained configuration before a
54/// forwarding or recursive resolver in order to pre-emptively block queries for hosts that are on a
55/// block list. Refer to tests/test-data/test_configs/chained_blocklist.toml for an example of this
56/// configuration.
57///
58/// The blocklist zone handler also supports the consult interface, which allows a zone handler to
59/// review a query/response that has been processed by another zone handler, and, optionally,
60/// overwrite that response before returning it to the requestor.  There is an example of this
61/// configuration in tests/test-data/test_configs/example_consulting_blocklist.toml.  The main
62/// intended use of this feature is to allow log-only configurations, to allow administrators to see
63/// if blocklist domains are being queried.  While this can be configured to overwrite responses, it
64/// is not recommended to do so - it is both more efficient, and more secure, to allow the blocklist
65/// to drop queries pre-emptively, as in the first example.
66pub struct BlocklistZoneHandler {
67    origin: LowerName,
68    blocklist: HashMap<LowerName, bool>,
69    wildcard_match: bool,
70    min_wildcard_depth: u8,
71    sinkhole_ipv4: Ipv4Addr,
72    sinkhole_ipv6: Ipv6Addr,
73    ttl: u32,
74    block_message: Option<String>,
75    consult_action: BlocklistConsultAction,
76    log_clients: bool,
77    #[cfg(feature = "metrics")]
78    metrics: BlocklistMetrics,
79}
80
81impl BlocklistZoneHandler {
82    /// Read the ZoneHandler for the origin from the specified configuration
83    pub fn try_from_config(
84        origin: Name,
85        config: BlocklistConfig,
86        base_dir: Option<&Path>,
87    ) -> Result<Self, String> {
88        info!("loading blocklist config: {origin}");
89
90        let mut handler = Self {
91            origin: origin.into(),
92            blocklist: HashMap::new(),
93            wildcard_match: config.wildcard_match,
94            min_wildcard_depth: config.min_wildcard_depth,
95            sinkhole_ipv4: config.sinkhole_ipv4.unwrap_or(Ipv4Addr::UNSPECIFIED),
96            sinkhole_ipv6: config.sinkhole_ipv6.unwrap_or(Ipv6Addr::UNSPECIFIED),
97            ttl: config.ttl,
98            block_message: config.block_message,
99            consult_action: config.consult_action,
100            log_clients: config.log_clients,
101            #[cfg(feature = "metrics")]
102            metrics: BlocklistMetrics::new(),
103        };
104
105        let base_dir = match base_dir {
106            Some(dir) => dir.display(),
107            None => {
108                return Err(format!(
109                    "invalid blocklist (zone directory) base path specified: '{base_dir:?}'"
110                ));
111            }
112        };
113
114        // Load block lists into the block table cache for this zone handler.
115        for bl in &config.lists {
116            info!("adding blocklist {bl}");
117
118            let file = match File::open(format!("{base_dir}/{bl}")) {
119                Ok(file) => file,
120                Err(e) => {
121                    return Err(format!(
122                        "unable to open blocklist file {base_dir}/{bl}: {e:?}"
123                    ));
124                }
125            };
126
127            if let Err(e) = handler.add(file) {
128                return Err(format!(
129                    "unable to add data from blocklist {base_dir}/{bl}: {e:?}"
130                ));
131            }
132        }
133
134        #[cfg(feature = "metrics")]
135        handler
136            .metrics
137            .entries
138            .set(handler.blocklist.keys().len() as f64);
139
140        Ok(handler)
141    }
142
143    /// Add the contents of a block list to the in-memory cache. This function is normally called
144    /// from try_from_config, but it can be invoked after the blocklist zone handler is created.
145    ///
146    /// # Arguments
147    ///
148    /// * `handle` - A source implementing `std::io::Read` that contains the blocklist entries
149    ///   to insert into the in-memory cache.
150    ///
151    /// # Return value
152    ///
153    /// `Result<(), std::io::Error>`
154    ///
155    /// # Expected format of blocklist entries
156    ///
157    /// * One entry per line
158    /// * Any character after a '\#' will be treated as a comment and stripped out.
159    /// * Leading wildcard entries are supported when the user has wildcard_match set to true.
160    ///   E.g., '\*.foo.com' will match any host in the foo.com domain.  Intermediate wildcard
161    ///   matches, such as 'www.\*.com' are not supported. **Note: when wildcard matching is enabled,
162    ///   min_wildcard_depth (default: 2) controls how many static name labels must be present for a
163    ///   wildcard entry to be valid.  With the default value of 2, an entry for '\*.foo.com' would
164    ///   be accepted, but an entry for '\*.com' would not.**
165    /// * All entries are treated as being fully-qualified. If an entry does not contain a trailing
166    ///   '.', one will be added before insertion into the cache.
167    ///
168    /// # Example
169    /// ```
170    /// use std::{fs::File, net::{Ipv4Addr, Ipv6Addr}, path::Path, str::FromStr, sync::Arc};
171    /// use hickory_proto::rr::{LowerName, RecordType, Name};
172    /// use hickory_server::{
173    ///     store::blocklist::*,
174    ///     zone_handler::{LookupControlFlow, LookupOptions, ZoneHandler, ZoneType},
175    /// };
176    ///
177    /// #[tokio::main]
178    /// async fn main() {
179    ///     let config = BlocklistConfig {
180    ///         wildcard_match: true,
181    ///         min_wildcard_depth: 2,
182    ///         lists: vec!["default/blocklist.txt".to_string()],
183    ///         sinkhole_ipv4: None,
184    ///         sinkhole_ipv6: None,
185    ///         block_message: None,
186    ///         ttl: 86_400,
187    ///         consult_action: BlocklistConsultAction::Disabled,
188    ///         log_clients: true,
189    ///     };
190    ///
191    ///     let mut blocklist = BlocklistZoneHandler::try_from_config(
192    ///         Name::root(),
193    ///         config,
194    ///         Some(Path::new("../../tests/test-data/test_configs")),
195    ///     ).unwrap();
196    ///
197    ///     let handle = File::open("../../tests/test-data/test_configs/default/blocklist2.txt").unwrap();
198    ///     if let Err(e) = blocklist.add(handle) {
199    ///         panic!("error adding blocklist: {e:?}");
200    ///     }
201    ///
202    ///     let origin = blocklist.origin().clone();
203    ///     let handler = Arc::new(blocklist) as Arc<dyn ZoneHandler>;
204    ///
205    ///     // In this example, malc0de.com only exists in the blocklist2.txt file we added to the
206    ///     // zone handler after instantiating it.  The following simulates a lookup against the
207    ///     // blocklist zone handler, and checks for the expected response for a blocklist match.
208    ///     use LookupControlFlow::*;
209    ///     let Break(Ok(_res)) = handler.lookup(
210    ///                             &LowerName::from(Name::from_ascii("malc0de.com.").unwrap()),
211    ///                             RecordType::A,
212    ///                             None,
213    ///                             LookupOptions::default(),
214    ///                           ).await else {
215    ///         panic!("blocklist zone handler did not return expected match");
216    ///     };
217    /// }
218    /// ```
219    pub fn add(&mut self, mut handle: impl Read) -> Result<(), io::Error> {
220        let mut contents = String::new();
221
222        handle.read_to_string(&mut contents)?;
223        for mut entry in contents.lines() {
224            // Strip comments
225            if let Some((item, _)) = entry.split_once('#') {
226                entry = item.trim();
227            }
228
229            if entry.is_empty() {
230                continue;
231            }
232
233            let name = match entry.split_once(' ') {
234                Some((ip, domain)) if ip.trim() == "0.0.0.0" && !domain.trim().is_empty() => domain,
235                Some(_) => {
236                    warn!("invalid blocklist entry '{entry}'; skipping entry");
237                    continue;
238                }
239                None => entry,
240            };
241
242            let Ok(mut name) = LowerName::from_str(name) else {
243                warn!("unable to derive LowerName for blocklist entry '{name}'; skipping entry");
244                continue;
245            };
246
247            trace!("inserting blocklist entry {name}");
248
249            // The boolean value is not significant; only the key is used.
250            name.set_fqdn(true);
251            self.blocklist.insert(name, true);
252        }
253
254        Ok(())
255    }
256
257    /// Number of unique blocklist entries currently loaded in memory.
258    pub fn entry_count(&self) -> usize {
259        self.blocklist.len()
260    }
261
262    /// Build a wildcard match list for a given host
263    fn wildcards(&self, host: &Name) -> Vec<LowerName> {
264        host.iter()
265            .enumerate()
266            .filter_map(|(i, _x)| {
267                if i > ((self.min_wildcard_depth - 1) as usize) {
268                    Some(host.trim_to(i + 1).into_wildcard().into())
269                } else {
270                    None
271                }
272            })
273            .collect()
274    }
275
276    /// Perform a blocklist lookup. Returns true on match, false on no match.  This is also where
277    /// wildcard expansion is done, if wildcard support is enabled for the blocklist zone handler.
278    fn is_blocked(&self, name: &LowerName) -> bool {
279        let mut match_list = vec![name.to_owned()];
280
281        if self.wildcard_match {
282            match_list.append(&mut self.wildcards(name));
283        }
284
285        trace!("blocklist match list: {match_list:?}");
286
287        match_list
288            .iter()
289            .any(|entry| self.blocklist.contains_key(entry))
290    }
291
292    /// Generate a BlocklistLookup to return on a blocklist match.  This will return a lookup with
293    /// either an A or AAAA record and, if the user has configured a block message, a TXT record
294    /// with the contents of that message.
295    fn blocklist_response(&self, name: Name, rtype: RecordType) -> Lookup {
296        let mut records = vec![];
297
298        match rtype {
299            RecordType::AAAA => records.push(Record::from_rdata(
300                name.clone(),
301                self.ttl,
302                RData::AAAA(AAAA(self.sinkhole_ipv6)),
303            )),
304            _ => records.push(Record::from_rdata(
305                name.clone(),
306                self.ttl,
307                RData::A(A(self.sinkhole_ipv4)),
308            )),
309        }
310
311        if let Some(block_message) = &self.block_message {
312            records.push(Record::from_rdata(
313                name.clone(),
314                self.ttl,
315                RData::TXT(TXT::new(vec![block_message.clone()])),
316            ));
317        }
318
319        Lookup::new_with_deadline(
320            Query::query(name.clone(), rtype),
321            records,
322            Instant::now() + Duration::from_secs(u64::from(self.ttl)),
323        )
324    }
325}
326
327#[async_trait::async_trait]
328impl ZoneHandler for BlocklistZoneHandler {
329    fn zone_type(&self) -> ZoneType {
330        ZoneType::External
331    }
332
333    fn axfr_policy(&self) -> AxfrPolicy {
334        AxfrPolicy::Deny
335    }
336
337    fn origin(&self) -> &LowerName {
338        &self.origin
339    }
340
341    /// Perform a blocklist lookup.  This will return LookupControlFlow::Break(Ok) on a match, or
342    /// LookupControlFlow::Skip on no match.
343    async fn lookup(
344        &self,
345        name: &LowerName,
346        rtype: RecordType,
347        request_info: Option<&RequestInfo<'_>>,
348        _lookup_options: LookupOptions,
349    ) -> LookupControlFlow<AuthLookup> {
350        use LookupControlFlow::*;
351
352        trace!("blocklist lookup: {name} {rtype}");
353
354        #[cfg(feature = "metrics")]
355        self.metrics.total_queries.increment(1);
356
357        if self.is_blocked(name) {
358            #[cfg(feature = "metrics")]
359            {
360                self.metrics.total_hits.increment(1);
361                self.metrics.blocked_queries.increment(1);
362            }
363            match request_info {
364                Some(info) if self.log_clients => info!(
365                    query = %name,
366                    client = %info.src,
367                    action = "BLOCK",
368                    "blocklist matched",
369                ),
370                _ => info!(
371                    query = %name,
372                    action = "BLOCK",
373                    "blocklist matched",
374                ),
375            }
376            return Break(Ok(AuthLookup::from(
377                self.blocklist_response(Name::from(name), rtype),
378            )));
379        }
380
381        trace!("query '{name}' is not in blocklist; returning Skip...");
382        Skip
383    }
384
385    /// Optionally, perform a blocklist lookup after another zone handler has done a lookup for this
386    /// query.
387    async fn consult(
388        &self,
389        name: &LowerName,
390        rtype: RecordType,
391        request_info: Option<&RequestInfo<'_>>,
392        lookup_options: LookupOptions,
393        last_result: LookupControlFlow<AuthLookup>,
394    ) -> (LookupControlFlow<AuthLookup>, Option<TSigResponseContext>) {
395        match self.consult_action {
396            BlocklistConsultAction::Disabled => (last_result, None),
397            BlocklistConsultAction::Log => {
398                #[cfg(feature = "metrics")]
399                self.metrics.total_queries.increment(1);
400
401                if self.is_blocked(name) {
402                    #[cfg(feature = "metrics")]
403                    {
404                        self.metrics.logged_queries.increment(1);
405                        self.metrics.total_hits.increment(1);
406                    }
407                    match request_info {
408                        Some(info) if self.log_clients => {
409                            info!(
410                                query = %name,
411                                client = %info.src,
412                                action = "LOG",
413                                "blocklist matched",
414                            );
415                        }
416                        _ => info!(query = %name, action = "LOG", "blocklist matched"),
417                    }
418                }
419
420                (last_result, None)
421            }
422            BlocklistConsultAction::Enforce => {
423                let lookup = self.lookup(name, rtype, request_info, lookup_options).await;
424                if lookup.is_break() {
425                    (lookup, None)
426                } else {
427                    (last_result, None)
428                }
429            }
430        }
431    }
432
433    async fn search(
434        &self,
435        request: &Request,
436        lookup_options: LookupOptions,
437    ) -> (LookupControlFlow<AuthLookup>, Option<TSigResponseContext>) {
438        let request_info = match request.request_info() {
439            Ok(info) => info,
440            Err(e) => return (LookupControlFlow::Break(Err(e)), None),
441        };
442        (
443            self.lookup(
444                request_info.query.name(),
445                request_info.query.query_type(),
446                Some(&request_info),
447                lookup_options,
448            )
449            .await,
450            None,
451        )
452    }
453
454    async fn zone_transfer(
455        &self,
456        _request: &Request,
457        _lookup_options: LookupOptions,
458        _now: u64,
459    ) -> Option<(
460        Result<ZoneTransfer, LookupError>,
461        Option<TSigResponseContext>,
462    )> {
463        None
464    }
465
466    async fn nsec_records(
467        &self,
468        _name: &LowerName,
469        _lookup_options: LookupOptions,
470    ) -> LookupControlFlow<AuthLookup> {
471        LookupControlFlow::Continue(Err(LookupError::from(io::Error::other(
472            "getting NSEC records is unimplemented for the blocklist",
473        ))))
474    }
475
476    #[cfg(feature = "__dnssec")]
477    async fn nsec3_records(
478        &self,
479        _info: Nsec3QueryInfo<'_>,
480        _lookup_options: LookupOptions,
481    ) -> LookupControlFlow<AuthLookup> {
482        LookupControlFlow::Continue(Err(LookupError::from(io::Error::other(
483            "getting NSEC3 records is unimplemented for the forwarder",
484        ))))
485    }
486
487    #[cfg(feature = "__dnssec")]
488    fn nx_proof_kind(&self) -> Option<&NxProofKind> {
489        None
490    }
491
492    #[cfg(feature = "metrics")]
493    fn metrics_label(&self) -> &'static str {
494        "blocklist"
495    }
496}
497
498/// Consult action enum.  Controls how consult lookups are handled.
499#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
500pub enum BlocklistConsultAction {
501    /// Do not log or block any request when the blocklist is called via consult
502    #[default]
503    Disabled,
504    /// Log and block matching requests when the blocklist is called via consult
505    Enforce,
506    /// Log but do not block matching requests when the blocklist is called via consult
507    Log,
508}
509
510/// Configuration for blocklist zones
511#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
512#[serde(default, deny_unknown_fields)]
513pub struct BlocklistConfig {
514    /// Support wildcards?  Defaults to true. If set to true, block list entries containing
515    /// asterisks will be expanded to match queries.
516    pub wildcard_match: bool,
517
518    /// Minimum wildcard depth.  Defaults to 2.  Any wildcard entries without at least this many
519    /// static elements will not be expanded (e.g., *.com has a depth of 1; *.example.com has a
520    /// depth of two.) This is meant as a safeguard against an errant block list entry, such as *
521    /// or *.com that might block many more hosts than intended.
522    pub min_wildcard_depth: u8,
523
524    /// Block lists to load.  These should be specified as relative (to the server zone directory)
525    /// paths in the config file.
526    pub lists: Vec<String>,
527
528    /// IPv4 sinkhole IP. This is the IP that is returned when a blocklist entry is matched for an
529    /// A query. If unspecified, an implementation-provided default will be used.
530    pub sinkhole_ipv4: Option<Ipv4Addr>,
531
532    /// IPv6 sinkhole IP.  This is the IP that is returned when a blocklist entry is matched for a
533    /// AAAA query. If unspecified, an implementation-provided default will be used.
534    pub sinkhole_ipv6: Option<Ipv6Addr>,
535
536    /// Block TTL. This is the length of time a block response should be stored in the requesting
537    /// resolvers cache, in seconds.  Defaults to 86,400 seconds.
538    pub ttl: u32,
539
540    /// Block message to return to the user.  This is an optional message that, if configured, will
541    /// be returned as a TXT record in the additionals section when a blocklist entry is matched for
542    /// a query.
543    pub block_message: Option<String>,
544
545    /// The consult action controls how the blocklist handles queries where another zone handler has
546    /// already provided an answer.  By default, it ignores any such queries ("Disabled",) however
547    /// it can be configured to log blocklist matches for those queries ("Log",) or can be
548    /// configured to overwrite the previous responses ("Enforce".)
549    pub consult_action: BlocklistConsultAction,
550
551    /// Controls client IP logging for blocklist matches
552    pub log_clients: bool,
553}
554
555impl Default for BlocklistConfig {
556    fn default() -> Self {
557        Self {
558            wildcard_match: true,
559            min_wildcard_depth: 2,
560            lists: vec![],
561            sinkhole_ipv4: None,
562            sinkhole_ipv6: None,
563            ttl: 86_400,
564            block_message: None,
565            consult_action: BlocklistConsultAction::default(),
566            log_clients: true,
567        }
568    }
569}
570
571#[cfg(test)]
572mod test {
573    use std::{
574        net::{Ipv4Addr, Ipv6Addr},
575        path::Path,
576        str::FromStr,
577        sync::Arc,
578    };
579
580    use super::*;
581    use crate::{
582        proto::rr::domain::Name,
583        proto::rr::{
584            LowerName, RData, RecordType,
585            rdata::{A, AAAA},
586        },
587        zone_handler::LookupOptions,
588    };
589    use test_support::subscribe;
590
591    #[tokio::test]
592    async fn test_blocklist_basic() {
593        subscribe();
594        let config = BlocklistConfig {
595            wildcard_match: true,
596            min_wildcard_depth: 2,
597            lists: vec!["default/blocklist.txt".to_string()],
598            sinkhole_ipv4: None,
599            sinkhole_ipv6: None,
600            block_message: None,
601            ttl: 86_400,
602            consult_action: BlocklistConsultAction::Disabled,
603            log_clients: true,
604        };
605
606        let h = handler(config);
607        let v4 = A::new(0, 0, 0, 0);
608        let v6 = AAAA::new(0, 0, 0, 0, 0, 0, 0, 0);
609
610        use RecordType::{A as Rec_A, AAAA as Rec_AAAA};
611        use TestResult::*;
612        // Test: lookup a record that is in the blocklist and that should match without a wildcard.
613        basic_test(&h, "foo.com.", Rec_A, Break, Some(v4), None, None).await;
614
615        // test: lookup a record that is not in the blocklist. This test should fail.
616        basic_test(&h, "test.com.", Rec_A, Skip, None, None, None).await;
617
618        // Test: lookup a record that will match a wildcard that is in the blocklist.
619        basic_test(&h, "www.foo.com.", Rec_A, Break, Some(v4), None, None).await;
620
621        // Test: lookup a record that will match a wildcard that is in the blocklist.
622        basic_test(&h, "www.com.foo.com.", Rec_A, Break, Some(v4), None, None).await;
623
624        // Test: lookup a record that is in the blocklist and that should match without a wildcard.
625        basic_test(&h, "foo.com.", Rec_AAAA, Break, None, Some(v6), None).await;
626
627        // test: lookup a record that is not in the blocklist. This test should fail.
628        basic_test(&h, "test.com.", Rec_AAAA, Skip, None, None, None).await;
629
630        // Test: lookup a record that will match a wildcard that is in the blocklist.
631        basic_test(&h, "www.foo.com.", Rec_AAAA, Break, None, Some(v6), None).await;
632
633        // Test: lookup a record that will match a wildcard that is in the blocklist.
634        basic_test(&h, "ab.cd.foo.com.", Rec_AAAA, Break, None, Some(v6), None).await;
635    }
636
637    #[tokio::test]
638    async fn test_blocklist_wildcard_disabled() {
639        subscribe();
640        let config = BlocklistConfig {
641            min_wildcard_depth: 2,
642            wildcard_match: false,
643            lists: vec!["default/blocklist.txt".to_string()],
644            sinkhole_ipv4: Some(Ipv4Addr::new(192, 0, 2, 1)),
645            sinkhole_ipv6: Some(Ipv6Addr::new(0, 0, 0, 0, 0xc0, 0, 2, 1)),
646            block_message: Some(String::from("blocked")),
647            ttl: 86_400,
648            consult_action: BlocklistConsultAction::Disabled,
649            log_clients: true,
650        };
651
652        let msg = config.block_message.clone();
653        let h = handler(config);
654        let v4 = A::new(192, 0, 2, 1);
655        let v6 = AAAA::new(0, 0, 0, 0, 0xc0, 0, 2, 1);
656
657        use RecordType::{A as Rec_A, AAAA as Rec_AAAA};
658        use TestResult::*;
659
660        // Test: lookup a record that is in the blocklist and that should match without a wildcard.
661        basic_test(&h, "foo.com.", Rec_A, Break, Some(v4), None, msg.clone()).await;
662
663        // Test: lookup a record that is not in the blocklist, but would match a wildcard; this
664        // should fail.
665        basic_test(&h, "www.foo.com.", Rec_A, Skip, None, None, msg.clone()).await;
666
667        // Test: lookup a record that is in the blocklist and that should match without a wildcard.
668        basic_test(&h, "foo.com.", Rec_AAAA, Break, None, Some(v6), msg).await;
669    }
670
671    #[tokio::test]
672    #[should_panic]
673    async fn test_blocklist_wrong_block_message() {
674        subscribe();
675        let config = BlocklistConfig {
676            min_wildcard_depth: 2,
677            wildcard_match: false,
678            lists: vec!["default/blocklist.txt".to_string()],
679            sinkhole_ipv4: Some(Ipv4Addr::new(192, 0, 2, 1)),
680            sinkhole_ipv6: Some(Ipv6Addr::new(0, 0, 0, 0, 0xc0, 0, 2, 1)),
681            block_message: Some(String::from("blocked")),
682            ttl: 86_400,
683            consult_action: BlocklistConsultAction::Disabled,
684            log_clients: true,
685        };
686
687        let h = handler(config);
688        let sinkhole_v4 = A::new(192, 0, 2, 1);
689
690        // Test: lookup a record that is in the blocklist, but specify an incorrect block message to
691        // match.
692        basic_test(
693            &h,
694            "foo.com.",
695            RecordType::A,
696            TestResult::Break,
697            Some(sinkhole_v4),
698            None,
699            Some(String::from("wrong message")),
700        )
701        .await;
702    }
703
704    #[tokio::test]
705    async fn test_blocklist_hosts_format() {
706        subscribe();
707        let config = BlocklistConfig {
708            min_wildcard_depth: 2,
709            wildcard_match: true,
710            lists: vec!["default/blocklist3.txt".to_string()],
711            sinkhole_ipv4: Some(Ipv4Addr::new(192, 0, 2, 1)),
712            sinkhole_ipv6: Some(Ipv6Addr::new(0, 0, 0, 0, 0xc0, 0, 2, 1)),
713            block_message: Some(String::from("blocked")),
714            ttl: 86_400,
715            consult_action: BlocklistConsultAction::Disabled,
716            log_clients: true,
717        };
718
719        let msg = config.block_message.clone();
720        let h = handler(config);
721        let v4 = A::new(192, 0, 2, 1);
722
723        use TestResult::*;
724
725        // Test: lookup a record from a blocklist file in plain format (only domain) which should match without a wildcard.
726        basic_test(
727            &h,
728            "test.com.",
729            RecordType::A,
730            Break,
731            Some(v4),
732            None,
733            msg.clone(),
734        )
735        .await;
736
737        // Test: lookup a record from a blocklist file in hosts format (ip <space> domain) which should match without a wildcard.
738        basic_test(
739            &h,
740            "anothertest.com.",
741            RecordType::A,
742            Break,
743            Some(v4),
744            None,
745            msg.clone(),
746        )
747        .await;
748
749        // Test: lookup a record from a blocklist file in hosts format (ip <space> domain) which should match with a wildcard.
750        basic_test(
751            &h,
752            "yet.anothertest.com.",
753            RecordType::A,
754            Break,
755            Some(v4),
756            None,
757            msg.clone(),
758        )
759        .await;
760    }
761
762    #[test]
763    fn test_blocklist_entry_count() {
764        subscribe();
765        let config = BlocklistConfig {
766            wildcard_match: true,
767            min_wildcard_depth: 2,
768            lists: vec!["default/blocklist.txt".to_string()],
769            sinkhole_ipv4: None,
770            sinkhole_ipv6: None,
771            block_message: None,
772            ttl: 86_400,
773            consult_action: BlocklistConsultAction::Disabled,
774            log_clients: true,
775        };
776
777        let zh = BlocklistZoneHandler::try_from_config(
778            Name::root(),
779            config,
780            Some(Path::new("../../tests/test-data/test_configs/")),
781        )
782        .expect("unable to create config");
783
784        assert_eq!(zh.entry_count(), 4);
785    }
786
787    #[test]
788    fn test_blocklist_entry_count_default() {
789        subscribe();
790        let config = BlocklistConfig::default();
791
792        let zh = BlocklistZoneHandler::try_from_config(
793            Name::root(),
794            config,
795            Some(Path::new("../../tests/test-data/test_configs/")),
796        )
797        .expect("unable to create config");
798
799        assert_eq!(zh.entry_count(), 0);
800    }
801
802    async fn basic_test(
803        ao: &Arc<dyn ZoneHandler>,
804        query: &'static str,
805        q_type: RecordType,
806        r_type: TestResult,
807        ipv4: Option<A>,
808        ipv6: Option<AAAA>,
809        msg: Option<String>,
810    ) {
811        let res = ao
812            .lookup(
813                &LowerName::from_str(query).unwrap(),
814                q_type,
815                None,
816                LookupOptions::default(),
817            )
818            .await;
819
820        use LookupControlFlow::*;
821        let lookup = match r_type {
822            TestResult::Break => match res {
823                Break(Ok(lookup)) => lookup,
824                _ => panic!("Unexpected result for {query}: {res}"),
825            },
826            TestResult::Skip => match res {
827                Skip => return,
828                _ => {
829                    panic!("unexpected result for {query}; expected Skip, found {res}");
830                }
831            },
832        };
833
834        if !lookup.iter().all(|x| match x.record_type() {
835            RecordType::TXT => {
836                if let Some(msg) = &msg {
837                    x.data.to_string() == *msg
838                } else {
839                    false
840                }
841            }
842            RecordType::AAAA => {
843                let Some(rec_ip) = ipv6 else {
844                    panic!("expected to validate record IPv6, but None was passed");
845                };
846
847                x.name == Name::from_str(query).unwrap() && x.data == RData::AAAA(rec_ip)
848            }
849            _ => {
850                let Some(rec_ip) = ipv4 else {
851                    panic!("expected to validate record IPv4, but None was passed");
852                };
853
854                x.name == Name::from_str(query).unwrap() && x.data == RData::A(rec_ip)
855            }
856        }) {
857            panic!("{query} lookup data is incorrect.");
858        }
859    }
860
861    fn handler(config: BlocklistConfig) -> Arc<dyn ZoneHandler> {
862        let handler = BlocklistZoneHandler::try_from_config(
863            Name::root(),
864            config,
865            Some(Path::new("../../tests/test-data/test_configs/")),
866        );
867
868        // Test: verify the blocklist zone handler was successfully created.
869        match handler {
870            Ok(handler) => Arc::new(handler),
871            Err(error) => panic!("error creating blocklist zone handler: {error}"),
872        }
873    }
874
875    enum TestResult {
876        Break,
877        Skip,
878    }
879}