diff --git a/Cargo.lock b/Cargo.lock index 337f2ce431f..ce5b99b4092 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3037,6 +3037,26 @@ 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", + "url", + "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..b8e6ec83b2f --- /dev/null +++ b/transports/dns-websys/CHANGELOG.md @@ -0,0 +1,9 @@ +## 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 new file mode 100644 index 00000000000..787c85d6c8c --- /dev/null +++ b/transports/dns-websys/Cargo.toml @@ -0,0 +1,32 @@ +[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 new file mode 100644 index 00000000000..c6739f043d2 --- /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..cf81101ae8b --- /dev/null +++ b/transports/dns-websys/src/resolver.rs @@ -0,0 +1,313 @@ +use std::{ + net::{Ipv4Addr, Ipv6Addr}, + time::Duration, +}; + +use url::Url; +use wasm_bindgen::{JsCast, JsValue}; +use wasm_bindgen_futures::JsFuture; +use web_sys::{AbortSignal, Request, RequestInit, Response}; + +use crate::web_context::WebContext; + +/// The Cloudflare DoH JSON endpoint. +pub const CLOUDFLARE: &str = "https://cloudflare-dns.com/dns-query"; + +/// The Google DoH JSON endpoint. +pub const GOOGLE: &str = "https://dns.google/resolve"; + +// TODO: Add other DoH endpoints for default? + +// DNS record type codes as used by the DoH JSON API. +const TYPE_A: u16 = 1; +const TYPE_AAAA: u16 = 28; +const TYPE_TXT: u16 = 16; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); + +/// Policy for resolving `/dns`, `/dns4` and `/dns6` components to IP addresses. +/// +/// `/dnsaddr` is always resolved regardless of this policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DnsResolution { + /// Resolve `/dns*` to `/ip*` only for addresses that require a literal IP, + /// i.e. those containing a `/webrtc-direct`. Otherwise pass `/dns*` through. + #[default] + Auto, + /// Always resolve `/dns*` to `/ip*`. + Always, + /// Never resolve `/dns*` + Never, +} + +/// Configuration for the DNS-over-HTTPS Resolver. +#[derive(Debug, Clone)] +pub struct Config { + /// A DoH endpoint that answers GET queries in the JSON (`application/dns-json`) format. + endpoint: String, + /// Resoluton for how `/dns`, `/dns4` and `/dns6` components are handled. + dns_resolution: DnsResolution, + /// Timeout for a single DoH request. + timeout: Duration, +} + +impl Default for Config { + fn default() -> Self { + Self::cloudflare() + } +} + +impl Config { + /// Creates a configuration pointing at a custom DoH JSON endpoint. + pub fn new(endpoint: impl Into) -> Self { + Config { + endpoint: endpoint.into(), + dns_resolution: DnsResolution::default(), + timeout: DEFAULT_TIMEOUT, + } + } + + /// Resolve via Cloudflare (see `https://cloudflare-dns.com/dns-query`). + pub fn cloudflare() -> Self { + Config::new(CLOUDFLARE) + } + + /// Resolve via Google (see `https://dns.google/resolve`). + pub fn google() -> Self { + Config::new(GOOGLE) + } + + /// Sets the [`DnsResolution`] policy for `/dns`, `/dns4` and `/dns6` + /// components. + pub fn dns_resolution(mut self, policy: DnsResolution) -> Self { + self.dns_resolution = policy; + self + } + + /// Sets the timeout for a single DoH request. Defaults to 10 seconds. + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } +} + +/// A DNS resolver 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 = 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()) + } +} + +/// The relevant subset of a DoH JSON response. +#[derive(serde::Deserialize)] +struct DohResponse { + #[serde(rename = "Status")] + status: u32, + #[serde(default, rename = "Answer")] + answer: Vec, +} + +#[derive(serde::Deserialize)] +struct DohAnswer { + #[serde(rename = "type")] + kind: u16, + data: String, +} + +fn build_query_url(endpoint: &str, name: &str, qtype: u16) -> Result { + let mut url = Url::parse(endpoint).map_err(|e| ResolveError::Url(e.to_string()))?; + url.query_pairs_mut() + .append_pair("name", name) + .append_pair("type", &qtype.to_string()); + Ok(url) +} + +/// Issues the actual `fetch` for a DoH JSON query and returns the response body. +async fn doh_get(url: &str, timeout: Duration) -> Result { + let opts = RequestInit::new(); + let timeout_ms = timeout.as_millis().clamp(1, u32::MAX as u128) as u32; + opts.set_signal(Some(&AbortSignal::timeout_with_u32(timeout_ms))); + + let request = Request::new_with_str_and_init(url, &opts).map_err(js_error)?; + request + .headers() + .set("accept", "application/dns-json") + .map_err(js_error)?; + + let context = WebContext::new() + .ok_or_else(|| ResolveError::Fetch("no browser global scope available".to_owned()))?; + + let response = JsFuture::from(context.fetch_with_request(&request)) + .await + .map_err(js_error)?; + let response: Response = response + .dyn_into() + .map_err(|_| ResolveError::Fetch("fetch did not return a Response".to_owned()))?; + + if !response.ok() { + return Err(ResolveError::Http(response.status())); + } + + let text = JsFuture::from(response.text().map_err(js_error)?) + .await + .map_err(js_error)?; + text.as_string() + .ok_or_else(|| ResolveError::Fetch("response body was not a string".to_owned())) +} + +/// DoH JSON returns TXT records wrapped in literal double quotes; strip a single +/// surrounding pair if present. +fn unquote_txt(s: &str) -> String { + // Some endpoints return short values unquoted; pass those through verbatim. + if !s.contains('"') { + return s.to_owned(); + } + + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + // Skip whitespace between quoted segments; only quoted content contributes. + if c != '"' { + continue; + } + while let Some(c) = chars.next() { + match c { + '"' => break, + '\\' => match chars.next() { + // `\DDD` decimal byte escape (RFC 1035 s5.1). + Some(d) if d.is_ascii_digit() => { + let mut code = d.to_digit(10).unwrap(); + for _ in 0..2 { + match chars.peek() { + Some(p) if p.is_ascii_digit() => { + code = code * 10 + chars.next().unwrap().to_digit(10).unwrap(); + } + _ => break, + } + } + if let Some(ch) = char::from_u32(code) { + out.push(ch); + } + } + // `\"`, `\\`, or any other escaped char: keep the char as-is. + Some(other) => out.push(other), + None => {} + }, + _ => out.push(c), + } + } + } + out +} + +fn js_error(value: JsValue) -> ResolveError { + ResolveError::Fetch(format!("{value:?}")) +} + +/// Errors that can occur while resolving a DNS over HTTPSs. +#[derive(Debug, Clone, thiserror::Error)] +pub enum ResolveError { + /// The `fetch` call or response handling failed (network error, wrong + /// global scope, non-string body, etcc). + #[error("DNS-over-HTTPS request failed: {0}")] + Fetch(String), + /// The DoH endpoint returned a non-success HTTP status. + #[error("DNS-over-HTTPS request returned HTTP status {0}")] + Http(u16), + /// The DoH endpoint returned a DNS error status. + #[error("DNS query failed with status {0}")] + Status(u32), + /// The DoH response could not be parsed. + #[error("failed to parse DNS-over-HTTPS response: {0}")] + Parse(String), + /// The configured DoH endpoint is not a valid URL. + #[error("invalid DNS-over-HTTPS endpoint URL: {0}")] + Url(String), +} + +#[cfg(test)] +mod tests { + use 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), + } + } +}