Answer DNS questions about the overlay
The zone and the server, without any of the operating system yet. `dns::zone` decides what the answer is and knows nothing about packets or sockets, so the parts worth getting right are testable on their own: which names exist, that a neighbouring name like `evillab` is not inside `lab`, and the difference between a name that is absent and one that exists with nothing of the type asked for. Getting that last one wrong would teach a resolver to stop asking for the A record it could have had. Names come from signed state, which is the point: a member that is switched off still resolves, because its claim outlived the session. Only IPv4 is served. The IPv6 overlay address derives from a WireGuard key that travels in live announcements and is not in signed state, so it cannot be answered for an absent member, and answering for some members and not others depending on who happens to be online is worse than not answering. `dns::server` puts that on the wire with simple-dns, which is already in the tree through iroh — a packet codec rather than a server framework, which is the right size for answering A records from memory. respond() goes from bytes to bytes so everything done to a packet is tested without a socket. It is authoritative for one zone and refuses everything else: no recursion, no forwarding, no cache, so pointing a resolver here can never make it a path to the outside. A message that is not a question gets no reply at all, rather than making this a reflector for anyone who can spoof a source address, and ANY is answered as an address question rather than by dumping the zone. Answers too large for the client's UDP limit are truncated so a resolver retries over TCP instead of waiting; TCP reads are length-checked before allocating, timed out, and bounded in number. The zone is shared rather than copied in, so a member joining is one write instead of a rebind that would drop questions in flight. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+1
@@ -3688,6 +3688,7 @@ dependencies = [
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"sha2",
|
||||
"simple-dns",
|
||||
"subtle",
|
||||
"tempfile",
|
||||
"thiserror 2.0.20",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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};
|
||||
@@ -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<RwLock<Arc<Zone>>>);
|
||||
|
||||
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<Zone> {
|
||||
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<Vec<u8>> {
|
||||
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<ResourceRecord<'static>> {
|
||||
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<u8>) -> Vec<u8> {
|
||||
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<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
+511
@@ -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<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)
|
||||
}
|
||||
|
||||
/// 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 { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user