use std::net::{IpAddr, Ipv4Addr};
use std::time::Duration;
use hickory_resolver::proto::dnssec::rdata::{DNSSECRData, CDNSKEY, CDS, DNSKEY};
use hickory_resolver::proto::dnssec::{Algorithm, DigestType, PublicKeyBuf};
use hickory_resolver::proto::op::{Edns, Message, OpCode, ResponseCode};
use hickory_resolver::proto::rr::rdata::svcb::{
Alpn, EchConfigList, IpHint, Mandatory, SvcParamKey, SvcParamValue, Unknown, SVCB,
};
use hickory_resolver::proto::rr::rdata::{self as wire, sshfp, tlsa, CAA, HTTPS};
use hickory_resolver::proto::rr::{
Name, RData as HickoryRData, Record, RecordType as HickoryRecordType,
};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream, UdpSocket};
use super::resolver::{fqdn, DnsResolver};
#[derive(Clone, Copy)]
pub(crate) enum MockMode {
Zone,
Nxdomain,
NoData,
Ignore,
}
fn name(s: &str) -> Name {
Name::from_ascii(s).expect("valid test name")
}
pub(crate) fn fq_name(s: &str) -> Name {
name(&fqdn(s))
}
pub(crate) fn a_rdata(ip: impl Into<Ipv4Addr>) -> HickoryRData {
HickoryRData::A(wire::A(ip.into()))
}
pub(crate) fn cname_rdata(target: &str) -> HickoryRData {
HickoryRData::CNAME(wire::CNAME(fq_name(target)))
}
fn zone_answers(qname: &str, qtype: HickoryRecordType) -> Vec<HickoryRData> {
match (qname.trim_end_matches('.'), qtype) {
("seer.test", HickoryRecordType::A) => vec![
HickoryRData::A(wire::A(Ipv4Addr::new(192, 0, 2, 1))),
HickoryRData::A(wire::A(Ipv4Addr::new(192, 0, 2, 2))),
],
("seer.test", HickoryRecordType::AAAA) => vec![HickoryRData::AAAA(wire::AAAA(
"2001:db8::1".parse().expect("valid IPv6 literal"),
))],
("seer.test", HickoryRecordType::MX) => vec![
HickoryRData::MX(wire::MX::new(30, name("c.mail.seer.test."))),
HickoryRData::MX(wire::MX::new(10, name("a.mail.seer.test."))),
HickoryRData::MX(wire::MX::new(20, name("b.mail.seer.test."))),
],
("seer.test", HickoryRecordType::NS) => {
vec![HickoryRData::NS(wire::NS(name("ns1.seer.test.")))]
}
("seer.test", HickoryRecordType::TXT) => vec![HickoryRData::TXT(wire::TXT::new(vec![
"v=spf1 ".to_string(),
"-all".to_string(),
]))],
("seer.test", HickoryRecordType::SOA) => vec![HickoryRData::SOA(wire::SOA::new(
name("ns1.seer.test."),
name("hostmaster.seer.test."),
2026070101,
7200,
3600,
1209600,
300,
))],
("seer.test", HickoryRecordType::CAA) => vec![
HickoryRData::CAA(CAA::new_issue(false, Some(name("letsencrypt.org")), vec![])),
HickoryRData::CAA(CAA::new_iodef(
true,
url::Url::parse("mailto:security@seer.test").expect("valid iodef URL"),
)),
],
("_443._tcp.seer.test", HickoryRecordType::TLSA) => {
vec![HickoryRData::TLSA(wire::TLSA::new(
tlsa::CertUsage::from(3),
tlsa::Selector::from(1),
tlsa::Matching::from(1),
vec![0xAB, 0xCD, 0x01],
))]
}
("seer.test", HickoryRecordType::SSHFP) => vec![HickoryRData::SSHFP(wire::SSHFP::new(
sshfp::Algorithm::from(4),
sshfp::FingerprintType::from(2),
vec![0xDE, 0xAD, 0xBE, 0xEF],
))],
("seer.test", HickoryRecordType::NAPTR) => vec![HickoryRData::NAPTR(wire::NAPTR::new(
100,
50,
b"U".to_vec().into_boxed_slice(),
b"E2U+sip".to_vec().into_boxed_slice(),
b"!^.*$!sip:info@seer.test!".to_vec().into_boxed_slice(),
Name::root(),
))],
("_sip._tcp.seer.test", HickoryRecordType::SRV) => vec![HickoryRData::SRV(wire::SRV::new(
10,
5,
5060,
name("sipserver.seer.test."),
))],
("1.2.0.192.in-addr.arpa", HickoryRecordType::PTR) => {
vec![HickoryRData::PTR(wire::PTR(name("ptr.seer.test.")))]
}
("www.seer.test", HickoryRecordType::CNAME) => {
vec![HickoryRData::CNAME(wire::CNAME(name("edge.cdn.test.")))]
}
(
"1.1.1.1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.7.4.0.0.7.4.6.0.6.2.ip6.arpa",
HickoryRecordType::PTR,
) => vec![HickoryRData::PTR(wire::PTR(name("one.one.one.one.")))],
("seer.test", HickoryRecordType::HTTPS) => {
vec![HickoryRData::HTTPS(HTTPS(SVCB::new(
1,
Name::root(),
vec![
(
SvcParamKey::Alpn,
SvcParamValue::Alpn(Alpn(vec!["h3".to_string(), "h2".to_string()])),
),
(SvcParamKey::Port, SvcParamValue::Port(8443)),
(
SvcParamKey::Ipv4Hint,
SvcParamValue::Ipv4Hint(IpHint(vec![
wire::A::new(192, 0, 2, 1),
wire::A::new(192, 0, 2, 2),
])),
),
(
SvcParamKey::EchConfigList,
SvcParamValue::EchConfigList(EchConfigList(vec![0x00, 0x01, 0xFE])),
),
(
SvcParamKey::Ipv6Hint,
SvcParamValue::Ipv6Hint(IpHint(vec![wire::AAAA::new(
0x2001, 0xdb8, 0, 0, 0, 0, 0, 1,
)])),
),
(
SvcParamKey::Key(65333),
SvcParamValue::Unknown(Unknown(b"ex 1".to_vec())),
),
],
)))]
}
("alias.seer.test", HickoryRecordType::HTTPS) => vec![HickoryRData::HTTPS(HTTPS(
SVCB::new(0, name("pool.seer.test."), vec![]),
))],
("_8443._foo.seer.test", HickoryRecordType::SVCB) => {
vec![HickoryRData::SVCB(SVCB::new(
2,
name("svc.seer.test."),
vec![
(
SvcParamKey::Mandatory,
SvcParamValue::Mandatory(Mandatory(vec![
SvcParamKey::Alpn,
SvcParamKey::Port,
])),
),
(
SvcParamKey::Alpn,
SvcParamValue::Alpn(Alpn(vec!["foo".to_string()])),
),
(SvcParamKey::NoDefaultAlpn, SvcParamValue::NoDefaultAlpn),
(SvcParamKey::Port, SvcParamValue::Port(8443)),
],
))]
}
("seer.test", HickoryRecordType::DNSKEY) => {
vec![HickoryRData::DNSSEC(DNSSECRData::DNSKEY(DNSKEY::new(
true,
true,
false,
PublicKeyBuf::new(vec![7u8; 32], Algorithm::ED25519),
)))]
}
("seer.test", HickoryRecordType::CDS) => vec![
HickoryRData::DNSSEC(DNSSECRData::CDS(CDS::new(
2371,
Some(Algorithm::ED25519),
DigestType::SHA256,
vec![0xAB, 0xCD, 0xEF],
))),
HickoryRData::DNSSEC(DNSSECRData::CDS(CDS::new(
0,
None,
DigestType::from(0),
vec![0x00],
))),
],
("seer.test", HickoryRecordType::CDNSKEY) => vec![
HickoryRData::DNSSEC(DNSSECRData::CDNSKEY(CDNSKEY::with_flags(
257,
Some(Algorithm::ED25519),
vec![7u8; 32],
))),
HickoryRData::DNSSEC(DNSSECRData::CDNSKEY(CDNSKEY::with_flags(
0,
None,
vec![0x00],
))),
],
_ => vec![],
}
}
fn response_skeleton(request: &Message) -> Message {
let mut response = Message::response(request.metadata.id, OpCode::Query);
response.metadata.recursion_desired = request.metadata.recursion_desired;
response.metadata.recursion_available = true;
for query in &request.queries {
response.add_query(query.clone());
}
response
}
pub(crate) async fn spawn_mock_dns(mode: MockMode) -> u16 {
spawn_mock_dns_fn(move |qname, qtype| match mode {
MockMode::Zone => MockReply::Answer(zone_answers(qname, qtype)),
MockMode::Nxdomain => MockReply::NxDomain,
MockMode::NoData => MockReply::NoData,
MockMode::Ignore => MockReply::NoReply,
})
.await
}
pub(crate) async fn spawn_mock_dns_sequence(answer_sets: Vec<Vec<HickoryRData>>) -> u16 {
let mut served = 0usize;
spawn_mock_dns_fn(move |_, _| {
let idx = served.min(answer_sets.len().saturating_sub(1));
served += 1;
MockReply::Answer(answer_sets.get(idx).cloned().unwrap_or_default())
})
.await
}
pub(crate) fn soa_rdata(zone: &str) -> HickoryRData {
HickoryRData::SOA(wire::SOA::new(
name(&format!("ns1.{zone}.")),
name(&format!("hostmaster.{zone}.")),
2026070101,
7200,
3600,
1209600,
300,
))
}
pub(crate) fn record(owner: &str, ttl: u32, rdata: HickoryRData) -> Record {
Record::from_rdata(fq_name(owner), ttl, rdata)
}
fn zone_soa(zone: &str) -> Record {
Record::from_rdata(fq_name(zone), 300, soa_rdata(zone))
}
pub(crate) enum MockReply {
Answer(Vec<HickoryRData>),
Records(Vec<Record>),
AuthoritativeAnswer(Vec<HickoryRData>),
NoData,
NoDataWithSoa(&'static str),
NxDomain,
NxDomainWithSoa(&'static str),
AuthoritativeNxDomain(&'static str),
Rcode(ResponseCode),
ServFail,
Refused,
NoReply,
Delegation {
zone: String,
servers: Vec<(String, Vec<IpAddr>)>,
},
Truncated,
Negative {
code: ResponseCode,
chain: Vec<Record>,
soa: &'static str,
},
}
pub(crate) async fn spawn_mock_dns_fn<F>(handler: F) -> u16
where
F: FnMut(&str, HickoryRecordType) -> MockReply + Send + 'static,
{
let socket = UdpSocket::bind("127.0.0.1:0").await.expect("bind mock DNS");
serve_udp(socket, handler)
}
pub(crate) async fn spawn_mock_dns_fn_with_tcp<F>(handler: F) -> u16
where
F: FnMut(&str, HickoryRecordType) -> MockReply + Send + 'static,
{
for _ in 0..16 {
let socket = UdpSocket::bind("127.0.0.1:0").await.expect("bind mock DNS");
let port = socket.local_addr().expect("mock DNS local addr").port();
if let Ok(listener) = TcpListener::bind(("127.0.0.1", port)).await {
let port = serve_udp(socket, handler);
tokio::spawn(relay_tcp(listener, port));
return port;
}
}
panic!("no loopback port free for both UDP and TCP");
}
async fn relay_tcp(listener: TcpListener, udp_port: u16) {
while let Ok((stream, _)) = listener.accept().await {
tokio::spawn(relay_connection(stream, udp_port));
}
}
async fn relay_connection(mut stream: TcpStream, udp_port: u16) {
let Ok(socket) = UdpSocket::bind("127.0.0.1:0").await else {
return;
};
let mut buf = [0u8; 4096];
loop {
let Ok(len) = stream.read_u16().await else {
return;
};
let mut query = vec![0u8; usize::from(len)];
if stream.read_exact(&mut query).await.is_err()
|| socket
.send_to(&query, ("127.0.0.1", udp_port))
.await
.is_err()
{
return;
}
let Ok(reply_len) = socket.recv(&mut buf).await else {
return;
};
let Ok(prefix) = u16::try_from(reply_len) else {
return;
};
if stream.write_u16(prefix).await.is_err()
|| stream.write_all(&buf[..reply_len]).await.is_err()
{
return;
}
}
}
fn serve_udp<F>(socket: UdpSocket, mut handler: F) -> u16
where
F: FnMut(&str, HickoryRecordType) -> MockReply + Send + 'static,
{
let port = socket.local_addr().expect("mock DNS local addr").port();
tokio::spawn(async move {
let mut buf = [0u8; 4096];
loop {
let Ok((len, src)) = socket.recv_from(&mut buf).await else {
return;
};
let Ok(request) = Message::from_vec(&buf[..len]) else {
continue;
};
let mut response = response_skeleton(&request);
if let Some(query) = request.queries.first() {
let qname = query.name.to_ascii().to_ascii_lowercase();
let owned = |rdata| Record::from_rdata(query.name.clone(), 300, rdata);
match handler(qname.trim_end_matches('.'), query.query_type) {
MockReply::Answer(answers) => {
response.add_answers(answers.into_iter().map(owned));
}
MockReply::Records(records) => {
response.add_answers(records);
}
MockReply::AuthoritativeAnswer(answers) => {
response.metadata.authoritative = true;
response.add_answers(answers.into_iter().map(owned));
}
MockReply::NoData => {}
MockReply::NoDataWithSoa(zone) => {
response.add_authority(zone_soa(zone));
}
MockReply::NxDomain => {
response.metadata.response_code = ResponseCode::NXDomain;
}
MockReply::NxDomainWithSoa(zone) => {
response.metadata.response_code = ResponseCode::NXDomain;
response.add_authority(zone_soa(zone));
}
MockReply::AuthoritativeNxDomain(zone) => {
response.metadata.authoritative = true;
response.metadata.response_code = ResponseCode::NXDomain;
response.add_authority(zone_soa(zone));
}
MockReply::Rcode(code) => {
response.metadata.response_code = code;
if code.high() > 0 {
response.edns = Some(Edns::new());
}
}
MockReply::ServFail => {
response.metadata.response_code = ResponseCode::ServFail;
}
MockReply::Refused => {
response.metadata.response_code = ResponseCode::Refused;
}
MockReply::NoReply => continue,
MockReply::Delegation { zone, servers } => {
response.metadata.recursion_available = false;
let zone = fq_name(&zone);
for (server, addrs) in servers {
let server = fq_name(&server);
response.add_authority(Record::from_rdata(
zone.clone(),
300,
HickoryRData::NS(wire::NS(server.clone())),
));
response.add_additionals(addrs.into_iter().map(|ip| {
let glue = match ip {
IpAddr::V4(v4) => HickoryRData::A(wire::A(v4)),
IpAddr::V6(v6) => HickoryRData::AAAA(wire::AAAA(v6)),
};
Record::from_rdata(server.clone(), 300, glue)
}));
}
}
MockReply::Truncated => {
response.metadata.truncation = true;
}
MockReply::Negative { code, chain, soa } => {
response.metadata.response_code = code;
response.add_answers(chain);
response.add_authority(zone_soa(soa));
}
}
}
let Ok(bytes) = response.to_vec() else {
continue;
};
let _ = socket.send_to(&bytes, src).await;
}
});
port
}
pub(crate) fn mock_dns_resolver(port: u16) -> DnsResolver {
DnsResolver::new()
.with_timeout(Duration::from_millis(500))
.allowing_private_hosts()
.with_port(port)
}
pub(crate) fn mock_dns_resolver_default(port: u16) -> DnsResolver {
mock_dns_resolver(port).with_default_nameserver("127.0.0.1")
}