1#![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
45pub 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 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 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 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 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 name.set_fqdn(true);
251 self.blocklist.insert(name, true);
252 }
253
254 Ok(())
255 }
256
257 pub fn entry_count(&self) -> usize {
259 self.blocklist.len()
260 }
261
262 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 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 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 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 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#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
500pub enum BlocklistConsultAction {
501 #[default]
503 Disabled,
504 Enforce,
506 Log,
508}
509
510#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
512#[serde(default, deny_unknown_fields)]
513pub struct BlocklistConfig {
514 pub wildcard_match: bool,
517
518 pub min_wildcard_depth: u8,
523
524 pub lists: Vec<String>,
527
528 pub sinkhole_ipv4: Option<Ipv4Addr>,
531
532 pub sinkhole_ipv6: Option<Ipv6Addr>,
535
536 pub ttl: u32,
539
540 pub block_message: Option<String>,
544
545 pub consult_action: BlocklistConsultAction,
550
551 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 basic_test(&h, "foo.com.", Rec_A, Break, Some(v4), None, None).await;
614
615 basic_test(&h, "test.com.", Rec_A, Skip, None, None, None).await;
617
618 basic_test(&h, "www.foo.com.", Rec_A, Break, Some(v4), None, None).await;
620
621 basic_test(&h, "www.com.foo.com.", Rec_A, Break, Some(v4), None, None).await;
623
624 basic_test(&h, "foo.com.", Rec_AAAA, Break, None, Some(v6), None).await;
626
627 basic_test(&h, "test.com.", Rec_AAAA, Skip, None, None, None).await;
629
630 basic_test(&h, "www.foo.com.", Rec_AAAA, Break, None, Some(v6), None).await;
632
633 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 basic_test(&h, "foo.com.", Rec_A, Break, Some(v4), None, msg.clone()).await;
662
663 basic_test(&h, "www.foo.com.", Rec_A, Skip, None, None, msg.clone()).await;
666
667 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 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 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 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 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 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}