diff --git a/Cargo.toml b/Cargo.toml
index 1d10463..06349a1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -39,7 +39,7 @@ mdns = ["libp2p/mdns"]
keypair_base64_encoding = ["dep:base64"]
-dns = ["libp2p/dns"]
+dns = ["libp2p/dns", "dep:parking_lot", "dep:serde", "dep:serde_json", "dep:url", "dep:js-sys", "dep:web-sys"]
tls = ["libp2p/tls"]
noise = ["libp2p/noise"]
tcp = ["libp2p/tcp"]
@@ -91,6 +91,7 @@ libp2p-webrtc = { version = "=0.9.0-alpha.1", features = ["pem"] }
libp2p-webrtc-websys = "0.4.0"
other-error = "0.1.1"
pollable-map = "0.1.7"
+parking_lot = "0.12"
pem = { version = "3.0.5" }
cbor4ii = { version = "1.2.2", features = ["serde1"] }
rand = "0.8.5"
@@ -102,14 +103,26 @@ send_wrapper = "0.6.0"
serde = { version = "1.0.219" }
serde-wasm-bindgen = "0.6.5"
serde_bytes = "0.11.19"
+serde_json = "1"
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite"] }
thiserror = "2.0.18"
tracing = { version = "0.1.41" }
zeroize = "1.8.1"
tokio = { default-features = false, version = "1.47.1" }
+js-sys = "0.3"
+url = "2"
wasm-bindgen = "0.2"
wasm-bindgen-futures = { version = "0.4.50" }
+web-sys = { version = "0.3", features = [
+ "AbortSignal",
+ "Headers",
+ "Request",
+ "RequestInit",
+ "Response",
+ "Window",
+ "WorkerGlobalScope",
+] }
web-time = "1.1.0"
@@ -156,10 +169,15 @@ futures-timer = { workspace = true, features = ["wasm-bindgen"] }
getrandom = { workspace = true, features = ["js"] }
getrandom_03 = { workspace = true, features = ["wasm_js"] }
idb = { workspace = true, optional = true }
+js-sys = { workspace = true, optional = true }
libp2p = { features = ["macros", "serde", "wasm-bindgen"], workspace = true }
libp2p-webrtc-websys = { workspace = true, optional = true }
+parking_lot = { workspace = true, optional = true }
send_wrapper = { workspace = true, features = ["futures"] }
serde-wasm-bindgen.workspace = true
+serde_json = { workspace = true, optional = true }
tokio = { default-features = false, features = ["sync", "macros"], workspace = true }
+url = { workspace = true, optional = true }
wasm-bindgen.workspace = true
wasm-bindgen-futures.workspace = true
+web-sys = { workspace = true, optional = true }
diff --git a/examples/browser-webrtc/src/lib.rs b/examples/browser-webrtc/src/lib.rs
index fb5fb19..ad151a4 100644
--- a/examples/browser-webrtc/src/lib.rs
+++ b/examples/browser-webrtc/src/lib.rs
@@ -22,7 +22,7 @@ pub async fn run(address: String) -> Result<(), JsError> {
.set_swarm_config(|c| c.with_idle_connection_timeout(Duration::from_secs(30)))
.set_swarm_event_callback({
let body = body.clone();
- move |_, e, _| {
+ move |_, _, e, _| {
body.append_p(&format!("{e:?}"))
.expect("failed to append
");
}
diff --git a/src/builder.rs b/src/builder.rs
index 82294b3..b106711 100644
--- a/src/builder.rs
+++ b/src/builder.rs
@@ -1,6 +1,9 @@
mod executor;
pub(crate) mod transport;
+#[cfg(all(feature = "dns", target_arch = "wasm32"))]
+pub(crate) mod dns_websys;
+
#[cfg(feature = "request-response")]
use crate::behaviour::request_response::RequestResponseConfig;
#[cfg(feature = "dns")]
@@ -812,6 +815,19 @@ where
self
}
+ /// Enables DNS with a specific resolver and configuration
+ #[cfg(target_arch = "wasm32")]
+ #[cfg(feature = "dns")]
+ pub fn enable_dns_with_resolver_and_config(mut self, resolver: DnsResolver, f: F) -> Self
+ where
+ F: FnOnce(dns_websys::Config) -> dns_websys::Config + 'static,
+ {
+ self.transport_config.dns_resolver = Some(resolver);
+ self.transport_config.dns_config_fn = Box::new(f);
+ self.transport_config.enable_dns = true;
+ self
+ }
+
/// Enables WebRTC transport
#[cfg(feature = "webrtc")]
pub fn enable_webrtc(mut self) -> Self {
diff --git a/src/builder/dns_websys/mod.rs b/src/builder/dns_websys/mod.rs
new file mode 100644
index 0000000..191823b
--- /dev/null
+++ b/src/builder/dns_websys/mod.rs
@@ -0,0 +1,454 @@
+mod resolver;
+mod web_context;
+
+use crate::builder::dns_websys::resolver::Resolver;
+pub use crate::builder::dns_websys::resolver::{Config, DnsResolution, ResolveError};
+use futures::{future, prelude::*};
+use libp2p::core::{
+ multiaddr::{Multiaddr, Protocol},
+ transport::{DialOpts, ListenerId, TransportError, TransportEvent},
+};
+use parking_lot::Mutex;
+use send_wrapper::SendWrapper;
+use std::{
+ error, fmt, io,
+ ops::DerefMut,
+ pin::Pin,
+ sync::Arc,
+ task::{Context, Poll},
+};
+
+/// 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 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::Transport for Transport
+where
+ T: libp2p::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::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::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 = Vec::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/src/builder/dns_websys/resolver.rs b/src/builder/dns_websys/resolver.rs
new file mode 100644
index 0000000..2b88c03
--- /dev/null
+++ b/src/builder/dns_websys/resolver.rs
@@ -0,0 +1,333 @@
+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::builder::dns_websys::web_context::WebContext;
+
+/// The Cloudflare DoH JSON endpoint.
+pub const CLOUDFLARE: &str = "https://cloudflare-dns.com/dns-query";
+
+/// The Google DoH JSON endpoint.
+pub const GOOGLE: &str = "https://dns.google/resolve";
+
+// TODO: Add other DoH endpoints for default?
+
+// DNS record type codes as used by the DoH JSON API.
+const TYPE_A: u16 = 1;
+const TYPE_AAAA: u16 = 28;
+const TYPE_TXT: u16 = 16;
+
+const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
+
+/// Policy for resolving `/dns`, `/dns4` and `/dns6` components to IP addresses.
+///
+/// `/dnsaddr` is always resolved regardless of this policy.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum DnsResolution {
+ /// Resolve `/dns*` to `/ip*` only for addresses that require a literal IP,
+ /// i.e. those containing a `/webrtc-direct`. Otherwise pass `/dns*` through.
+ #[default]
+ Auto,
+ /// Always resolve `/dns*` to `/ip*`.
+ Always,
+ /// Never resolve `/dns*`
+ Never,
+}
+
+/// Configuration for the DNS-over-HTTPS Resolver.
+#[derive(Debug, Clone)]
+pub struct Config {
+ /// A DoH endpoint that answers GET queries in the JSON (`application/dns-json`) format.
+ endpoint: String,
+ /// Resoluton for how `/dns`, `/dns4` and `/dns6` components are handled.
+ dns_resolution: DnsResolution,
+ /// Timeout for a single DoH request.
+ timeout: Duration,
+}
+
+impl Default for Config {
+ fn default() -> Self {
+ Self::cloudflare()
+ }
+}
+
+impl Config {
+ /// Creates a configuration pointing at a custom DoH JSON endpoint.
+ pub fn new(endpoint: impl Into) -> Self {
+ Config {
+ endpoint: endpoint.into(),
+ dns_resolution: DnsResolution::default(),
+ timeout: DEFAULT_TIMEOUT,
+ }
+ }
+
+ /// Resolve via Cloudflare (see `https://cloudflare-dns.com/dns-query`).
+ pub fn cloudflare() -> Self {
+ Config::new(CLOUDFLARE)
+ }
+
+ /// Resolve via Google (see `https://dns.google/resolve`).
+ pub fn google() -> Self {
+ Config::new(GOOGLE)
+ }
+
+ /// Sets the [`DnsResolution`] policy for `/dns`, `/dns4` and `/dns6`
+ /// components.
+ pub fn dns_resolution(mut self, policy: DnsResolution) -> Self {
+ self.dns_resolution = policy;
+ self
+ }
+
+ /// Sets the timeout for a single DoH request. Defaults to 10 seconds.
+ pub fn timeout(mut self, timeout: Duration) -> Self {
+ self.timeout = timeout;
+ self
+ }
+}
+
+/// A DNS resolver 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_single_segment_and_unquoted() {
+ 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 unquote_txt_concatenates_multi_segment() {
+ // A >255-byte TXT record arrives as multiple quoted character-strings, which must be
+ // concatenated with no separator — both space-separated and adjacent forms.
+ assert_eq!(unquote_txt("\"part1\" \"part2\""), "part1part2");
+ assert_eq!(unquote_txt("\"part1\"\"part2\""), "part1part2");
+ // A dnsaddr multiaddr split across two segments must reassemble exactly.
+ assert_eq!(
+ unquote_txt("\"dnsaddr=/dns4/exa\" \"mple.com/tcp/443/wss\""),
+ "dnsaddr=/dns4/example.com/tcp/443/wss"
+ );
+ }
+
+ #[test]
+ fn unquote_txt_handles_escapes() {
+ assert_eq!(unquote_txt("\"a\\\"b\""), "a\"b"); // \" -> "
+ assert_eq!(unquote_txt("\"a\\\\b\""), "a\\b"); // \\ -> \
+ assert_eq!(unquote_txt("\"\\097\""), "a"); // \DDD decimal -> byte
+ }
+
+ #[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/src/builder/dns_websys/web_context.rs b/src/builder/dns_websys/web_context.rs
new file mode 100644
index 0000000..c4f0c51
--- /dev/null
+++ b/src/builder/dns_websys/web_context.rs
@@ -0,0 +1,42 @@
+use js_sys::Promise;
+use wasm_bindgen::prelude::*;
+use web_sys::{Request, window};
+
+/// Web context that abstracts the `window` vs web worker global scope, so that
+/// DoH lookups work both on the main thread and inside workers.
+#[derive(Debug)]
+pub(crate) enum WebContext {
+ Window(web_sys::Window),
+ Worker(web_sys::WorkerGlobalScope),
+}
+
+impl WebContext {
+ pub(crate) fn new() -> Option {
+ match window() {
+ Some(window) => Some(Self::Window(window)),
+ None => {
+ #[wasm_bindgen]
+ extern "C" {
+ type Global;
+
+ #[wasm_bindgen(method, getter, js_name = WorkerGlobalScope)]
+ fn worker(this: &Global) -> JsValue;
+ }
+ let global: Global = js_sys::global().unchecked_into();
+ if !global.worker().is_undefined() {
+ Some(Self::Worker(global.unchecked_into()))
+ } else {
+ None
+ }
+ }
+ }
+ }
+
+ /// The `fetch()` method.
+ pub(crate) fn fetch_with_request(&self, request: &Request) -> Promise {
+ match self {
+ WebContext::Window(w) => w.fetch_with_request(request),
+ WebContext::Worker(w) => w.fetch_with_request(request),
+ }
+ }
+}
diff --git a/src/builder/transport.rs b/src/builder/transport.rs
index 8564281..22b3171 100644
--- a/src/builder/transport.rs
+++ b/src/builder/transport.rs
@@ -25,6 +25,8 @@ use crate::error::ConnexaResult;
#[cfg(not(target_arch = "wasm32"))]
use crate::error::Error;
+#[cfg(all(feature = "dns", target_arch = "wasm32"))]
+use crate::builder::dns_websys;
use libp2p::identity::Keypair;
use std::io;
use std::time::Duration;
@@ -94,6 +96,8 @@ pub struct TransportConfig {
#[cfg(not(target_arch = "wasm32"))]
#[cfg(feature = "pnet")]
pub pnet_psk: Option,
+ #[cfg(all(feature = "dns", target_arch = "wasm32"))]
+ pub dns_config_fn: Box dns_websys::Config>,
}
impl Debug for TransportConfig {
@@ -144,6 +148,9 @@ impl Default for TransportConfig {
#[cfg(not(target_arch = "wasm32"))]
#[cfg(feature = "pnet")]
pnet_psk: None,
+ #[cfg(target_arch = "wasm32")]
+ #[cfg(feature = "dns")]
+ dns_config_fn: Box::new(|config| config),
}
}
}
@@ -496,6 +503,12 @@ pub(crate) fn build_transport(
#[cfg(feature = "webtransport")]
enable_webtransport,
enable_memory_transport,
+ #[cfg(feature = "dns")]
+ enable_dns,
+ #[cfg(feature = "dns")]
+ dns_resolver,
+ #[cfg(feature = "dns")]
+ dns_config_fn,
..
}: TransportConfig,
) -> ConnexaResult {
@@ -573,6 +586,24 @@ pub(crate) fn build_transport(
false => transport,
};
+ #[cfg(feature = "dns")]
+ let transport = match enable_dns {
+ true => {
+ let resolver = dns_resolver.unwrap_or_default();
+
+ let config = match resolver {
+ DnsResolver::Cloudflare => dns_websys::Config::cloudflare(),
+ DnsResolver::Google | _ => dns_websys::Config::google(),
+ };
+
+ let config = dns_config_fn(config);
+
+ let transport = dns_websys::Transport::with_config(transport, config);
+ transport.boxed()
+ }
+ false => transport,
+ };
+
Ok(transport.boxed())
}