mail_auth/dns/mod.rs
1/*
2 * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
3 *
4 * SPDX-License-Identifier: Apache-2.0 OR MIT
5 */
6
7//! DNS resolution and caching.
8//!
9//! Every verifier resolves DNS records through the backend wrapped by
10//! [`MessageAuthenticator`](crate::MessageAuthenticator). Exactly one backend
11//! is compiled in:
12//!
13//! - `dns-hickory` (default): UDP, TCP or DNS-over-TLS queries through
14//! [hickory-resolver](https://crates.io/crates/hickory-resolver). Not
15//! available on WebAssembly.
16//! - `dns-doh`: DNS-over-HTTPS queries (RFC 8484) through `DohResolver`,
17//! using either the JSON API or the DNS wire format. Works on WebAssembly.
18//!
19//! Lookups can be cached. A cache is supplied per call by wrapping the
20//! verifier input in [`Parameters`] with [`Parameters::with_cache`]. The cache
21//! type implements [`DnsCache`], which hands out one [`ResolverCache`] per
22//! record type (TXT, MX, A, AAAA and PTR). TXT records are cached already
23//! parsed, as [`TxtRecord`] values. [`NoCache`] disables caching and is the
24//! default.
25//!
26//! Negative answers (RFC 2308) are cached too: a name that does not exist
27//! (`NXDOMAIN`) or has no records of the requested type (NODATA) is stored as
28//! [`TxtRecord::Error`] in the TXT cache and as `Err(`[`Negative`]`)` in the
29//! other caches, until the negative TTL of the answer expires. That TTL is the
30//! SOA-derived value of RFC 2308 Section 5, capped by
31//! [`MessageAuthenticator::with_max_negative_ttl`](crate::MessageAuthenticator::with_max_negative_ttl).
32//! Answers without an SOA record and resolver failures are never cached.
33//!
34//! The lookup helpers on `MessageAuthenticator` (`txt_lookup`, `mx_lookup`,
35//! `ipv4_lookup`, `ipv6_lookup`, `ip_lookup`, `ptr_lookup`, `exists`) are
36//! public so that applications can reuse the resolver and caches for their
37//! own queries.
38
39use crate::{DnsError, Error, Instant};
40#[cfg(not(feature = "dns-doh"))]
41pub(crate) use hickory_resolver::proto::op::ResponseCode;
42use std::{
43 borrow::Cow,
44 net::{IpAddr, Ipv4Addr},
45 sync::Arc,
46};
47
48pub mod cache;
49#[cfg(feature = "dns-doh")]
50mod doh;
51#[cfg(not(feature = "dns-doh"))]
52mod hickory;
53mod lookup;
54mod query;
55mod txt;
56
57pub use cache::{DnsCache, NoCache, Parameters, ResolverCache};
58#[cfg(feature = "dns-doh")]
59pub use doh::DohResolver;
60#[cfg(any(test, feature = "test"))]
61pub use query::mock_resolve;
62pub(crate) use query::{QueryError, QueryResult};
63pub use txt::{TxtRecord, TxtRecordParser, UnwrapTxtRecord};
64
65#[cfg(not(feature = "dns-doh"))]
66pub(crate) const DNS_RCODE_NXDOMAIN: ResponseCode = ResponseCode::NXDomain;
67#[cfg(feature = "dns-doh")]
68pub(crate) const DNS_RCODE_NXDOMAIN: u16 = 3;
69#[cfg(feature = "dns-doh")]
70pub(crate) type ResponseCode = u16;
71
72/// Address families queried by
73/// [`MessageAuthenticator::ip_lookup`](crate::MessageAuthenticator::ip_lookup),
74/// and in which order.
75#[derive(Debug, Clone, Copy, Default, Hash, PartialEq, Eq)]
76pub enum IpLookupStrategy {
77 /// Only query for A (IPv4) records.
78 Ipv4Only,
79 /// Only query for AAAA (IPv6) records.
80 Ipv6Only,
81 /// Query for AAAA (IPv6) records; if that fails, query for A (IPv4)
82 /// records.
83 Ipv6thenIpv4,
84 /// Query for A (IPv4) records; if that fails, query for AAAA (IPv6)
85 /// records. This is the default.
86 #[default]
87 Ipv4thenIpv6,
88}
89
90/// The records returned by a DNS lookup, as stored in a [`ResolverCache`].
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct RecordSet<T> {
93 /// The records in the answer, shared so that cache hits do not copy them.
94 pub records: Arc<[T]>,
95 /// DNSSEC validation state of the answer.
96 pub dnssec_status: DnssecStatus,
97}
98
99/// A cached negative answer (RFC 2308): the name does not exist (`NXDOMAIN`),
100/// or it has no records of the requested type (NODATA).
101///
102/// The MX, A, AAAA and PTR caches of a [`DnsCache`] hold
103/// `Result<RecordSet<T>, Negative>`. The lookup helpers store `Err(Negative)`
104/// only for answers that carry an SOA record, and return a cached `Err` as
105/// [`DnsError::RecordNotFound`] with the same response code, exactly as the
106/// query would.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub struct Negative {
109 /// The response code of the answer: `NXDOMAIN`, or `NOERROR` for NODATA.
110 #[cfg(not(feature = "dns-doh"))]
111 pub code: ResponseCode,
112 /// The numeric response code of the answer: 3 for `NXDOMAIN`, 0 for
113 /// NODATA.
114 #[cfg(feature = "dns-doh")]
115 pub code: u16,
116}
117
118const _: () =
119 assert!(size_of::<Result<RecordSet<Ipv4Addr>, Negative>>() == size_of::<RecordSet<Ipv4Addr>>());
120
121impl From<Negative> for Error {
122 fn from(negative: Negative) -> Self {
123 Error::Dns(DnsError::RecordNotFound(negative.code))
124 }
125}
126
127/// A group of MX records (RFC 5321 Section 5.1) that share a preference
128/// value.
129///
130/// [`MessageAuthenticator::mx_lookup`](crate::MessageAuthenticator::mx_lookup)
131/// returns one `Mx` per distinct preference, sorted from the lowest (most
132/// preferred) value to the highest.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct Mx {
135 /// Lowercased exchange host names with this preference, in answer order.
136 pub exchanges: Box<[Box<str>]>,
137 /// MX preference value; lower values are tried first.
138 pub preference: u16,
139}
140
141/// DNSSEC validation state of a DNS answer (RFC 4035 Section 4.3).
142///
143/// The built-in backends do not validate DNSSEC and always report
144/// [`DnssecStatus::Indeterminate`]. The other variants are available to
145/// [`DnsCache`] implementations that fill records from a validating resolver.
146#[derive(Debug, Clone, Copy, Default, Hash, PartialEq, Eq)]
147#[repr(u16)]
148pub enum DnssecStatus {
149 /// The answer has a valid chain of trust to a trust anchor.
150 Secure,
151 /// The answer is provably not signed.
152 Insecure,
153 /// The answer is signed but failed validation.
154 Bogus,
155 /// The validation state is unknown. This is the default.
156 #[default]
157 Indeterminate,
158}
159
160/// A raw DNS answer together with the time at which it expires, as returned
161/// by [`MessageAuthenticator::ipv4_lookup_raw`](crate::MessageAuthenticator::ipv4_lookup_raw)
162/// and [`MessageAuthenticator::ipv6_lookup_raw`](crate::MessageAuthenticator::ipv6_lookup_raw).
163pub struct DnsEntry<T> {
164 /// The answer data.
165 pub entry: T,
166 /// When the answer expires, derived from the record TTLs.
167 pub expires: Instant,
168}
169
170/// Returns `true` if `domain` looks like a valid host name for a DNS query.
171///
172/// The check requires every label to be at most 63 characters long (RFC 1035
173/// Section 2.3.4), and the name to contain at least one dot and at least one
174/// alphanumeric character. It does not check the total length. SPF uses it to
175/// reject malformed domains before querying (RFC 7208 Section 4.3).
176#[inline]
177pub fn has_valid_labels(domain: &str) -> bool {
178 let mut has_dots = false;
179 let mut has_chars = false;
180 let mut label_len = 0;
181 for ch in domain.chars() {
182 label_len += 1;
183
184 if ch.is_alphanumeric() {
185 has_chars = true;
186 } else if ch == '.' {
187 has_dots = true;
188 label_len = 0;
189 }
190
191 if label_len > 63 {
192 return false;
193 }
194 }
195 has_chars && has_dots
196}
197
198pub(crate) fn to_a_label(domain: &str) -> Cow<'_, str> {
199 if !domain.is_ascii() {
200 idna::domain_to_ascii(domain)
201 .map(Cow::Owned)
202 .unwrap_or(Cow::Borrowed(domain))
203 } else if domain.bytes().any(|byte| byte.is_ascii_uppercase()) {
204 Cow::Owned(domain.to_ascii_lowercase())
205 } else {
206 Cow::Borrowed(domain)
207 }
208}
209
210/// Conversion of a domain name to the fully qualified form used as a DNS
211/// query and cache key.
212///
213/// Implemented for every `T: AsRef<str>`.
214pub trait ToFqdn {
215 /// Returns the name lowercased and with a trailing dot. Borrows when the
216 /// name is already in that form.
217 fn to_fqdn(&self) -> Cow<'_, str>;
218}
219
220impl<T: AsRef<str>> ToFqdn for T {
221 fn to_fqdn(&self) -> Cow<'_, str> {
222 let value = self.as_ref();
223 let bytes = value.as_bytes();
224 if matches!(bytes.last(), Some(b'.'))
225 && !bytes
226 .iter()
227 .any(|byte| byte.is_ascii_uppercase() || !byte.is_ascii())
228 {
229 Cow::Borrowed(value)
230 } else if value.is_ascii() {
231 let mut fqdn = String::with_capacity(value.len() + 1);
232 fqdn.push_str(value);
233 fqdn.make_ascii_lowercase();
234 if !matches!(bytes.last(), Some(b'.')) {
235 fqdn.push('.');
236 }
237 Cow::Owned(fqdn)
238 } else {
239 let mut fqdn = value.to_lowercase();
240 if !value.ends_with('.') {
241 fqdn.push('.');
242 }
243 Cow::Owned(fqdn)
244 }
245 }
246}
247
248/// Conversion of an IP address to the label sequence of its reverse DNS name.
249pub trait ToReverseName {
250 /// Returns the address labels in reverse order, without the
251 /// `in-addr.arpa` (RFC 1035 Section 3.5) or `ip6.arpa` (RFC 3596
252 /// Section 2.5) suffix. IPv4 addresses produce decimal octets
253 /// (`4.3.2.1` for `1.2.3.4`); IPv6 addresses produce one hexadecimal
254 /// nibble per label.
255 fn to_reverse_name(&self) -> String;
256}
257
258impl ToReverseName for IpAddr {
259 fn to_reverse_name(&self) -> String {
260 match self {
261 IpAddr::V4(ip) => {
262 let mut segments = String::with_capacity(15);
263 let mut buf = [0u8; 3];
264 for octet in ip.octets().iter().rev() {
265 if !segments.is_empty() {
266 segments.push('.');
267 }
268 for &digit in decimal_u8(*octet, &mut buf) {
269 segments.push(char::from(digit));
270 }
271 }
272 segments
273 }
274 IpAddr::V6(ip) => {
275 let mut segments = String::with_capacity(63);
276 for segment in ip.segments().iter().rev() {
277 for shift in [0u32, 4, 8, 12] {
278 if !segments.is_empty() {
279 segments.push('.');
280 }
281 segments.push(char::from(hex_nibble((segment >> shift) as u8)));
282 }
283 }
284 segments
285 }
286 }
287 }
288}
289
290#[inline(always)]
291pub(crate) fn hex_nibble(value: u8) -> u8 {
292 b"0123456789abcdef"[(value & 0x0f) as usize]
293}
294
295#[inline(always)]
296pub(crate) fn decimal_u8(value: u8, buf: &mut [u8; 3]) -> &[u8] {
297 buf[0] = b'0' + value / 100;
298 buf[1] = b'0' + (value / 10) % 10;
299 buf[2] = b'0' + value % 10;
300 let start = if value >= 100 {
301 0
302 } else if value >= 10 {
303 1
304 } else {
305 2
306 };
307 &buf[start..]
308}
309
310#[cfg(test)]
311mod test {
312 use super::ToReverseName;
313 use std::net::IpAddr;
314
315 #[test]
316 fn reverse_lookup_addr() {
317 for (addr, expected) in [
318 ("1.2.3.4", "4.3.2.1"),
319 (
320 "2001:db8::cb01",
321 "1.0.b.c.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2",
322 ),
323 (
324 "2a01:4f9:c011:b43c::1",
325 "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.c.3.4.b.1.1.0.c.9.f.4.0.1.0.a.2",
326 ),
327 ] {
328 assert_eq!(addr.parse::<IpAddr>().unwrap().to_reverse_name(), expected);
329 }
330 }
331}