fix(dns): return local CNAME address chains in answers (#2807)

Static CNAME records use Hickory's in-memory lookup results, which put
target addresses in ADDITIONAL. Stub resolvers therefore fail to resolve
valid aliases, and target lookups stop at the current zone.

Wrap local authorities for A/AAAA lookups and follow CNAME targets
through the most specific local zone. Return the full chain and final
address RRset in ANSWER while keeping owners and TTLs. Share raw local
authorities with the catalog under its write lock so lookups do not
reenter that lock. Other query types and unresolved chains retain their
existing behavior; no public recursion is added.

Extend the static-record lifecycle regression for address RRsets,
chains, zone selection, refreshes, explicit CNAME queries and cycles.

Validation: 19 DNS tests pass in the rust container; strict package
Clippy and workspace fmt pass. Existing glibc and Go clients resolve
same-zone and cross-zone aliases through the actual EasyTier listener.
This commit is contained in:
KKRainbow authored and GitHub committed 2026-11-08 16:14:22 +08:00
1 parent 105f44d322
commit 921181dc38
2 files changed
+270 -20

No files matched your search

+162 -7
View File
@@ -1,14 +1,18 @@
use anyhow::{Context, Result};
use hickory_proto::rr;
use hickory_proto::rr::LowerName;
use hickory_proto::rr::{LowerName, RecordType};
use hickory_resolver::config::ResolverOpts;
use hickory_resolver::name_server::TokioConnectionProvider;
use hickory_resolver::system_conf::read_system_conf;
use hickory_server::ServerFuture;
use hickory_server::authority::{AuthorityObject, Catalog, ZoneType};
use hickory_server::server::{Request, RequestHandler, ResponseHandler, ResponseInfo};
use hickory_server::authority::{
AuthLookup, Authority, Catalog, LookupControlFlow, LookupOptions, LookupRecords,
MessageRequest, UpdateResult, ZoneType,
};
use hickory_server::server::{Request, RequestHandler, RequestInfo, ResponseHandler, ResponseInfo};
use hickory_server::store::forwarder::ForwardConfig;
use hickory_server::store::{forwarder::ForwardAuthority, in_memory::InMemoryAuthority};
use std::collections::{BTreeMap, HashSet};
use std::net::SocketAddr;
use std::str::FromStr;
use std::sync::Arc;
@@ -23,10 +27,136 @@ use super::config::{GeneralConfig, Record, RunConfig};
pub struct Server {
server: ServerFuture<CatalogRequestHandler>,
catalog: Arc<RwLock<Catalog>>,
local_zones: LocalZones,
general_config: GeneralConfig,
udp_local_addr: Option<SocketAddr>,
}
type LocalZones = Arc<RwLock<BTreeMap<LowerName, Arc<InMemoryAuthority>>>>;
struct LocalAuthority {
inner: Arc<InMemoryAuthority>,
zones: LocalZones,
}
#[async_trait::async_trait]
impl Authority for LocalAuthority {
type Lookup = AuthLookup;
fn zone_type(&self) -> ZoneType {
self.inner.zone_type()
}
fn is_axfr_allowed(&self) -> bool {
self.inner.is_axfr_allowed()
}
async fn update(&self, update: &MessageRequest) -> UpdateResult<bool> {
self.inner.update(update).await
}
fn origin(&self) -> &LowerName {
self.inner.origin()
}
async fn lookup(
&self,
name: &LowerName,
record_type: RecordType,
options: LookupOptions,
) -> LookupControlFlow<Self::Lookup> {
let result = self.inner.lookup(name, record_type, options).await;
if !matches!(record_type, RecordType::A | RecordType::AAAA) {
return result;
}
let LookupControlFlow::Continue(Ok(AuthLookup::Records {
answers: LookupRecords::Records { records, .. },
..
})) = &result
else {
return result;
};
if records.record_type() != RecordType::CNAME {
return result;
}
// Hickory puts CNAME targets in ADDITIONAL. Follow only the actual
// address chain, selecting the most specific local zone at each hop.
let zones = self.zones.read().await;
let mut records = records.clone();
let mut chain = Vec::new();
let mut visited = HashSet::from([name.clone()]);
loop {
let Some(target) = records
.records_without_rrsigs()
.next()
.and_then(|record| record.data().as_cname())
.map(|cname| LowerName::from(&cname.0))
else {
return result;
};
if !visited.insert(target.clone()) {
return result;
}
chain.push(records);
let mut origin = target.clone();
let authority = loop {
if let Some(authority) = zones.get(&origin) {
break authority;
}
if origin.is_root() {
return result;
}
origin = origin.base_name();
};
let LookupControlFlow::Continue(Ok(AuthLookup::Records {
answers:
LookupRecords::Records {
records: answer, ..
},
..
})) = authority.lookup(&target, record_type, options).await
else {
// Preserve the original partial answer for unresolved chains.
return result;
};
records = answer;
if records.record_type() == record_type {
chain.push(records);
return LookupControlFlow::Continue(Ok(AuthLookup::answers(
LookupRecords::many(options, chain),
None,
)));
}
if records.record_type() != RecordType::CNAME {
return result;
}
}
}
async fn search(
&self,
request: RequestInfo<'_>,
options: LookupOptions,
) -> LookupControlFlow<Self::Lookup> {
let query = request.query;
if matches!(query.query_type(), RecordType::A | RecordType::AAAA) {
self.lookup(query.name(), query.query_type(), options).await
} else {
self.inner.search(request, options).await
}
}
async fn get_nsec_records(
&self,
name: &LowerName,
options: LookupOptions,
) -> LookupControlFlow<Self::Lookup> {
self.inner.get_nsec_records(name, options).await
}
}
struct CatalogRequestHandler {
catalog: Arc<RwLock<Catalog>>,
}
@@ -77,11 +207,22 @@ impl Server {
fn try_new(config: RunConfig) -> Result<Self> {
let mut catalog = Catalog::new();
let mut authorities = BTreeMap::new();
for (domain, records) in config.zones().iter() {
let zone = rr::Name::from_str(domain.as_str())?;
let lower = zone.clone().into();
let authroty = build_authority(&lower, records)?;
catalog.upsert(lower, vec![Arc::new(authroty)]);
let authority = Arc::new(build_authority(&lower, records)?);
authorities.insert(lower, authority);
}
let local_zones = Arc::new(RwLock::new(authorities.clone()));
for (origin, inner) in authorities {
catalog.upsert(
origin,
vec![Arc::new(LocalAuthority {
inner,
zones: local_zones.clone(),
})],
);
}
// use forwarder authority for the root zone
@@ -118,6 +259,7 @@ impl Server {
Ok(Self {
server,
catalog,
local_zones,
general_config: config.general().clone(),
udp_local_addr: None,
})
@@ -194,8 +336,21 @@ impl Server {
Ok(())
}
pub async fn upsert(&self, name: LowerName, authority: Arc<dyn AuthorityObject>) {
self.catalog.write().await.upsert(name, vec![authority]);
pub async fn upsert(&self, name: LowerName, authority: Arc<InMemoryAuthority>) {
// Requests hold the Catalog read lock. Publish the local lookup index
// under the same write lock, without reentering Catalog during lookup.
let mut catalog = self.catalog.write().await;
self.local_zones
.write()
.await
.insert(name.clone(), authority.clone());
catalog.upsert(
name,
vec![Arc::new(LocalAuthority {
inner: authority,
zones: self.local_zones.clone(),
})],
);
}
pub async fn read_catalog(&self) -> RwLockReadGuard<'_, Catalog> {
@@ -782,15 +782,33 @@ mod tests {
#[tokio::test]
async fn custom_records_survive_route_refresh_and_support_common_types() {
let dns = config(&[(
"ET.NET",
&[
"nas IN A 10.0.0.8",
"nas IN AAAA fd00::8",
"alias IN CNAME nas",
"info IN TXT \"local record\"",
],
)]);
let dns = config(&[
(
"ET.NET",
&[
"nas IN A 10.0.0.8",
"nas IN A 10.0.0.18",
"nas IN AAAA fd00::8",
"nas IN AAAA fd00::18",
"alias 45 IN CNAME nas",
"chain 30 IN CNAME alias",
"node-alias IN CNAME node",
"nas.sub IN A 10.0.0.98",
"specific IN CNAME nas.sub",
"missing IN CNAME absent",
"cycle IN CNAME cycle.example.test.",
"info IN TXT \"local record\"",
],
),
(
"example.test.",
&[
"cross 20 IN CNAME chain.et.net.",
"cycle IN CNAME cycle.et.net.",
],
),
("sub.et.net.", &["nas IN A 10.0.0.99"]),
]);
let data = data_with(Some(&dns)).await;
for ip in ["10.0.0.1/24", "10.0.0.2/24"] {
data.update_dns_record(controller(54001), request(ip))
@@ -798,15 +816,87 @@ mod tests {
.unwrap();
assert_eq!(
a(&data, "nas.et.net.").await,
vec![Ipv4Addr::new(10, 0, 0, 8)]
vec![Ipv4Addr::new(10, 0, 0, 8), Ipv4Addr::new(10, 0, 0, 18)]
);
for (record_type, addresses) in [
(RecordType::A, ["10.0.0.8", "10.0.0.18"]),
(RecordType::AAAA, ["fd00::8", "fd00::18"]),
] {
for (name, cnames) in [
("alias.et.net.", vec![("alias.et.net.", 45, "nas.et.net.")]),
(
"chain.et.net.",
vec![
("chain.et.net.", 30, "alias.et.net."),
("alias.et.net.", 45, "nas.et.net."),
],
),
(
"cross.example.test.",
vec![
("cross.example.test.", 20, "chain.et.net."),
("chain.et.net.", 30, "alias.et.net."),
("alias.et.net.", 45, "nas.et.net."),
],
),
] {
let mut expected = cnames
.into_iter()
.map(|(owner, ttl, target)| {
(owner.to_owned(), ttl, RecordType::CNAME, target.to_owned())
})
.collect::<Vec<_>>();
expected.extend(addresses.map(|address| {
(
"nas.et.net.".to_owned(),
60,
record_type,
address.to_owned(),
)
}));
let response = query(&data, name, record_type).await;
let actual = response
.answers()
.iter()
.map(|record| {
(
record.name().to_string(),
record.ttl(),
record.record_type(),
record.data().to_string(),
)
})
.collect::<Vec<_>>();
assert_eq!(actual, expected, "{name} {record_type}");
}
}
assert_eq!(
a(&data, "specific.et.net.").await,
vec![Ipv4Addr::new(10, 0, 0, 99)]
);
assert_eq!(
a(&data, "node-alias.et.net.").await,
vec![Ipv4Inet::from_str(ip).unwrap().address()]
);
assert!(query(&data, "alias.et.net.", RecordType::A).await.answers().iter()
.any(|record| matches!(record.data(), RData::CNAME(name) if name.0.to_string() == "nas.et.net.")));
assert_eq!(
a(&data, "node.et.net.").await,
vec![Ipv4Inet::from_str(ip).unwrap().address()]
);
}
let cname = query(&data, "chain.et.net.", RecordType::CNAME).await;
assert_eq!(cname.answers().len(), 1);
assert!(matches!(
cname.answers()[0].data(),
RData::CNAME(name) if name.0.to_string() == "alias.et.net."
));
for name in ["missing.et.net.", "cycle.et.net."] {
assert!(
tokio::time::timeout(Duration::from_secs(1), a(&data, name))
.await
.unwrap()
.is_empty()
);
}
assert!(matches!(
query(&data, "nas.et.net.", RecordType::AAAA)
.await
@@ -837,8 +927,13 @@ mod tests {
);
assert_eq!(
a(&data, "nas.et.net.").await,
vec![Ipv4Addr::new(10, 0, 0, 8)]
vec![Ipv4Addr::new(10, 0, 0, 8), Ipv4Addr::new(10, 0, 0, 18)]
);
assert_eq!(
a(&data, "cross.example.test.").await,
vec![Ipv4Addr::new(10, 0, 0, 8), Ipv4Addr::new(10, 0, 0, 18)]
);
assert!(a(&data, "node-alias.et.net.").await.is_empty());
assert!(
data.dns_server
.read_catalog()