use std::fmt;
use std::net::IpAddr;
use std::sync::OnceLock;
use std::time::Duration;
use hickory_resolver::config::ResolverOpts;
use hickory_resolver::net::{DnsError, NetError};
use hickory_resolver::proto::op::ResponseCode;
use hickory_resolver::TokioResolver;
use tokio::runtime::{Builder, Handle, Runtime};
#[cfg(test)]
use hickory_resolver::config::ResolverConfig;
#[cfg(test)]
use hickory_resolver::net::runtime::TokioRuntimeProvider;
#[cfg(test)]
use hickory_resolver::Hosts;
#[cfg(test)]
use std::sync::Arc;
pub(crate) const LOOKUP_DEADLINE: Duration = Duration::from_secs(5);
const NAMESERVER_TIMEOUT: Duration = Duration::from_secs(2);
const NAMESERVER_ATTEMPTS: usize = 2;
const RESOLVER_WORKER_THREADS: usize = 2;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Resolution {
Resolved(Vec<IpAddr>),
DoesNotExist,
DidNotAnswer(NoAnswer),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NoAnswer {
DeadlineElapsed,
NoNameserverAnswered,
ResolverUnavailable,
}
impl fmt::Display for NoAnswer {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DeadlineElapsed => write!(
f,
"the resolver did not answer within {}s",
LOOKUP_DEADLINE.as_secs()
),
Self::NoNameserverAnswered => write!(f, "no nameserver answered the query"),
Self::ResolverUnavailable => write!(
f,
"this process has no resolver: the system resolver configuration could not be read"
),
}
}
}
#[derive(Clone)]
pub(crate) struct DnsResolver {
inner: TokioResolver,
}
impl DnsResolver {
pub(crate) fn from_system() -> Result<Self, String> {
let handle = runtime_handle().ok_or_else(|| {
"the runtime that drives name resolution could not be built".to_string()
})?;
let _guard = handle.enter();
let mut builder = TokioResolver::builder_tokio()
.map_err(|error| format!("/etc/resolv.conf could not be read: {error}"))?;
bound(builder.options_mut());
builder
.build()
.map(|inner| Self { inner })
.map_err(|error| format!("the system resolver could not be built: {error}"))
}
pub(crate) async fn lookup(&self, name: &str) -> Resolution {
if let Ok(address) = name.parse::<IpAddr>() {
return Resolution::Resolved(vec![address]);
}
match tokio::time::timeout(LOOKUP_DEADLINE, self.inner.lookup_ip(name)).await {
Ok(Ok(found)) => {
let mut addresses: Vec<IpAddr> = Vec::new();
for address in found.iter() {
if !addresses.contains(&address) {
addresses.push(address);
}
}
Resolution::Resolved(addresses)
}
Ok(Err(error)) => classify(&error),
Err(_elapsed) => Resolution::DidNotAnswer(NoAnswer::DeadlineElapsed),
}
}
pub(crate) fn resolve(&self, name: &str) -> Resolution {
let Some(handle) = runtime_handle() else {
return Resolution::DidNotAnswer(NoAnswer::ResolverUnavailable);
};
let resolver = self.clone();
let name = name.to_string();
let (answer_tx, answer_rx) = std::sync::mpsc::sync_channel(1);
handle.spawn(async move {
let _ = answer_tx.send(resolver.lookup(&name).await);
});
answer_rx
.recv()
.unwrap_or(Resolution::DidNotAnswer(NoAnswer::ResolverUnavailable))
}
}
#[cfg(test)]
impl Resolution {
fn addresses(&self) -> &[IpAddr] {
match self {
Self::Resolved(addresses) => addresses,
_ => &[],
}
}
}
#[cfg(test)]
impl DnsResolver {
pub(crate) fn from_config(config: ResolverConfig) -> Result<Self, String> {
let handle = runtime_handle().ok_or_else(|| {
"the runtime that drives name resolution could not be built".to_string()
})?;
let _guard = handle.enter();
let mut builder = TokioResolver::builder_with_config(config, TokioRuntimeProvider::new());
bound(builder.options_mut());
builder
.build()
.map(|inner| Self { inner })
.map_err(|error| format!("the resolver could not be built: {error}"))
}
pub(crate) fn set_hosts(&mut self, hosts: Hosts) {
self.inner.set_hosts(Arc::new(hosts));
}
pub(crate) fn options(&self) -> &ResolverOpts {
self.inner.options()
}
}
fn bound(options: &mut ResolverOpts) {
options.timeout = NAMESERVER_TIMEOUT;
options.attempts = NAMESERVER_ATTEMPTS;
}
fn classify(error: &NetError) -> Resolution {
match error {
NetError::Dns(DnsError::NoRecordsFound(no_records)) => match no_records.response_code {
ResponseCode::NXDomain => Resolution::DoesNotExist,
ResponseCode::NoError => Resolution::Resolved(Vec::new()),
_ => Resolution::DidNotAnswer(NoAnswer::NoNameserverAnswered),
},
NetError::Dns(DnsError::ResponseCode(ResponseCode::NXDomain)) => Resolution::DoesNotExist,
NetError::Timeout => Resolution::DidNotAnswer(NoAnswer::DeadlineElapsed),
_ => Resolution::DidNotAnswer(NoAnswer::NoNameserverAnswered),
}
}
fn runtime() -> Option<&'static Runtime> {
static RUNTIME: OnceLock<Option<Runtime>> = OnceLock::new();
RUNTIME
.get_or_init(|| {
Builder::new_multi_thread()
.worker_threads(RESOLVER_WORKER_THREADS)
.thread_name("murmur-dns")
.enable_all()
.build()
.ok()
})
.as_ref()
}
pub(crate) fn runtime_handle() -> Option<&'static Handle> {
runtime().map(Runtime::handle)
}
pub(crate) fn shared() -> Option<&'static DnsResolver> {
static SHARED: OnceLock<Option<DnsResolver>> = OnceLock::new();
SHARED
.get_or_init(|| match DnsResolver::from_system() {
Ok(resolver) => Some(resolver),
Err(error) => {
crate::runtime_err!(
"[capsule-runtime] warning: no name resolver could be built: {error} \
(every name lookup this process makes reports that nothing answered)"
);
None
}
})
.as_ref()
}
pub(crate) fn resolve(name: &str) -> Resolution {
match shared() {
Some(resolver) => resolver.resolve(name),
None => Resolution::DidNotAnswer(NoAnswer::ResolverUnavailable),
}
}
#[cfg(test)]
pub(crate) mod scripted {
use std::io;
use std::net::{IpAddr, Ipv4Addr, UdpSocket};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use hickory_resolver::config::{NameServerConfig, ResolverConfig};
use crate::egress_proxy::{
build_dns_response, parse_dns_query, DNS_RCODE_NOERROR, DNS_RCODE_NXDOMAIN, DNS_TYPE_A,
DNS_TYPE_AAAA,
};
const POLL_INTERVAL: Duration = Duration::from_millis(100);
#[derive(Debug, Clone)]
pub(crate) enum Behaviour {
Answer(Vec<IpAddr>),
DoesNotExist,
Blackhole,
}
pub(crate) struct ScriptedResolver {
port: u16,
asked: Arc<Mutex<Vec<(String, u16)>>>,
stop: Arc<AtomicBool>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl ScriptedResolver {
pub(crate) fn start(
script: &[(&str, Behaviour)],
otherwise: Behaviour,
) -> io::Result<Self> {
let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0))?;
socket.set_read_timeout(Some(POLL_INTERVAL))?;
let port = socket.local_addr()?.port();
let script: Vec<(String, Behaviour)> = script
.iter()
.map(|(name, behaviour)| ((*name).to_string(), behaviour.clone()))
.collect();
let asked = Arc::new(Mutex::new(Vec::new()));
let stop = Arc::new(AtomicBool::new(false));
let thread = {
let asked = Arc::clone(&asked);
let stop = Arc::clone(&stop);
std::thread::spawn(move || serve(socket, script, otherwise, asked, stop))
};
Ok(Self {
port,
asked,
stop,
thread: Some(thread),
})
}
pub(crate) fn config(&self) -> ResolverConfig {
let mut name_server = NameServerConfig::udp(IpAddr::V4(Ipv4Addr::LOCALHOST));
for connection in &mut name_server.connections {
connection.port = self.port;
}
ResolverConfig::from_name_servers(vec![name_server])
}
pub(crate) fn asked(&self) -> Vec<(String, u16)> {
self.asked.lock().unwrap().clone()
}
pub(crate) fn names_asked(&self) -> Vec<String> {
let mut names: Vec<String> = Vec::new();
for (name, _) in self.asked.lock().unwrap().iter() {
if !names.contains(name) {
names.push(name.clone());
}
}
names
}
}
impl Drop for ScriptedResolver {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
fn serve(
socket: UdpSocket,
script: Vec<(String, Behaviour)>,
otherwise: Behaviour,
asked: Arc<Mutex<Vec<(String, u16)>>>,
stop: Arc<AtomicBool>,
) {
let mut buffer = [0u8; 4096];
while !stop.load(Ordering::SeqCst) {
let Ok((len, peer)) = socket.recv_from(&mut buffer) else {
continue;
};
let Some(query) = parse_dns_query(&buffer[..len]) else {
continue;
};
let qname = format!("{}.", query.name);
asked.lock().unwrap().push((qname.clone(), query.qtype));
let behaviour = script
.iter()
.find(|(name, _)| *name == qname)
.map(|(_, behaviour)| behaviour)
.unwrap_or(&otherwise);
let reply = match behaviour {
Behaviour::Blackhole => continue,
Behaviour::DoesNotExist => build_dns_response(&query, DNS_RCODE_NXDOMAIN, &[]),
Behaviour::Answer(addresses) => {
let matching: Vec<IpAddr> = addresses
.iter()
.copied()
.filter(|address| match query.qtype {
DNS_TYPE_A => address.is_ipv4(),
DNS_TYPE_AAAA => address.is_ipv6(),
_ => false,
})
.collect();
build_dns_response(&query, DNS_RCODE_NOERROR, &matching)
}
};
let _ = socket.send_to(&reply, peer);
}
}
}
#[cfg(test)]
mod tests {
use std::net::{IpAddr, Ipv4Addr};
use std::str::FromStr;
use hickory_resolver::config::ResolveHosts;
use hickory_resolver::proto::rr::Name;
use hickory_resolver::Hosts;
use super::scripted::{Behaviour, ScriptedResolver};
use super::{DnsResolver, NoAnswer, Resolution};
use crate::egress_proxy::DNS_TYPE_AAAA;
const ANSWER: IpAddr = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7));
const PINNED: IpAddr = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 9));
#[test]
fn a_name_the_upstream_answers_resolves_to_exactly_its_address() {
let upstream = ScriptedResolver::start(
&[("api.example.test.", Behaviour::Answer(vec![ANSWER]))],
Behaviour::DoesNotExist,
)
.unwrap();
let resolver = DnsResolver::from_config(upstream.config()).unwrap();
assert_eq!(
resolver.resolve("api.example.test"),
Resolution::Resolved(vec![ANSWER])
);
assert!(
upstream
.names_asked()
.contains(&"api.example.test.".to_string()),
"the scripted upstream must have been the one asked, got {:?}",
upstream.names_asked()
);
}
fn resolver_with_hosts(upstream: &ScriptedResolver, hosts_file: &str) -> DnsResolver {
let mut hosts = Hosts::default();
hosts.read_hosts_conf(hosts_file.as_bytes()).unwrap();
let mut resolver = DnsResolver::from_config(upstream.config()).unwrap();
resolver.set_hosts(hosts);
resolver
}
#[test]
fn a_name_in_the_hosts_file_resolves_without_a_query_leaving_the_process() {
let upstream = ScriptedResolver::start(&[], Behaviour::DoesNotExist).unwrap();
let resolver = resolver_with_hosts(
&upstream,
"198.51.100.9 pinned.example.test\n2001:db8::9 pinned.example.test\n",
);
assert!(resolver
.resolve("pinned.example.test")
.addresses()
.contains(&PINNED));
assert_eq!(
upstream.asked().len(),
0,
"a name the hosts file answers in both families must not reach a nameserver"
);
}
#[test]
fn a_hosts_entry_in_one_family_only_still_asks_upstream_for_the_other() {
let upstream = ScriptedResolver::start(&[], Behaviour::DoesNotExist).unwrap();
let resolver = resolver_with_hosts(&upstream, "198.51.100.9 pinned.example.test\n");
assert_eq!(
resolver.resolve("pinned.example.test"),
Resolution::Resolved(vec![PINNED])
);
assert_eq!(
upstream.asked(),
vec![("pinned.example.test.".to_string(), DNS_TYPE_AAAA)]
);
}
#[test]
fn the_shipped_resolver_reads_the_system_hosts_file() {
let resolver = DnsResolver::from_system().unwrap();
assert_ne!(
resolver.options().use_hosts_file,
ResolveHosts::Never,
"the shipped resolver must consult /etc/hosts"
);
assert!(
resolver
.resolve("localhost")
.addresses()
.contains(&IpAddr::V4(Ipv4Addr::LOCALHOST)),
"localhost must resolve to the loopback address the system files name"
);
}
fn with_search(upstream: &ScriptedResolver) -> DnsResolver {
let mut config = upstream.config();
config.add_search(Name::from_str("example.test.").unwrap());
let resolver = DnsResolver::from_config(config).unwrap();
assert_eq!(resolver.options().ndots, 1);
resolver
}
#[test]
fn a_single_label_name_is_tried_suffixed_before_it_is_tried_bare() {
let upstream = ScriptedResolver::start(&[], Behaviour::DoesNotExist).unwrap();
let resolver = with_search(&upstream);
assert_eq!(resolver.resolve("web"), Resolution::DoesNotExist);
assert_eq!(
upstream.names_asked(),
vec!["web.example.test.".to_string(), "web.".to_string()],
);
}
#[test]
fn a_single_label_name_resolves_through_the_search_suffix() {
let upstream = ScriptedResolver::start(
&[("web.example.test.", Behaviour::Answer(vec![ANSWER]))],
Behaviour::DoesNotExist,
)
.unwrap();
let resolver = with_search(&upstream);
assert_eq!(resolver.resolve("web"), Resolution::Resolved(vec![ANSWER]));
assert_eq!(
upstream.names_asked(),
vec!["web.example.test.".to_string()]
);
}
#[test]
fn a_name_carrying_ndots_dots_is_tried_absolute_before_it_is_suffixed() {
let upstream = ScriptedResolver::start(
&[(
"api.example.test.example.test.",
Behaviour::Answer(vec![ANSWER]),
)],
Behaviour::DoesNotExist,
)
.unwrap();
let resolver = with_search(&upstream);
assert_eq!(
resolver.resolve("api.example.test"),
Resolution::Resolved(vec![ANSWER])
);
assert_eq!(
upstream.names_asked(),
vec![
"api.example.test.".to_string(),
"api.example.test.example.test.".to_string(),
],
);
}
#[test]
fn an_address_literal_is_returned_without_a_lookup() {
let upstream = ScriptedResolver::start(&[], Behaviour::Blackhole).unwrap();
let resolver = DnsResolver::from_config(upstream.config()).unwrap();
assert_eq!(
resolver.resolve("127.0.0.1"),
Resolution::Resolved(vec![IpAddr::V4(Ipv4Addr::LOCALHOST)])
);
assert_eq!(upstream.asked().len(), 0);
}
#[test]
fn the_two_failure_outcomes_read_differently() {
assert_ne!(
NoAnswer::DeadlineElapsed.to_string(),
NoAnswer::NoNameserverAnswered.to_string()
);
assert!(NoAnswer::DeadlineElapsed
.to_string()
.contains("did not answer within 5s"));
}
}