Skip to main content

abuse_contact/
resolver.rs

1//! The DNS sources: the Abusix and abuse.net zones, and the mail host of a domain.
2//!
3//! [`crate::dns`] builds the names to ask for and reads the answers. This module asks.
4
5use std::net::{IpAddr, SocketAddr};
6
7use hickory_resolver::TokioResolver;
8use hickory_resolver::config::{NameServerConfig, ResolverConfig};
9use hickory_resolver::net::NetError;
10use hickory_resolver::net::runtime::TokioRuntimeProvider;
11use hickory_resolver::proto::rr::RData;
12
13use crate::contact::{Contact, Scope, Source};
14use crate::dns;
15use crate::error::Error;
16use crate::query::DomainName;
17
18/// Looks up the DNS sources.
19///
20/// Build one and keep it. It holds the resolver configuration and a cache of answers.
21///
22/// ```no_run
23/// use abuse_contact::Resolver;
24///
25/// # async fn run() -> Result<(), abuse_contact::Error> {
26/// let resolver = Resolver::new()?;
27///
28/// // AFRINIC publishes no abuse contact in RDAP. Abusix has one.
29/// for contact in resolver.abusix("196.216.2.1".parse().unwrap()).await? {
30///     println!("{} ({:?})", contact.email, contact.scope);
31/// }
32/// # Ok(())
33/// # }
34/// ```
35#[derive(Clone, Debug)]
36pub struct Resolver {
37    inner: TokioResolver,
38}
39
40impl Resolver {
41    /// Returns a resolver that uses the resolver configuration of the system.
42    ///
43    /// # Errors
44    ///
45    /// Returns [`Error::Dns`] when the system configuration cannot be read.
46    pub fn new() -> Result<Self, Error> {
47        let inner = TokioResolver::builder_tokio()
48            .and_then(|builder| builder.build())
49            .map_err(|source| dns_error("the system resolver configuration", source))?;
50
51        Ok(Self { inner })
52    }
53
54    /// Returns a resolver that asks these nameservers, over UDP and TCP.
55    ///
56    /// # Errors
57    ///
58    /// Returns [`Error::Dns`] when the resolver cannot be built.
59    pub fn with_nameservers(nameservers: &[SocketAddr]) -> Result<Self, Error> {
60        let servers = nameservers
61            .iter()
62            .map(|address| {
63                let mut server = NameServerConfig::udp_and_tcp(address.ip());
64                for connection in &mut server.connections {
65                    connection.port = address.port();
66                }
67                server
68            })
69            .collect();
70
71        let config = ResolverConfig::from_parts(None, Vec::new(), servers);
72        let inner = TokioResolver::builder_with_config(config, TokioRuntimeProvider::default())
73            .build()
74            .map_err(|source| dns_error("the nameservers given", source))?;
75
76        Ok(Self { inner })
77    }
78
79    /// Returns the contacts Abusix gives for the network that holds an address.
80    ///
81    /// Abusix answers for AFRINIC space, where RDAP publishes no abuse entity.
82    ///
83    /// # Errors
84    ///
85    /// Returns [`Error::NotPublic`] for a private or reserved address, and
86    /// [`Error::Dns`] when the lookup does not finish.
87    pub async fn abusix(&self, ip: IpAddr) -> Result<Vec<Contact>, Error> {
88        // The zone has no record for the IPv6 spelling of an IPv4 address, and a
89        // private address has no network to report to.
90        let ip = crate::query::unmap(ip);
91        if !crate::is_public(ip) {
92            return Err(Error::NotPublic {
93                target: ip.to_string(),
94            });
95        }
96
97        let records = self.txt(&dns::abusix_name(ip)).await?;
98        Ok(dns::contacts_from_txt(
99            &records,
100            Scope::Network,
101            Source::Abusix,
102        ))
103    }
104
105    /// Returns the contacts abuse.net gives for a domain.
106    ///
107    /// # Errors
108    ///
109    /// Returns [`Error::Dns`] when the lookup does not finish.
110    pub async fn abuse_net(&self, domain: &DomainName) -> Result<Vec<Contact>, Error> {
111        // A long domain with the zone appended is longer than DNS allows. The zone
112        // cannot hold that name, and the resolver refuses to ask for it.
113        let name = dns::abuse_net_name(domain);
114        if name.len() > DomainName::MAX_BYTES {
115            return Ok(Vec::new());
116        }
117
118        let records = self.txt(&name).await?;
119        Ok(dns::contacts_from_txt(
120            &records,
121            Scope::Domain,
122            Source::AbuseNet,
123        ))
124    }
125
126    /// Returns `abuse@` at the domain, when the domain takes mail.
127    ///
128    /// RFC 2142 requires the mailbox, and many domains do not have it, so the address
129    /// is a guess. A domain that takes no mail cannot have it at all, and gives `None`:
130    /// a name that does not exist, a null MX, or no MX and no address.
131    ///
132    /// # Errors
133    ///
134    /// Returns [`Error::Dns`] when a lookup does not finish.
135    pub async fn rfc2142(&self, domain: &DomainName) -> Result<Option<Contact>, Error> {
136        let name = absolute(domain.as_str());
137
138        let exchanges: Vec<String> = match self.inner.mx_lookup(name.as_str()).await {
139            Ok(lookup) => lookup
140                .answers()
141                .iter()
142                .filter_map(|record| match &record.data {
143                    RData::MX(mx) => Some(mx.exchange.to_string()),
144                    _ => None,
145                })
146                .collect(),
147            Err(error) if error.is_nx_domain() => return Ok(None),
148            Err(error) if error.is_no_records_found() => Vec::new(),
149            Err(source) => return Err(dns_error(&name, source)),
150        };
151
152        let takes_mail = if exchanges.is_empty() {
153            // RFC 5321 section 5.1: with no MX, mail goes to the address of the domain.
154            self.has_address(&name).await?
155        } else {
156            dns::names_a_mail_host(exchanges.iter().map(String::as_str))
157        };
158        if !takes_mail {
159            return Ok(None);
160        }
161
162        dns::rfc2142_contact(domain).map(Some).map_err(Error::from)
163    }
164
165    /// Returns the TXT records at a name, each as one string.
166    ///
167    /// A name that does not exist, or has no TXT records, gives no records. That is
168    /// an answer, not a failure. A record that is not UTF-8 is dropped.
169    async fn txt(&self, name: &str) -> Result<Vec<String>, Error> {
170        let name = absolute(name);
171
172        match self.inner.txt_lookup(name.as_str()).await {
173            Ok(lookup) => Ok(lookup
174                .answers()
175                .iter()
176                .filter_map(|record| match &record.data {
177                    RData::TXT(txt) => txt_value(&txt.txt_data),
178                    _ => None,
179                })
180                .collect()),
181            Err(error) if error.is_no_records_found() => Ok(Vec::new()),
182            Err(source) => Err(dns_error(&name, source)),
183        }
184    }
185
186    /// Returns whether a name has an IPv4 or IPv6 address.
187    async fn has_address(&self, name: &str) -> Result<bool, Error> {
188        match self.inner.lookup_ip(name).await {
189            Ok(lookup) => Ok(lookup.iter().next().is_some()),
190            Err(error) if error.is_no_records_found() => Ok(false),
191            Err(source) => Err(dns_error(name, source)),
192        }
193    }
194}
195
196/// Returns the name with one trailing dot, so the resolver asks for it as it is.
197///
198/// Without the dot, a name that does not exist is asked for again with each search
199/// domain of the system appended, and a zone of the local network could answer.
200fn absolute(name: &str) -> String {
201    format!("{}.", name.trim_end_matches('.'))
202}
203
204/// Joins the character strings of one TXT record into its value.
205///
206/// A value longer than 255 bytes is sent as more than one string, and the strings
207/// together are the value. A string can end in the middle of a character, so the
208/// bytes are joined before they are read as UTF-8. Returns `None` when the value is
209/// not UTF-8.
210fn txt_value(strings: &[Box<[u8]>]) -> Option<String> {
211    String::from_utf8(strings.concat()).ok()
212}
213
214/// Returns the error for a lookup that did not finish.
215fn dns_error(name: &str, source: NetError) -> Error {
216    Error::Dns {
217        name: name.to_owned(),
218        source: Box::new(source),
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn a_name_gets_the_trailing_dot_that_keeps_it_from_a_search_domain() {
228        assert_eq!(
229            absolute("229.132.16.104.abuse-contacts.abusix.zone"),
230            "229.132.16.104.abuse-contacts.abusix.zone."
231        );
232        assert_eq!(absolute("example.com."), "example.com.");
233    }
234
235    #[test]
236    fn the_strings_of_a_record_join_into_its_value() {
237        let strings: Vec<Box<[u8]>> = vec![
238            b"arin-contact@google.com,".to_vec().into_boxed_slice(),
239            b"network-abuse@google.com".to_vec().into_boxed_slice(),
240        ];
241
242        assert_eq!(
243            txt_value(&strings),
244            Some("arin-contact@google.com,network-abuse@google.com".to_owned())
245        );
246    }
247
248    #[test]
249    fn a_character_split_across_two_strings_is_kept_whole() {
250        // "\xc3\xa6" is "æ" in UTF-8.
251        let strings: Vec<Box<[u8]>> = vec![
252            b"abuse@ex\xc3".to_vec().into_boxed_slice(),
253            b"\xa6mple.com".to_vec().into_boxed_slice(),
254        ];
255
256        assert_eq!(txt_value(&strings), Some("abuse@exæmple.com".to_owned()));
257    }
258
259    #[test]
260    fn a_record_that_is_not_utf8_has_no_value() {
261        let strings: Vec<Box<[u8]>> = vec![b"abuse@\xffexample.com".to_vec().into_boxed_slice()];
262
263        assert_eq!(txt_value(&strings), None);
264    }
265}