#![cfg(feature = "blocklist")]
use std::{
collections::HashMap,
fs::File,
io::{self, Read},
net::{Ipv4Addr, Ipv6Addr},
path::Path,
str::FromStr,
time::{Duration, Instant},
};
use serde::Deserialize;
use tracing::{info, trace, warn};
#[cfg(feature = "metrics")]
use crate::metrics::blocklist::BlocklistMetrics;
#[cfg(feature = "__dnssec")]
use crate::{dnssec::NxProofKind, zone_handler::Nsec3QueryInfo};
use crate::{
proto::{
op::Query,
rr::{
LowerName, Name, RData, Record, RecordType, TSigResponseContext,
rdata::{A, AAAA, TXT},
},
},
resolver::lookup::Lookup,
server::{Request, RequestInfo},
zone_handler::{
AuthLookup, AxfrPolicy, LookupControlFlow, LookupError, LookupOptions, ZoneHandler,
ZoneTransfer, ZoneType,
},
};
pub struct BlocklistZoneHandler {
origin: LowerName,
blocklist: HashMap<LowerName, bool>,
wildcard_match: bool,
min_wildcard_depth: u8,
sinkhole_ipv4: Ipv4Addr,
sinkhole_ipv6: Ipv6Addr,
ttl: u32,
block_message: Option<String>,
consult_action: BlocklistConsultAction,
log_clients: bool,
#[cfg(feature = "metrics")]
metrics: BlocklistMetrics,
}
impl BlocklistZoneHandler {
pub fn try_from_config(
origin: Name,
config: BlocklistConfig,
base_dir: Option<&Path>,
) -> Result<Self, String> {
info!("loading blocklist config: {origin}");
let mut handler = Self {
origin: origin.into(),
blocklist: HashMap::new(),
wildcard_match: config.wildcard_match,
min_wildcard_depth: config.min_wildcard_depth,
sinkhole_ipv4: config.sinkhole_ipv4.unwrap_or(Ipv4Addr::UNSPECIFIED),
sinkhole_ipv6: config.sinkhole_ipv6.unwrap_or(Ipv6Addr::UNSPECIFIED),
ttl: config.ttl,
block_message: config.block_message,
consult_action: config.consult_action,
log_clients: config.log_clients,
#[cfg(feature = "metrics")]
metrics: BlocklistMetrics::new(),
};
let base_dir = match base_dir {
Some(dir) => dir.display(),
None => {
return Err(format!(
"invalid blocklist (zone directory) base path specified: '{base_dir:?}'"
));
}
};
for bl in &config.lists {
info!("adding blocklist {bl}");
let file = match File::open(format!("{base_dir}/{bl}")) {
Ok(file) => file,
Err(e) => {
return Err(format!(
"unable to open blocklist file {base_dir}/{bl}: {e:?}"
));
}
};
if let Err(e) = handler.add(file) {
return Err(format!(
"unable to add data from blocklist {base_dir}/{bl}: {e:?}"
));
}
}
#[cfg(feature = "metrics")]
handler
.metrics
.entries
.set(handler.blocklist.keys().len() as f64);
Ok(handler)
}
pub fn add(&mut self, mut handle: impl Read) -> Result<(), io::Error> {
let mut contents = String::new();
handle.read_to_string(&mut contents)?;
for mut entry in contents.lines() {
if let Some((item, _)) = entry.split_once('#') {
entry = item.trim();
}
if entry.is_empty() {
continue;
}
let name = match entry.split_once(' ') {
Some((ip, domain)) if ip.trim() == "0.0.0.0" && !domain.trim().is_empty() => domain,
Some(_) => {
warn!("invalid blocklist entry '{entry}'; skipping entry");
continue;
}
None => entry,
};
let Ok(mut name) = LowerName::from_str(name) else {
warn!("unable to derive LowerName for blocklist entry '{name}'; skipping entry");
continue;
};
trace!("inserting blocklist entry {name}");
name.set_fqdn(true);
self.blocklist.insert(name, true);
}
Ok(())
}
pub fn entry_count(&self) -> usize {
self.blocklist.len()
}
fn wildcards(&self, host: &Name) -> Vec<LowerName> {
host.iter()
.enumerate()
.filter_map(|(i, _x)| {
if i > ((self.min_wildcard_depth - 1) as usize) {
Some(host.trim_to(i + 1).into_wildcard().into())
} else {
None
}
})
.collect()
}
fn is_blocked(&self, name: &LowerName) -> bool {
let mut match_list = vec![name.to_owned()];
if self.wildcard_match {
match_list.append(&mut self.wildcards(name));
}
trace!("blocklist match list: {match_list:?}");
match_list
.iter()
.any(|entry| self.blocklist.contains_key(entry))
}
fn blocklist_response(&self, name: Name, rtype: RecordType) -> Lookup {
let mut records = vec![];
match rtype {
RecordType::AAAA => records.push(Record::from_rdata(
name.clone(),
self.ttl,
RData::AAAA(AAAA(self.sinkhole_ipv6)),
)),
_ => records.push(Record::from_rdata(
name.clone(),
self.ttl,
RData::A(A(self.sinkhole_ipv4)),
)),
}
if let Some(block_message) = &self.block_message {
records.push(Record::from_rdata(
name.clone(),
self.ttl,
RData::TXT(TXT::new(vec![block_message.clone()])),
));
}
Lookup::new_with_deadline(
Query::query(name.clone(), rtype),
records,
Instant::now() + Duration::from_secs(u64::from(self.ttl)),
)
}
}
#[async_trait::async_trait]
impl ZoneHandler for BlocklistZoneHandler {
fn zone_type(&self) -> ZoneType {
ZoneType::External
}
fn axfr_policy(&self) -> AxfrPolicy {
AxfrPolicy::Deny
}
fn origin(&self) -> &LowerName {
&self.origin
}
async fn lookup(
&self,
name: &LowerName,
rtype: RecordType,
request_info: Option<&RequestInfo<'_>>,
_lookup_options: LookupOptions,
) -> LookupControlFlow<AuthLookup> {
use LookupControlFlow::*;
trace!("blocklist lookup: {name} {rtype}");
#[cfg(feature = "metrics")]
self.metrics.total_queries.increment(1);
if self.is_blocked(name) {
#[cfg(feature = "metrics")]
{
self.metrics.total_hits.increment(1);
self.metrics.blocked_queries.increment(1);
}
match request_info {
Some(info) if self.log_clients => info!(
query = %name,
client = %info.src,
action = "BLOCK",
"blocklist matched",
),
_ => info!(
query = %name,
action = "BLOCK",
"blocklist matched",
),
}
return Break(Ok(AuthLookup::from(
self.blocklist_response(Name::from(name), rtype),
)));
}
trace!("query '{name}' is not in blocklist; returning Skip...");
Skip
}
async fn consult(
&self,
name: &LowerName,
rtype: RecordType,
request_info: Option<&RequestInfo<'_>>,
lookup_options: LookupOptions,
last_result: LookupControlFlow<AuthLookup>,
) -> (LookupControlFlow<AuthLookup>, Option<TSigResponseContext>) {
match self.consult_action {
BlocklistConsultAction::Disabled => (last_result, None),
BlocklistConsultAction::Log => {
#[cfg(feature = "metrics")]
self.metrics.total_queries.increment(1);
if self.is_blocked(name) {
#[cfg(feature = "metrics")]
{
self.metrics.logged_queries.increment(1);
self.metrics.total_hits.increment(1);
}
match request_info {
Some(info) if self.log_clients => {
info!(
query = %name,
client = %info.src,
action = "LOG",
"blocklist matched",
);
}
_ => info!(query = %name, action = "LOG", "blocklist matched"),
}
}
(last_result, None)
}
BlocklistConsultAction::Enforce => {
let lookup = self.lookup(name, rtype, request_info, lookup_options).await;
if lookup.is_break() {
(lookup, None)
} else {
(last_result, None)
}
}
}
}
async fn search(
&self,
request: &Request,
lookup_options: LookupOptions,
) -> (LookupControlFlow<AuthLookup>, Option<TSigResponseContext>) {
let request_info = match request.request_info() {
Ok(info) => info,
Err(e) => return (LookupControlFlow::Break(Err(e)), None),
};
(
self.lookup(
request_info.query.name(),
request_info.query.query_type(),
Some(&request_info),
lookup_options,
)
.await,
None,
)
}
async fn zone_transfer(
&self,
_request: &Request,
_lookup_options: LookupOptions,
_now: u64,
) -> Option<(
Result<ZoneTransfer, LookupError>,
Option<TSigResponseContext>,
)> {
None
}
async fn nsec_records(
&self,
_name: &LowerName,
_lookup_options: LookupOptions,
) -> LookupControlFlow<AuthLookup> {
LookupControlFlow::Continue(Err(LookupError::from(io::Error::other(
"getting NSEC records is unimplemented for the blocklist",
))))
}
#[cfg(feature = "__dnssec")]
async fn nsec3_records(
&self,
_info: Nsec3QueryInfo<'_>,
_lookup_options: LookupOptions,
) -> LookupControlFlow<AuthLookup> {
LookupControlFlow::Continue(Err(LookupError::from(io::Error::other(
"getting NSEC3 records is unimplemented for the forwarder",
))))
}
#[cfg(feature = "__dnssec")]
fn nx_proof_kind(&self) -> Option<&NxProofKind> {
None
}
#[cfg(feature = "metrics")]
fn metrics_label(&self) -> &'static str {
"blocklist"
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
pub enum BlocklistConsultAction {
#[default]
Disabled,
Enforce,
Log,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(default, deny_unknown_fields)]
pub struct BlocklistConfig {
pub wildcard_match: bool,
pub min_wildcard_depth: u8,
pub lists: Vec<String>,
pub sinkhole_ipv4: Option<Ipv4Addr>,
pub sinkhole_ipv6: Option<Ipv6Addr>,
pub ttl: u32,
pub block_message: Option<String>,
pub consult_action: BlocklistConsultAction,
pub log_clients: bool,
}
impl Default for BlocklistConfig {
fn default() -> Self {
Self {
wildcard_match: true,
min_wildcard_depth: 2,
lists: vec![],
sinkhole_ipv4: None,
sinkhole_ipv6: None,
ttl: 86_400,
block_message: None,
consult_action: BlocklistConsultAction::default(),
log_clients: true,
}
}
}
#[cfg(test)]
mod test {
use std::{
net::{Ipv4Addr, Ipv6Addr},
path::Path,
str::FromStr,
sync::Arc,
};
use super::*;
use crate::{
proto::rr::domain::Name,
proto::rr::{
LowerName, RData, RecordType,
rdata::{A, AAAA},
},
zone_handler::LookupOptions,
};
use test_support::subscribe;
#[tokio::test]
async fn test_blocklist_basic() {
subscribe();
let config = BlocklistConfig {
wildcard_match: true,
min_wildcard_depth: 2,
lists: vec!["default/blocklist.txt".to_string()],
sinkhole_ipv4: None,
sinkhole_ipv6: None,
block_message: None,
ttl: 86_400,
consult_action: BlocklistConsultAction::Disabled,
log_clients: true,
};
let h = handler(config);
let v4 = A::new(0, 0, 0, 0);
let v6 = AAAA::new(0, 0, 0, 0, 0, 0, 0, 0);
use RecordType::{A as Rec_A, AAAA as Rec_AAAA};
use TestResult::*;
basic_test(&h, "foo.com.", Rec_A, Break, Some(v4), None, None).await;
basic_test(&h, "test.com.", Rec_A, Skip, None, None, None).await;
basic_test(&h, "www.foo.com.", Rec_A, Break, Some(v4), None, None).await;
basic_test(&h, "www.com.foo.com.", Rec_A, Break, Some(v4), None, None).await;
basic_test(&h, "foo.com.", Rec_AAAA, Break, None, Some(v6), None).await;
basic_test(&h, "test.com.", Rec_AAAA, Skip, None, None, None).await;
basic_test(&h, "www.foo.com.", Rec_AAAA, Break, None, Some(v6), None).await;
basic_test(&h, "ab.cd.foo.com.", Rec_AAAA, Break, None, Some(v6), None).await;
}
#[tokio::test]
async fn test_blocklist_wildcard_disabled() {
subscribe();
let config = BlocklistConfig {
min_wildcard_depth: 2,
wildcard_match: false,
lists: vec!["default/blocklist.txt".to_string()],
sinkhole_ipv4: Some(Ipv4Addr::new(192, 0, 2, 1)),
sinkhole_ipv6: Some(Ipv6Addr::new(0, 0, 0, 0, 0xc0, 0, 2, 1)),
block_message: Some(String::from("blocked")),
ttl: 86_400,
consult_action: BlocklistConsultAction::Disabled,
log_clients: true,
};
let msg = config.block_message.clone();
let h = handler(config);
let v4 = A::new(192, 0, 2, 1);
let v6 = AAAA::new(0, 0, 0, 0, 0xc0, 0, 2, 1);
use RecordType::{A as Rec_A, AAAA as Rec_AAAA};
use TestResult::*;
basic_test(&h, "foo.com.", Rec_A, Break, Some(v4), None, msg.clone()).await;
basic_test(&h, "www.foo.com.", Rec_A, Skip, None, None, msg.clone()).await;
basic_test(&h, "foo.com.", Rec_AAAA, Break, None, Some(v6), msg).await;
}
#[tokio::test]
#[should_panic]
async fn test_blocklist_wrong_block_message() {
subscribe();
let config = BlocklistConfig {
min_wildcard_depth: 2,
wildcard_match: false,
lists: vec!["default/blocklist.txt".to_string()],
sinkhole_ipv4: Some(Ipv4Addr::new(192, 0, 2, 1)),
sinkhole_ipv6: Some(Ipv6Addr::new(0, 0, 0, 0, 0xc0, 0, 2, 1)),
block_message: Some(String::from("blocked")),
ttl: 86_400,
consult_action: BlocklistConsultAction::Disabled,
log_clients: true,
};
let h = handler(config);
let sinkhole_v4 = A::new(192, 0, 2, 1);
basic_test(
&h,
"foo.com.",
RecordType::A,
TestResult::Break,
Some(sinkhole_v4),
None,
Some(String::from("wrong message")),
)
.await;
}
#[tokio::test]
async fn test_blocklist_hosts_format() {
subscribe();
let config = BlocklistConfig {
min_wildcard_depth: 2,
wildcard_match: true,
lists: vec!["default/blocklist3.txt".to_string()],
sinkhole_ipv4: Some(Ipv4Addr::new(192, 0, 2, 1)),
sinkhole_ipv6: Some(Ipv6Addr::new(0, 0, 0, 0, 0xc0, 0, 2, 1)),
block_message: Some(String::from("blocked")),
ttl: 86_400,
consult_action: BlocklistConsultAction::Disabled,
log_clients: true,
};
let msg = config.block_message.clone();
let h = handler(config);
let v4 = A::new(192, 0, 2, 1);
use TestResult::*;
basic_test(
&h,
"test.com.",
RecordType::A,
Break,
Some(v4),
None,
msg.clone(),
)
.await;
basic_test(
&h,
"anothertest.com.",
RecordType::A,
Break,
Some(v4),
None,
msg.clone(),
)
.await;
basic_test(
&h,
"yet.anothertest.com.",
RecordType::A,
Break,
Some(v4),
None,
msg.clone(),
)
.await;
}
#[test]
fn test_blocklist_entry_count() {
subscribe();
let config = BlocklistConfig {
wildcard_match: true,
min_wildcard_depth: 2,
lists: vec!["default/blocklist.txt".to_string()],
sinkhole_ipv4: None,
sinkhole_ipv6: None,
block_message: None,
ttl: 86_400,
consult_action: BlocklistConsultAction::Disabled,
log_clients: true,
};
let zh = BlocklistZoneHandler::try_from_config(
Name::root(),
config,
Some(Path::new("../../tests/test-data/test_configs/")),
)
.expect("unable to create config");
assert_eq!(zh.entry_count(), 4);
}
#[test]
fn test_blocklist_entry_count_default() {
subscribe();
let config = BlocklistConfig::default();
let zh = BlocklistZoneHandler::try_from_config(
Name::root(),
config,
Some(Path::new("../../tests/test-data/test_configs/")),
)
.expect("unable to create config");
assert_eq!(zh.entry_count(), 0);
}
async fn basic_test(
ao: &Arc<dyn ZoneHandler>,
query: &'static str,
q_type: RecordType,
r_type: TestResult,
ipv4: Option<A>,
ipv6: Option<AAAA>,
msg: Option<String>,
) {
let res = ao
.lookup(
&LowerName::from_str(query).unwrap(),
q_type,
None,
LookupOptions::default(),
)
.await;
use LookupControlFlow::*;
let lookup = match r_type {
TestResult::Break => match res {
Break(Ok(lookup)) => lookup,
_ => panic!("Unexpected result for {query}: {res}"),
},
TestResult::Skip => match res {
Skip => return,
_ => {
panic!("unexpected result for {query}; expected Skip, found {res}");
}
},
};
if !lookup.iter().all(|x| match x.record_type() {
RecordType::TXT => {
if let Some(msg) = &msg {
x.data().to_string() == *msg
} else {
false
}
}
RecordType::AAAA => {
let Some(rec_ip) = ipv6 else {
panic!("expected to validate record IPv6, but None was passed");
};
x.name() == &Name::from_str(query).unwrap() && x.data() == &RData::AAAA(rec_ip)
}
_ => {
let Some(rec_ip) = ipv4 else {
panic!("expected to validate record IPv4, but None was passed");
};
x.name() == &Name::from_str(query).unwrap() && x.data() == &RData::A(rec_ip)
}
}) {
panic!("{query} lookup data is incorrect.");
}
}
fn handler(config: BlocklistConfig) -> Arc<dyn ZoneHandler> {
let handler = BlocklistZoneHandler::try_from_config(
Name::root(),
config,
Some(Path::new("../../tests/test-data/test_configs/")),
);
match handler {
Ok(handler) => Arc::new(handler),
Err(error) => panic!("error creating blocklist zone handler: {error}"),
}
}
enum TestResult {
Break,
Skip,
}
}