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