reserve-core 0.1.0

Core lookup, catalog, and rate-limiting engine behind the reserve domain finder
Documentation
//! @docgen The platform resolver is unusable on a static binary and on Android, so names are resolved in process.

use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;

use hickory_resolver::config::{
    ConnectionConfig, NameServerConfig, ProtocolConfig, ResolverConfig, ResolverOpts,
};
use hickory_resolver::net::runtime::TokioRuntimeProvider;
use hickory_resolver::proto::rr::RecordType;
use hickory_resolver::{Resolver, TokioResolver};

use crate::error::Error;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DnsVerdict {
    InUse,
    /// @docgen A registered name with no nameservers looks exactly like an unregistered one, so absence must never be read as available.
    Absent,
    NoAnswer,
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DnsRecords {
    pub by_type: Vec<(String, Vec<String>)>,
}

impl DnsRecords {
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.by_type.is_empty()
    }
}

const RECORD_TYPES: &[(RecordType, &str)] = &[
    (RecordType::A, "a"),
    (RecordType::AAAA, "aaaa"),
    (RecordType::NS, "ns"),
    (RecordType::MX, "mx"),
    (RecordType::TXT, "txt"),
    (RecordType::SOA, "soa"),
];

pub(crate) fn build(timeout: Duration) -> Result<TokioResolver, Error> {
    let mut options = ResolverOpts::default();
    // @docgen One try must fit inside the per-request budget with room left over for the connection itself.
    options.timeout = (timeout / 4).max(Duration::from_secs(2));
    options.attempts = 2;
    // @docgen Networks that discard UDP port 53 resolve nothing without this.
    options.try_tcp_on_error = true;

    if let Ok(builder) = Resolver::builder_tokio()
        && let Ok(resolver) = builder.with_options(options.clone()).build()
    {
        return Ok(resolver);
    }

    // @docgen Two public providers are configured so one being unreachable or blocked is not fatal.
    let servers = [
        IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)),
        IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)),
    ]
    .into_iter()
    .map(|ip| {
        NameServerConfig::new(
            ip,
            true,
            vec![
                ConnectionConfig::new(ProtocolConfig::Udp),
                ConnectionConfig::new(ProtocolConfig::Tcp),
            ],
        )
    })
    .collect();

    let config = ResolverConfig::from_parts(None, Vec::new(), servers);
    Resolver::builder_with_config(config, TokioRuntimeProvider::default())
        .with_options(options)
        .build()
        .map_err(|source| Error::NetworkUnreachable {
            source: Box::new(source),
        })
}

/// @docgen Nameservers are asked first because a delegated name always has them, so one query usually settles it.
pub(crate) async fn query(resolver: &TokioResolver, domain: &str) -> DnsVerdict {
    let mut saw_absence = false;

    for record_type in [RecordType::NS, RecordType::SOA, RecordType::A] {
        match resolver.lookup(domain, record_type).await {
            Ok(answer) if !answer.answers().is_empty() => return DnsVerdict::InUse,
            Ok(_) => saw_absence = true,
            Err(error) => {
                if looks_absent(&error.to_string()) {
                    saw_absence = true;
                } else {
                    return DnsVerdict::NoAnswer;
                }
            }
        }
    }

    if saw_absence {
        DnsVerdict::Absent
    } else {
        DnsVerdict::NoAnswer
    }
}

pub async fn dns_records(resolver: &TokioResolver, domain: &str) -> DnsRecords {
    let mut by_type = Vec::new();

    for (record_type, label) in RECORD_TYPES {
        let Ok(answer) = resolver.lookup(domain, *record_type).await else {
            continue;
        };
        let mut values: Vec<String> = answer
            .answers()
            .iter()
            .filter(|record| record.record_type() == *record_type)
            .map(|record| {
                let line = record.to_string();
                line.split_whitespace()
                    .skip(4)
                    .collect::<Vec<_>>()
                    .join(" ")
            })
            .filter(|value| !value.is_empty())
            .collect();
        values.sort_unstable();
        values.dedup();
        if !values.is_empty() {
            by_type.push(((*label).to_owned(), values));
        }
    }

    DnsRecords { by_type }
}

/// @docgen Only a real "nothing is here" says anything about the name; an unreachable resolver says nothing.
fn looks_absent(message: &str) -> bool {
    let message = message.to_lowercase();
    let absent = message.contains("no records resolved")
        || message.contains("nxdomain")
        || message.contains("no record");
    let unreachable = message.contains("timed out")
        || message.contains("timeout")
        || message.contains("connection")
        || message.contains("no connections");
    absent && !unreachable
}

/// @docgen The HTTP client would otherwise call the platform resolver on a blocking thread and hit the same wall.
#[derive(Debug)]
pub(crate) struct HttpResolver(TokioResolver);

impl HttpResolver {
    #[must_use]
    pub(crate) fn new(resolver: &TokioResolver) -> Arc<Self> {
        Arc::new(Self(resolver.clone()))
    }
}

impl reqwest::dns::Resolve for HttpResolver {
    fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
        let resolver = self.0.clone();
        Box::pin(async move {
            let resolved = resolver.lookup_ip(name.as_str()).await?;
            // @docgen Port zero lets the client fill in the real port from the URL.
            let sockets: Vec<SocketAddr> =
                resolved.iter().map(|ip| SocketAddr::new(ip, 0)).collect();
            let addresses: reqwest::dns::Addrs = Box::new(sockets.into_iter());
            Ok(addresses)
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_resolver_builds_without_panicking() {
        assert!(build(Duration::from_secs(8)).is_ok());
    }

    #[test]
    fn absence_is_told_apart_from_unreachability() {
        assert!(looks_absent("no records resolved for Query"));
        assert!(looks_absent("NXDOMAIN reported by server"));

        assert!(!looks_absent("request timed out"));
        assert!(!looks_absent("no connections available"));
        assert!(!looks_absent("connection refused"));
        assert!(!looks_absent("no records resolved: request timed out"));
    }

    #[test]
    fn dns_has_no_way_to_say_available() {
        // @docgen The verdict set deliberately has no "available" member: absence in DNS never proves a name can be registered.
        for verdict in [DnsVerdict::InUse, DnsVerdict::Absent, DnsVerdict::NoAnswer] {
            assert!(matches!(
                verdict,
                DnsVerdict::InUse | DnsVerdict::Absent | DnsVerdict::NoAnswer
            ));
        }
    }

    #[test]
    fn empty_records_report_empty() {
        assert!(DnsRecords::default().is_empty());
        let filled = DnsRecords {
            by_type: vec![("a".to_owned(), vec!["127.0.0.1".to_owned()])],
        };
        assert!(!filled.is_empty());
    }
}