1#![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
46pub 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 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 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 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 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 name.set_fqdn(true);
245 self.blocklist.insert(name, true);
246 }
247
248 Ok(())
249 }
250
251 pub fn entry_count(&self) -> usize {
253 self.blocklist.len()
254 }
255
256 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 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 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 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 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#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
494pub enum BlocklistConsultAction {
495 #[default]
497 Disabled,
498 Enforce,
500 Log,
502}
503
504#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
506#[serde(default, deny_unknown_fields)]
507pub struct BlocklistConfig {
508 pub wildcard_match: bool,
511
512 pub min_wildcard_depth: u8,
517
518 pub lists: Vec<PathBuf>,
520
521 pub sinkhole_ipv4: Option<Ipv4Addr>,
524
525 pub sinkhole_ipv6: Option<Ipv6Addr>,
528
529 pub ttl: u32,
532
533 pub block_message: Option<String>,
537
538 pub consult_action: BlocklistConsultAction,
543
544 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 basic_test(&h, "foo.com.", Rec_A, Break, Some(v4), None, None).await;
607
608 basic_test(&h, "test.com.", Rec_A, Skip, None, None, None).await;
610
611 basic_test(&h, "www.foo.com.", Rec_A, Break, Some(v4), None, None).await;
613
614 basic_test(&h, "www.com.foo.com.", Rec_A, Break, Some(v4), None, None).await;
616
617 basic_test(&h, "foo.com.", Rec_AAAA, Break, None, Some(v6), None).await;
619
620 basic_test(&h, "test.com.", Rec_AAAA, Skip, None, None, None).await;
622
623 basic_test(&h, "www.foo.com.", Rec_AAAA, Break, None, Some(v6), None).await;
625
626 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 basic_test(&h, "foo.com.", Rec_A, Break, Some(v4), None, msg.clone()).await;
655
656 basic_test(&h, "www.foo.com.", Rec_A, Skip, None, None, msg.clone()).await;
659
660 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 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 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 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 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 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}