diff --git a/Cargo.lock b/Cargo.lock index 87dd31fe556..f47c80de722 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3080,14 +3080,25 @@ name = "libp2p-dns" version = "0.45.0" dependencies = [ "futures", + "getrandom 0.2.15", "hickory-resolver", + "js-sys", "libp2p-core", "libp2p-identity", "parking_lot", + "send_wrapper 0.6.0", + "serde", + "serde_json", "smallvec", + "thiserror 2.0.18", "tokio", "tracing", "tracing-subscriber", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test", + "web-sys", ] [[package]] diff --git a/libp2p/CHANGELOG.md b/libp2p/CHANGELOG.md index 6a9310fe6a4..d6125524498 100644 --- a/libp2p/CHANGELOG.md +++ b/libp2p/CHANGELOG.md @@ -1,5 +1,7 @@ ## 0.57.0 - +- Make the `dns` feature available on `wasm32` targets, where `libp2p-dns` resolves names over + DNS-over-HTTPS. Previously `libp2p-dns` was a non-`wasm32` dependency only. + See [PR XXXX](https://github.com/libp2p/rust-libp2p/pull/XXXX) - Remove `wasm-bindgen` feature and make `wasm` support implicit. See [PR 6102](https://github.com/libp2p/rust-libp2p/pull/6102) - Raise MSRV to 1.88.0. diff --git a/libp2p/Cargo.toml b/libp2p/Cargo.toml index 6a3dc37d34b..4476745e752 100644 --- a/libp2p/Cargo.toml +++ b/libp2p/Cargo.toml @@ -99,6 +99,7 @@ libp2p-autonat = { workspace = true, optional = true } libp2p-connection-limits = { workspace = true } libp2p-core = { workspace = true } libp2p-dcutr = { workspace = true, optional = true } +libp2p-dns = { workspace = true, optional = true } libp2p-floodsub = { workspace = true, optional = true } libp2p-gossipsub = { workspace = true, optional = true } libp2p-identify = { workspace = true, optional = true } @@ -124,7 +125,6 @@ libp2p-websocket-websys = { workspace = true, optional = true } libp2p-webtransport-websys = { workspace = true, optional = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -libp2p-dns = { workspace = true, optional = true } libp2p-mdns = { workspace = true, optional = true } libp2p-memory-connection-limits = { workspace = true, optional = true } libp2p-quic = { workspace = true, optional = true } diff --git a/libp2p/src/lib.rs b/libp2p/src/lib.rs index 838884eaebe..1ba269cfb86 100644 --- a/libp2p/src/lib.rs +++ b/libp2p/src/lib.rs @@ -50,7 +50,6 @@ pub use libp2p_core::multihash; pub use libp2p_dcutr as dcutr; #[cfg(feature = "dns")] #[cfg_attr(docsrs, doc(cfg(feature = "dns")))] -#[cfg(not(target_arch = "wasm32"))] #[doc(inline)] pub use libp2p_dns as dns; #[cfg(feature = "floodsub")] diff --git a/transports/dns/CHANGELOG.md b/transports/dns/CHANGELOG.md index 5d8f9bd5c89..388cc09941b 100644 --- a/transports/dns/CHANGELOG.md +++ b/transports/dns/CHANGELOG.md @@ -1,5 +1,13 @@ ## 0.45.0 +- Support DNS transport for wasm32 targets that resolves DNS components over DNS-over-HTTPS (DoH). + `/dnsaddr` is always resolved, however `/dns`, `/dns4` and `/dns6` are governed by the + `DnsResolution` policy (default to `DnsResolutionAuto`) where addresses containing a explicit protocols + (i.e. `webrtc-direct`) are resolved to `/ip4`/`/ip6`, while the rest are passed through to the inner + transport unchanged, since browsers resolve those hostnames natively and need + the hostname preserved for SNI. + See [PR XXXX](https://github.com/libp2p/rust-libp2p/pull/XXXX). + - Update `hickory` dependencies to `v0.26`. See [PR 6423](https://github.com/libp2p/rust-libp2p/pull/6423) `ResolveError` now aliases hickory's `NetError`, diff --git a/transports/dns/Cargo.toml b/transports/dns/Cargo.toml index f95325be2c8..8dc24f81510 100644 --- a/transports/dns/Cargo.toml +++ b/transports/dns/Cargo.toml @@ -13,13 +13,29 @@ categories = ["network-programming", "asynchronous"] [dependencies] futures = { workspace = true } libp2p-core = { workspace = true } -libp2p-identity = { workspace = true } parking_lot = "0.12.5" -hickory-resolver = { workspace = true, features = ["system-config"] } smallvec = "1.15.1" tracing = { workspace = true } -[dev-dependencies] +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +hickory-resolver = { workspace = true, features = ["system-config"] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { workspace = true } +js-sys = "0.3.77" +send_wrapper = { version = "0.6.0", features = ["futures"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +thiserror = { workspace = true } +url = "2.5.4" +wasm-bindgen = "0.2.100" +wasm-bindgen-futures = { workspace = true } +web-sys = { version = "0.3.77", features = ["AbortSignal", "Headers", "Request", "RequestInit", "Response", "Window", "WorkerGlobalScope"] } + +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] +wasm-bindgen-test = "0.3.50" + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] libp2p-identity = { workspace = true, features = ["rand"] } tokio = { workspace = true, features = ["rt", "time"] } tracing-subscriber = { workspace = true, features = ["env-filter"] } @@ -31,6 +47,7 @@ tokio = ["hickory-resolver/tokio"] # More information: https://docs.rs/about/builds#cross-compiling [package.metadata.docs.rs] all-features = true +targets = ["x86_64-unknown-linux-gnu", "wasm32-unknown-unknown"] [lints] workspace = true diff --git a/transports/dns/src/lib.rs b/transports/dns/src/lib.rs index 3b2e68120d9..b38d95e53a9 100644 --- a/transports/dns/src/lib.rs +++ b/transports/dns/src/lib.rs @@ -21,15 +21,18 @@ //! # [DNS name resolution](https://github.com/libp2p/specs/blob/master/addressing/README.md#ip-and-name-resolution) //! [`Transport`] for libp2p. //! -//! This crate provides the type [`tokio::Transport`] based on [`hickory_resolver::TokioResolver`]. -//! //! A [`Transport`] is an address-rewriting [`libp2p_core::Transport`] wrapper around //! an inner `Transport`. The composed transport behaves like the inner //! transport, except that [`libp2p_core::Transport::dial`] resolves `/dns/...`, `/dns4/...`, //! `/dns6/...` and `/dnsaddr/...` components of the given `Multiaddr` through //! a DNS, replacing them with the resolved protocols (typically TCP/IP). //! -//! The [`tokio::Transport`] is enabled by default under the `tokio` feature. +//! Which resolver backs the [`Transport`] is chosen by the compilation target: +//! +//! # Native targets +//! +//! Name resolution is performed by [hickory-resolver], which is enabled by default under +//! the `tokio` feature. //! Tokio users can furthermore opt-in to the `tokio-dns-over-rustls` and //! `tokio-dns-over-https-rustls` features. //! For more information about these features, please refer to the documentation @@ -49,65 +52,42 @@ //! If the implementation requires different characteristics, one should //! consider providing their own implementation of [`Transport`] or use //! platform specific APIs to extract the host's DNS configuration (if possible) -//! and provide a custom [`ResolverConfig`]. +//! and provide a custom `ResolverConfig`. +//! +//! # `wasm32` targets +//! +//! Name resolution are performed over +//! [DNS-over-HTTPS](https://datatracker.ietf.org/doc/html/rfc8484) using the +//! browser's `fetch` API and a JSON (`application/dns-json`) endpoint. The endpoint is +//! configurable via `websys::Config` and defaults to Cloudflare. +//! +//! `/dnsaddr` is always resolved (browsers cannot look up TXT records, so this +//! is the gap worth filling such as dialing `/dnsaddr/bootstrap.libp2p.io`). +//! `/dns`, `/dns4` and `/dns6` are governed by `websys::DnsResolution`, which +//! defaults to `Auto`: addresses containing a `/webrtc-direct` (or any future +//! specific protocols) are resolved to `/ip4`/`/ip6` (that transport needs a +//! numeric IP), while everything else is passed through unchanged, because the +//! name-bound TLS transports (WebSocket, WebTransport) resolve hostnames +//! natively and need the hostname preserved for SNI and certificate validation. //! //! [hickory-resolver]: https://docs.rs/hickory-resolver #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] -#[cfg(feature = "tokio")] -pub mod tokio { - use std::sync::Arc; - - use hickory_resolver::{TokioResolver, net::runtime::TokioRuntimeProvider, system_conf}; - use parking_lot::Mutex; - - /// A `Transport` wrapper for performing DNS lookups when dialing `Multiaddr`esses - /// using `tokio` for all async I/O. - pub type Transport = crate::Transport; - - impl Transport { - /// Creates a new [`Transport`] from the OS's DNS configuration and defaults. - pub fn system(inner: T) -> Result, std::io::Error> { - let (cfg, opts) = system_conf::read_system_conf() - .map_err(|e| std::io::Error::other(e.to_string()))?; - Ok(Self::custom(inner, cfg, opts)) - } - - /// Creates a [`Transport`] with a custom resolver configuration - /// and options. - pub fn custom( - inner: T, - cfg: hickory_resolver::config::ResolverConfig, - opts: hickory_resolver::config::ResolverOpts, - ) -> Transport { - Transport { - inner: Arc::new(Mutex::new(inner)), - resolver: TokioResolver::builder_with_config(cfg, TokioRuntimeProvider::default()) - .with_options(opts) - .build() - .expect("valid resolver config should build"), - } - } - } -} +#[cfg(not(target_arch = "wasm32"))] +mod native; +#[cfg(target_arch = "wasm32")] +pub mod websys; use std::{ - error, fmt, io, iter, - net::{Ipv4Addr, Ipv6Addr}, + error, fmt, io, ops::DerefMut, pin::Pin, - str, sync::Arc, task::{Context, Poll}, }; use futures::{future::BoxFuture, prelude::*}; -use hickory_resolver::{ConnectionProvider, lookup::Lookup, lookup_ip::LookupIp, proto::rr::RData}; -pub use hickory_resolver::{ - config::{ResolverConfig, ResolverOpts}, - net::NetError as ResolveError, -}; use libp2p_core::{ multiaddr::{Multiaddr, Protocol}, transport::{DialOpts, ListenerId, TransportError, TransportEvent}, @@ -115,6 +95,17 @@ use libp2p_core::{ use parking_lot::Mutex; use smallvec::SmallVec; +#[cfg(all(not(target_arch = "wasm32"), feature = "tokio"))] +pub use crate::native::tokio; +#[cfg(not(target_arch = "wasm32"))] +pub use crate::native::{ResolveError, Resolver, ResolverConfig, ResolverOpts}; +#[cfg(not(target_arch = "wasm32"))] +use crate::native::{next_unresolved, no_records_found, resolve}; +#[cfg(target_arch = "wasm32")] +pub use crate::websys::{ResolveError, Resolver}; +#[cfg(target_arch = "wasm32")] +use crate::websys::{next_unresolved, no_records_found, resolve}; + /// The prefix for `dnsaddr` protocol TXT record lookups. const DNSADDR_PREFIX: &str = "_dnsaddr."; @@ -134,7 +125,8 @@ const MAX_DNS_LOOKUPS: usize = 32; const MAX_TXT_RECORDS: usize = 16; /// A [`Transport`] for performing DNS lookups when dialing `Multiaddr`esses. -/// You shouldn't need to use this type directly. Use [`tokio::Transport`] instead. +/// You shouldn't need to use this type directly. Use `tokio::Transport` on +/// non-`wasm32` targets and `websys::Transport` on `wasm32` targets instead. #[derive(Debug)] pub struct Transport { /// The underlying transport. @@ -211,7 +203,7 @@ where // Asynchronously resolve all DNS names in the address before proceeding // with dialing on the underlying transport. - async move { + let dial = async move { let mut dial_errors: Vec> = Vec::new(); let mut dns_lookups = 0; let mut dial_attempts = 0; @@ -224,15 +216,7 @@ where // dialing attempts as soon as there is another fully resolved // address. while let Some(addr) = unresolved.pop() { - if let Some((i, name)) = addr.iter().enumerate().find(|(_, p)| { - matches!( - p, - Protocol::Dns(_) - | Protocol::Dns4(_) - | Protocol::Dns6(_) - | Protocol::Dnsaddr(_) - ) - }) { + if let Some((i, name)) = next_unresolved(&addr, &resolver) { if dns_lookups == MAX_DNS_LOOKUPS { tracing::debug!(address=%addr, "Too many DNS lookups, dropping unresolved address"); dial_errors.push(Error::TooManyLookups); @@ -329,13 +313,16 @@ where if !dial_errors.is_empty() { Err(Error::Dial(dial_errors)) } else { - Err(Error::ResolveError( - ResolveError::from("No Matching Records Found"), - )) + Err(Error::ResolveError(no_records_found())) } - } - .boxed() - .right_future() + }; + + // Note that the lookups are driven by the browser's `fetch` API, whose futures are `!Send` + // `SendWrapper` makes the resulting future `Send` so it satisfies the bound on `Dial`. + #[cfg(target_arch = "wasm32")] + let dial = send_wrapper::SendWrapper::new(dial); + + dial.boxed().right_future() } } @@ -412,136 +399,9 @@ enum Resolved<'a> { Addrs(Vec), } -/// Asynchronously resolves the domain name of a `Dns`, `Dns4`, `Dns6` or `Dnsaddr` protocol -/// component. If the given protocol is of a different type, it is returned unchanged as a -/// [`Resolved::One`]. -fn resolve<'a, E: 'a + Send, R: Resolver>( - proto: &Protocol<'a>, - resolver: &'a R, -) -> BoxFuture<'a, Result, Error>> { - match proto { - Protocol::Dns(name) => resolver - .lookup_ip(name.clone().into_owned()) - .map(move |res| match res { - Ok(ips) => { - let mut ips = ips.iter(); - let one = ips - .next() - .expect("If there are no results, `Err(NoRecordsFound)` is expected."); - if let Some(two) = ips.next() { - Ok(Resolved::Many( - iter::once(one) - .chain(iter::once(two)) - .chain(ips) - .map(Protocol::from) - .collect(), - )) - } else { - Ok(Resolved::One(Protocol::from(one))) - } - } - Err(e) => Err(Error::ResolveError(e)), - }) - .boxed(), - Protocol::Dns4(name) => resolver - .ipv4_lookup(name.clone().into_owned()) - .map(move |res| match res { - Ok(ips) => { - let mut ips = ips - .answers() - .iter() - .filter_map(|record| match &record.data { - RData::A(ip) => Some(Ipv4Addr::from(*ip)), - _ => None, - }); - let one = ips - .next() - .expect("If there are no results, `Err(NoRecordsFound)` is expected."); - if let Some(two) = ips.next() { - Ok(Resolved::Many( - iter::once(one) - .chain(iter::once(two)) - .chain(ips) - .map(Protocol::from) - .collect(), - )) - } else { - Ok(Resolved::One(Protocol::from(one))) - } - } - Err(e) => Err(Error::ResolveError(e)), - }) - .boxed(), - Protocol::Dns6(name) => resolver - .ipv6_lookup(name.clone().into_owned()) - .map(move |res| match res { - Ok(ips) => { - let mut ips = ips - .answers() - .iter() - .filter_map(|record| match &record.data { - RData::AAAA(ip) => Some(Ipv6Addr::from(*ip)), - _ => None, - }); - let one = ips - .next() - .expect("If there are no results, `Err(NoRecordsFound)` is expected."); - if let Some(two) = ips.next() { - Ok(Resolved::Many( - iter::once(one) - .chain(iter::once(two)) - .chain(ips) - .map(Protocol::from) - .collect(), - )) - } else { - Ok(Resolved::One(Protocol::from(one))) - } - } - Err(e) => Err(Error::ResolveError(e)), - }) - .boxed(), - Protocol::Dnsaddr(name) => { - let name = [DNSADDR_PREFIX, name].concat(); - resolver - .txt_lookup(name) - .map(move |res| match res { - Ok(txts) => { - let mut addrs = Vec::new(); - for txt in txts - .answers() - .iter() - .filter_map(|record| match &record.data { - RData::TXT(txt) => Some(txt), - _ => None, - }) - { - if let Some(chars) = txt.txt_data.first() { - match parse_dnsaddr_txt(chars) { - Err(e) => { - // Skip over seemingly invalid entries. - tracing::debug!("Invalid TXT record: {:?}", e); - } - Ok(a) => { - addrs.push(a); - } - } - } - } - Ok(Resolved::Addrs(addrs)) - } - Err(e) => Err(Error::ResolveError(e)), - }) - .boxed() - } - proto => future::ready(Ok(Resolved::One(proto.clone()))).boxed(), - } -} - /// Parses a `` of a `dnsaddr` TXT record. -fn parse_dnsaddr_txt(txt: &[u8]) -> io::Result { - let s = str::from_utf8(txt).map_err(invalid_data)?; - match s.strip_prefix("dnsaddr=") { +fn parse_dnsaddr_txt(txt: &str) -> io::Result { + match txt.strip_prefix("dnsaddr=") { None => Err(invalid_data("Missing `dnsaddr=` prefix.")), Some(a) => Ok(Multiaddr::try_from(a).map_err(invalid_data)?), } @@ -551,311 +411,19 @@ fn invalid_data(e: impl Into>) -> io::E io::Error::new(io::ErrorKind::InvalidData, e) } -#[doc(hidden)] -pub trait Resolver { - fn lookup_ip( - &self, - name: String, - ) -> impl Future> + Send; - fn ipv4_lookup( - &self, - name: String, - ) -> impl Future> + Send; - fn ipv6_lookup( - &self, - name: String, - ) -> impl Future> + Send; - fn txt_lookup(&self, name: String) - -> impl Future> + Send; -} - -impl Resolver for hickory_resolver::Resolver -where - C: ConnectionProvider, -{ - async fn lookup_ip(&self, name: String) -> Result { - self.lookup_ip(name).await - } - - async fn ipv4_lookup(&self, name: String) -> Result { - self.ipv4_lookup(name).await - } - - async fn ipv6_lookup(&self, name: String) -> Result { - self.ipv6_lookup(name).await - } - - async fn txt_lookup(&self, name: String) -> Result { - self.txt_lookup(name).await - } -} - -#[cfg(all(test, feature = "tokio"))] +#[cfg(all(test, not(target_arch = "wasm32")))] mod tests { - use futures::future::BoxFuture; - use hickory_resolver::config::QUAD9; - use libp2p_core::{ - Endpoint, Transport, - multiaddr::{Multiaddr, Protocol}, - transport::{PortUse, TransportError, TransportEvent}, - }; - use libp2p_identity::PeerId; - use super::*; - fn test_tokio>( - transport: T, - test_fn: impl FnOnce(tokio::Transport) -> F, - ) { - let config = ResolverConfig::udp_and_tcp(&QUAD9); - let opts = ResolverOpts::default(); - let transport = tokio::Transport::custom(transport, config, opts); - let rt = ::tokio::runtime::Builder::new_current_thread() - .enable_io() - .enable_time() - .build() - .unwrap(); - rt.block_on(test_fn(transport)); - } - #[test] - fn basic_resolve() { - let _ = tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) - .try_init(); - - #[derive(Clone)] - struct CustomTransport; - - impl Transport for CustomTransport { - type Output = (); - type Error = std::io::Error; - type ListenerUpgrade = BoxFuture<'static, Result>; - type Dial = BoxFuture<'static, Result>; - - fn listen_on( - &mut self, - _: ListenerId, - _: Multiaddr, - ) -> Result<(), TransportError> { - unreachable!() - } - - fn remove_listener(&mut self, _: ListenerId) -> bool { - false - } - - fn dial( - &mut self, - addr: Multiaddr, - _: DialOpts, - ) -> Result> { - // Check that all DNS components have been resolved, i.e. replaced. - assert!(!addr.iter().any(|p| matches!( - p, - Protocol::Dns(_) | Protocol::Dns4(_) | Protocol::Dns6(_) | Protocol::Dnsaddr(_) - ))); - Ok(Box::pin(future::ready(Ok(())))) - } - - fn poll( - self: Pin<&mut Self>, - _: &mut Context<'_>, - ) -> Poll> { - unreachable!() - } - } - - async fn run(mut transport: super::Transport) - where - T: Transport + Clone + Send + Unpin + 'static, - T::Error: Send, - T::Dial: Send, - R: Clone + Send + Sync + Resolver + 'static, - { - let dial_opts = DialOpts { - role: Endpoint::Dialer, - port_use: PortUse::Reuse, - }; - - // Success due to existing A record for example.com. - let _ = transport - .dial("/dns4/example.com/tcp/20000".parse().unwrap(), dial_opts) - .unwrap() - .await - .unwrap(); - - // Success due to existing AAAA record for example.com. - let _ = transport - .dial("/dns6/example.com/tcp/20000".parse().unwrap(), dial_opts) + fn parse_dnsaddr_txt_requires_prefix() { + let addr = parse_dnsaddr_txt("dnsaddr=/dns4/example.com/tcp/443/wss").unwrap(); + assert_eq!( + addr, + "/dns4/example.com/tcp/443/wss" + .parse::() .unwrap() - .await - .unwrap(); - - // Success due to pass-through, i.e. nothing to resolve. - let _ = transport - .dial("/ip4/1.2.3.4/tcp/20000".parse().unwrap(), dial_opts) - .unwrap() - .await - .unwrap(); - - // Success due to the DNS TXT records at _dnsaddr.bootstrap.libp2p.io. - let _ = transport - .dial("/dnsaddr/bootstrap.libp2p.io".parse().unwrap(), dial_opts) - .unwrap() - .await - .unwrap(); - - // Success due to the DNS TXT records at _dnsaddr.bootstrap.libp2p.io having - // an entry with suffix `/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN`, - // i.e. a bootnode with such a peer ID. - let _ = transport - .dial("/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN".parse().unwrap(), dial_opts) - .unwrap() - .await - .unwrap(); - - // Failure due to the DNS TXT records at _dnsaddr.libp2p.io not having - // an entry with a random `p2p` suffix. - match transport - .dial( - format!("/dnsaddr/bootstrap.libp2p.io/p2p/{}", PeerId::random()) - .parse() - .unwrap(), - dial_opts, - ) - .unwrap() - .await - { - Err(Error::ResolveError(_)) => {} - Err(e) => panic!("Unexpected error: {e:?}"), - Ok(_) => panic!("Unexpected success."), - } - - // Failure due to no records. - match transport - .dial( - "/dns4/example.invalid/tcp/20000".parse().unwrap(), - dial_opts, - ) - .unwrap() - .await - { - Err(Error::Dial(dial_errs)) => { - assert_eq!( - dial_errs.len(), - 1, - "Expected exactly 1 error for 'no records' scenario, got {dial_errs:?}" - ); - - match &dial_errs[0] { - Error::ResolveError(e) if e.is_no_records_found() => {} - Error::ResolveError(e) => panic!("Unexpected DNS error: {e:?}"), - other => { - panic!("Expected a single ResolveError(...) sub-error, got {other:?}") - } - } - } - - Err(e) => panic!("Unexpected error: {e:?}"), - Ok(_) => panic!("Unexpected success."), - } - } - - test_tokio(CustomTransport, run); - } - - #[test] - fn aggregated_dial_errors() { - let _ = tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) - .try_init(); - - #[derive(Clone)] - struct AlwaysFailTransport; - - impl libp2p_core::Transport for AlwaysFailTransport { - type Output = (); - type Error = std::io::Error; - type ListenerUpgrade = BoxFuture<'static, Result>; - type Dial = BoxFuture<'static, Result>; - - fn listen_on( - &mut self, - _id: ListenerId, - _addr: Multiaddr, - ) -> Result<(), TransportError> { - unimplemented!() - } - - fn remove_listener(&mut self, _id: ListenerId) -> bool { - false - } - - fn dial( - &mut self, - addr: Multiaddr, - _: DialOpts, - ) -> Result> { - // Every dial attempt fails with an error that includes the address. - Ok(Box::pin(future::ready(Err(io::Error::new( - io::ErrorKind::Unsupported, - format!("No support for dialing {addr}"), - ))))) - } - - fn poll( - self: Pin<&mut Self>, - _cx: &mut Context<'_>, - ) -> Poll> { - unimplemented!() - } - } - - async fn run_test(mut transport: super::Transport) - where - T: Transport + Clone + Send + Unpin + 'static, - T::Error: Send, - T::Dial: Send, - R: Clone + Send + Sync + Resolver + 'static, - { - let dial_opts = DialOpts { - role: Endpoint::Dialer, - port_use: PortUse::Reuse, - }; - - // This address requires DNS resolution, yielding two IP addresses, - // forcing two dial attempts. Both fail. - let addr: Multiaddr = "/dnsaddr/bootstrap.libp2p.io".parse().unwrap(); - let dial_future = transport.dial(addr, dial_opts).unwrap(); - let result = dial_future.await; - - match result { - Err(Error::Dial(errs)) => { - // We expect at least 2 errors, one per resolved IP. - assert!( - errs.len() >= 2, - "Expected multiple dial errors, but got {}", - errs.len() - ); - for e in errs { - match e { - Error::Transport(io_err) => { - assert_eq!( - io_err.kind(), - io::ErrorKind::Unsupported, - "Expected Unsupported dial error, got: {io_err:?}" - ); - } - _ => panic!("Expected Error::Transport(Unsupported), got: {e:?}"), - } - } - } - Err(e) => panic!("Expected aggregated dial errors, got {e:?}"), - Ok(_) => panic!("Dial unexpectedly succeeded"), - } - } - - test_tokio(AlwaysFailTransport, run_test); + ); + assert!(parse_dnsaddr_txt("/dns4/example.com").is_err()); } } diff --git a/transports/dns/src/native.rs b/transports/dns/src/native.rs new file mode 100644 index 00000000000..a73d0186e40 --- /dev/null +++ b/transports/dns/src/native.rs @@ -0,0 +1,533 @@ +// Copyright 2018 Parity Technologies (UK) Ltd. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +//! DNS name resolution through [hickory-resolver](https://docs.rs/hickory-resolver). + +#[cfg(feature = "tokio")] +pub mod tokio { + use std::sync::Arc; + + use hickory_resolver::{TokioResolver, net::runtime::TokioRuntimeProvider, system_conf}; + use parking_lot::Mutex; + + /// A `Transport` wrapper for performing DNS lookups when dialing `Multiaddr`esses + /// using `tokio` for all async I/O. + pub type Transport = crate::Transport; + + impl Transport { + /// Creates a new [`Transport`] from the OS's DNS configuration and defaults. + pub fn system(inner: T) -> Result, std::io::Error> { + let (cfg, opts) = system_conf::read_system_conf() + .map_err(|e| std::io::Error::other(e.to_string()))?; + Ok(Self::custom(inner, cfg, opts)) + } + + /// Creates a [`Transport`] with a custom resolver configuration + /// and options. + pub fn custom( + inner: T, + cfg: hickory_resolver::config::ResolverConfig, + opts: hickory_resolver::config::ResolverOpts, + ) -> Transport { + Transport { + inner: Arc::new(Mutex::new(inner)), + resolver: TokioResolver::builder_with_config(cfg, TokioRuntimeProvider::default()) + .with_options(opts) + .build() + .expect("valid resolver config should build"), + } + } + } +} + +use std::{ + iter, + net::{Ipv4Addr, Ipv6Addr}, + str, +}; + +use hickory_resolver::{ConnectionProvider, lookup::Lookup, lookup_ip::LookupIp, proto::rr::RData}; +pub use hickory_resolver::{ + config::{ResolverConfig, ResolverOpts}, + net::NetError as ResolveError, +}; +use libp2p_core::multiaddr::{Multiaddr, Protocol}; + +use crate::{DNSADDR_PREFIX, Error, Resolved, invalid_data, parse_dnsaddr_txt}; + +#[doc(hidden)] +pub trait Resolver { + fn lookup_ip( + &self, + name: String, + ) -> impl Future> + Send; + fn ipv4_lookup( + &self, + name: String, + ) -> impl Future> + Send; + fn ipv6_lookup( + &self, + name: String, + ) -> impl Future> + Send; + fn txt_lookup(&self, name: String) + -> impl Future> + Send; +} + +impl Resolver for hickory_resolver::Resolver +where + C: ConnectionProvider, +{ + async fn lookup_ip(&self, name: String) -> Result { + self.lookup_ip(name).await + } + + async fn ipv4_lookup(&self, name: String) -> Result { + self.ipv4_lookup(name).await + } + + async fn ipv6_lookup(&self, name: String) -> Result { + self.ipv6_lookup(name).await + } + + async fn txt_lookup(&self, name: String) -> Result { + self.txt_lookup(name).await + } +} + +/// The error reported when a lookup succeeded but yielded no record applicable +/// to the address being dialed. +pub(crate) fn no_records_found() -> ResolveError { + ResolveError::from("No Matching Records Found") +} + +/// Returns the next DNS protocol component of `addr` that needs resolving. +pub(crate) fn next_unresolved<'a, R>( + addr: &'a Multiaddr, + _resolver: &R, +) -> Option<(usize, Protocol<'a>)> +where + R: Resolver, +{ + addr.iter().enumerate().find(|(_, p)| { + matches!( + p, + Protocol::Dns(_) | Protocol::Dns4(_) | Protocol::Dns6(_) | Protocol::Dnsaddr(_) + ) + }) +} + +/// Asynchronously resolves the domain name of a `Dns`, `Dns4`, `Dns6` or `Dnsaddr` protocol +/// component. If the given protocol is of a different type, it is returned unchanged as a +/// [`Resolved::One`]. +pub(crate) async fn resolve<'a, E, R>( + proto: &Protocol<'a>, + resolver: &R, +) -> Result, Error> +where + R: Resolver, +{ + match proto { + Protocol::Dns(name) => { + let lookup = resolver + .lookup_ip(name.clone().into_owned()) + .await + .map_err(Error::ResolveError)?; + let mut ips = lookup.iter(); + let one = ips + .next() + .expect("If there are no results, `Err(NoRecordsFound)` is expected."); + if let Some(two) = ips.next() { + Ok(Resolved::Many( + iter::once(one) + .chain(iter::once(two)) + .chain(ips) + .map(Protocol::from) + .collect(), + )) + } else { + Ok(Resolved::One(Protocol::from(one))) + } + } + Protocol::Dns4(name) => { + let lookup = resolver + .ipv4_lookup(name.clone().into_owned()) + .await + .map_err(Error::ResolveError)?; + let mut ips = lookup + .answers() + .iter() + .filter_map(|record| match &record.data { + RData::A(ip) => Some(Ipv4Addr::from(*ip)), + _ => None, + }); + let one = ips + .next() + .expect("If there are no results, `Err(NoRecordsFound)` is expected."); + if let Some(two) = ips.next() { + Ok(Resolved::Many( + iter::once(one) + .chain(iter::once(two)) + .chain(ips) + .map(Protocol::from) + .collect(), + )) + } else { + Ok(Resolved::One(Protocol::from(one))) + } + } + Protocol::Dns6(name) => { + let lookup = resolver + .ipv6_lookup(name.clone().into_owned()) + .await + .map_err(Error::ResolveError)?; + let mut ips = lookup + .answers() + .iter() + .filter_map(|record| match &record.data { + RData::AAAA(ip) => Some(Ipv6Addr::from(*ip)), + _ => None, + }); + let one = ips + .next() + .expect("If there are no results, `Err(NoRecordsFound)` is expected."); + if let Some(two) = ips.next() { + Ok(Resolved::Many( + iter::once(one) + .chain(iter::once(two)) + .chain(ips) + .map(Protocol::from) + .collect(), + )) + } else { + Ok(Resolved::One(Protocol::from(one))) + } + } + Protocol::Dnsaddr(name) => { + let name = [DNSADDR_PREFIX, name].concat(); + let txts = resolver + .txt_lookup(name) + .await + .map_err(Error::ResolveError)?; + let mut addrs = Vec::new(); + for txt in txts + .answers() + .iter() + .filter_map(|record| match &record.data { + RData::TXT(txt) => Some(txt), + _ => None, + }) + { + if let Some(chars) = txt.txt_data.first() { + match str::from_utf8(chars) + .map_err(invalid_data) + .and_then(parse_dnsaddr_txt) + { + Err(e) => { + // Skip over seemingly invalid entries. + tracing::debug!("Invalid TXT record: {:?}", e); + } + Ok(a) => { + addrs.push(a); + } + } + } + } + Ok(Resolved::Addrs(addrs)) + } + proto => Ok(Resolved::One(proto.clone())), + } +} + +#[cfg(all(test, feature = "tokio"))] +mod tests { + use std::{ + io, + pin::Pin, + task::{Context, Poll}, + }; + + use futures::{future, future::BoxFuture, prelude::*}; + use hickory_resolver::config::QUAD9; + use libp2p_core::{ + Endpoint, Transport, + multiaddr::{Multiaddr, Protocol}, + transport::{DialOpts, ListenerId, PortUse, TransportError, TransportEvent}, + }; + use libp2p_identity::PeerId; + + use super::*; + use crate::Error; + + fn test_tokio>( + transport: T, + test_fn: impl FnOnce(tokio::Transport) -> F, + ) { + let config = ResolverConfig::udp_and_tcp(&QUAD9); + let opts = ResolverOpts::default(); + let transport = tokio::Transport::custom(transport, config, opts); + let rt = ::tokio::runtime::Builder::new_current_thread() + .enable_io() + .enable_time() + .build() + .unwrap(); + rt.block_on(test_fn(transport)); + } + + #[test] + fn basic_resolve() { + let _ = tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .try_init(); + + #[derive(Clone)] + struct CustomTransport; + + impl Transport for CustomTransport { + type Output = (); + type Error = std::io::Error; + type ListenerUpgrade = BoxFuture<'static, Result>; + type Dial = BoxFuture<'static, Result>; + + fn listen_on( + &mut self, + _: ListenerId, + _: Multiaddr, + ) -> Result<(), TransportError> { + unreachable!() + } + + fn remove_listener(&mut self, _: ListenerId) -> bool { + false + } + + fn dial( + &mut self, + addr: Multiaddr, + _: DialOpts, + ) -> Result> { + // Check that all DNS components have been resolved, i.e. replaced. + assert!(!addr.iter().any(|p| matches!( + p, + Protocol::Dns(_) | Protocol::Dns4(_) | Protocol::Dns6(_) | Protocol::Dnsaddr(_) + ))); + Ok(Box::pin(future::ready(Ok(())))) + } + + fn poll( + self: Pin<&mut Self>, + _: &mut Context<'_>, + ) -> Poll> { + unreachable!() + } + } + + async fn run(mut transport: crate::Transport) + where + T: Transport + Clone + Send + Unpin + 'static, + T::Error: Send, + T::Dial: Send, + R: Clone + Send + Sync + Resolver + 'static, + { + let dial_opts = DialOpts { + role: Endpoint::Dialer, + port_use: PortUse::Reuse, + }; + + // Success due to existing A record for example.com. + let _ = transport + .dial("/dns4/example.com/tcp/20000".parse().unwrap(), dial_opts) + .unwrap() + .await + .unwrap(); + + // Success due to existing AAAA record for example.com. + let _ = transport + .dial("/dns6/example.com/tcp/20000".parse().unwrap(), dial_opts) + .unwrap() + .await + .unwrap(); + + // Success due to pass-through, i.e. nothing to resolve. + let _ = transport + .dial("/ip4/1.2.3.4/tcp/20000".parse().unwrap(), dial_opts) + .unwrap() + .await + .unwrap(); + + // Success due to the DNS TXT records at _dnsaddr.bootstrap.libp2p.io. + let _ = transport + .dial("/dnsaddr/bootstrap.libp2p.io".parse().unwrap(), dial_opts) + .unwrap() + .await + .unwrap(); + + // Success due to the DNS TXT records at _dnsaddr.bootstrap.libp2p.io having + // an entry with suffix `/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN`, + // i.e. a bootnode with such a peer ID. + let _ = transport + .dial("/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN".parse().unwrap(), dial_opts) + .unwrap() + .await + .unwrap(); + + // Failure due to the DNS TXT records at _dnsaddr.libp2p.io not having + // an entry with a random `p2p` suffix. + match transport + .dial( + format!("/dnsaddr/bootstrap.libp2p.io/p2p/{}", PeerId::random()) + .parse() + .unwrap(), + dial_opts, + ) + .unwrap() + .await + { + Err(Error::ResolveError(_)) => {} + Err(e) => panic!("Unexpected error: {e:?}"), + Ok(_) => panic!("Unexpected success."), + } + + // Failure due to no records. + match transport + .dial( + "/dns4/example.invalid/tcp/20000".parse().unwrap(), + dial_opts, + ) + .unwrap() + .await + { + Err(Error::Dial(dial_errs)) => { + assert_eq!( + dial_errs.len(), + 1, + "Expected exactly 1 error for 'no records' scenario, got {dial_errs:?}" + ); + + match &dial_errs[0] { + Error::ResolveError(e) if e.is_no_records_found() => {} + Error::ResolveError(e) => panic!("Unexpected DNS error: {e:?}"), + other => { + panic!("Expected a single ResolveError(...) sub-error, got {other:?}") + } + } + } + + Err(e) => panic!("Unexpected error: {e:?}"), + Ok(_) => panic!("Unexpected success."), + } + } + + test_tokio(CustomTransport, run); + } + + #[test] + fn aggregated_dial_errors() { + let _ = tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .try_init(); + + #[derive(Clone)] + struct AlwaysFailTransport; + + impl libp2p_core::Transport for AlwaysFailTransport { + type Output = (); + type Error = std::io::Error; + type ListenerUpgrade = BoxFuture<'static, Result>; + type Dial = BoxFuture<'static, Result>; + + fn listen_on( + &mut self, + _id: ListenerId, + _addr: Multiaddr, + ) -> Result<(), TransportError> { + unimplemented!() + } + + fn remove_listener(&mut self, _id: ListenerId) -> bool { + false + } + + fn dial( + &mut self, + addr: Multiaddr, + _: DialOpts, + ) -> Result> { + // Every dial attempt fails with an error that includes the address. + Ok(Box::pin(future::ready(Err(io::Error::new( + io::ErrorKind::Unsupported, + format!("No support for dialing {addr}"), + ))))) + } + + fn poll( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + unimplemented!() + } + } + + async fn run_test(mut transport: crate::Transport) + where + T: Transport + Clone + Send + Unpin + 'static, + T::Error: Send, + T::Dial: Send, + R: Clone + Send + Sync + Resolver + 'static, + { + let dial_opts = DialOpts { + role: Endpoint::Dialer, + port_use: PortUse::Reuse, + }; + + // This address requires DNS resolution, yielding two IP addresses, + // forcing two dial attempts. Both fail. + let addr: Multiaddr = "/dnsaddr/bootstrap.libp2p.io".parse().unwrap(); + let dial_future = transport.dial(addr, dial_opts).unwrap(); + let result = dial_future.await; + + match result { + Err(Error::Dial(errs)) => { + // We expect at least 2 errors, one per resolved IP. + assert!( + errs.len() >= 2, + "Expected multiple dial errors, but got {}", + errs.len() + ); + for e in errs { + match e { + Error::Transport(io_err) => { + assert_eq!( + io_err.kind(), + io::ErrorKind::Unsupported, + "Expected Unsupported dial error, got: {io_err:?}" + ); + } + _ => panic!("Expected Error::Transport(Unsupported), got: {e:?}"), + } + } + } + Err(e) => panic!("Expected aggregated dial errors, got {e:?}"), + Ok(_) => panic!("Dial unexpectedly succeeded"), + } + } + + test_tokio(AlwaysFailTransport, run_test); + } +} diff --git a/transports/dns/src/websys.rs b/transports/dns/src/websys.rs new file mode 100644 index 00000000000..d69892a4624 --- /dev/null +++ b/transports/dns/src/websys.rs @@ -0,0 +1,191 @@ +//! DNS name resolution for `wasm32` targets, via DNS-over-HTTPS. +//! +//! Browsers do not expose raw UDP/TCP sockets, so traditional DNS resolution +//! (as used on non-`wasm32` targets) is impossible. Instead, this module +//! resolves names over +//! [DNS-over-HTTPS](https://datatracker.ietf.org/doc/html/rfc8484) using the +//! browser's `fetch` API and a JSON (`application/dns-json`) endpoint. The +//! endpoint is configurable via [`Config`] and defaults to Cloudflare. +//! +//! `/dnsaddr` is always resolved (browsers cannot look up TXT records, so this +//! is the gap worth filling such as dialing `/dnsaddr/bootstrap.libp2p.io`). +//! `/dns`, `/dns4` and `/dns6` are governed by [`DnsResolution`], which defaults +//! to [`DnsResolution::Auto`]. Addresses containing a `/webrtc-direct` (or any future specific +//! protocols) are resolved to `/ip4`/`/ip6` (that transport needs a numeric IP), while +//! everything else is passed through unchanged, because the name-bound TLS +//! transports (WebSocket, WebTransport) resolve hostnames natively and need the +//! hostname preserved for SNI and certificate validation. Override via +//! [`Config::dns_resolution`]. + +mod resolver; +mod web_context; + +use std::sync::Arc; + +use libp2p_core::multiaddr::{Multiaddr, Protocol}; +use parking_lot::Mutex; + +pub use crate::websys::resolver::{ + CLOUDFLARE, Config, DnsResolution, DohResolver, GOOGLE, ResolveError, Resolver, +}; +use crate::{DNSADDR_PREFIX, Error, Resolved, parse_dnsaddr_txt}; + +/// A `Transport` wrapper for performing DNS lookups over HTTPS when dialing +/// `Multiaddr`esses from within a browser. +pub type Transport = crate::Transport; + +impl Transport { + /// Creates a new [`Transport`] using the default ([`Config::cloudflare`]) + /// DoH endpoint. + pub fn new(inner: T) -> Self { + Self::with_config(inner, Config::default()) + } + + /// Creates a new [`Transport`] using the given DoH [`Config`]. + pub fn with_config(inner: T, config: Config) -> Self { + crate::Transport { + inner: Arc::new(Mutex::new(inner)), + resolver: DohResolver::new(config), + } + } +} + +/// The error reported when a lookup succeeded but yielded no record applicable +/// to the address being dialed. +pub(crate) fn no_records_found() -> ResolveError { + ResolveError::Fetch("no matching records found".to_owned()) +} + +/// Returns the next DNS protocol component of `addr` that needs resolving, +/// honouring the resolver's [`DnsResolution`] policy. +pub(crate) fn next_unresolved<'a, R>( + addr: &'a Multiaddr, + resolver: &R, +) -> Option<(usize, Protocol<'a>)> +where + R: Resolver, +{ + let resolve_dns = should_resolve_dns(addr, resolver.dns_resolution()); + addr.iter() + .enumerate() + .find(|(_, p)| is_resolvable(p, resolve_dns)) +} + +fn should_resolve_dns(addr: &Multiaddr, policy: DnsResolution) -> bool { + match policy { + DnsResolution::Always => true, + DnsResolution::Never => false, + DnsResolution::Auto => addr.iter().any(|p| matches!(p, Protocol::WebRTCDirect)), + } +} + +fn is_resolvable(proto: &Protocol<'_>, resolve_dns: bool) -> bool { + match proto { + Protocol::Dnsaddr(_) => true, + Protocol::Dns(_) | Protocol::Dns4(_) | Protocol::Dns6(_) => resolve_dns, + _ => false, + } +} + +/// Asynchronously resolves the domain name of a `Dns`, `Dns4`, `Dns6` or +/// `Dnsaddr` protocol component. If the given protocol is of a different type, +/// it is returned unchanged as a [`Resolved::One`]. +pub(crate) async fn resolve<'a, E, R>( + proto: &Protocol<'a>, + resolver: &R, +) -> Result, Error> +where + R: Resolver, +{ + match proto { + Protocol::Dns(name) => { + // `/dns` resolves to both A and AAAA records; tolerate one family + // failing as long as the other yields a result. + let v4 = resolver.ipv4_lookup(name.clone().into_owned()).await; + let v6 = resolver.ipv6_lookup(name.clone().into_owned()).await; + if let (Err(e), Err(_)) = (&v4, &v6) { + return Err(Error::ResolveError(e.clone())); + } + let mut ips: Vec> = Vec::new(); + ips.extend(v4.into_iter().flatten().map(Protocol::from)); + ips.extend(v6.into_iter().flatten().map(Protocol::from)); + collect(ips) + } + Protocol::Dns4(name) => { + let ips = resolver + .ipv4_lookup(name.clone().into_owned()) + .await + .map_err(Error::ResolveError)?; + collect(ips.into_iter().map(Protocol::from).collect()) + } + Protocol::Dns6(name) => { + let ips = resolver + .ipv6_lookup(name.clone().into_owned()) + .await + .map_err(Error::ResolveError)?; + collect(ips.into_iter().map(Protocol::from).collect()) + } + Protocol::Dnsaddr(name) => { + let lookup = [DNSADDR_PREFIX, name].concat(); + let txts = resolver + .txt_lookup(lookup) + .await + .map_err(Error::ResolveError)?; + let mut addrs = Vec::new(); + for txt in txts { + match parse_dnsaddr_txt(&txt) { + Ok(a) => addrs.push(a), + // Skip over seemingly invalid entries. + Err(e) => tracing::debug!("Invalid TXT record: {:?}", e), + } + } + Ok(Resolved::Addrs(addrs)) + } + proto => Ok(Resolved::One(proto.clone())), + } +} + +/// Turns the resolved protocols into a [`Resolved`], erroring if empty. +fn collect<'a, E>(mut protocols: Vec>) -> Result, Error> { + match protocols.len() { + 0 => Err(Error::ResolveError(no_records_found())), + 1 => Ok(Resolved::One(protocols.remove(0))), + _ => Ok(Resolved::Many(protocols)), + } +} + +#[cfg(test)] +mod tests { + use wasm_bindgen_test::wasm_bindgen_test; + + use super::*; + + #[wasm_bindgen_test] + fn dnsaddr_is_always_resolvable() { + let dnsaddr = Protocol::Dnsaddr("bootstrap.libp2p.io".into()); + assert!(is_resolvable(&dnsaddr, false)); + assert!(is_resolvable(&dnsaddr, true)); + + let dns4 = Protocol::Dns4("example.com".into()); + assert!(!is_resolvable(&dns4, false)); + assert!(is_resolvable(&dns4, true)); + } + + #[wasm_bindgen_test] + fn auto_resolves_dns_only_for_webrtc_direct() { + let wss: Multiaddr = "/dns4/example.com/tcp/443/wss".parse().unwrap(); + let webrtc: Multiaddr = + "/dns4/example.com/udp/4001/webrtc-direct/certhash/uEiDDq4_xNyDorZBH3TlGazyJdOWSwvo4PUo0dVwsfStPnQ" + .parse() + .unwrap(); + + assert!(!should_resolve_dns(&wss, DnsResolution::Auto)); + assert!(should_resolve_dns(&webrtc, DnsResolution::Auto)); + + assert!(should_resolve_dns(&wss, DnsResolution::Always)); + assert!(should_resolve_dns(&webrtc, DnsResolution::Always)); + + assert!(!should_resolve_dns(&wss, DnsResolution::Never)); + assert!(!should_resolve_dns(&webrtc, DnsResolution::Never)); + } +} diff --git a/transports/dns/src/websys/resolver.rs b/transports/dns/src/websys/resolver.rs new file mode 100644 index 00000000000..f2d4b7195e4 --- /dev/null +++ b/transports/dns/src/websys/resolver.rs @@ -0,0 +1,336 @@ +use std::{ + net::{Ipv4Addr, Ipv6Addr}, + time::Duration, +}; + +use url::Url; +use wasm_bindgen::{JsCast, JsValue}; +use wasm_bindgen_futures::JsFuture; +use web_sys::{AbortSignal, Request, RequestInit, Response}; + +use crate::websys::web_context::WebContext; + +/// The Cloudflare DoH JSON endpoint. +pub const CLOUDFLARE: &str = "https://cloudflare-dns.com/dns-query"; + +/// The Google DoH JSON endpoint. +pub const GOOGLE: &str = "https://dns.google/resolve"; + +// TODO: Add other DoH endpoints for default? + +// DNS record type codes as used by the DoH JSON API. +const TYPE_A: u16 = 1; +const TYPE_AAAA: u16 = 28; +const TYPE_TXT: u16 = 16; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); + +/// Policy for resolving `/dns`, `/dns4` and `/dns6` components to IP addresses. +/// +/// `/dnsaddr` is always resolved regardless of this policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DnsResolution { + /// Resolve `/dns*` to `/ip*` only for addresses that require a literal IP, + /// i.e. those containing a `/webrtc-direct`. Otherwise pass `/dns*` through. + #[default] + Auto, + /// Always resolve `/dns*` to `/ip*`. + Always, + /// Never resolve `/dns*` + Never, +} + +/// Configuration for the DNS-over-HTTPS Resolver. +#[derive(Debug, Clone)] +pub struct Config { + /// A DoH endpoint that answers GET queries in the JSON (`application/dns-json`) format. + endpoint: String, + /// Resoluton for how `/dns`, `/dns4` and `/dns6` components are handled. + dns_resolution: DnsResolution, + /// Timeout for a single DoH request. + timeout: Duration, +} + +impl Default for Config { + fn default() -> Self { + Self::cloudflare() + } +} + +impl Config { + /// Creates a configuration pointing at a custom DoH JSON endpoint. + pub fn new(endpoint: impl Into) -> Self { + Config { + endpoint: endpoint.into(), + dns_resolution: DnsResolution::default(), + timeout: DEFAULT_TIMEOUT, + } + } + + /// Resolve via Cloudflare (see `https://cloudflare-dns.com/dns-query`). + pub fn cloudflare() -> Self { + Config::new(CLOUDFLARE) + } + + /// Resolve via Google (see `https://dns.google/resolve`). + pub fn google() -> Self { + Config::new(GOOGLE) + } + + /// Sets the [`DnsResolution`] policy for `/dns`, `/dns4` and `/dns6` + /// components. + pub fn dns_resolution(mut self, policy: DnsResolution) -> Self { + self.dns_resolution = policy; + self + } + + /// Sets the timeout for a single DoH request. Defaults to 10 seconds. + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } +} + +/// A DNS resolver, as used by [`crate::Transport`] on `wasm32` targets. +#[doc(hidden)] +pub trait Resolver { + fn ipv4_lookup( + &self, + name: String, + ) -> impl Future, ResolveError>>; + fn ipv6_lookup( + &self, + name: String, + ) -> impl Future, ResolveError>>; + fn txt_lookup(&self, name: String) -> impl Future, ResolveError>>; + + /// The policy applied to `/dns`, `/dns4` and `/dns6` components. + fn dns_resolution(&self) -> DnsResolution { + DnsResolution::default() + } +} + +/// A DNS resolver that performs lookups over HTTPS (DoH) using the browser's +/// `fetch` API. This is the only way to resolve arbitrary DNS records (in +/// particular the TXT records behind `/dnsaddr`) from within a browser. +#[derive(Debug, Clone)] +pub struct DohResolver { + config: Config, +} + +impl DohResolver { + /// Creates a resolver for the endpoint and policy given by `config`. + pub fn new(config: Config) -> Self { + DohResolver { config } + } + + /// Performs a single DoH lookup, returning the `data` field of every answer + /// whose record type matches `qtype`. An empty result means the lookup + /// succeeded but no matching records exist. + async fn query(&self, name: &str, qtype: u16) -> Result, ResolveError> { + let url = build_query_url(&self.config.endpoint, name, qtype)?; + let body = doh_get(url.as_str(), self.config.timeout).await?; + let response: DohResponse = + serde_json::from_str(&body).map_err(|e| ResolveError::Parse(e.to_string()))?; + if response.status != 0 { + return Err(ResolveError::Status(response.status)); + } + Ok(response + .answer + .into_iter() + .filter(|a| a.kind == qtype) + .map(|a| a.data) + .collect()) + } +} + +impl Resolver for DohResolver { + async fn ipv4_lookup(&self, name: String) -> Result, ResolveError> { + Ok(self + .query(&name, TYPE_A) + .await? + .iter() + .filter_map(|d| d.parse::().ok()) + .collect()) + } + + async fn ipv6_lookup(&self, name: String) -> Result, ResolveError> { + Ok(self + .query(&name, TYPE_AAAA) + .await? + .iter() + .filter_map(|d| d.parse::().ok()) + .collect()) + } + + async fn txt_lookup(&self, name: String) -> Result, ResolveError> { + Ok(self + .query(&name, TYPE_TXT) + .await? + .iter() + .map(|d| unquote_txt(d)) + .collect()) + } + + fn dns_resolution(&self) -> DnsResolution { + self.config.dns_resolution + } +} + +/// The relevant subset of a DoH JSON response. +#[derive(serde::Deserialize)] +struct DohResponse { + #[serde(rename = "Status")] + status: u32, + #[serde(default, rename = "Answer")] + answer: Vec, +} + +#[derive(serde::Deserialize)] +struct DohAnswer { + #[serde(rename = "type")] + kind: u16, + data: String, +} + +fn build_query_url(endpoint: &str, name: &str, qtype: u16) -> Result { + let mut url = Url::parse(endpoint).map_err(|e| ResolveError::Url(e.to_string()))?; + url.query_pairs_mut() + .append_pair("name", name) + .append_pair("type", &qtype.to_string()); + Ok(url) +} + +/// Issues the actual `fetch` for a DoH JSON query and returns the response body. +async fn doh_get(url: &str, timeout: Duration) -> Result { + let opts = RequestInit::new(); + let timeout_ms = timeout.as_millis().clamp(1, u32::MAX as u128) as u32; + opts.set_signal(Some(&AbortSignal::timeout_with_u32(timeout_ms))); + + let request = Request::new_with_str_and_init(url, &opts).map_err(js_error)?; + request + .headers() + .set("accept", "application/dns-json") + .map_err(js_error)?; + + let context = WebContext::new() + .ok_or_else(|| ResolveError::Fetch("no browser global scope available".to_owned()))?; + + let response = JsFuture::from(context.fetch_with_request(&request)) + .await + .map_err(js_error)?; + let response: Response = response + .dyn_into() + .map_err(|_| ResolveError::Fetch("fetch did not return a Response".to_owned()))?; + + if !response.ok() { + return Err(ResolveError::Http(response.status())); + } + + let text = JsFuture::from(response.text().map_err(js_error)?) + .await + .map_err(js_error)?; + text.as_string() + .ok_or_else(|| ResolveError::Fetch("response body was not a string".to_owned())) +} + +/// DoH JSON returns TXT records wrapped in literal double quotes; strip a single +/// surrounding pair if present. +fn unquote_txt(s: &str) -> String { + // Some endpoints return short values unquoted; pass those through verbatim. + if !s.contains('"') { + return s.to_owned(); + } + + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + // Skip whitespace between quoted segments; only quoted content contributes. + if c != '"' { + continue; + } + while let Some(c) = chars.next() { + match c { + '"' => break, + '\\' => match chars.next() { + // `\DDD` decimal byte escape (RFC 1035 s5.1). + Some(d) if d.is_ascii_digit() => { + let mut code = d.to_digit(10).unwrap(); + for _ in 0..2 { + match chars.peek() { + Some(p) if p.is_ascii_digit() => { + code = code * 10 + chars.next().unwrap().to_digit(10).unwrap(); + } + _ => break, + } + } + if let Some(ch) = char::from_u32(code) { + out.push(ch); + } + } + // `\"`, `\\`, or any other escaped char: keep the char as-is. + Some(other) => out.push(other), + None => {} + }, + _ => out.push(c), + } + } + } + out +} + +fn js_error(value: JsValue) -> ResolveError { + ResolveError::Fetch(format!("{value:?}")) +} + +/// Errors that can occur while resolving a DNS over HTTPSs. +#[derive(Debug, Clone, thiserror::Error)] +pub enum ResolveError { + /// The `fetch` call or response handling failed (network error, wrong + /// global scope, non-string body, etcc). + #[error("DNS-over-HTTPS request failed: {0}")] + Fetch(String), + /// The DoH endpoint returned a non-success HTTP status. + #[error("DNS-over-HTTPS request returned HTTP status {0}")] + Http(u16), + /// The DoH endpoint returned a DNS error status. + #[error("DNS query failed with status {0}")] + Status(u32), + /// The DoH response could not be parsed. + #[error("failed to parse DNS-over-HTTPS response: {0}")] + Parse(String), + /// The configured DoH endpoint is not a valid URL. + #[error("invalid DNS-over-HTTPS endpoint URL: {0}")] + Url(String), +} + +#[cfg(test)] +mod tests { + use wasm_bindgen_test::wasm_bindgen_test; + + use super::*; + + #[wasm_bindgen_test] + fn unquote_txt_strips_single_surrounding_pair() { + assert_eq!(unquote_txt("\"dnsaddr=/dns4/foo\""), "dnsaddr=/dns4/foo"); + assert_eq!(unquote_txt("dnsaddr=/dns4/foo"), "dnsaddr=/dns4/foo"); + assert_eq!(unquote_txt("\"\""), ""); + } + + #[wasm_bindgen_test] + fn parses_doh_json() { + let body = r#"{"Status":0,"Answer":[ + {"name":"example.com.","type":1,"TTL":60,"data":"1.2.3.4"}, + {"name":"example.com.","type":5,"TTL":60,"data":"cname.example.com."} + ]}"#; + let response: DohResponse = serde_json::from_str(body).unwrap(); + assert_eq!(response.status, 0); + let a_records: Vec<_> = response + .answer + .into_iter() + .filter(|a| a.kind == TYPE_A) + .map(|a| a.data) + .collect(); + assert_eq!(a_records, vec!["1.2.3.4".to_owned()]); + } +} diff --git a/transports/dns/src/websys/web_context.rs b/transports/dns/src/websys/web_context.rs new file mode 100644 index 00000000000..c4f0c51289b --- /dev/null +++ b/transports/dns/src/websys/web_context.rs @@ -0,0 +1,42 @@ +use js_sys::Promise; +use wasm_bindgen::prelude::*; +use web_sys::{Request, window}; + +/// Web context that abstracts the `window` vs web worker global scope, so that +/// DoH lookups work both on the main thread and inside workers. +#[derive(Debug)] +pub(crate) enum WebContext { + Window(web_sys::Window), + Worker(web_sys::WorkerGlobalScope), +} + +impl WebContext { + pub(crate) fn new() -> Option { + match window() { + Some(window) => Some(Self::Window(window)), + None => { + #[wasm_bindgen] + extern "C" { + type Global; + + #[wasm_bindgen(method, getter, js_name = WorkerGlobalScope)] + fn worker(this: &Global) -> JsValue; + } + let global: Global = js_sys::global().unchecked_into(); + if !global.worker().is_undefined() { + Some(Self::Worker(global.unchecked_into())) + } else { + None + } + } + } + } + + /// The `fetch()` method. + pub(crate) fn fetch_with_request(&self, request: &Request) -> Promise { + match self { + WebContext::Window(w) => w.fetch_with_request(request), + WebContext::Worker(w) => w.fetch_with_request(request), + } + } +} diff --git a/wasm-tests/run-all.sh b/wasm-tests/run-all.sh index 77b896a167d..e92bd98aaae 100755 --- a/wasm-tests/run-all.sh +++ b/wasm-tests/run-all.sh @@ -5,3 +5,7 @@ set -e cd "$(dirname "${BASH_SOURCE[0]}")" || exit 1 ./webtransport-tests/run.sh + +# `libp2p-dns` compiles its DNS-over-HTTPS resolver only for `wasm32`, so its +# unit tests are unreachable from the native `cargo test` matrix. +wasm-pack test --chrome --headless ../transports/dns