zone: fallthrough policy

heartbeat test

try_from
This commit is contained in:
Luna Yao
2026-04-29 15:26:08 +02:00
parent ee1f656d6b
commit b7677031cb
10 changed files with 146 additions and 147 deletions
+1
View File
@@ -71,6 +71,7 @@ tokio-util = { version = "0.7.9", features = ["codec", "net", "io", "rt"] }
async-stream = "0.3.5"
async-trait = "0.1.74"
maplit = "1.0.2"
dashmap = "6.0"
timedmap = "=1.0.1"
+1 -1
View File
@@ -735,7 +735,7 @@ impl DnsGlobalCtxExt for GlobalCtx {
.clone()
.unwrap_or_else(|| dns::parse(self.get_hostname()))
.into();
let fqdn = name.append_domain(&*dns.domain).unwrap_or_default().into();
let fqdn = name.append_domain(&dns.domain).unwrap_or_default().into();
let ipv4 = self.get_ipv4().map(|ip| ip.address());
let ipv6 = self.get_ipv6().map(|ip| ip.address());
let ipv6 = ipv6.map(|a| vec![a]).unwrap_or_default();
+33 -5
View File
@@ -3,12 +3,40 @@ use crate::dns::config::policy::{DnsExportPolicy, ZonePolicyConfig};
use crate::dns::utils::addr::NameServerAddrGroup;
use crate::dns::zone::Zone;
use crate::proto::dns::ZoneData;
use derive_more::From;
use hickory_proto::op::ResponseCode;
use hickory_proto::rr::LowerName;
use maplit::hashset;
use optional_struct::{Applicable, optional_struct};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::convert::TryFrom;
use std::net::{Ipv4Addr, Ipv6Addr};
use url::Url;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Hash, From, Deserialize, Serialize)]
#[serde(untagged)]
pub enum Fallthrough {
Any,
ResponseCode(ResponseCode),
}
impl From<Fallthrough> for i32 {
fn from(value: Fallthrough) -> Self {
match value {
Fallthrough::ResponseCode(code) => u16::from(code).into(),
Fallthrough::Any => -1,
}
}
}
impl From<i32> for Fallthrough {
fn from(value: i32) -> Self {
match u16::try_from(value) {
Ok(value) => Self::ResponseCode(value.into()),
Err(_) => Self::Any,
}
}
}
#[optional_struct(ZoneConfigRaw)]
#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
@@ -21,7 +49,7 @@ pub struct ZoneConfigParsed {
#[optional_skip_wrap]
#[serde(flatten)]
pub policy: ZonePolicyConfig,
pub fallthrough: bool,
pub fallthrough: HashSet<Fallthrough>,
}
impl From<&ZoneConfigParsed> for ZoneData {
@@ -30,8 +58,8 @@ impl From<&ZoneConfigParsed> for ZoneData {
&value.origin,
value.ttl,
&value.records,
value.forwarders.iter().map(Url::from),
value.fallthrough,
value.forwarders.iter().map(Into::into),
value.fallthrough.iter().copied(),
)
}
}
@@ -43,7 +71,7 @@ impl TryFrom<ZoneConfigRaw> for ZoneConfig {
fn try_from(raw: ZoneConfigRaw) -> Result<Self, Self::Error> {
let default = ZoneConfigParsed {
fallthrough: true,
fallthrough: hashset! {Fallthrough::Any},
..Default::default()
};
+2 -8
View File
@@ -254,10 +254,10 @@ mod tests {
use crate::peers::peer_manager::RouteAlgoType;
use crate::peers::tests::{connect_peer_manager, wait_route_appear};
use crate::proto::dns::GetExportConfigRequest;
use std::collections::HashSet;
use std::net::Ipv4Addr;
use tokio::time::{Duration, sleep};
use url::Url;
async fn create_peer_manager_with_zone(
host: &str,
@@ -296,13 +296,7 @@ mod tests {
#[test]
fn dns_peer_info_try_from_invalid_zone_rejected() {
let cfg = DnsExportConfig {
zones: vec![ZoneData::new(
&".".parse().unwrap(),
60,
["?"],
Vec::<Url>::new(),
false,
)],
zones: vec![ZoneData::new(&".".parse().unwrap(), 60, ["?"], [], [])],
};
assert!(DnsPeerInfo::try_from(cfg).is_err());
+3 -4
View File
@@ -28,6 +28,7 @@ use hickory_proto::rr;
use hickory_proto::rr::{DNSClass, Name, RData, RecordType};
use hickory_proto::serialize::binary::{BinEncodable, BinEncoder};
use hickory_server::server::Request;
use maplit::hashset;
use tokio::sync::Notify;
use uuid::Uuid;
@@ -108,10 +109,8 @@ pub fn zone_data_a_with_forwarders(origin: &str, record: &str, forwarders: Vec<&
&origin.parse().unwrap(),
60,
[format!("@ IN A {record}")],
forwarders
.into_iter()
.map(|f| Url::from_str(f).expect("invalid forwarder")),
false,
forwarders.into_iter().map(|f| Url::from_str(f).unwrap()),
hashset! {},
)
}
+31 -25
View File
@@ -39,31 +39,19 @@ impl From<(IpAddr, &ConnectionConfig)> for NameServerAddr {
}
}
impl From<&NameServerAddr> for Url {
fn from(value: &NameServerAddr) -> Self {
Url::parse(&format!("{}://{}", value.protocol, value.addr)).unwrap()
}
}
impl From<NameServerAddr> for Url {
fn from(value: NameServerAddr) -> Self {
(&value).into()
}
}
impl TryFrom<&Url> for NameServerAddr {
type Error = Error;
fn try_from(value: &Url) -> Result<Self, Self::Error> {
let protocol = match Protocol::deserialize(value.scheme().into_deserializer()).map_err(
|e: de::value::Error| anyhow!("invalid protocol '{}': {}", value.scheme(), e),
)? {
fn try_from(url: &Url) -> Result<Self, Self::Error> {
let protocol = match Protocol::deserialize(url.scheme().into_deserializer())
.map_err(|e: de::value::Error| anyhow!("invalid protocol '{}': {}", url.scheme(), e))?
{
Protocol::Udp => ProtocolConfig::Udp,
Protocol::Tcp => ProtocolConfig::Tcp,
p => return Err(anyhow!("unsupported protocol: {}", p)),
};
let host = value.host_str().ok_or(anyhow!("host not found"))?;
let port = value.port().unwrap_or(protocol.default_port());
let host = url.host_str().ok_or(anyhow!("host not found"))?;
let port = url.port().unwrap_or(protocol.default_port());
let addr = if let Ok(addr) = IpAddr::from_str(host) {
SocketAddr::new(addr, port)
} else {
@@ -76,17 +64,35 @@ impl TryFrom<&Url> for NameServerAddr {
}
}
impl From<NameServerAddr> for proto::common::Url {
fn from(value: NameServerAddr) -> Self {
Url::from(value).into()
}
}
impl TryFrom<&proto::common::Url> for NameServerAddr {
type Error = Error;
fn try_from(value: &proto::common::Url) -> Result<Self, Self::Error> {
Self::try_from(&Url::try_from(value)?)
(&Url::try_from(value)?).try_into()
}
}
impl From<&NameServerAddr> for Url {
fn from(value: &NameServerAddr) -> Self {
Url::parse(&format!("{}://{}", value.protocol, value.addr)).unwrap()
}
}
impl From<&NameServerAddr> for proto::common::Url {
fn from(value: &NameServerAddr) -> Self {
Url::from(value).into()
}
}
impl From<NameServerAddr> for Url {
fn from(value: NameServerAddr) -> Self {
(&value).into()
}
}
impl From<NameServerAddr> for proto::common::Url {
fn from(value: NameServerAddr) -> Self {
(&value).into()
}
}
+27 -46
View File
@@ -1,35 +1,27 @@
use crate::dns::config::zone::Fallthrough;
use delegate::delegate;
use derive_more::{Deref, DerefMut, From};
use derive_more::{Constructor, Deref, DerefMut};
use hickory_proto::op::ResponseCode;
use hickory_proto::rr::{LowerName, RecordType, TSigResponseContext};
use hickory_server::server::{Request, RequestInfo};
use hickory_server::zone_handler::{
AuthLookup, AxfrPolicy, LookupControlFlow, LookupError, LookupOptions, ZoneHandler, ZoneType,
};
use std::collections::HashSet;
use std::sync::Arc;
pub type ArcZoneHandler = Arc<dyn ZoneHandler>;
pub trait LookupControlFlowExt {
fn skip_negative(self) -> Self;
}
impl LookupControlFlowExt for LookupControlFlow<AuthLookup> {
fn skip_negative(self) -> Self {
match self {
Self::Continue(e) | Self::Break(e) if matches!(e, Err(LookupError::NameExists)) => {
Self::Continue(Ok(Default::default()))
}
Self::Continue(Err(_)) | Self::Break(Err(_)) => Self::Skip,
other => other,
}
}
}
#[derive(From, Deref, DerefMut)]
pub struct ChainedZoneHandler<H>(H)
#[derive(Constructor, Deref, DerefMut)]
pub struct ChainedZoneHandler<H>
where
H: ZoneHandler;
H: ZoneHandler,
{
#[deref]
#[deref_mut]
handler: H,
fallthrough: HashSet<Fallthrough>,
}
#[async_trait::async_trait]
impl<H> ZoneHandler for ChainedZoneHandler<H>
@@ -37,7 +29,7 @@ where
H: ZoneHandler,
{
delegate! {
to self.0 {
to self.handler {
fn zone_type(&self) -> ZoneType;
fn axfr_policy(&self) -> AxfrPolicy;
fn origin(&self) -> &LowerName;
@@ -50,7 +42,7 @@ where
update: &Request,
now: u64,
) -> (Result<bool, ResponseCode>, Option<TSigResponseContext>) {
self.0.update(update, now).await
self.handler.update(update, now).await
}
#[inline]
async fn lookup(
@@ -60,29 +52,9 @@ where
request_info: Option<&RequestInfo<'_>>,
lookup_options: LookupOptions,
) -> LookupControlFlow<AuthLookup> {
self.0
self.handler
.lookup(name, rtype, request_info, lookup_options)
.await
.skip_negative()
}
#[inline]
async fn consult(
&self,
name: &LowerName,
rtype: RecordType,
request_info: Option<&RequestInfo<'_>>,
lookup_options: LookupOptions,
last_result: LookupControlFlow<AuthLookup>,
) -> (LookupControlFlow<AuthLookup>, Option<TSigResponseContext>) {
let result = if let Some(Ok(l)) = last_result.map_result() {
LookupControlFlow::Break(Ok(l))
} else {
self.0
.lookup(name, rtype, request_info, lookup_options)
.await
.skip_negative()
};
(result, None)
}
#[inline]
async fn search(
@@ -90,8 +62,17 @@ where
request: &Request,
lookup_options: LookupOptions,
) -> (LookupControlFlow<AuthLookup>, Option<TSigResponseContext>) {
let (result, tsig) = self.0.search(request, lookup_options).await;
(result.skip_negative(), tsig)
let (result, tsig) = self.handler.search(request, lookup_options).await;
match &result {
LookupControlFlow::Continue(Err(e)) | LookupControlFlow::Break(Err(e))
if self.fallthrough.contains(&Fallthrough::Any)
|| matches!(e, LookupError::ResponseCode(c) if self.fallthrough.contains(&(*c).into())) =>
{
(LookupControlFlow::Skip, None)
}
_ => (result, tsig),
}
}
#[inline]
async fn nsec_records(
@@ -99,6 +80,6 @@ where
name: &LowerName,
lookup_options: LookupOptions,
) -> LookupControlFlow<AuthLookup> {
self.0.nsec_records(name, lookup_options).await
self.handler.nsec_records(name, lookup_options).await
}
}
+39 -45
View File
@@ -1,3 +1,4 @@
use crate::dns::config::zone::Fallthrough;
use crate::dns::utils::addr::{NameServerAddr, NameServerAddrGroup};
use crate::dns::utils::zone_handler::{ArcZoneHandler, ChainedZoneHandler};
use crate::proto::dns::ZoneData;
@@ -11,16 +12,16 @@ use hickory_server::store::in_memory::InMemoryZoneHandler;
use hickory_server::zone_handler::{AxfrPolicy, ZoneType};
use indexmap::IndexMap;
use itertools::chain;
use std::collections::BTreeMap;
use maplit::hashset;
use std::collections::{BTreeMap, HashSet};
use std::sync::Arc;
use url::Url;
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
pub struct Zone {
origin: LowerName,
records: BTreeMap<RrKey, RecordSet>,
pub forward: Option<ForwardConfig>,
fallthrough: bool,
fallthrough: HashSet<Fallthrough>,
}
impl Zone {
@@ -30,23 +31,16 @@ impl Zone {
name_servers: config.name_servers().to_vec(),
options: Some(opts),
};
let mut zone = Self::new(".".parse().unwrap());
zone.forward = Some(forward);
zone.fallthrough = false;
zone
Self {
origin: ".".parse().unwrap(),
forward: Some(forward),
fallthrough: hashset! {},
..Default::default()
}
}
}
impl Zone {
pub fn new(name: LowerName) -> Self {
Self {
origin: name,
records: BTreeMap::new(),
forward: None,
fallthrough: true,
}
}
pub fn create_memory_zone_handler(&self) -> Option<ArcZoneHandler> {
(!self.records.is_empty()).then(|| {
let mut memory = InMemoryZoneHandler::<TokioRuntimeProvider>::empty(
@@ -62,11 +56,7 @@ impl Zone {
.map(|(k, v)| (k, Arc::new(v))),
);
if self.fallthrough {
Arc::new(ChainedZoneHandler::from(memory)) as _
} else {
Arc::new(memory) as _
}
Arc::new(ChainedZoneHandler::new(memory, self.fallthrough.clone())) as _
})
}
@@ -77,14 +67,10 @@ impl Zone {
TokioRuntimeProvider::default(),
)
.build()
.inspect_err(|e| tracing::error!("failed to create forward zone_handler: {:?}", e))
.inspect_err(|error| tracing::error!(?error, "failed to create forward zone_handler"))
.ok()
.map(|f| {
if self.fallthrough {
Arc::new(ChainedZoneHandler::from(f)) as _
} else {
Arc::new(f) as _
}
.map(|handler| {
Arc::new(ChainedZoneHandler::new(handler, self.fallthrough.clone())) as _
})
})
}
@@ -101,7 +87,7 @@ impl TryFrom<&ZoneData> for Zone {
let name_servers = value
.forwarders
.iter()
.map(TryInto::<NameServerAddr>::try_into)
.map(NameServerAddr::try_from)
.map(|a| a.map(Into::into))
.collect::<Result<Vec<_>, _>>()?;
let forward = (!name_servers.is_empty()).then_some(ForwardConfig {
@@ -109,11 +95,13 @@ impl TryFrom<&ZoneData> for Zone {
options: None,
});
let fallthrough = value.fallthrough.iter().copied().map(Into::into).collect();
Ok(Self {
origin: origin.into(),
records,
forward,
fallthrough: value.fallthrough,
fallthrough,
})
}
}
@@ -132,7 +120,7 @@ impl From<Zone> for ZoneData {
.flat_map(|f| f.name_servers.into_iter())
.map(|ns| (&ns).into())
.flat_map(NameServerAddrGroup::into_iter)
.map(Url::from);
.map(Into::into);
Self::new(&value.origin, 0, records, forwarders, value.fallthrough)
}
@@ -169,7 +157,9 @@ mod tests {
use hickory_proto::rr::{RData, Record, RecordType, RrsetRecords};
use hickory_server::Server;
use hickory_server::zone_handler::Catalog;
use std::net::{Ipv4Addr, SocketAddr};
use maplit::hashset;
use std::collections::HashSet;
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
use std::str::FromStr;
use tokio::net::UdpSocket;
use tokio::task::JoinHandle;
@@ -193,21 +183,21 @@ mod tests {
origin: &str,
records: Vec<&str>,
forwarders: Vec<&str>,
fallthrough: bool,
fallthrough: HashSet<Fallthrough>,
) -> ZoneData {
ZoneData::new(
&origin.parse().unwrap(),
60,
records,
forwarders.into_iter().map(|url| Url {
url: url.to_string(),
}),
forwarders
.into_iter()
.map(|url| Url::from_str(url).unwrap()),
fallthrough,
)
}
fn zone_data(origin: &str, records: Vec<&str>, forwarders: Vec<&str>) -> ZoneData {
zone_data_with_fallthrough(origin, records, forwarders, true)
zone_data_with_fallthrough(origin, records, forwarders, hashset! {Fallthrough::Any})
}
fn build_catalog(zones: ZoneGroup) -> Catalog {
@@ -242,6 +232,13 @@ mod tests {
.any(|record| matches!(record.data, RData::A(addr) if *addr == expected))
}
fn has_aaaa_answer(message: &Message, expected: Ipv6Addr) -> bool {
message
.answers
.iter()
.any(|record| matches!(record.data, RData::AAAA(addr) if *addr == expected))
}
async fn start_upstream_server() -> anyhow::Result<(SocketAddr, JoinHandle<()>)> {
let upstream = Zone::try_from(&zone_data(
"upstream.test",
@@ -400,7 +397,7 @@ mod tests {
}
#[tokio::test]
async fn catalog_lookup_returns_nodata_on_nameexists() -> anyhow::Result<()> {
async fn catalog_lookup_forwards_on_nameexists() -> anyhow::Result<()> {
let upstream = Zone::try_from(&zone_data(
"forward-aaaa.test",
vec!["host 60 IN AAAA 2001:db8::1"],
@@ -435,10 +432,7 @@ mod tests {
assert_eq!(rcode, ResponseCode::NoError);
let message = message.expect("response should exist");
assert!(
message.answers.is_empty(),
"NameExists should return NODATA"
);
assert!(has_aaaa_answer(&message, "2001:db8::1".parse()?));
upstream_handle.abort();
let _ = upstream_handle.await;
@@ -528,13 +522,13 @@ mod tests {
"fallback-disabled.test",
vec!["first IN A 10.20.31.1"],
vec![],
false,
hashset! {},
))?,
Zone::try_from(&zone_data_with_fallthrough(
"fallback-disabled.test",
vec!["target IN A 10.20.31.2"],
vec![],
false,
hashset! {},
))?,
]
.into();
+1 -1
View File
@@ -7,7 +7,7 @@ package dns;
message ZoneData {
string content = 1;
repeated common.Url forwarders = 2;
bool fallthrough = 3;
repeated int32 fallthrough = 3;
}
message GetExportConfigRequest {}
+8 -12
View File
@@ -1,3 +1,4 @@
use crate::dns::config::zone::Fallthrough;
use crate::proto::common::Url;
use crate::proto::utils::TransientDigest;
use hickory_proto::rr::LowerName;
@@ -13,19 +14,13 @@ impl HeartbeatRequest {
}
impl ZoneData {
pub fn new<Records, R, Urls, U>(
pub fn new<Record: AsRef<str>>(
origin: &LowerName,
ttl: u32,
records: Records,
forwarders: Urls,
fallthrough: bool,
) -> Self
where
Records: IntoIterator<Item = R>,
R: AsRef<str>,
Urls: IntoIterator<Item = U>,
U: Into<Url>,
{
records: impl IntoIterator<Item = Record>,
forwarders: impl IntoIterator<Item = Url>,
fallthrough: impl IntoIterator<Item = Fallthrough>,
) -> Self {
let mut content = String::new();
content.push_str("; EasyTier Magic DNS zone data\n");
@@ -44,7 +39,8 @@ impl ZoneData {
content.push('\n');
}
let forwarders = forwarders.into_iter().map(Into::into).collect();
let forwarders = forwarders.into_iter().collect();
let fallthrough = fallthrough.into_iter().map(Into::into).collect();
Self {
content,