From 6d3e26b15dda9b2b5dc326298fe5ed0b8ab30d49 Mon Sep 17 00:00:00 2001 From: Darius Clark Date: Tue, 2 Jun 2026 10:58:53 -0500 Subject: [PATCH 1/7] chore: implement DoH for wasm32 target chore: add DnsResolution to indicate how dns are resolved chore: update changelog --- Cargo.lock | 19 + Cargo.toml | 2 + transports/dns-websys/CHANGELOG.md | 9 + transports/dns-websys/Cargo.toml | 31 ++ transports/dns-websys/src/lib.rs | 490 +++++++++++++++++++++++ transports/dns-websys/src/resolver.rs | 257 ++++++++++++ transports/dns-websys/src/web_context.rs | 42 ++ 7 files changed, 850 insertions(+) create mode 100644 transports/dns-websys/CHANGELOG.md create mode 100644 transports/dns-websys/Cargo.toml create mode 100644 transports/dns-websys/src/lib.rs create mode 100644 transports/dns-websys/src/resolver.rs create mode 100644 transports/dns-websys/src/web_context.rs diff --git a/Cargo.lock b/Cargo.lock index 1d8eef482c4..bc6d0cca7dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3037,6 +3037,25 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "libp2p-dns-websys" +version = "0.1.0-alpha" +dependencies = [ + "futures", + "js-sys", + "libp2p-core", + "parking_lot", + "send_wrapper 0.6.0", + "serde", + "serde_json", + "smallvec", + "thiserror 2.0.18", + "tracing", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "libp2p-floodsub" version = "0.48.0" diff --git a/Cargo.toml b/Cargo.toml index d59f9e15941..7763bffee00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,7 @@ members = [ "swarm-test", "swarm", "transports/dns", + "transports/dns-websys", "transports/noise", "transports/plaintext", "transports/pnet", @@ -82,6 +83,7 @@ libp2p-connection-limits = { version = "0.7.0", path = "misc/connection-limits" libp2p-core = { version = "0.44.0", path = "core" } libp2p-dcutr = { version = "0.15.0", path = "protocols/dcutr" } libp2p-dns = { version = "0.45.0", path = "transports/dns" } +libp2p-dns-websys = { version = "0.1.0-alpha", path = "transports/dns-websys" } libp2p-floodsub = { version = "0.48.0", path = "protocols/floodsub" } libp2p-gossipsub = { version = "0.50.0", path = "protocols/gossipsub" } libp2p-identify = { version = "0.48.0", path = "protocols/identify" } diff --git a/transports/dns-websys/CHANGELOG.md b/transports/dns-websys/CHANGELOG.md new file mode 100644 index 00000000000..07a59839c86 --- /dev/null +++ b/transports/dns-websys/CHANGELOG.md @@ -0,0 +1,9 @@ +## 0.1.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`): 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). \ No newline at end of file diff --git a/transports/dns-websys/Cargo.toml b/transports/dns-websys/Cargo.toml new file mode 100644 index 00000000000..fc7e3e2fa07 --- /dev/null +++ b/transports/dns-websys/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "libp2p-dns-websys" +edition.workspace = true +rust-version = { workspace = true } +description = "DNS transport implementation via DNS-over-HTTPS for libp2p under WASM environment" +version = "0.1.0-alpha" +license = "MIT" +repository = "https://github.com/libp2p/rust-libp2p" +keywords = ["peer-to-peer", "libp2p", "networking"] +categories = ["network-programming", "asynchronous"] + +[dependencies] +futures = { workspace = true } +js-sys = "0.3.77" +libp2p-core = { workspace = true } +parking_lot = "0.12.5" +send_wrapper = { version = "0.6.0", features = ["futures"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +smallvec = "1.15.1" +thiserror = { workspace = true } +tracing = { workspace = true } +wasm-bindgen = "0.2.100" +wasm-bindgen-futures = { workspace = true } +web-sys = { version = "0.3.77", features = ["Headers", "Request", "Response", "Window", "WorkerGlobalScope"] } + +[package.metadata.docs.rs] +all-features = true + +[lints] +workspace = true diff --git a/transports/dns-websys/src/lib.rs b/transports/dns-websys/src/lib.rs new file mode 100644 index 00000000000..c4245c79529 --- /dev/null +++ b/transports/dns-websys/src/lib.rs @@ -0,0 +1,490 @@ +//! # DNS name resolution for libp2p under WASM, via DNS-over-HTTPS. +//! +//! This crate provides a [`Transport`] for `wasm32` (browser) targets. Much llike +//! [`libp2p-dns`](https://docs.rs/libp2p-dns), it is an address-rewriting +//! wrapper around an inner [`libp2p_core::Transport`]: on +//! [`libp2p_core::Transport::dial`] it resolves the DNS components of a +//! [`Multiaddr`], replacing them with the resolved protocols before handing the +//! address to the inner transport. +//! +//! Browsers do not expose raw UDP/TCP sockets, so traditional DNS resolution +//! (as used by `libp2p-dns`) is impossible. Instead, this crate 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`]. + +#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] + +mod resolver; +mod web_context; + +use std::{ + error, fmt, io, + ops::DerefMut, + pin::Pin, + sync::Arc, + task::{Context, Poll}, +}; + +use futures::{future, prelude::*}; +use libp2p_core::{ + multiaddr::{Multiaddr, Protocol}, + transport::{DialOpts, ListenerId, TransportError, TransportEvent}, +}; +use parking_lot::Mutex; +use send_wrapper::SendWrapper; +use smallvec::SmallVec; + +use crate::resolver::Resolver; +pub use crate::resolver::{CLOUDFLARE, Config, DnsResolution, GOOGLE, ResolveError}; + +/// The prefix for `dnsaddr` protocol TXT record lookups. +const DNSADDR_PREFIX: &str = "_dnsaddr."; + +/// The maximum number of dialing attempts to resolved addresses. +const MAX_DIAL_ATTEMPTS: usize = 16; + +/// The maximum number of DNS lookups when dialing. +/// +/// This limit is primarily a safeguard against too many, possibly even cyclic, +/// indirections in the addresses obtained from the TXT records of a `/dnsaddr`. +const MAX_DNS_LOOKUPS: usize = 32; + +/// The maximum number of TXT records applicable for the address being dialed +/// that are considered for further lookups as a result of a single `/dnsaddr` +/// lookup. +const MAX_TXT_RECORDS: usize = 16; + +/// A [`libp2p_core::Transport`] that resolves DNS names over HTTPS before +/// dialing the inner transport. Intended for `wasm32` (browser) targets. +#[derive(Debug)] +pub struct Transport { + /// The underlying transport. + inner: Arc>, + /// The DoH resolver used when dialing addresses with DNS + /// components. + resolver: Resolver, +} + +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 { + Transport { + inner: Arc::new(Mutex::new(inner)), + resolver: Resolver::new(config), + } + } +} + +impl libp2p_core::Transport for Transport +where + T: libp2p_core::Transport + Send + Unpin + 'static, + T::Error: Send, + T::Dial: Send, +{ + type Output = T::Output; + type Error = Error; + type ListenerUpgrade = future::MapErr Self::Error>; + type Dial = Pin> + Send>>; + + fn listen_on( + &mut self, + id: ListenerId, + addr: Multiaddr, + ) -> Result<(), TransportError> { + self.inner + .lock() + .listen_on(id, addr) + .map_err(|e| e.map(Error::Transport)) + } + + fn remove_listener(&mut self, id: ListenerId) -> bool { + self.inner.lock().remove_listener(id) + } + + fn dial( + &mut self, + addr: Multiaddr, + dial_opts: DialOpts, + ) -> Result> { + Ok(self.do_dial(addr, dial_opts)) + } + + fn poll( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll> { + let mut inner = self.inner.lock(); + libp2p_core::Transport::poll(Pin::new(inner.deref_mut()), cx).map(|event| { + event + .map_upgrade(|upgr| upgr.map_err::<_, fn(_) -> _>(Error::Transport)) + .map_err(Error::Transport) + }) + } +} + +impl Transport +where + T: libp2p_core::Transport + Send + Unpin + 'static, + T::Error: Send, + T::Dial: Send, +{ + fn do_dial( + &mut self, + addr: Multiaddr, + dial_opts: DialOpts, + ) -> ::Dial { + let resolver = self.resolver.clone(); + let inner = self.inner.clone(); + let dns_resolution = self.resolver.dns_resolution(); + + // The lookups are driven by the browser's `fetch` API, whose futures are + // `!Send`. `SendWrapper` makes the resulting future `Send` (sound on the + // single-threaded wasm runtime), so it satisfies the bound on `Dial`. + SendWrapper::new(async move { + let mut dial_errors: Vec> = Vec::new(); + let mut dns_lookups = 0; + let mut dial_attempts = 0; + // We optimise for the common case of a single DNS component in the + // address that is resolved with a single lookup. + let mut unresolved = SmallVec::<[Multiaddr; 1]>::new(); + unresolved.push(addr.clone()); + + // Resolve (i.e. replace) all DNS protocol components, initiating + // dialing attempts as soon as there is another fully resolved + // address. + while let Some(addr) = unresolved.pop() { + let resolve_dns = should_resolve_dns(&addr, dns_resolution); + if let Some((i, name)) = addr + .iter() + .enumerate() + .find(|(_, p)| is_resolvable(p, resolve_dns)) + { + if dns_lookups == MAX_DNS_LOOKUPS { + tracing::debug!(address=%addr, "Too many DNS lookups, dropping unresolved address"); + dial_errors.push(Error::TooManyLookups); + // There may still be fully resolved addresses in + // `unresolved`, so keep going until it is empty. + continue; + } + dns_lookups += 1; + match resolve(&name, &resolver).await { + Err(e) => { + // Record the resolution error. + dial_errors.push(e); + } + Ok(Resolved::One(ip)) => { + tracing::trace!(protocol=%name, resolved=%ip); + let addr = addr.replace(i, |_| Some(ip)).expect("`i` is a valid index"); + unresolved.push(addr); + } + Ok(Resolved::Many(ips)) => { + for ip in ips { + tracing::trace!(protocol=%name, resolved=%ip); + let addr = + addr.replace(i, |_| Some(ip)).expect("`i` is a valid index"); + unresolved.push(addr); + } + } + Ok(Resolved::Addrs(addrs)) => { + let suffix = addr.iter().skip(i + 1).collect::(); + let prefix = addr.iter().take(i).collect::(); + let mut n = 0; + for a in addrs { + if a.ends_with(&suffix) { + if n < MAX_TXT_RECORDS { + n += 1; + tracing::trace!(protocol=%name, resolved=%a); + let addr = + prefix.iter().chain(a.iter()).collect::(); + unresolved.push(addr); + } else { + tracing::debug!( + resolved=%a, + "Too many TXT records, dropping resolved" + ); + } + } + } + } + } + } else { + // We have a fully resolved address, so try to dial it. + tracing::debug!(address=%addr, "Dialing address"); + + let transport = inner.clone(); + let dial = transport.lock().dial(addr, dial_opts); + let result = match dial { + Ok(out) => { + // We only count attempts that the inner transport + // actually accepted, i.e. for which it produced a + // dialing future. + dial_attempts += 1; + out.await.map_err(Error::Transport) + } + Err(TransportError::MultiaddrNotSupported(a)) => { + Err(Error::MultiaddrNotSupported(a)) + } + Err(TransportError::Other(err)) => Err(Error::Transport(err)), + }; + + match result { + Ok(out) => return Ok(out), + Err(err) => { + tracing::debug!("Dial error: {:?}.", err); + dial_errors.push(err); + + if unresolved.is_empty() { + break; + } + + if dial_attempts == MAX_DIAL_ATTEMPTS { + tracing::debug!( + "Aborting dialing after {} attempts.", + MAX_DIAL_ATTEMPTS + ); + break; + } + } + } + } + } + + // If we have any dial errors, aggregate them. Otherwise there were + // no valid DNS records for the given address to begin with. + if !dial_errors.is_empty() { + Err(Error::Dial(dial_errors)) + } else { + Err(Error::ResolveError(ResolveError::Fetch( + "no matching records found".to_owned(), + ))) + } + }) + .boxed() + } +} + +/// The possible errors of a [`Transport`]-wrapped transport. +#[derive(Debug)] +#[allow(clippy::large_enum_variant)] +pub enum Error { + /// The underlying transport encountered an error. + Transport(TErr), + /// DNS resolution failed. + #[allow(clippy::enum_variant_names)] + ResolveError(ResolveError), + /// DNS resolution was successful, but the underlying transport refused the + /// resolved address. + MultiaddrNotSupported(Multiaddr), + /// DNS resolution involved too many lookups. + TooManyLookups, + /// Multiple dial errors were encountered. + Dial(Vec>), +} + +impl fmt::Display for Error +where + TErr: fmt::Display, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Transport(err) => write!(f, "{err}"), + Error::ResolveError(err) => write!(f, "{err}"), + Error::MultiaddrNotSupported(a) => write!(f, "Unsupported resolved address: {a}"), + Error::TooManyLookups => write!(f, "Too many DNS lookups"), + Error::Dial(errs) => { + write!(f, "Multiple dial errors occurred:")?; + for err in errs { + write!(f, "\n - {err}")?; + } + Ok(()) + } + } + } +} + +impl error::Error for Error +where + TErr: error::Error + 'static, +{ + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + match self { + Error::Transport(err) => Some(err), + Error::ResolveError(err) => Some(err), + Error::MultiaddrNotSupported(_) => None, + Error::TooManyLookups => None, + Error::Dial(errs) => errs.last().and_then(|e| e.source()), + } + } +} + +/// The successful outcome of [`resolve`] for a given [`Protocol`]. +enum Resolved<'a> { + /// The given `Protocol` has been resolved to a single `Protocol`, which may + /// be identical to the one given, in case it is not a DNS protocol + /// component. + One(Protocol<'a>), + /// The given `Protocol` has been resolved to multiple alternative + /// `Protocol`s as a result of a DNS lookup. + Many(Vec>), + /// The given `Protocol` has been resolved to a new list of `Multiaddr`s + /// obtained from DNS TXT records representing possible alternatives. These + /// addresses may contain further DNS names that need resolving. + Addrs(Vec), +} + +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`]. +async fn resolve<'a, E>( + proto: &Protocol<'a>, + resolver: &Resolver, +) -> Result, Error> { + 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.as_ref()).await; + let v6 = resolver.ipv6_lookup(name.as_ref()).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.as_ref()) + .await + .map_err(Error::ResolveError)?; + collect(ips.into_iter().map(Protocol::from).collect()) + } + Protocol::Dns6(name) => { + let ips = resolver + .ipv6_lookup(name.as_ref()) + .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(ResolveError::Fetch( + "no matching records found".to_owned(), + ))), + 1 => Ok(Resolved::One(protocols.remove(0))), + _ => Ok(Resolved::Many(protocols)), + } +} + +/// Parses a `` of a `dnsaddr` TXT record. +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)?), + } +} + +fn invalid_data(e: impl Into>) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, e) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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)); + } + + #[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)); + } + + #[test] + 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() + ); + assert!(parse_dnsaddr_txt("/dns4/example.com").is_err()); + } +} diff --git a/transports/dns-websys/src/resolver.rs b/transports/dns-websys/src/resolver.rs new file mode 100644 index 00000000000..fb69da6cd22 --- /dev/null +++ b/transports/dns-websys/src/resolver.rs @@ -0,0 +1,257 @@ +use std::net::{Ipv4Addr, Ipv6Addr}; + +use wasm_bindgen::{JsCast, JsValue}; +use wasm_bindgen_futures::JsFuture; +use web_sys::{Request, Response}; + +use crate::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; + +/// 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, +} + +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(), + } + } + + /// 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 + } +} + +/// 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(crate) struct Resolver { + config: Config, +} + +impl Resolver { + pub(crate) fn new(config: Config) -> Self { + Resolver { config } + } + + /// The configured [`DnsResolution`] policy for `/dns*` components. + pub(crate) fn dns_resolution(&self) -> DnsResolution { + self.config.dns_resolution + } + + pub(crate) async fn ipv4_lookup(&self, name: &str) -> Result, ResolveError> { + Ok(self + .query(name, TYPE_A) + .await? + .iter() + .filter_map(|d| d.parse::().ok()) + .collect()) + } + + pub(crate) async fn ipv6_lookup(&self, name: &str) -> Result, ResolveError> { + Ok(self + .query(name, TYPE_AAAA) + .await? + .iter() + .filter_map(|d| d.parse::().ok()) + .collect()) + } + + pub(crate) async fn txt_lookup(&self, name: &str) -> Result, ResolveError> { + Ok(self + .query(name, TYPE_TXT) + .await? + .iter() + .map(|d| unquote_txt(d)) + .collect()) + } + + /// 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 = format!( + "{}?name={}&type={}", + self.config.endpoint, + encode_name(name), + qtype + ); + let body = doh_get(&url).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()) + } +} + +/// 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, +} + +/// Issues the actual `fetch` for a DoH JSON query and returns the response body. +async fn doh_get(url: &str) -> Result { + let request = Request::new_with_str(url).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())) +} + +/// Percent-encodes any characters in a DNS name that are not URL-safe. +fn encode_name(name: &str) -> String { + String::from(js_sys::encode_uri_component(name)) +} + +/// DoH JSON returns TXT records wrapped in literal double quotes; strip a single +/// surrounding pair if present. +fn unquote_txt(s: &str) -> String { + s.strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .unwrap_or(s) + .to_owned() +} + +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), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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("\"\""), ""); + } + + #[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-websys/src/web_context.rs b/transports/dns-websys/src/web_context.rs new file mode 100644 index 00000000000..c4f0c51289b --- /dev/null +++ b/transports/dns-websys/src/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), + } + } +} From d2c1ab4671dd80689662fa2f0574dfeb89b06477 Mon Sep 17 00:00:00 2001 From: Darius Clark Date: Wed, 3 Jun 2026 22:00:27 -0500 Subject: [PATCH 2/7] chore: fmt --- transports/dns-websys/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/transports/dns-websys/src/lib.rs b/transports/dns-websys/src/lib.rs index c4245c79529..c6739f043d2 100644 --- a/transports/dns-websys/src/lib.rs +++ b/transports/dns-websys/src/lib.rs @@ -16,8 +16,8 @@ //! `/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 +//! 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 From 938befc58d00301fa4b05d8cfbdda0fedd72c62f Mon Sep 17 00:00:00 2001 From: Darius Clark Date: Wed, 3 Jun 2026 22:05:29 -0500 Subject: [PATCH 3/7] chore: correct comment --- transports/dns-websys/CHANGELOG.md | 2 +- transports/dns-websys/src/resolver.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/transports/dns-websys/CHANGELOG.md b/transports/dns-websys/CHANGELOG.md index 07a59839c86..b8e6ec83b2f 100644 --- a/transports/dns-websys/CHANGELOG.md +++ b/transports/dns-websys/CHANGELOG.md @@ -1,4 +1,4 @@ -## 0.1.0 +## 0.1.0-alpha - 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 diff --git a/transports/dns-websys/src/resolver.rs b/transports/dns-websys/src/resolver.rs index fb69da6cd22..0b06528ff38 100644 --- a/transports/dns-websys/src/resolver.rs +++ b/transports/dns-websys/src/resolver.rs @@ -34,7 +34,7 @@ pub enum DnsResolution { Never, } -/// Configuration for the DNS-over-HTTPS [`Resolver`]. +/// 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. From b0c9aa9ba67a0f0669d0bff4ff1bf0365a693a91 Mon Sep 17 00:00:00 2001 From: Darius Clark Date: Fri, 5 Jun 2026 18:49:10 -0500 Subject: [PATCH 4/7] chore: use url for handling the uri; add timeout --- Cargo.lock | 1 + transports/dns-websys/Cargo.toml | 3 +- transports/dns-websys/src/resolver.rs | 53 +++++++++++++++++++-------- 3 files changed, 40 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7924e8802e7..ce5b99b4092 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3051,6 +3051,7 @@ dependencies = [ "smallvec", "thiserror 2.0.18", "tracing", + "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", diff --git a/transports/dns-websys/Cargo.toml b/transports/dns-websys/Cargo.toml index fc7e3e2fa07..787c85d6c8c 100644 --- a/transports/dns-websys/Cargo.toml +++ b/transports/dns-websys/Cargo.toml @@ -20,9 +20,10 @@ serde_json = "1.0.150" smallvec = "1.15.1" thiserror = { workspace = true } tracing = { workspace = true } +url = "2.5.4" wasm-bindgen = "0.2.100" wasm-bindgen-futures = { workspace = true } -web-sys = { version = "0.3.77", features = ["Headers", "Request", "Response", "Window", "WorkerGlobalScope"] } +web-sys = { version = "0.3.77", features = ["AbortSignal", "Headers", "Request", "RequestInit", "Response", "Window", "WorkerGlobalScope"] } [package.metadata.docs.rs] all-features = true diff --git a/transports/dns-websys/src/resolver.rs b/transports/dns-websys/src/resolver.rs index 0b06528ff38..f0686000bc0 100644 --- a/transports/dns-websys/src/resolver.rs +++ b/transports/dns-websys/src/resolver.rs @@ -1,8 +1,12 @@ -use std::net::{Ipv4Addr, Ipv6Addr}; +use std::{ + net::{Ipv4Addr, Ipv6Addr}, + time::Duration, +}; +use url::Url; use wasm_bindgen::{JsCast, JsValue}; use wasm_bindgen_futures::JsFuture; -use web_sys::{Request, Response}; +use web_sys::{AbortSignal, Request, RequestInit, Response}; use crate::web_context::WebContext; @@ -19,6 +23,8 @@ 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. @@ -41,6 +47,8 @@ pub struct Config { 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 { @@ -55,6 +63,7 @@ impl Config { Config { endpoint: endpoint.into(), dns_resolution: DnsResolution::default(), + timeout: DEFAULT_TIMEOUT, } } @@ -74,6 +83,12 @@ impl Config { 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 that performs lookups over HTTPS (DoH) using the browser's @@ -125,13 +140,8 @@ impl Resolver { /// 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 = format!( - "{}?name={}&type={}", - self.config.endpoint, - encode_name(name), - qtype - ); - let body = doh_get(&url).await?; + 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 { @@ -162,9 +172,22 @@ struct DohAnswer { 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) -> Result { - let request = Request::new_with_str(url).map_err(js_error)?; +async fn doh_get(url: &str, timeout: Duration) -> Result { + let opts = RequestInit::new(); + opts.set_signal(Some(&AbortSignal::timeout_with_f64( + timeout.as_millis() as f64 + ))); + + let request = Request::new_with_str_and_init(url, &opts).map_err(js_error)?; request .headers() .set("accept", "application/dns-json") @@ -191,11 +214,6 @@ async fn doh_get(url: &str) -> Result { .ok_or_else(|| ResolveError::Fetch("response body was not a string".to_owned())) } -/// Percent-encodes any characters in a DNS name that are not URL-safe. -fn encode_name(name: &str) -> String { - String::from(js_sys::encode_uri_component(name)) -} - /// DoH JSON returns TXT records wrapped in literal double quotes; strip a single /// surrounding pair if present. fn unquote_txt(s: &str) -> String { @@ -225,6 +243,9 @@ pub enum ResolveError { /// 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)] From f3dc943d98a9267ef6cc1e2e380257b213fd0eb8 Mon Sep 17 00:00:00 2001 From: Darius Clark Date: Sun, 14 Jun 2026 19:46:44 -0400 Subject: [PATCH 5/7] chore: change timeout so duration cannot be set or produce zero --- transports/dns-websys/src/resolver.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/transports/dns-websys/src/resolver.rs b/transports/dns-websys/src/resolver.rs index f0686000bc0..276ad8ebd5c 100644 --- a/transports/dns-websys/src/resolver.rs +++ b/transports/dns-websys/src/resolver.rs @@ -183,9 +183,8 @@ fn build_query_url(endpoint: &str, name: &str, qtype: u16) -> Result Result { let opts = RequestInit::new(); - opts.set_signal(Some(&AbortSignal::timeout_with_f64( - timeout.as_millis() as f64 - ))); + 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 From 2b63a5be5183aeb5f6221c8b2f950fb5ed8726b2 Mon Sep 17 00:00:00 2001 From: Darius Clark Date: Sun, 14 Jun 2026 19:47:32 -0400 Subject: [PATCH 6/7] chore: expand unquote_txt to properly decode --- transports/dns-websys/src/resolver.rs | 44 ++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/transports/dns-websys/src/resolver.rs b/transports/dns-websys/src/resolver.rs index 276ad8ebd5c..cf81101ae8b 100644 --- a/transports/dns-websys/src/resolver.rs +++ b/transports/dns-websys/src/resolver.rs @@ -216,10 +216,46 @@ async fn doh_get(url: &str, timeout: Duration) -> Result { /// DoH JSON returns TXT records wrapped in literal double quotes; strip a single /// surrounding pair if present. fn unquote_txt(s: &str) -> String { - s.strip_prefix('"') - .and_then(|s| s.strip_suffix('"')) - .unwrap_or(s) - .to_owned() + // 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 { From a3aef8c17c2bdc6b6ca239e944301cbe955f1326 Mon Sep 17 00:00:00 2001 From: Darius Clark Date: Thu, 23 Jul 2026 11:11:11 -0500 Subject: [PATCH 7/7] refactor: merge in dns-websys into dns crate --- Cargo.lock | 19 +- Cargo.toml | 2 - libp2p/CHANGELOG.md | 4 +- libp2p/Cargo.toml | 2 +- libp2p/src/lib.rs | 1 - transports/dns-websys/CHANGELOG.md | 9 - transports/dns-websys/Cargo.toml | 32 - transports/dns-websys/src/lib.rs | 490 --------------- transports/dns/CHANGELOG.md | 8 + transports/dns/Cargo.toml | 23 +- transports/dns/src/lib.rs | 558 ++---------------- transports/dns/src/native.rs | 533 +++++++++++++++++ transports/dns/src/websys.rs | 191 ++++++ .../src => dns/src/websys}/resolver.rs | 89 +-- .../src => dns/src/websys}/web_context.rs | 0 wasm-tests/run-all.sh | 4 + 16 files changed, 884 insertions(+), 1081 deletions(-) delete mode 100644 transports/dns-websys/CHANGELOG.md delete mode 100644 transports/dns-websys/Cargo.toml delete mode 100644 transports/dns-websys/src/lib.rs create mode 100644 transports/dns/src/native.rs create mode 100644 transports/dns/src/websys.rs rename transports/{dns-websys/src => dns/src/websys}/resolver.rs (87%) rename transports/{dns-websys/src => dns/src/websys}/web_context.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index ce5b99b4092..fb0a15c7761 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3027,33 +3027,24 @@ name = "libp2p-dns" version = "0.45.0" dependencies = [ "futures", + "getrandom 0.2.15", "hickory-resolver", - "libp2p-core", - "libp2p-identity", - "parking_lot", - "smallvec", - "tokio", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "libp2p-dns-websys" -version = "0.1.0-alpha" -dependencies = [ - "futures", "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", ] diff --git a/Cargo.toml b/Cargo.toml index 7763bffee00..d59f9e15941 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,6 @@ members = [ "swarm-test", "swarm", "transports/dns", - "transports/dns-websys", "transports/noise", "transports/plaintext", "transports/pnet", @@ -83,7 +82,6 @@ libp2p-connection-limits = { version = "0.7.0", path = "misc/connection-limits" libp2p-core = { version = "0.44.0", path = "core" } libp2p-dcutr = { version = "0.15.0", path = "protocols/dcutr" } libp2p-dns = { version = "0.45.0", path = "transports/dns" } -libp2p-dns-websys = { version = "0.1.0-alpha", path = "transports/dns-websys" } libp2p-floodsub = { version = "0.48.0", path = "protocols/floodsub" } libp2p-gossipsub = { version = "0.50.0", path = "protocols/gossipsub" } libp2p-identify = { version = "0.48.0", path = "protocols/identify" } 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-websys/CHANGELOG.md b/transports/dns-websys/CHANGELOG.md deleted file mode 100644 index b8e6ec83b2f..00000000000 --- a/transports/dns-websys/CHANGELOG.md +++ /dev/null @@ -1,9 +0,0 @@ -## 0.1.0-alpha - -- 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`): 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). \ No newline at end of file diff --git a/transports/dns-websys/Cargo.toml b/transports/dns-websys/Cargo.toml deleted file mode 100644 index 787c85d6c8c..00000000000 --- a/transports/dns-websys/Cargo.toml +++ /dev/null @@ -1,32 +0,0 @@ -[package] -name = "libp2p-dns-websys" -edition.workspace = true -rust-version = { workspace = true } -description = "DNS transport implementation via DNS-over-HTTPS for libp2p under WASM environment" -version = "0.1.0-alpha" -license = "MIT" -repository = "https://github.com/libp2p/rust-libp2p" -keywords = ["peer-to-peer", "libp2p", "networking"] -categories = ["network-programming", "asynchronous"] - -[dependencies] -futures = { workspace = true } -js-sys = "0.3.77" -libp2p-core = { workspace = true } -parking_lot = "0.12.5" -send_wrapper = { version = "0.6.0", features = ["futures"] } -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.150" -smallvec = "1.15.1" -thiserror = { workspace = true } -tracing = { 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"] } - -[package.metadata.docs.rs] -all-features = true - -[lints] -workspace = true diff --git a/transports/dns-websys/src/lib.rs b/transports/dns-websys/src/lib.rs deleted file mode 100644 index c6739f043d2..00000000000 --- a/transports/dns-websys/src/lib.rs +++ /dev/null @@ -1,490 +0,0 @@ -//! # DNS name resolution for libp2p under WASM, via DNS-over-HTTPS. -//! -//! This crate provides a [`Transport`] for `wasm32` (browser) targets. Much llike -//! [`libp2p-dns`](https://docs.rs/libp2p-dns), it is an address-rewriting -//! wrapper around an inner [`libp2p_core::Transport`]: on -//! [`libp2p_core::Transport::dial`] it resolves the DNS components of a -//! [`Multiaddr`], replacing them with the resolved protocols before handing the -//! address to the inner transport. -//! -//! Browsers do not expose raw UDP/TCP sockets, so traditional DNS resolution -//! (as used by `libp2p-dns`) is impossible. Instead, this crate 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`]. - -#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] - -mod resolver; -mod web_context; - -use std::{ - error, fmt, io, - ops::DerefMut, - pin::Pin, - sync::Arc, - task::{Context, Poll}, -}; - -use futures::{future, prelude::*}; -use libp2p_core::{ - multiaddr::{Multiaddr, Protocol}, - transport::{DialOpts, ListenerId, TransportError, TransportEvent}, -}; -use parking_lot::Mutex; -use send_wrapper::SendWrapper; -use smallvec::SmallVec; - -use crate::resolver::Resolver; -pub use crate::resolver::{CLOUDFLARE, Config, DnsResolution, GOOGLE, ResolveError}; - -/// The prefix for `dnsaddr` protocol TXT record lookups. -const DNSADDR_PREFIX: &str = "_dnsaddr."; - -/// The maximum number of dialing attempts to resolved addresses. -const MAX_DIAL_ATTEMPTS: usize = 16; - -/// The maximum number of DNS lookups when dialing. -/// -/// This limit is primarily a safeguard against too many, possibly even cyclic, -/// indirections in the addresses obtained from the TXT records of a `/dnsaddr`. -const MAX_DNS_LOOKUPS: usize = 32; - -/// The maximum number of TXT records applicable for the address being dialed -/// that are considered for further lookups as a result of a single `/dnsaddr` -/// lookup. -const MAX_TXT_RECORDS: usize = 16; - -/// A [`libp2p_core::Transport`] that resolves DNS names over HTTPS before -/// dialing the inner transport. Intended for `wasm32` (browser) targets. -#[derive(Debug)] -pub struct Transport { - /// The underlying transport. - inner: Arc>, - /// The DoH resolver used when dialing addresses with DNS - /// components. - resolver: Resolver, -} - -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 { - Transport { - inner: Arc::new(Mutex::new(inner)), - resolver: Resolver::new(config), - } - } -} - -impl libp2p_core::Transport for Transport -where - T: libp2p_core::Transport + Send + Unpin + 'static, - T::Error: Send, - T::Dial: Send, -{ - type Output = T::Output; - type Error = Error; - type ListenerUpgrade = future::MapErr Self::Error>; - type Dial = Pin> + Send>>; - - fn listen_on( - &mut self, - id: ListenerId, - addr: Multiaddr, - ) -> Result<(), TransportError> { - self.inner - .lock() - .listen_on(id, addr) - .map_err(|e| e.map(Error::Transport)) - } - - fn remove_listener(&mut self, id: ListenerId) -> bool { - self.inner.lock().remove_listener(id) - } - - fn dial( - &mut self, - addr: Multiaddr, - dial_opts: DialOpts, - ) -> Result> { - Ok(self.do_dial(addr, dial_opts)) - } - - fn poll( - self: Pin<&mut Self>, - cx: &mut Context<'_>, - ) -> Poll> { - let mut inner = self.inner.lock(); - libp2p_core::Transport::poll(Pin::new(inner.deref_mut()), cx).map(|event| { - event - .map_upgrade(|upgr| upgr.map_err::<_, fn(_) -> _>(Error::Transport)) - .map_err(Error::Transport) - }) - } -} - -impl Transport -where - T: libp2p_core::Transport + Send + Unpin + 'static, - T::Error: Send, - T::Dial: Send, -{ - fn do_dial( - &mut self, - addr: Multiaddr, - dial_opts: DialOpts, - ) -> ::Dial { - let resolver = self.resolver.clone(); - let inner = self.inner.clone(); - let dns_resolution = self.resolver.dns_resolution(); - - // The lookups are driven by the browser's `fetch` API, whose futures are - // `!Send`. `SendWrapper` makes the resulting future `Send` (sound on the - // single-threaded wasm runtime), so it satisfies the bound on `Dial`. - SendWrapper::new(async move { - let mut dial_errors: Vec> = Vec::new(); - let mut dns_lookups = 0; - let mut dial_attempts = 0; - // We optimise for the common case of a single DNS component in the - // address that is resolved with a single lookup. - let mut unresolved = SmallVec::<[Multiaddr; 1]>::new(); - unresolved.push(addr.clone()); - - // Resolve (i.e. replace) all DNS protocol components, initiating - // dialing attempts as soon as there is another fully resolved - // address. - while let Some(addr) = unresolved.pop() { - let resolve_dns = should_resolve_dns(&addr, dns_resolution); - if let Some((i, name)) = addr - .iter() - .enumerate() - .find(|(_, p)| is_resolvable(p, resolve_dns)) - { - if dns_lookups == MAX_DNS_LOOKUPS { - tracing::debug!(address=%addr, "Too many DNS lookups, dropping unresolved address"); - dial_errors.push(Error::TooManyLookups); - // There may still be fully resolved addresses in - // `unresolved`, so keep going until it is empty. - continue; - } - dns_lookups += 1; - match resolve(&name, &resolver).await { - Err(e) => { - // Record the resolution error. - dial_errors.push(e); - } - Ok(Resolved::One(ip)) => { - tracing::trace!(protocol=%name, resolved=%ip); - let addr = addr.replace(i, |_| Some(ip)).expect("`i` is a valid index"); - unresolved.push(addr); - } - Ok(Resolved::Many(ips)) => { - for ip in ips { - tracing::trace!(protocol=%name, resolved=%ip); - let addr = - addr.replace(i, |_| Some(ip)).expect("`i` is a valid index"); - unresolved.push(addr); - } - } - Ok(Resolved::Addrs(addrs)) => { - let suffix = addr.iter().skip(i + 1).collect::(); - let prefix = addr.iter().take(i).collect::(); - let mut n = 0; - for a in addrs { - if a.ends_with(&suffix) { - if n < MAX_TXT_RECORDS { - n += 1; - tracing::trace!(protocol=%name, resolved=%a); - let addr = - prefix.iter().chain(a.iter()).collect::(); - unresolved.push(addr); - } else { - tracing::debug!( - resolved=%a, - "Too many TXT records, dropping resolved" - ); - } - } - } - } - } - } else { - // We have a fully resolved address, so try to dial it. - tracing::debug!(address=%addr, "Dialing address"); - - let transport = inner.clone(); - let dial = transport.lock().dial(addr, dial_opts); - let result = match dial { - Ok(out) => { - // We only count attempts that the inner transport - // actually accepted, i.e. for which it produced a - // dialing future. - dial_attempts += 1; - out.await.map_err(Error::Transport) - } - Err(TransportError::MultiaddrNotSupported(a)) => { - Err(Error::MultiaddrNotSupported(a)) - } - Err(TransportError::Other(err)) => Err(Error::Transport(err)), - }; - - match result { - Ok(out) => return Ok(out), - Err(err) => { - tracing::debug!("Dial error: {:?}.", err); - dial_errors.push(err); - - if unresolved.is_empty() { - break; - } - - if dial_attempts == MAX_DIAL_ATTEMPTS { - tracing::debug!( - "Aborting dialing after {} attempts.", - MAX_DIAL_ATTEMPTS - ); - break; - } - } - } - } - } - - // If we have any dial errors, aggregate them. Otherwise there were - // no valid DNS records for the given address to begin with. - if !dial_errors.is_empty() { - Err(Error::Dial(dial_errors)) - } else { - Err(Error::ResolveError(ResolveError::Fetch( - "no matching records found".to_owned(), - ))) - } - }) - .boxed() - } -} - -/// The possible errors of a [`Transport`]-wrapped transport. -#[derive(Debug)] -#[allow(clippy::large_enum_variant)] -pub enum Error { - /// The underlying transport encountered an error. - Transport(TErr), - /// DNS resolution failed. - #[allow(clippy::enum_variant_names)] - ResolveError(ResolveError), - /// DNS resolution was successful, but the underlying transport refused the - /// resolved address. - MultiaddrNotSupported(Multiaddr), - /// DNS resolution involved too many lookups. - TooManyLookups, - /// Multiple dial errors were encountered. - Dial(Vec>), -} - -impl fmt::Display for Error -where - TErr: fmt::Display, -{ - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Error::Transport(err) => write!(f, "{err}"), - Error::ResolveError(err) => write!(f, "{err}"), - Error::MultiaddrNotSupported(a) => write!(f, "Unsupported resolved address: {a}"), - Error::TooManyLookups => write!(f, "Too many DNS lookups"), - Error::Dial(errs) => { - write!(f, "Multiple dial errors occurred:")?; - for err in errs { - write!(f, "\n - {err}")?; - } - Ok(()) - } - } - } -} - -impl error::Error for Error -where - TErr: error::Error + 'static, -{ - fn source(&self) -> Option<&(dyn error::Error + 'static)> { - match self { - Error::Transport(err) => Some(err), - Error::ResolveError(err) => Some(err), - Error::MultiaddrNotSupported(_) => None, - Error::TooManyLookups => None, - Error::Dial(errs) => errs.last().and_then(|e| e.source()), - } - } -} - -/// The successful outcome of [`resolve`] for a given [`Protocol`]. -enum Resolved<'a> { - /// The given `Protocol` has been resolved to a single `Protocol`, which may - /// be identical to the one given, in case it is not a DNS protocol - /// component. - One(Protocol<'a>), - /// The given `Protocol` has been resolved to multiple alternative - /// `Protocol`s as a result of a DNS lookup. - Many(Vec>), - /// The given `Protocol` has been resolved to a new list of `Multiaddr`s - /// obtained from DNS TXT records representing possible alternatives. These - /// addresses may contain further DNS names that need resolving. - Addrs(Vec), -} - -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`]. -async fn resolve<'a, E>( - proto: &Protocol<'a>, - resolver: &Resolver, -) -> Result, Error> { - 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.as_ref()).await; - let v6 = resolver.ipv6_lookup(name.as_ref()).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.as_ref()) - .await - .map_err(Error::ResolveError)?; - collect(ips.into_iter().map(Protocol::from).collect()) - } - Protocol::Dns6(name) => { - let ips = resolver - .ipv6_lookup(name.as_ref()) - .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(ResolveError::Fetch( - "no matching records found".to_owned(), - ))), - 1 => Ok(Resolved::One(protocols.remove(0))), - _ => Ok(Resolved::Many(protocols)), - } -} - -/// Parses a `` of a `dnsaddr` TXT record. -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)?), - } -} - -fn invalid_data(e: impl Into>) -> io::Error { - io::Error::new(io::ErrorKind::InvalidData, e) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[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)); - } - - #[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)); - } - - #[test] - 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() - ); - assert!(parse_dnsaddr_txt("/dns4/example.com").is_err()); - } -} 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-websys/src/resolver.rs b/transports/dns/src/websys/resolver.rs similarity index 87% rename from transports/dns-websys/src/resolver.rs rename to transports/dns/src/websys/resolver.rs index cf81101ae8b..f2d4b7195e4 100644 --- a/transports/dns-websys/src/resolver.rs +++ b/transports/dns/src/websys/resolver.rs @@ -8,7 +8,7 @@ use wasm_bindgen::{JsCast, JsValue}; use wasm_bindgen_futures::JsFuture; use web_sys::{AbortSignal, Request, RequestInit, Response}; -use crate::web_context::WebContext; +use crate::websys::web_context::WebContext; /// The Cloudflare DoH JSON endpoint. pub const CLOUDFLARE: &str = "https://cloudflare-dns.com/dns-query"; @@ -91,68 +91,89 @@ impl Config { } } +/// 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(crate) struct Resolver { +pub struct DohResolver { config: Config, } -impl Resolver { - pub(crate) fn new(config: Config) -> Self { - Resolver { config } +impl DohResolver { + /// Creates a resolver for the endpoint and policy given by `config`. + pub fn new(config: Config) -> Self { + DohResolver { config } } - /// The configured [`DnsResolution`] policy for `/dns*` components. - pub(crate) fn dns_resolution(&self) -> DnsResolution { - self.config.dns_resolution + /// 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()) } +} - pub(crate) async fn ipv4_lookup(&self, name: &str) -> Result, ResolveError> { +impl Resolver for DohResolver { + async fn ipv4_lookup(&self, name: String) -> Result, ResolveError> { Ok(self - .query(name, TYPE_A) + .query(&name, TYPE_A) .await? .iter() .filter_map(|d| d.parse::().ok()) .collect()) } - pub(crate) async fn ipv6_lookup(&self, name: &str) -> Result, ResolveError> { + async fn ipv6_lookup(&self, name: String) -> Result, ResolveError> { Ok(self - .query(name, TYPE_AAAA) + .query(&name, TYPE_AAAA) .await? .iter() .filter_map(|d| d.parse::().ok()) .collect()) } - pub(crate) async fn txt_lookup(&self, name: &str) -> Result, ResolveError> { + async fn txt_lookup(&self, name: String) -> Result, ResolveError> { Ok(self - .query(name, TYPE_TXT) + .query(&name, TYPE_TXT) .await? .iter() .map(|d| unquote_txt(d)) .collect()) } - /// 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()) + fn dns_resolution(&self) -> DnsResolution { + self.config.dns_resolution } } @@ -285,16 +306,18 @@ pub enum ResolveError { #[cfg(test)] mod tests { + use wasm_bindgen_test::wasm_bindgen_test; + use super::*; - #[test] + #[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("\"\""), ""); } - #[test] + #[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"}, diff --git a/transports/dns-websys/src/web_context.rs b/transports/dns/src/websys/web_context.rs similarity index 100% rename from transports/dns-websys/src/web_context.rs rename to transports/dns/src/websys/web_context.rs 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