portier_broker 0.5.2

Reference implementation of the Portier broker protocol
use std::{
    collections::HashSet,
    io,
    net::{IpAddr, ToSocketAddrs},
    time::Duration,
};
use thiserror::Error;
use trust_dns_resolver::{
    config::{LookupIpStrategy, NameServerConfig, Protocol, ResolverConfig, ResolverOpts},
    proto::{error::ProtoError, rr::rdata::MX},
    Name, TokioAsyncResolver,
};

use crate::metrics;

/// Errors produced by `DomainValidator::validate`.
#[derive(Debug, Error)]
pub enum DomainValidationError {
    #[error("invalid domain name: {0}")]
    Invalid(ProtoError),
    #[error("the domain is blocked")]
    Blocked,
    #[error("the domain indicated it does not accept mail")]
    NullMx,
    #[error("could not resolve the domain mail servers")]
    NoServers,
    #[error("none of the domain mail servers have public IP addresses")]
    NoPublicIps,
}

impl DomainValidationError {
    /// Count this error in metrics.
    pub fn apply_metric(&self) {
        match self {
            Self::Invalid(_) => metrics::DOMAIN_VALIDATION_INVALID_NAME.inc(),
            Self::Blocked => metrics::DOMAIN_VALIDATION_BLOCKED.inc(),
            Self::NullMx => metrics::DOMAIN_VALIDATION_NULL_MX.inc(),
            Self::NoServers => metrics::DOMAIN_VALIDATION_NO_SERVERS.inc(),
            Self::NoPublicIps => metrics::DOMAIN_VALIDATION_NO_PUBLIC_IPS.inc(),
        }
    }
}

/// Validates domains based on some configuration.
pub struct DomainValidator {
    /// Exact domains to allow.
    allowed_domains: HashSet<Name>,
    /// Exact domains to block.
    blocked_domains: HashSet<Name>,
    /// DNS resolver for email domain validation.
    dns_resolver: Option<TokioAsyncResolver>,
    /// Whether to ignore reserved IP addresses in DNS results.
    pub verify_public_ip: bool,
    /// Whether to treat anything not in the allow-list as blocked.
    pub allowed_domains_only: bool,
}

impl DomainValidator {
    pub fn new() -> Self {
        Self {
            allowed_domains: HashSet::new(),
            blocked_domains: HashSet::new(),
            dns_resolver: None,
            verify_public_ip: true,
            allowed_domains_only: false,
        }
    }

    /// Add a domain to the set of allowed domains.
    pub fn add_allowed_domain(&mut self, domain: &str) -> Result<(), ProtoError> {
        let mut domain = Name::from_utf8(domain)?.to_lowercase();
        domain.set_fqdn(true);
        self.allowed_domains.insert(domain);
        Ok(())
    }

    /// Add a domain to the set of blocked domains.
    pub fn add_blocked_domain(&mut self, domain: &str) -> Result<(), ProtoError> {
        let mut domain = Name::from_utf8(domain)?.to_lowercase();
        domain.set_fqdn(true);
        self.blocked_domains.insert(domain);
        Ok(())
    }

    /// Set the DNS resolver for email domain validation.
    pub fn set_resolver(&mut self, addr: Option<&str>) -> Result<(), io::Error> {
        let addr = match addr {
            Some(addr) => addr,
            None => {
                self.dns_resolver = None;
                return Ok(());
            }
        };

        let mut cfg = ResolverConfig::new();
        for socket_addr in addr.to_socket_addrs()? {
            cfg.add_name_server(NameServerConfig {
                socket_addr,
                protocol: Protocol::Udp,
                tls_dns_name: None,
                trust_nx_responses: true,
            });
        }

        let opts = ResolverOpts {
            // Email domains must always be FQDNs.
            ndots: 0,
            // Tighter timeouts and retries, because we're handling a user agent request.
            timeout: Duration::from_secs(5),
            attempts: 1,
            // Trust the server, don't do DNSSEC ourselves.
            validate: false,
            // Assume mail servers still require IPv4, so try query only A-records first.
            // This creates an edge case with `verify_public_ip`, where the mail server only has
            // private IPv4, but public IPv6, yet we fail. We consider this extremely unlikely.
            ip_strategy: LookupIpStrategy::Ipv4thenIpv6,
            // Leave all caching to the server.
            cache_size: 0,
            // Per our config docs, using `/etc/hosts` would be surprising behaviour.
            use_hosts_file: false,
            ..ResolverOpts::default()
        };

        // Unwrap, because this currently doesn't appear to fail ever.
        self.dns_resolver = Some(TokioAsyncResolver::tokio(cfg, opts).unwrap());
        Ok(())
    }

    /// Validate a domain.
    pub async fn validate(&self, domain: &str) -> Result<(), DomainValidationError> {
        // Use trust-dns to do domain name validation. This does the punycode transform for us, as
        // well as validating there are no invalid characters or empty labels.
        let mut domain = Name::from_utf8(domain)
            .map_err(DomainValidationError::Invalid)?
            .to_lowercase();
        // Mark as FQDN. (It's likely the domain did not contain a trailing dot.)
        domain.set_fqdn(true);

        // Short-circuit for allow/block-lists.
        if self.allowed_domains.contains(&domain) {
            return Ok(());
        }
        if self.allowed_domains_only || self.blocked_domains.contains(&domain) {
            return Err(DomainValidationError::Blocked);
        }

        // Validate with a resolver if requested.
        if let Some(ref resolver) = self.dns_resolver {
            // Start with just an MX lookup. The spec allows just A/AAAA records, but it's very
            // likely a real mail domain has MX records.
            let res = resolver.mx_lookup(domain.clone()).await;
            let mut has_mx = false;
            let mail_servers: Vec<&Name> = match res {
                Ok(ref mx) => {
                    has_mx = true;
                    // Answers should always be FQDNs. We also ignore priority.
                    mx.iter()
                        .map(MX::exchange)
                        .filter(|name| name.is_fqdn())
                        .collect()
                }
                Err(err) => {
                    log::debug!(
                        "Falling back to A/AAAA lookup for domain '{}', because MX lookup failed: {}",
                        domain,
                        err
                    );
                    vec![&domain]
                }
            };

            // Check for a null MX record.
            if mail_servers.len() == 1 && mail_servers[0].is_root() {
                return Err(DomainValidationError::NullMx);
            }

            // If we didn't find an MX record, do a regular IP lookup of the domain itself. Also do
            // an IP lookup of mail servers if config is set to allow only public IP addresses.
            if !has_mx || self.verify_public_ip {
                let mut ok = false;
                let mut has_private_ips = false;
                for server in mail_servers {
                    match resolver.lookup_ip(server.clone()).await {
                        Ok(_) if !self.verify_public_ip => ok = true,
                        Ok(ref ips) => {
                            // TODO: Once stabilized, see: https://github.com/rust-lang/rust/issues/27709
                            // In fact, we currently cannot check for IPv6 private address space. :(
                            //ok = ips.iter().any(|ip| match ip {
                            //    IpAddr::V4(ip) => ip.is_global(),
                            //    IpAddr::V6(ip) => ip.is_unicast_global(),
                            //});
                            ok = ips.iter().any(|ip| match ip {
                                IpAddr::V4(ip) => {
                                    !ip.is_private()
                                        && !ip.is_loopback()
                                        && !ip.is_link_local()
                                        && !ip.is_broadcast()
                                        && !ip.is_documentation()
                                        && !ip.is_multicast()
                                        && !ip.is_unspecified()
                                }
                                IpAddr::V6(ip) => {
                                    !ip.is_multicast() && !ip.is_loopback() && !ip.is_unspecified()
                                }
                            });
                            if !ok {
                                has_private_ips = true;
                            }
                        }
                        Err(err) => {
                            log::debug!(
                                "Could not resolve mail server '{}' for domain '{}': {}",
                                server,
                                domain,
                                err
                            );
                        }
                    }
                    if ok {
                        break;
                    }
                }
                if !ok {
                    return Err(if has_private_ips {
                        DomainValidationError::NoPublicIps
                    } else {
                        DomainValidationError::NoServers
                    });
                }
            }
        }

        Ok(())
    }
}