Skip to main content

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}