diff --git a/Cargo.lock b/Cargo.lock index 79f73ce681f..e2de42eb3f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2302,6 +2302,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.90", +] + [[package]] name = "env_filter" version = "0.1.2" @@ -3109,6 +3121,61 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ebdb29d2ea9ed0083cd8cece49bbd968021bd99b0849edb4a9a7ee0fdf6a4e0" +[[package]] +name = "hickory-proto" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "447afdcdb8afb9d0a852af6dc65d9b285ce720ed7a59e42a8bf2e931c67bc1b5" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "h2", + "http 0.2.12", + "idna 1.0.3", + "ipnet", + "once_cell", + "rand", + "rustls 0.21.12", + "rustls-pemfile 1.0.4", + "thiserror 1.0.69", + "tinyvec", + "tokio", + "tokio-rustls 0.24.1", + "tracing", + "url", + "webpki-roots 0.25.4", +] + +[[package]] +name = "hickory-resolver" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a2e2aba9c389ce5267d31cf1e4dace82390ae276b0b364ea55630b1fa1b44b4" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "lru-cache", + "once_cell", + "parking_lot", + "rand", + "resolv-conf", + "rustls 0.21.12", + "smallvec", + "thiserror 1.0.69", + "tokio", + "tokio-rustls 0.24.1", + "tracing", + "webpki-roots 0.25.4", +] + [[package]] name = "hidapi" version = "1.5.0" @@ -3148,6 +3215,17 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "hostname" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867" +dependencies = [ + "libc", + "match_cfg", + "winapi", +] + [[package]] name = "html5ever" version = "0.27.0" @@ -3756,6 +3834,18 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa2f047c0a98b2f299aa5d6d7088443570faae494e9ae1305e48be000c9e0eb1" +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2", + "widestring", + "windows-sys 0.48.0", + "winreg 0.50.0", +] + [[package]] name = "ipnet" version = "2.9.0" @@ -4006,6 +4096,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.4.14" @@ -4080,6 +4176,15 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "mac" version = "0.1.1" @@ -4154,6 +4259,12 @@ dependencies = [ "tendril", ] +[[package]] +name = "match_cfg" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" + [[package]] name = "matchers" version = "0.1.0" @@ -5748,12 +5859,15 @@ name = "nym-http-api-client" version = "0.1.0" dependencies = [ "async-trait", + "hickory-resolver", "http 1.1.0", "nym-bin-common", + "once_cell", "reqwest 0.12.4", "serde", "serde_json", "thiserror 1.0.69", + "tokio", "tracing", "url", "wasmtimer", @@ -8242,6 +8356,16 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "resolv-conf" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52e44394d2086d010551b14b53b1f24e31647570cd1deb0379e2c21b329aba00" +dependencies = [ + "hostname", + "quick-error 1.2.3", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -11352,6 +11476,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "widestring" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7219d36b6eac893fa81e84ebe06485e7dcbb616177469b142df14f1f4deb1311" + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index c4efce95b7b..662cc8348e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -254,6 +254,7 @@ handlebars = "3.5.5" headers = "0.4.0" hex = "0.4.3" hex-literal = "0.3.3" +hickory-resolver = "0.24.2" hkdf = "0.12.3" hmac = "0.12.1" http = "1" diff --git a/common/http-api-client/Cargo.toml b/common/http-api-client/Cargo.toml index a0dab4b00a9..a70c8d9259b 100644 --- a/common/http-api-client/Cargo.toml +++ b/common/http-api-client/Cargo.toml @@ -15,6 +15,7 @@ async-trait = { workspace = true } reqwest = { workspace = true, features = ["json"] } http.workspace = true url = { workspace = true } +once_cell = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } @@ -22,7 +23,13 @@ tracing = { workspace = true } nym-bin-common = { path = "../bin-common" } +[target."cfg(not(target_arch = \"wasm32\"))".dependencies] +hickory-resolver = { workspace = true, features = ["dns-over-https-rustls", "webpki-roots"] } + # for request timeout until https://github.com/seanmonstar/reqwest/issues/1135 is fixed [target."cfg(target_arch = \"wasm32\")".dependencies.wasmtimer] workspace = true features = ["tokio"] + +[dev-dependencies] +tokio = { workspace = true, features=["rt", "macros"] } diff --git a/common/http-api-client/src/dns.rs b/common/http-api-client/src/dns.rs new file mode 100644 index 00000000000..137809d6a73 --- /dev/null +++ b/common/http-api-client/src/dns.rs @@ -0,0 +1,177 @@ +//! DNS resolver configuration for internal lookups. +//! +//! The resolver itself is the set combination of the google, cloudflare, and quad9 endpoints +//! supporting DoH and DoT. +//! +//! This resolver implements a fallback mechanism where, should the DNS-over-TLS resolution fail, a +//! followup resolution will be done using the hosts configured default (e.g. `/etc/resolve.conf` on +//! linux). +//! +//! Requires the `dns-over-https-rustls`, `webpki-roots` feature for the +//! `hickory-resolver` crate +#![deny(missing_docs)] + +use crate::ClientBuilder; + +use std::{net::SocketAddr, sync::Arc}; + +use hickory_resolver::lookup_ip::LookupIp; +use hickory_resolver::{ + config::{LookupIpStrategy, NameServerConfigGroup, ResolverConfig, ResolverOpts}, + error::ResolveError, + lookup_ip::LookupIpIntoIter, + TokioAsyncResolver, +}; +use once_cell::sync::OnceCell; +use reqwest::dns::{Addrs, Name, Resolve, Resolving}; +use tracing::warn; + +impl ClientBuilder { + /// Override the DNS resolver implementation used by the underlying http client. + pub fn dns_resolver(mut self, resolver: Arc) -> Self { + self.reqwest_client_builder = self.reqwest_client_builder.dns_resolver(resolver); + self + } +} + +struct SocketAddrs { + iter: LookupIpIntoIter, +} + +#[derive(Debug, thiserror::Error)] +#[error("hickory-dns resolver error: {hickory_error}")] +pub struct HickoryDnsError { + #[from] + hickory_error: ResolveError, +} + +/// Wrapper around an `AsyncResolver`, which implements the `Resolve` trait. +#[derive(Debug, Default, Clone)] +pub struct HickoryDnsResolver { + /// Since we might not have been called in the context of a + /// Tokio Runtime in initialization, so we must delay the actual + /// construction of the resolver. + state: Arc>, + fallback: Arc>, +} + +impl Resolve for HickoryDnsResolver { + fn resolve(&self, name: Name) -> Resolving { + let resolver = self.state.clone(); + let fallback = self.fallback.clone(); + Box::pin(async move { + let resolver = resolver.get_or_try_init(new_resolver)?; + + // try the primary DNS resolver that we set up (DoH or DoT or whatever) + let lookup = match resolver.lookup_ip(name.as_str()).await { + Ok(res) => res, + Err(e) => { + // on failure use the fall back system configured DNS resolver + warn!("primary DNS failed w/ error {e}: using system fallback"); + let resolver = fallback.get_or_try_init(new_resolver_system)?; + resolver.lookup_ip(name.as_str()).await? + } + }; + + let addrs: Addrs = Box::new(SocketAddrs { + iter: lookup.into_iter(), + }); + Ok(addrs) + }) + } +} + +impl Iterator for SocketAddrs { + type Item = SocketAddr; + + fn next(&mut self) -> Option { + self.iter.next().map(|ip_addr| SocketAddr::new(ip_addr, 0)) + } +} + +impl HickoryDnsResolver { + /// Attempt to resolve a domain name to a set of ['IpAddr']s + pub async fn resolve_str(&self, name: &str) -> Result { + let resolver = self.state.get_or_try_init(new_resolver)?; + + // try the primary DNS resolver that we set up (DoH or DoT or whatever) + let lookup = match resolver.lookup_ip(name).await { + Ok(res) => res, + Err(e) => { + // on failure use the fall back system configured DNS resolver + warn!("primary DNS failed w/ error {e}: using system fallback"); + let resolver = self.fallback.get_or_try_init(new_resolver_system)?; + resolver.lookup_ip(name).await? + } + }; + + Ok(lookup) + } +} + +/// Create a new resolver with a custom DoT based configuration. The options are overridden to look +/// up for both IPv4 and IPv6 addresses to work with "happy eyeballs" algorithm. +fn new_resolver() -> Result { + let mut name_servers = NameServerConfigGroup::google_tls(); + name_servers.merge(NameServerConfigGroup::google_https()); + // name_servers.merge(NameServerConfigGroup::google_h3()); + name_servers.merge(NameServerConfigGroup::quad9_tls()); + name_servers.merge(NameServerConfigGroup::quad9_https()); + name_servers.merge(NameServerConfigGroup::cloudflare_tls()); + name_servers.merge(NameServerConfigGroup::cloudflare_https()); + + let config = ResolverConfig::from_parts(None, Vec::new(), name_servers); + + let mut opts = ResolverOpts::default(); + opts.ip_strategy = LookupIpStrategy::Ipv4AndIpv6; + // Would like to enable this when 0.25 stabilizes + // opts.server_ordering_strategy = ServerOrderingStrategy::RoundRobin; + + Ok(TokioAsyncResolver::tokio(config, opts)) +} + +/// Create a new resolver with the default configuration, which reads from the system DNS config +/// (i.e. `/etc/resolve.conf` in unix). The options are overridden to look up for both IPv4 and IPv6 +/// addresses to work with "happy eyeballs" algorithm. +fn new_resolver_system() -> Result { + let (config, mut opts) = hickory_resolver::system_conf::read_system_conf()?; + opts.ip_strategy = LookupIpStrategy::Ipv4AndIpv6; + Ok(TokioAsyncResolver::tokio(config, opts)) +} + +#[cfg(test)] +mod test { + use super::*; + + #[tokio::test] + async fn reqwest_hickory_doh() { + let resolver = HickoryDnsResolver::default(); + let client = reqwest::ClientBuilder::new() + .dns_resolver(resolver.into()) + .build() + .unwrap(); + + let resp = client + .get("http://ifconfig.me:80") + .send() + .await + .unwrap() + .bytes() + .await + .unwrap(); + + assert!(!resp.is_empty()); + } + + #[tokio::test] + async fn dns_lookup() -> Result<(), HickoryDnsError> { + let resolver = HickoryDnsResolver::default(); + + let domain = "ifconfig.me"; + let addrs = resolver.resolve_str(domain).await?; + + assert!(addrs.into_iter().next().is_some()); + + Ok(()) + } +} diff --git a/common/http-api-client/src/lib.rs b/common/http-api-client/src/lib.rs index 1792a85a224..c75e7d64cdc 100644 --- a/common/http-api-client/src/lib.rs +++ b/common/http-api-client/src/lib.rs @@ -6,17 +6,23 @@ use reqwest::header::HeaderValue; use reqwest::{RequestBuilder, Response, StatusCode}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; -use std::fmt::Display; -use std::time::Duration; use thiserror::Error; use tracing::{instrument, warn}; use url::Url; +#[cfg(not(target_arch = "wasm32"))] +use std::sync::Arc; +use std::{fmt::Display, time::Duration}; + pub use reqwest::IntoUrl; +mod user_agent; pub use user_agent::UserAgent; -mod user_agent; +#[cfg(not(target_arch = "wasm32"))] +mod dns; +#[cfg(not(target_arch = "wasm32"))] +pub use dns::HickoryDnsResolver; // The timeout is relatively high as we are often making requests over the mixnet, where latency is // high and chatty protocols take a while to complete. @@ -86,11 +92,18 @@ impl ClientBuilder { // TODO: or should we maybe default to https? Self::new(alt) } else { + #[cfg(target_arch = "wasm32")] + let reqwest_client_builder = reqwest::ClientBuilder::new(); + + #[cfg(not(target_arch = "wasm32"))] + let reqwest_client_builder = + reqwest::ClientBuilder::new().dns_resolver(Arc::new(HickoryDnsResolver::default())); + Ok(ClientBuilder { url: url.into_url()?, timeout: None, custom_user_agent: false, - reqwest_client_builder: reqwest::ClientBuilder::new(), + reqwest_client_builder, }) } }