dyns 0.7.0-beta.2

DNS discovery and resolver support for DHTTP applications
Documentation
use std::{convert::Infallible, fmt, io, sync::Arc, time::Duration};

use dquic::{
    qbase::net::addr::EndpointAddr,
    qresolve::{Publish, PublishFuture, Resolve, ResolveFuture},
};
use h3x::{
    dhttp::message::{MessageStreamError, hyper::client::RequestError as HyperRequestError},
    endpoint::H3Endpoint,
    quic,
};
use url::Url;

mod cache;
mod lookup;
mod publish;
mod request;

const LOOKUP_REQUEST_TIMEOUT: Duration = Duration::from_secs(3);
const LOOKUP_REQUEST_ATTEMPTS: usize = 3;

pub struct H3Resolver<C>
where
    C: quic::Connect + quic::WithLocalAuthority,
{
    endpoint: Arc<H3Endpoint<C, C::Connection>>,
    base_url: Url,
    cache: cache::LookupCache,
}

impl<C> fmt::Debug for H3Resolver<C>
where
    C: quic::Connect + quic::WithLocalAuthority,
{
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("H3Resolver")
            .field("base_url", &self.base_url)
            .finish_non_exhaustive()
    }
}

impl<C> fmt::Display for H3Resolver<C>
where
    C: quic::Connect + quic::WithLocalAuthority,
{
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "H3 DNS Resolver({})", self.base_url)
    }
}

#[derive(Debug, snafu::Snafu)]
#[snafu(module)]
pub enum H3RequestError<E: std::error::Error + Send + Sync + 'static> {
    #[snafu(display("failed to connect h3 endpoint"))]
    Connect { source: h3x::pool::ConnectError<E> },
    #[snafu(display("h3 request error"))]
    Request {
        source: HyperRequestError<Infallible>,
    },
}

#[derive(Debug, snafu::Snafu)]
#[snafu(module)]
pub enum H3PublishError<E: std::error::Error + Send + Sync + 'static> {
    #[snafu(transparent)]
    Request { source: H3RequestError<E> },
    #[snafu(display("anonymous h3 endpoint cannot sign dns publish request"))]
    AnonymousEndpoint,
    #[snafu(display("failed to get h3 endpoint local authority"))]
    LocalAuthority { source: h3x::quic::ConnectionError },
    #[snafu(display("failed to sign h3 dns publish request"))]
    SignRequest {
        source: crate::core::signature::SignatureFieldsError,
    },
    #[snafu(display("failed to encode h3 dns publish packet"))]
    EncodePacket {
        source: crate::publishers::packet::EncodeAuthorityDnsPacketError,
    },
    #[snafu(display("h3 dns publish response body error"))]
    ResponseBody { source: MessageStreamError },
    #[snafu(display("{status}{message}"))]
    Status {
        status: http::StatusCode,
        message: StatusBody,
    },
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatusBody(String);

impl StatusBody {
    pub fn new(body: String) -> Self {
        Self(body)
    }
}

impl fmt::Display for StatusBody {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if self.0.is_empty() {
            Ok(())
        } else {
            write!(f, ": {}", self.0)
        }
    }
}

#[derive(Debug, snafu::Snafu)]
#[snafu(module)]
pub enum H3LookupError<E: std::error::Error + Send + Sync + 'static> {
    #[snafu(transparent)]
    Request { source: H3RequestError<E> },
    #[snafu(display("h3 stream error"))]
    H3Stream { source: MessageStreamError },
    #[snafu(display("h3 request timed out after {timeout:?}"))]
    RequestTimeout { timeout: Duration },
    #[snafu(display("{status}"))]
    Status { status: http::StatusCode },
    #[snafu(display("no DNS record found"))]
    NoRecordFound,
    #[snafu(display("failed to decode h3 dns lookup response"))]
    Decode { source: LookupDecodeError },
}

#[derive(Debug, snafu::Snafu)]
#[snafu(module)]
pub enum LookupDecodeError {
    #[snafu(display("failed to decode multi-record response"))]
    MultiResponse,
    #[snafu(display("failed to parse DNS records from response"))]
    ParseRecords {
        source: nom::Err<nom::error::Error<Vec<u8>>>,
    },
}

impl<C> H3Resolver<C>
where
    C: quic::Connect + quic::WithLocalAuthority + Send + Sync + 'static,
    C::Error: Send + Sync + 'static,
    C::Connection: Send + 'static,
{
    pub fn new(
        base_url: impl AsRef<str>,
        client: H3Endpoint<C, C::Connection>,
    ) -> io::Result<Self> {
        Self::from_endpoint(base_url, Arc::new(client))
    }

    pub fn from_endpoint(
        base_url: impl AsRef<str>,
        endpoint: Arc<H3Endpoint<C, C::Connection>>,
    ) -> io::Result<Self> {
        let base_url = Url::parse(base_url.as_ref())
            .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
        base_url.host_str().ok_or_else(|| {
            io::Error::new(
                io::ErrorKind::InvalidInput,
                "base URL must have a valid host",
            )
        })?;

        Ok(Self {
            endpoint,
            base_url,
            cache: cache::LookupCache::default(),
        })
    }

    pub fn clear_pool(&self) {
        self.endpoint.clear_pool();
    }
}

impl<C> Publish for H3Resolver<C>
where
    C: quic::Connect + quic::WithLocalAuthority + Send + Sync + 'static,
    C::Error: Send + Sync + 'static,
    C::Connection: Send + 'static,
{
    fn publish<'a>(
        &'a self,
        name: &'a str,
        endpoints: &mut dyn Iterator<Item = EndpointAddr>,
    ) -> PublishFuture<'a> {
        let endpoints: Vec<_> = endpoints.collect();
        Box::pin(async move {
            self.publish_endpoints(name, endpoints)
                .await
                .map_err(io::Error::other)
        })
    }
}

impl<C> Resolve for H3Resolver<C>
where
    C: quic::Connect + quic::WithLocalAuthority + Send + Sync + 'static,
    C::Error: Send + Sync + 'static,
    C::Connection: Send + 'static,
{
    fn lookup<'l>(&'l self, name: &'l str) -> ResolveFuture<'l> {
        Box::pin(async move {
            H3Resolver::lookup(self, name)
                .await
                .map_err(io::Error::other)
        })
    }
}

#[cfg(test)]
mod tests {
    use std::time::Duration;

    #[cfg(feature = "dquic-network")]
    use dquic::{qbase::net::addr::EndpointAddr, qresolve::Source};
    #[cfg(feature = "dquic-network")]
    use futures::StreamExt;

    use super::*;
    #[cfg(feature = "dquic-network")]
    use crate::resolvers::DHTTP_H3_DNS_SERVER;

    #[test]
    fn lookup_retry_budget_leaves_external_timeout_margin() {
        let total_budget = LOOKUP_REQUEST_TIMEOUT * LOOKUP_REQUEST_ATTEMPTS as u32;

        assert!(
            total_budget <= Duration::from_secs(10),
            "h3 lookup must return before common 15s command timeouts so callers can retry"
        );
    }

    #[cfg(feature = "dquic-network")]
    #[tokio::test]
    async fn cached_lookup_reports_h3_dns_source() {
        let endpoint = Arc::new(h3x::endpoint::H3Endpoint::new(
            h3x::dquic::QuicEndpoint::builder().build().await,
        ));
        let resolver = H3Resolver::from_endpoint(DHTTP_H3_DNS_SERVER, endpoint).unwrap();
        resolver.cache.insert_positive(
            "car.lab.dhttp.net",
            vec![EndpointAddr::direct("192.168.5.78:41748".parse().unwrap())],
        );

        let mut records = resolver.lookup("car.lab.dhttp.net").await.unwrap();
        let (source, endpoint) = records.next().await.unwrap();

        assert_eq!(
            source,
            Source::H3 {
                server: Arc::from(resolver.base_url.origin().ascii_serialization())
            }
        );
        assert_eq!(
            endpoint,
            EndpointAddr::direct("192.168.5.78:41748".parse().unwrap())
        );
    }

    #[cfg(feature = "dquic-network")]
    #[tokio::test]
    async fn cached_dns_genmeta_net_record_is_returned() {
        let endpoint = Arc::new(h3x::endpoint::H3Endpoint::new(
            h3x::dquic::QuicEndpoint::builder().build().await,
        ));
        let resolver = H3Resolver::from_endpoint(DHTTP_H3_DNS_SERVER, endpoint).unwrap();
        resolver.cache.insert_positive(
            "dns.genmeta.net",
            vec![EndpointAddr::direct("192.0.2.53:4433".parse().unwrap())],
        );

        let mut records = resolver.lookup("dns.genmeta.net").await.unwrap();
        let (_source, endpoint) = records.next().await.unwrap();

        assert_eq!(
            endpoint,
            EndpointAddr::direct("192.0.2.53:4433".parse().unwrap())
        );
    }

    #[cfg(feature = "dquic-network")]
    #[tokio::test]
    async fn cached_lookup_uses_e_record_port_not_input_port() {
        let endpoint = Arc::new(h3x::endpoint::H3Endpoint::new(
            h3x::dquic::QuicEndpoint::builder().build().await,
        ));
        let resolver = H3Resolver::from_endpoint(DHTTP_H3_DNS_SERVER, endpoint).unwrap();
        resolver.cache.insert_positive(
            "nat.genmeta.net:20004",
            vec![EndpointAddr::direct("192.0.2.10:21000".parse().unwrap())],
        );

        let mut records = resolver.lookup("nat.genmeta.net:20004").await.unwrap();
        let (_source, endpoint) = records.next().await.unwrap();

        assert_eq!(
            endpoint,
            EndpointAddr::direct("192.0.2.10:21000".parse().unwrap())
        );
    }
}