Files
tsunagi/crates/tsunagi-core/src/dns/server.rs
T
Aleksandr BogomiakovandClaude 9966b50407 Rename the CLI package to tsunagi and its executable to tsng
The command line package is now `tsunagi` and builds the `tsng` executable,
which the packages, the unit, the installers and the workflows install and
run. The core library package becomes `tsunagi-core`, keeping the library name
`tsunagi`, so no code changes `use tsunagi::...`. Directories follow
(crates/tsunagi-core, crates/tsunagi). Every command shown to a person is now
`tsng ...`.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-03 02:14:43 +01:00

626 lines
23 KiB
Rust

//! 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, Zones};
/// 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 zones the server answers from, swapped as the rosters change.
///
/// Shared rather than copied into the server so that a roster change — or a
/// network joining or leaving — is one write, not a restart: rebinding the
/// socket would drop questions in flight for no reason.
#[derive(Debug, Clone, Default)]
pub struct SharedZone(Arc<RwLock<Arc<Zones>>>);
impl SharedZone {
/// Wraps a set of zones.
pub fn new(zones: Zones) -> Self {
Self(Arc::new(RwLock::new(Arc::new(zones))))
}
/// Wraps a single zone, which is the common case in a test.
pub fn one(zone: Zone) -> Self {
Self::new(Zones::new([zone]))
}
/// Replaces them.
pub fn set(&self, zones: Zones) {
match self.0.write() {
Ok(mut guard) => *guard = Arc::new(zones),
Err(poisoned) => *poisoned.into_inner() = Arc::new(zones),
}
}
/// The zones as they are now.
pub fn get(&self) -> Arc<Zones> {
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(zones: &Zones, 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();
// Which zone answers is part of the answer: a denial is bounded by the
// SOA of the zone that denied it, and this server may hold several.
let (zone, answer) = match zones.lookup(&qname, query_kind(question.qtype)) {
Some(answered) => answered,
None => {
reply.questions.push(question.clone());
*reply.rcode_mut() = RCODE::Refused;
return reply.build_bytes_vec_compressed().ok();
}
};
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() -> Zones {
Zones::new([one_zone()])
}
fn one_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 = Zones::new([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 the_same_zone_is_answered_over_ipv6_as_over_ipv4() {
// A question arrives over whichever family the resolver chooses, so
// a listener is opened from each list in the plan and both have to
// answer the same thing. Answering one family only leaves the other
// timing out, which looks like a broken overlay.
let shared = SharedZone::new(zone());
let plan = crate::dns::listen_plan(0);
let mut answered = 0;
for family in plan.families() {
let candidate = family[0];
let server = match DnsServer::bind(candidate, shared.clone()).await {
Ok(server) => server,
// A host with IPv6 switched off in the kernel cannot bind
// `::1`, and the agent copes with that by design; so does
// this. The other family still has to work.
Err(err) if candidate.is_ipv6() => {
eprintln!("no IPv6 loopback on this host ({err}); skipping that family");
continue;
}
Err(err) => panic!("cannot listen on {candidate}: {err}"),
};
let addr = server.local_addr();
assert_eq!(addr.is_ipv6(), candidate.is_ipv6());
let client = tokio::net::UdpSocket::bind(if addr.is_ipv6() {
"[::1]:0"
} else {
"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.rcode(), RCODE::NoError, "over {addr}");
match &reply.answers[0].rdata {
// The same IPv4 answer either way: the family a question
// travelled over says nothing about what the answer is.
RData::A(a) => {
assert_eq!(Ipv4Addr::from(a.address), Ipv4Addr::new(10, 13, 37, 237))
}
other => panic!("expected an A record over {addr}, got {other:?}"),
}
answered += 1;
}
assert!(answered > 0, "at least one family must have answered");
}
#[tokio::test]
async fn replacing_the_zone_changes_what_the_running_server_answers() {
let shared = SharedZone::one(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);
}
}