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