diff --git a/Cargo.lock b/Cargo.lock index 2e2308d..ddca03b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3688,6 +3688,7 @@ dependencies = [ "rusqlite", "serde", "sha2", + "simple-dns", "subtle", "tempfile", "thiserror 2.0.20", diff --git a/Cargo.toml b/Cargo.toml index 0c1e240..de5f7c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,10 @@ zeroize = { version = "1.9", features = ["derive"] } rand = "0.10" serde = { version = "1.0", features = ["derive"] } postcard = { version = "1.1", default-features = false, features = ["use-std"] } +# Already in the tree through iroh. A packet codec, not a DNS server: the +# zone logic is ours and a full server framework would be a large dependency +# for answering A records from memory. +simple-dns = "0.12" data-encoding = "2.11" hex = "0.4" thiserror = "2.0" diff --git a/src/dns/mod.rs b/src/dns/mod.rs new file mode 100644 index 0000000..4b66488 --- /dev/null +++ b/src/dns/mod.rs @@ -0,0 +1,19 @@ +//! A DNS view of the overlay. +//! +//! The names and addresses of a network, served to the host that runs the +//! agent, so members can be reached by name. Answered from signed state, so a +//! member that is switched off still resolves. +//! +//! Three parts, kept apart on purpose: +//! +//! * [`zone`] decides what the answer is. Pure, and knows nothing about +//! packets or sockets. +//! * [`server`] puts that on the wire. +//! * `publish` tells the operating system where to send its questions, +//! which is the only part that differs between platforms. + +pub mod server; +pub mod zone; + +pub use server::{DnsServer, SharedZone}; +pub use zone::{Answer, Query, Zone, ZoneError, ZoneName}; diff --git a/src/dns/server.rs b/src/dns/server.rs new file mode 100644 index 0000000..4b1228d --- /dev/null +++ b/src/dns/server.rs @@ -0,0 +1,552 @@ +//! Answering DNS questions about the overlay, over UDP and TCP. +//! +//! Authoritative for one zone and nothing else. There is no recursion, no +//! forwarding and no cache: a question this agent cannot answer from the +//! signed roster is refused rather than passed anywhere, so pointing a +//! resolver at this server can never make it a path to the outside. +//! +//! The decision of what to answer lives in [`super::zone`]; this module is +//! only the wire format and the sockets. [`respond`] sits between them and +//! takes bytes to bytes, so everything the server does to a packet is +//! testable without opening a socket. + +use std::net::SocketAddr; +use std::sync::{Arc, RwLock}; + +use simple_dns::rdata::{A, PTR, RData, SOA}; +use simple_dns::{Name, PacketFlag, QCLASS, QTYPE, RCODE, ResourceRecord, TYPE}; + +use super::zone::{Answer, Query, Zone}; + +/// How long an answer may be cached. +/// +/// Short, because the roster changes when members come and go and a stale +/// answer is worse than another question. +pub const TTL: u32 = 30; + +/// The largest question this server will read. +/// +/// A DNS message is 512 bytes without EDNS and 4096 with it; anything past +/// that is not a question worth answering. +pub const MAX_MESSAGE_LEN: usize = 4096; + +/// The largest answer sent over UDP without the client offering EDNS. +const CLASSIC_UDP_LIMIT: usize = 512; + +/// How long a TCP client may take over one question. +const TCP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + +/// How many TCP questions may be in flight at once. +const MAX_TCP_CONNECTIONS: usize = 32; + +/// The zone the server answers from, swapped as the roster changes. +/// +/// Shared rather than copied into the server so that a roster change is one +/// write, not a restart: rebinding the socket would drop questions in flight +/// for no reason. +#[derive(Debug, Clone)] +pub struct SharedZone(Arc>>); + +impl SharedZone { + /// Wraps a zone. + pub fn new(zone: Zone) -> Self { + Self(Arc::new(RwLock::new(Arc::new(zone)))) + } + + /// Replaces it. + pub fn set(&self, zone: Zone) { + match self.0.write() { + Ok(mut guard) => *guard = Arc::new(zone), + Err(poisoned) => *poisoned.into_inner() = Arc::new(zone), + } + } + + /// The zone as it is now. + pub fn get(&self) -> Arc { + match self.0.read() { + Ok(guard) => Arc::clone(&guard), + Err(poisoned) => Arc::clone(&poisoned.into_inner()), + } + } +} + +/// Builds the answer to one question. +/// +/// `None` means say nothing at all: the message was not a question this +/// server should reply to, and replying anyway would make this a useful +/// amplifier for somebody spoofing a source address. +pub fn respond(zone: &Zone, query: &[u8]) -> Option> { + let packet = simple_dns::Packet::parse(query).ok()?; + if packet.has_flags(PacketFlag::RESPONSE) { + return None; + } + + let mut reply = simple_dns::Packet::new_reply(packet.id()); + // Recursion is not available here and the flag says so honestly; the + // desired bit is echoed because a resolver compares it. + if packet.has_flags(PacketFlag::RECURSION_DESIRED) { + reply.set_flags(PacketFlag::RECURSION_DESIRED); + } + + if packet.opcode() != simple_dns::OPCODE::StandardQuery { + *reply.rcode_mut() = RCODE::NotImplemented; + return reply.build_bytes_vec().ok(); + } + + // Exactly one question. Zero is nothing to answer; more than one has no + // agreed meaning and every real server rejects it. + let [question] = packet.questions.as_slice() else { + *reply.rcode_mut() = RCODE::FormatError; + return reply.build_bytes_vec().ok(); + }; + if !matches!(question.qclass, QCLASS::CLASS(simple_dns::CLASS::IN)) { + *reply.rcode_mut() = RCODE::Refused; + return reply.build_bytes_vec().ok(); + } + + let qname = question.qname.to_string(); + let answer = zone.lookup(&qname, query_kind(question.qtype)); + reply.questions.push(question.clone()); + + let name = Name::new(&qname).ok()?; + match answer { + Answer::NotOurs => *reply.rcode_mut() = RCODE::Refused, + Answer::Addresses(addresses) => { + reply.set_flags(PacketFlag::AUTHORITATIVE_ANSWER); + for address in addresses { + reply.answers.push(ResourceRecord::new( + name.clone(), + simple_dns::CLASS::IN, + TTL, + RData::A(A::from(address)), + )); + } + } + Answer::Name(target) => { + reply.set_flags(PacketFlag::AUTHORITATIVE_ANSWER); + let target = Name::new(&target).ok()?.into_owned(); + reply.answers.push(ResourceRecord::new( + name.clone(), + simple_dns::CLASS::IN, + TTL, + RData::PTR(PTR(target)), + )); + } + Answer::Soa => { + reply.set_flags(PacketFlag::AUTHORITATIVE_ANSWER); + reply.answers.push(soa_record(zone)?); + } + Answer::NoData => { + reply.set_flags(PacketFlag::AUTHORITATIVE_ANSWER); + // The authority section carries the SOA so a resolver knows how + // long it may remember that there is nothing here. + reply.name_servers.push(soa_record(zone)?); + } + Answer::NoSuchName => { + reply.set_flags(PacketFlag::AUTHORITATIVE_ANSWER); + *reply.rcode_mut() = RCODE::NameError; + reply.name_servers.push(soa_record(zone)?); + } + } + + reply.build_bytes_vec_compressed().ok() +} + +/// The zone's start of authority. +fn soa_record(zone: &Zone) -> Option> { + let origin = Name::new(zone.origin().as_str()).ok()?.into_owned(); + Some(ResourceRecord::new( + origin.clone(), + simple_dns::CLASS::IN, + TTL, + RData::SOA(SOA { + mname: origin.clone(), + // There is no mailbox behind this zone and inventing one would + // be a fiction; the origin itself is the honest answer. + rname: origin, + serial: zone.serial(), + refresh: TTL as i32, + retry: TTL as i32, + expire: 86_400, + minimum: TTL, + }), + )) +} + +fn query_kind(qtype: QTYPE) -> Query { + match qtype { + QTYPE::TYPE(TYPE::A) => Query::A, + QTYPE::TYPE(TYPE::PTR) => Query::Ptr, + QTYPE::TYPE(TYPE::SOA) => Query::Soa, + QTYPE::TYPE(TYPE::NS) => Query::Ns, + // ANY is answered as an address question rather than by dumping the + // zone: an ANY that returns everything is an amplification gift. + QTYPE::ANY => Query::A, + _ => Query::Other, + } +} + +/// Whether the client offered EDNS, and so how large an answer it will take. +fn udp_limit(query: &[u8]) -> usize { + simple_dns::Packet::parse(query) + .ok() + .and_then(|packet| packet.opt().map(|opt| opt.udp_packet_size as usize)) + .unwrap_or(CLASSIC_UDP_LIMIT as u16 as usize) + .clamp(CLASSIC_UDP_LIMIT, MAX_MESSAGE_LEN) +} + +/// Cuts an answer down to what the client said it would take. +/// +/// The records are dropped and the truncated bit set, which tells a resolver +/// to ask again over TCP. Sending a reply it cannot reassemble would just +/// look like packet loss. +fn truncate_for_udp(query: &[u8], reply: Vec) -> Vec { + let limit = udp_limit(query); + if reply.len() <= limit { + return reply; + } + let Ok(parsed) = simple_dns::Packet::parse(&reply) else { + return reply; + }; + let mut short = simple_dns::Packet::new_reply(parsed.id()); + short.set_flags(PacketFlag::TRUNCATION | PacketFlag::AUTHORITATIVE_ANSWER); + *short.rcode_mut() = parsed.rcode(); + for question in &parsed.questions { + short.questions.push(question.clone()); + } + short.build_bytes_vec().unwrap_or(reply) +} + +/// A running DNS server. +#[derive(Debug)] +pub struct DnsServer { + local_addr: SocketAddr, + tasks: Vec>, +} + +impl DnsServer { + /// Binds and starts answering. + /// + /// Both transports on the same address and port, as a resolver expects: + /// it falls back to TCP when an answer does not fit, and a server that + /// only listened on UDP would leave it with nowhere to go. + pub async fn bind(addr: SocketAddr, zone: SharedZone) -> std::io::Result { + let udp = tokio::net::UdpSocket::bind(addr).await?; + let local_addr = udp.local_addr()?; + let tcp = tokio::net::TcpListener::bind(local_addr).await?; + + let udp_zone = zone.clone(); + let udp_task = tokio::spawn(async move { + let mut buffer = vec![0u8; MAX_MESSAGE_LEN]; + loop { + let (read, from) = match udp.recv_from(&mut buffer).await { + Ok(pair) => pair, + Err(err) => { + tracing::debug!(%err, "dns udp receive failed"); + continue; + } + }; + let query = &buffer[..read]; + let Some(reply) = respond(&udp_zone.get(), query) else { + continue; + }; + let reply = truncate_for_udp(query, reply); + if let Err(err) = udp.send_to(&reply, from).await { + tracing::debug!(%err, "dns udp reply failed"); + } + } + }); + + let tcp_zone = zone.clone(); + let tcp_task = tokio::spawn(async move { + let permits = Arc::new(tokio::sync::Semaphore::new(MAX_TCP_CONNECTIONS)); + loop { + let (stream, _) = match tcp.accept().await { + Ok(pair) => pair, + Err(err) => { + tracing::debug!(%err, "dns tcp accept failed"); + continue; + } + }; + let Ok(permit) = Arc::clone(&permits).acquire_owned().await else { + return; + }; + let zone = tcp_zone.clone(); + tokio::spawn(async move { + let _permit = permit; + // Bounded, so a client that connects and says nothing + // cannot hold a slot open. + let _ = tokio::time::timeout(TCP_TIMEOUT, serve_tcp(stream, zone)).await; + }); + } + }); + + Ok(Self { + local_addr, + tasks: vec![udp_task, tcp_task], + }) + } + + /// The address it is answering on. + pub fn local_addr(&self) -> SocketAddr { + self.local_addr + } +} + +impl Drop for DnsServer { + fn drop(&mut self) { + for task in &self.tasks { + task.abort(); + } + } +} + +async fn serve_tcp(mut stream: tokio::net::TcpStream, zone: SharedZone) -> std::io::Result<()> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + loop { + let mut header = [0u8; 2]; + if stream.read_exact(&mut header).await.is_err() { + return Ok(()); + } + // Checked before the buffer is allocated, as everywhere else that + // reads a length off a wire. + let len = u16::from_be_bytes(header) as usize; + if len == 0 || len > MAX_MESSAGE_LEN { + return Ok(()); + } + let mut query = vec![0u8; len]; + stream.read_exact(&mut query).await?; + + let Some(reply) = respond(&zone.get(), &query) else { + return Ok(()); + }; + let Ok(len) = u16::try_from(reply.len()) else { + return Ok(()); + }; + stream.write_all(&len.to_be_bytes()).await?; + stream.write_all(&reply).await?; + stream.flush().await?; + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + + use super::*; + use crate::dns::zone::ZoneName; + use std::net::Ipv4Addr; + + 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)), + ], + ) + } + + fn ask(name: &str, qtype: TYPE) -> Vec { + let mut packet = simple_dns::Packet::new_query(0x1234); + packet.questions.push(simple_dns::Question::new( + Name::new(name).unwrap(), + qtype.into(), + QCLASS::CLASS(simple_dns::CLASS::IN), + false, + )); + packet.build_bytes_vec().unwrap() + } + + /// The bytes of a reply. Parsed by each caller, because a parsed packet + /// borrows from them. + fn answer(query: &[u8]) -> Vec { + respond(&zone(), query).expect("a reply") + } + + #[test] + fn a_member_is_answered_authoritatively() { + let bytes = answer(&ask("music.lab", TYPE::A)); + let reply = simple_dns::Packet::parse(&bytes).unwrap(); + assert_eq!(reply.rcode(), RCODE::NoError); + assert!(reply.has_flags(PacketFlag::RESPONSE)); + assert!(reply.has_flags(PacketFlag::AUTHORITATIVE_ANSWER)); + assert!(!reply.has_flags(PacketFlag::RECURSION_AVAILABLE)); + assert_eq!(reply.answers.len(), 1); + assert_eq!(reply.questions.len(), 1, "the question is echoed"); + match &reply.answers[0].rdata { + RData::A(a) => assert_eq!(Ipv4Addr::from(a.address), Ipv4Addr::new(10, 13, 37, 237)), + other => panic!("expected an A record, got {other:?}"), + } + } + + #[test] + fn an_absent_name_is_denied_with_a_soa_to_cache_the_denial() { + let bytes = answer(&ask("nobody.lab", TYPE::A)); + let reply = simple_dns::Packet::parse(&bytes).unwrap(); + assert_eq!(reply.rcode(), RCODE::NameError); + assert!(reply.answers.is_empty()); + assert_eq!( + reply.name_servers.len(), + 1, + "a SOA bounds the negative cache" + ); + } + + #[test] + fn a_name_that_exists_without_that_record_is_not_denied() { + // NODATA, not NXDOMAIN: denying the name would stop a resolver + // asking for the A record it could have had. + let bytes = answer(&ask("music.lab", TYPE::AAAA)); + let reply = simple_dns::Packet::parse(&bytes).unwrap(); + assert_eq!(reply.rcode(), RCODE::NoError); + assert!(reply.answers.is_empty()); + assert_eq!(reply.name_servers.len(), 1); + } + + #[test] + fn anything_outside_the_zone_is_refused_and_never_forwarded() { + for name in ["example.com", "evillab", "google.com"] { + let bytes = answer(&ask(name, TYPE::A)); + let reply = simple_dns::Packet::parse(&bytes).unwrap(); + assert_eq!(reply.rcode(), RCODE::Refused, "{name}"); + assert!(reply.answers.is_empty()); + } + } + + #[test] + fn an_address_is_answered_backwards() { + let bytes = answer(&ask("237.37.13.10.in-addr.arpa", TYPE::PTR)); + let reply = simple_dns::Packet::parse(&bytes).unwrap(); + assert_eq!(reply.rcode(), RCODE::NoError); + match &reply.answers[0].rdata { + RData::PTR(ptr) => assert_eq!(ptr.0.to_string(), "music.lab"), + other => panic!("expected a PTR record, got {other:?}"), + } + } + + #[test] + fn a_reply_is_never_sent_to_something_that_was_not_a_question() { + // Answering a response would make this a reflector for anyone who + // can spoof a source address. + let mut packet = simple_dns::Packet::new_reply(1); + packet.set_flags(PacketFlag::RESPONSE); + assert!(respond(&zone(), &packet.build_bytes_vec().unwrap()).is_none()); + assert!(respond(&zone(), b"").is_none()); + assert!(respond(&zone(), b"not a dns packet at all").is_none()); + } + + #[test] + fn a_question_with_no_question_in_it_is_a_format_error() { + let packet = simple_dns::Packet::new_query(7); + let bytes = respond(&zone(), &packet.build_bytes_vec().unwrap()).unwrap(); + let reply = simple_dns::Packet::parse(&bytes).unwrap(); + assert_eq!(reply.rcode(), RCODE::FormatError); + } + + #[test] + fn a_class_other_than_internet_is_refused() { + let mut packet = simple_dns::Packet::new_query(9); + packet.questions.push(simple_dns::Question::new( + Name::new("music.lab").unwrap(), + TYPE::A.into(), + QCLASS::CLASS(simple_dns::CLASS::CH), + false, + )); + let bytes = respond(&zone(), &packet.build_bytes_vec().unwrap()).unwrap(); + assert_eq!( + simple_dns::Packet::parse(&bytes).unwrap().rcode(), + RCODE::Refused + ); + } + + #[test] + fn an_answer_too_large_for_udp_is_truncated_rather_than_dropped() { + // A resolver that gets a truncated reply asks again over TCP; one + // that gets nothing back just waits. + let many: Vec<(String, Ipv4Addr)> = (0..200) + .map(|i| ("host".to_string(), Ipv4Addr::new(10, 13, 37, i as u8))) + .collect(); + let wide = Zone::new(ZoneName::new("lab").unwrap(), many); + let query = ask("host.lab", TYPE::A); + let full = respond(&wide, &query).unwrap(); + assert!( + full.len() > CLASSIC_UDP_LIMIT, + "the test needs a big answer" + ); + + let short = truncate_for_udp(&query, full); + assert!(short.len() <= CLASSIC_UDP_LIMIT); + let parsed = simple_dns::Packet::parse(&short).unwrap(); + assert!(parsed.has_flags(PacketFlag::TRUNCATION)); + assert_eq!(parsed.questions.len(), 1); + } + + #[tokio::test] + async fn the_server_answers_over_udp_and_tcp_on_one_address() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let shared = SharedZone::new(zone()); + let server = DnsServer::bind("127.0.0.1:0".parse().unwrap(), shared.clone()) + .await + .unwrap(); + let addr = server.local_addr(); + + let client = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + client + .send_to(&ask("music.lab", TYPE::A), addr) + .await + .unwrap(); + let mut buffer = vec![0u8; MAX_MESSAGE_LEN]; + let read = client.recv(&mut buffer).await.unwrap(); + let reply = simple_dns::Packet::parse(&buffer[..read]).unwrap(); + assert_eq!(reply.answers.len(), 1); + + let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap(); + let query = ask("ai.lab", TYPE::A); + stream + .write_all(&(query.len() as u16).to_be_bytes()) + .await + .unwrap(); + stream.write_all(&query).await.unwrap(); + let mut header = [0u8; 2]; + stream.read_exact(&mut header).await.unwrap(); + let mut body = vec![0u8; u16::from_be_bytes(header) as usize]; + stream.read_exact(&mut body).await.unwrap(); + let reply = simple_dns::Packet::parse(&body).unwrap(); + assert_eq!(reply.answers.len(), 1); + } + + #[tokio::test] + async fn replacing_the_zone_changes_what_the_running_server_answers() { + let shared = SharedZone::new(Zone::new(ZoneName::new("lab").unwrap(), [])); + let server = DnsServer::bind("127.0.0.1:0".parse().unwrap(), shared.clone()) + .await + .unwrap(); + let addr = server.local_addr(); + let client = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let mut buffer = vec![0u8; MAX_MESSAGE_LEN]; + + client + .send_to(&ask("music.lab", TYPE::A), addr) + .await + .unwrap(); + let read = client.recv(&mut buffer).await.unwrap(); + assert_eq!( + simple_dns::Packet::parse(&buffer[..read]).unwrap().rcode(), + RCODE::NameError + ); + + // A member joins: no rebind, no dropped socket. + shared.set(zone()); + client + .send_to(&ask("music.lab", TYPE::A), addr) + .await + .unwrap(); + let read = client.recv(&mut buffer).await.unwrap(); + let reply = simple_dns::Packet::parse(&buffer[..read]).unwrap(); + assert_eq!(reply.rcode(), RCODE::NoError); + assert_eq!(reply.answers.len(), 1); + } +} diff --git a/src/dns/zone.rs b/src/dns/zone.rs new file mode 100644 index 0000000..69dfb0f --- /dev/null +++ b/src/dns/zone.rs @@ -0,0 +1,511 @@ +//! 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 { + 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) + } + + /// 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 { + 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), + /// 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>, + /// Address to the name that holds it. + names: BTreeMap, + /// 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) -> Self { + let mut hosts: BTreeMap> = BTreeMap::new(); + let mut names: BTreeMap = 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 { + 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 { + 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 { + 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>) -> 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 { .. } + )); + } + + #[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()); + } +} diff --git a/src/lib.rs b/src/lib.rs index c786f62..45f5780 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -39,6 +39,7 @@ pub mod agent; pub mod config; pub mod dataplane; pub mod discovery; +pub mod dns; pub mod error; pub mod identity; pub mod ipc;