Files
tsunagi/src/dns/zone.rs
T

581 lines
20 KiB
Rust
Raw Normal View History

2026-09-21 16:50:55 +01:00
//! What the overlay answers to, as a DNS zone.
//!
//! Nothing here touches a socket or the operating system, and it does not
//! depend on a DNS wire library either: it takes a roster and a question and
//! says what the answer is. That is what makes the interesting parts — which
//! names exist, what "does not exist" means as against "exists with nothing
//! of that type", and what is outside the zone entirely — testable on their
//! own.
//!
//! # Where the names come from
//!
//! From signed state, which is why a member that is switched off still
//! resolves. Its claim outlived the session, so the name and the address are
//! both still there to answer with. Nothing is invented for a member that
//! claimed neither.
//!
//! Only IPv4 is served. The IPv6 overlay address is derived from a
//! WireGuard key that travels in live announcements and is not in signed
//! state, so it cannot be answered for a member that is away — and answering
//! for some members and not others depending on whether they happen to be
//! online is worse than not answering at all.
use std::collections::BTreeMap;
use std::net::Ipv4Addr;
/// Longest a DNS name may be, in the presentation form used here.
const MAX_NAME_LEN: usize = 253;
/// Longest one label may be.
const MAX_LABEL_LEN: usize = 63;
/// Why a zone name cannot be used.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ZoneError {
/// The name was empty, or became empty once normalised.
#[error("a zone name must have at least one label")]
Empty,
/// One label was unusable.
#[error("`{label}` is not a usable DNS label: {reason}")]
Label {
/// The offending label.
label: String,
/// What is wrong with it.
reason: &'static str,
},
/// The whole name is too long.
#[error("a zone name must be at most {MAX_NAME_LEN} characters")]
TooLong,
}
/// A validated, canonical zone name.
///
/// Held without a trailing dot and lower-cased, so comparison is a plain
/// string comparison rather than a special case at every use.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ZoneName(String);
impl ZoneName {
/// Validates and normalises a zone name.
pub fn new(raw: &str) -> Result<Self, ZoneError> {
let trimmed = raw.trim().trim_end_matches('.').to_ascii_lowercase();
if trimmed.is_empty() {
return Err(ZoneError::Empty);
}
if trimmed.len() > MAX_NAME_LEN {
return Err(ZoneError::TooLong);
}
for label in trimmed.split('.') {
let reason = if label.is_empty() {
Some("it is empty")
} else if label.len() > MAX_LABEL_LEN {
Some("it is longer than 63 characters")
} else if !label
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
{
Some("only letters, digits, `-` and `_` are allowed")
} else if label.starts_with('-') || label.ends_with('-') {
Some("a label may not start or end with `-`")
} else {
None
};
if let Some(reason) = reason {
return Err(ZoneError::Label {
label: label.to_string(),
reason,
});
}
}
Ok(Self(trimmed))
}
/// The name, without a trailing dot.
pub fn as_str(&self) -> &str {
&self.0
}
/// The last label, which is what would collide with a real top-level
/// domain.
pub fn top_label(&self) -> &str {
self.0.rsplit('.').next().unwrap_or(&self.0)
}
2026-09-21 17:05:24 +01:00
/// What is worrying about this zone name, if anything.
///
/// A warning and never a refusal: the name is the user's to choose, and
/// a private zone that shadows a public one is a decision, not a
/// mistake. Saying nothing would let it be an accident.
pub fn collision(&self) -> Option<String> {
let top = self.top_label();
// Reserved for exactly this use and never delegated, so nothing to
// say. See RFC 6761 and RFC 8375.
const RESERVED: &[&str] = &[
"internal",
"home",
"test",
"example",
"invalid",
"localhost",
];
if RESERVED.contains(&top) {
return None;
}
if top == "local" {
return Some(
"`.local` belongs to multicast DNS: on a host running Avahi or \
systemd-resolved's mDNS, names under it are resolved by that and \
not by this agent"
.to_string(),
);
}
if tld::exist_case_insensitive(top) {
return Some(format!(
"`.{top}` is a real top-level domain, so every public name under it \
becomes unreachable from this host while the overlay is up"
));
}
// Not delegated today is not a promise about tomorrow.
(!self.0.contains('.')).then(|| {
format!(
"`.{top}` is not a delegated top-level domain today, but it could \
become one; `.internal` is reserved for private use and never will"
)
})
}
2026-09-21 16:50:55 +01:00
/// Whether `name` is this zone or sits under it.
///
/// Compared label-wise, so `evilzone` does not count as being under
/// `zone`.
pub fn covers(&self, name: &str) -> bool {
let name = name.trim_end_matches('.').to_ascii_lowercase();
name == self.0
|| name
.strip_suffix(&self.0)
.is_some_and(|rest| rest.ends_with('.'))
}
/// The part of `name` below this zone, if it is under it.
fn relative(&self, name: &str) -> Option<String> {
let name = name.trim_end_matches('.').to_ascii_lowercase();
if name == self.0 {
return Some(String::new());
}
let rest = name.strip_suffix(&self.0)?;
let rest = rest.strip_suffix('.')?;
(!rest.is_empty()).then(|| rest.to_string())
}
}
/// The kinds of question this zone knows how to answer.
///
/// Its own enum rather than the wire library's, so the decision of what to
/// answer does not depend on how a packet is encoded.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Query {
/// An IPv4 address.
A,
/// A name for an address.
Ptr,
/// The zone's start of authority.
Soa,
/// The zone's name servers.
Ns,
/// Anything else, including AAAA.
Other,
}
/// What the zone has to say.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Answer {
/// Addresses for the name asked about.
Addresses(Vec<Ipv4Addr>),
/// A name for the address asked about.
Name(String),
/// The zone's start of authority.
Soa,
/// The name exists here but has nothing of the type asked for.
///
/// Distinct from [`Answer::NoSuchName`] because the two are different
/// answers on the wire: this one is a success with no records, and a
/// resolver must not take it as proof the name is absent. Getting them
/// the wrong way round teaches a resolver to cache the wrong thing.
NoData,
/// No such name in this zone.
NoSuchName,
/// Not a name this zone is responsible for.
NotOurs,
}
/// The names and addresses of one network, ready to answer questions.
#[derive(Debug, Clone)]
pub struct Zone {
origin: ZoneName,
/// Name relative to the origin, to the addresses it answers to.
hosts: BTreeMap<String, Vec<Ipv4Addr>>,
/// Address to the name that holds it.
names: BTreeMap<Ipv4Addr, String>,
/// Changes whenever the contents do.
serial: u32,
}
impl Zone {
/// Builds a zone from the members that have both a name and an address.
///
/// A member with one but not the other contributes nothing: a name with
/// no address cannot be answered, and an address with no name has nothing
/// to be asked about.
pub fn new(origin: ZoneName, members: impl IntoIterator<Item = (String, Ipv4Addr)>) -> Self {
let mut hosts: BTreeMap<String, Vec<Ipv4Addr>> = BTreeMap::new();
let mut names: BTreeMap<Ipv4Addr, String> = BTreeMap::new();
for (hostname, address) in members {
let hostname = hostname.trim_matches('.').to_ascii_lowercase();
if hostname.is_empty() {
continue;
}
let addresses = hosts.entry(hostname.clone()).or_default();
if !addresses.contains(&address) {
addresses.push(address);
}
// First name wins, and the map is ordered, so the reverse answer
// is the same on every replica rather than depending on the order
// records happened to arrive in.
names.entry(address).or_insert(hostname);
}
for addresses in hosts.values_mut() {
addresses.sort();
}
let serial = content_serial(&hosts);
Self {
origin,
hosts,
names,
serial,
}
}
/// The zone's origin.
pub fn origin(&self) -> &ZoneName {
&self.origin
}
/// A number that changes whenever the contents do.
pub fn serial(&self) -> u32 {
self.serial
}
/// How many names it answers for.
pub fn len(&self) -> usize {
self.hosts.len()
}
/// Whether it answers for nothing.
pub fn is_empty(&self) -> bool {
self.hosts.is_empty()
}
/// Every name it answers for, with its addresses.
pub fn entries(&self) -> impl Iterator<Item = (&str, &[Ipv4Addr])> {
self.hosts
.iter()
.map(|(name, addresses)| (name.as_str(), addresses.as_slice()))
}
/// The reverse zones this zone is authoritative for.
///
/// Only when the range lands on an octet boundary. Claiming a reverse
/// zone larger than the range would shadow reverse lookups for addresses
/// that are nothing to do with us, which is worse than not answering.
pub fn reverse_origin(base: Ipv4Addr, prefix_len: u8) -> Option<String> {
let octets = base.octets();
match prefix_len {
8 => Some(format!("{}.in-addr.arpa", octets[0])),
16 => Some(format!("{}.{}.in-addr.arpa", octets[1], octets[0])),
24 => Some(format!(
"{}.{}.{}.in-addr.arpa",
octets[2], octets[1], octets[0]
)),
_ => None,
}
}
/// Answers one question.
pub fn lookup(&self, qname: &str, query: Query) -> Answer {
if let Some(address) = reverse_address(qname) {
return match self.names.get(&address) {
Some(name) if query == Query::Ptr => {
Answer::Name(format!("{name}.{}", self.origin.as_str()))
}
Some(_) => Answer::NoData,
None => Answer::NoSuchName,
};
}
let Some(relative) = self.origin.relative(qname) else {
return Answer::NotOurs;
};
// The apex: the zone itself exists whether or not anybody is in it.
if relative.is_empty() {
return match query {
Query::Soa => Answer::Soa,
Query::Ns => Answer::NoData,
_ => Answer::NoData,
};
}
match self.hosts.get(&relative) {
Some(addresses) if query == Query::A => Answer::Addresses(addresses.clone()),
// The name is here, it just has no AAAA and never will while
// only IPv4 is served. Saying "no such name" instead would tell
// a resolver to stop asking for the A record too.
Some(_) => Answer::NoData,
None => Answer::NoSuchName,
}
}
}
/// The address a reverse name asks about, if it is one.
fn reverse_address(qname: &str) -> Option<Ipv4Addr> {
let name = qname.trim_end_matches('.').to_ascii_lowercase();
let rest = name.strip_suffix(".in-addr.arpa")?;
let mut octets = [0u8; 4];
let mut seen = 0;
for (index, part) in rest.split('.').enumerate() {
if index >= 4 {
return None;
}
octets[3 - index] = part.parse().ok()?;
seen += 1;
}
(seen == 4).then(|| Ipv4Addr::from(octets))
}
/// A serial that changes with the contents and not otherwise.
///
/// Derived rather than counted, so two agents holding the same roster agree,
/// and a restart does not go backwards.
fn content_serial(hosts: &BTreeMap<String, Vec<Ipv4Addr>>) -> u32 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
for (name, addresses) in hosts {
name.hash(&mut hasher);
for address in addresses {
address.octets().hash(&mut hasher);
}
}
// Never zero: a zero serial is legal but reads like "unset" in a log.
(hasher.finish() as u32).max(1)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
fn zone() -> Zone {
Zone::new(
ZoneName::new("lab").unwrap(),
[
("music".to_string(), Ipv4Addr::new(10, 13, 37, 237)),
("ai".to_string(), Ipv4Addr::new(10, 13, 37, 69)),
],
)
}
#[test]
fn a_zone_name_is_normalised_and_checked() {
assert_eq!(ZoneName::new("LAB.").unwrap().as_str(), "lab");
assert_eq!(
ZoneName::new(" lab.internal ").unwrap().as_str(),
"lab.internal"
);
assert_eq!(
ZoneName::new("lab.internal").unwrap().top_label(),
"internal"
);
assert_eq!(ZoneName::new("").unwrap_err(), ZoneError::Empty);
assert_eq!(ZoneName::new(".").unwrap_err(), ZoneError::Empty);
assert!(matches!(
ZoneName::new("a..b").unwrap_err(),
ZoneError::Label { .. }
));
assert!(matches!(
ZoneName::new("-lab").unwrap_err(),
ZoneError::Label { .. }
));
assert!(matches!(
ZoneName::new("la b").unwrap_err(),
ZoneError::Label { .. }
));
assert!(matches!(
ZoneName::new(&"x".repeat(64)).unwrap_err(),
ZoneError::Label { .. }
));
}
2026-09-21 17:05:24 +01:00
#[test]
fn a_zone_name_that_shadows_a_public_one_is_flagged_but_allowed() {
// Flagged, never refused: shadowing is the user's decision to make.
let ru = ZoneName::new("ru").unwrap();
let warning = ru.collision().expect("a real TLD is worth mentioning");
assert!(warning.contains("real top-level domain"), "{warning}");
assert!(ZoneName::new("com").unwrap().collision().is_some());
assert!(ZoneName::new("lab.com").unwrap().collision().is_some());
// Reserved for private use, so nothing to say.
for quiet in ["internal", "lab.internal", "home", "test", "invalid"] {
assert_eq!(ZoneName::new(quiet).unwrap().collision(), None, "{quiet}");
}
// `.local` is not a delegated TLD, but it is not free either.
let local = ZoneName::new("local").unwrap().collision().unwrap();
assert!(local.contains("multicast DNS"), "{local}");
// An undelegated single label is a maybe, not a yes.
let lab = ZoneName::new("lab").unwrap().collision().unwrap();
assert!(lab.contains("could"), "{lab}");
// A multi-label name under something undelegated is not worth a word.
assert_eq!(ZoneName::new("a.lab").unwrap().collision(), None);
}
2026-09-21 16:50:55 +01:00
#[test]
fn a_neighbouring_name_is_not_inside_the_zone() {
// `evillab` ends with `lab`, and a suffix comparison that forgot the
// label boundary would hand it to us.
let origin = ZoneName::new("lab").unwrap();
assert!(origin.covers("lab"));
assert!(origin.covers("music.lab."));
assert!(origin.covers("a.b.lab"));
assert!(!origin.covers("evillab"));
assert!(!origin.covers("lab.example.com"));
assert!(!origin.covers("example.com"));
}
#[test]
fn a_member_resolves_by_name() {
let zone = zone();
assert_eq!(
zone.lookup("music.lab", Query::A),
Answer::Addresses(vec![Ipv4Addr::new(10, 13, 37, 237)])
);
// Case and a trailing dot are the same question.
assert_eq!(
zone.lookup("MUSIC.LAB.", Query::A),
Answer::Addresses(vec![Ipv4Addr::new(10, 13, 37, 237)])
);
}
#[test]
fn a_name_with_no_record_of_that_type_is_not_a_missing_name() {
// AAAA for a member that exists must be NODATA, not NXDOMAIN: an
// NXDOMAIN would tell the resolver the name is absent and stop it
// asking for the A record.
let zone = zone();
assert_eq!(zone.lookup("music.lab", Query::Other), Answer::NoData);
assert_eq!(zone.lookup("nobody.lab", Query::A), Answer::NoSuchName);
}
#[test]
fn questions_outside_the_zone_are_refused_rather_than_denied() {
// Denying them would be a lie: this server knows nothing about them.
let zone = zone();
assert_eq!(zone.lookup("example.com", Query::A), Answer::NotOurs);
assert_eq!(zone.lookup("evillab", Query::A), Answer::NotOurs);
}
#[test]
fn the_apex_exists_even_with_nobody_in_the_network() {
let empty = Zone::new(ZoneName::new("lab").unwrap(), []);
assert!(empty.is_empty());
assert_eq!(empty.lookup("lab", Query::Soa), Answer::Soa);
assert_eq!(empty.lookup("lab", Query::A), Answer::NoData);
assert_eq!(empty.lookup("music.lab", Query::A), Answer::NoSuchName);
}
#[test]
fn an_address_resolves_back_to_its_name() {
let zone = zone();
assert_eq!(
zone.lookup("237.37.13.10.in-addr.arpa", Query::Ptr),
Answer::Name("music.lab".into())
);
assert_eq!(
zone.lookup("9.37.13.10.in-addr.arpa", Query::Ptr),
Answer::NoSuchName
);
// A reverse name asked for the wrong type is still a name we know.
assert_eq!(
zone.lookup("237.37.13.10.in-addr.arpa", Query::A),
Answer::NoData
);
}
#[test]
fn a_reverse_zone_is_claimed_only_when_it_is_exactly_ours() {
// Claiming a reverse zone wider than the range would shadow lookups
// for addresses that have nothing to do with this network.
assert_eq!(
Zone::reverse_origin(Ipv4Addr::new(10, 13, 37, 0), 24).as_deref(),
Some("37.13.10.in-addr.arpa")
);
assert_eq!(
Zone::reverse_origin(Ipv4Addr::new(10, 13, 0, 0), 16).as_deref(),
Some("13.10.in-addr.arpa")
);
assert_eq!(Zone::reverse_origin(Ipv4Addr::new(10, 13, 37, 0), 25), None);
assert_eq!(Zone::reverse_origin(Ipv4Addr::new(100, 64, 0, 0), 10), None);
}
#[test]
fn members_with_nothing_to_say_are_left_out() {
let zone = Zone::new(
ZoneName::new("lab").unwrap(),
[(String::new(), Ipv4Addr::new(10, 0, 0, 1))],
);
assert!(zone.is_empty());
}
#[test]
fn two_members_sharing_a_name_both_answer_and_the_order_is_stable() {
// The state layer resolves name ownership; if two records still
// reach here, answering with both beats picking one at random.
let build = |flip: bool| {
let members = if flip {
vec![
("music".to_string(), Ipv4Addr::new(10, 0, 0, 2)),
("music".to_string(), Ipv4Addr::new(10, 0, 0, 1)),
]
} else {
vec![
("music".to_string(), Ipv4Addr::new(10, 0, 0, 1)),
("music".to_string(), Ipv4Addr::new(10, 0, 0, 2)),
]
};
Zone::new(ZoneName::new("lab").unwrap(), members)
};
assert_eq!(
build(false).lookup("music.lab", Query::A),
build(true).lookup("music.lab", Query::A)
);
assert_eq!(build(false).serial(), build(true).serial());
}
#[test]
fn the_serial_follows_the_contents() {
let a = zone();
let b = zone();
assert_eq!(a.serial(), b.serial(), "the same roster, the same serial");
let changed = Zone::new(
ZoneName::new("lab").unwrap(),
[("music".to_string(), Ipv4Addr::new(10, 13, 37, 238))],
);
assert_ne!(a.serial(), changed.serial());
}
}