Skip to content

Commit 3d14dc0

Browse files
committed
fix(iroh-relay): happy eyeballs for relay clients
1 parent 295a715 commit 3d14dc0

5 files changed

Lines changed: 195 additions & 55 deletions

File tree

iroh-dns/src/dns.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,53 @@ impl DnsResolver {
479479
}
480480
}
481481

482+
/// Resolves a hostname from a URL to all of its IP addresses, ordered by preference.
483+
///
484+
/// The addresses of the preferred family come first (IPv6 when `prefer_ipv6`,
485+
/// otherwise IPv4), followed by the addresses of the other family. Callers can
486+
/// dial these in order and fall back across families when the preferred address
487+
/// is unreachable.
488+
pub async fn resolve_host_all(
489+
&self,
490+
url: &Url,
491+
prefer_ipv6: bool,
492+
timeout: Duration,
493+
) -> Result<Vec<IpAddr>, DnsError> {
494+
let host = url.host().ok_or_else(|| e!(DnsError::MissingHost))?;
495+
match host {
496+
url::Host::Domain(domain) => {
497+
let lookup = tokio::join!(
498+
self.lookup_ipv4(domain, timeout),
499+
self.lookup_ipv6(domain, timeout)
500+
);
501+
let addrs: Vec<IpAddr> = match lookup {
502+
(Err(ipv4_err), Err(ipv6_err)) => {
503+
return Err(e!(DnsError::ResolveBoth {
504+
ipv4: Box::new(ipv4_err),
505+
ipv6: Box::new(ipv6_err)
506+
}));
507+
}
508+
(Err(_), Ok(v6)) => v6.collect(),
509+
(Ok(v4), Err(_)) => v4.collect(),
510+
(Ok(v4), Ok(v6)) => {
511+
if prefer_ipv6 {
512+
v6.chain(v4).collect()
513+
} else {
514+
v4.chain(v6).collect()
515+
}
516+
}
517+
};
518+
if addrs.is_empty() {
519+
Err(e!(DnsError::NoResponse))
520+
} else {
521+
Ok(addrs)
522+
}
523+
}
524+
url::Host::Ipv4(ip) => Ok(vec![IpAddr::V4(ip)]),
525+
url::Host::Ipv6(ip) => Ok(vec![IpAddr::V6(ip)]),
526+
}
527+
}
528+
482529
/// Performs an IPv4 lookup with a timeout in a staggered fashion.
483530
///
484531
/// From the moment this function is called, each lookup is scheduled after the delays in

iroh-relay/src/client.rs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -210,12 +210,13 @@ impl ClientBuilder {
210210
self
211211
}
212212

213-
/// Returns if we should prefer ipv6
214-
/// it replaces the relayhttp.AddressFamilySelector we pass
215-
/// It provides the hint as to whether in an IPv4-vs-IPv6 race that
216-
/// IPv4 should be held back a bit to give IPv6 a better-than-50/50
217-
/// chance of winning. We only return true when we believe IPv6 will
218-
/// work anyway, so we don't artificially delay the connection speed.
213+
/// Sets a callback hinting whether to prefer IPv6 when dialing the relay.
214+
///
215+
/// The callback runs on each dial. When it returns `true`, IPv6 addresses
216+
/// are tried first and IPv4 dials are held back slightly, biasing the
217+
/// happy-eyeballs race towards IPv6; when it returns `false`, IPv4 is
218+
/// preferred. Only return `true` when IPv6 is expected to work, since
219+
/// otherwise the bias just delays the connection.
219220
pub fn address_family_selector<S>(mut self, selector: S) -> Self
220221
where
221222
S: Fn() -> bool + Send + Sync + 'static,

iroh-relay/src/client/tls.rs

Lines changed: 61 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ use bytes::Bytes;
1010
use data_encoding::BASE64URL;
1111
use http_body_util::Empty;
1212
use hyper::{Request, upgrade::Parts};
13+
use hyper_util::rt::TokioIo;
1314
use n0_error::e;
14-
use n0_future::{task, time};
15+
use n0_future::{IterExt, StreamExt, task, time};
1516
use rustls::client::Resumption;
17+
use tokio::net::TcpStream;
1618
use tracing::error;
1719

1820
use super::{
@@ -111,61 +113,26 @@ impl MaybeTlsStreamBuilder {
111113
let stream = self.dial_url_proxy(proxy.clone(), tls_connector).await?;
112114
Ok(ProxyStream::Proxied(stream))
113115
} else {
114-
let stream = self.dial_url_direct().await?;
116+
let stream =
117+
dial_happy_eyeballs(&self.dns_resolver, &self.url, self.prefer_ipv6).await?;
115118
Ok(ProxyStream::Raw(stream))
116119
}
117120
}
118121

119-
async fn dial_url_direct(&self) -> Result<tokio::net::TcpStream, DialError> {
120-
use tokio::net::TcpStream;
121-
let dst_ip = self
122-
.dns_resolver
123-
.resolve_host(&self.url, self.prefer_ipv6, DNS_TIMEOUT)
124-
.await?;
125-
126-
let port = url_port(&self.url).ok_or_else(|| e!(DialError::InvalidTargetPort))?;
127-
let addr = SocketAddr::new(dst_ip, port);
128-
129-
trace!("connecting to {}", addr);
130-
let tcp_stream = time::timeout(DIAL_ENDPOINT_TIMEOUT, async move {
131-
TcpStream::connect(addr).await
132-
})
133-
.await
134-
.map_err(|err| e!(DialError::Timeout, err))??;
135-
136-
tcp_stream.set_nodelay(true)?;
137-
138-
Ok(tcp_stream)
139-
}
140-
141122
async fn dial_url_proxy(
142123
&self,
143124
proxy_url: Url,
144125
tls_connector: &tokio_rustls::TlsConnector,
145126
) -> Result<util::Chain<std::io::Cursor<Bytes>, MaybeTlsStream<tokio::net::TcpStream>>, DialError>
146127
{
147-
use hyper_util::rt::TokioIo;
148-
use tokio::net::TcpStream;
149128
debug!(%self.url, %proxy_url, "dial url via proxy");
150129

151-
// Resolve proxy DNS
152-
let proxy_ip = self
153-
.dns_resolver
154-
.resolve_host(&proxy_url, self.prefer_ipv6, DNS_TIMEOUT)
155-
.await?;
156-
157-
let proxy_port =
158-
url_port(&proxy_url).ok_or_else(|| e!(DialError::ProxyInvalidTargetPort))?;
159-
let proxy_addr = SocketAddr::new(proxy_ip, proxy_port);
160-
161-
debug!(%proxy_addr, "connecting to proxy");
162-
163-
let tcp_stream = time::timeout(DIAL_ENDPOINT_TIMEOUT, async move {
164-
TcpStream::connect(proxy_addr).await
165-
})
166-
.await??;
167-
168-
tcp_stream.set_nodelay(true)?;
130+
let tcp_stream = dial_happy_eyeballs(&self.dns_resolver, &proxy_url, self.prefer_ipv6)
131+
.await
132+
.map_err(|err| match err {
133+
DialError::InvalidTargetPort { meta } => DialError::ProxyInvalidTargetPort { meta },
134+
err @ _ => err,
135+
})?;
169136

170137
// Setup TLS if necessary
171138
let io = if proxy_url.scheme() == "http" {
@@ -254,6 +221,56 @@ impl MaybeTlsStreamBuilder {
254221
}
255222
}
256223

224+
/// Resolves `url` and races a TCP connection across the resolved addresses.
225+
///
226+
/// The host is resolved to all of its addresses in preference order (IPv6 first
227+
/// when `prefer_ipv6`, otherwise IPv4). Each successive dial is started
228+
/// [`DIAL_STAGGER_DELAY`] after the previous one and capped at
229+
/// [`DIAL_ENDPOINT_TIMEOUT`], so a slow or unreachable preferred address
230+
/// does not hold back the rest for the full timeout. The first connection to succeed
231+
/// is returned. The remaining attempts are cancelled.
232+
async fn dial_happy_eyeballs(
233+
dns_resolver: &DnsResolver,
234+
url: &Url,
235+
prefer_ipv6: bool,
236+
) -> Result<TcpStream, DialError> {
237+
let port = url_port(url).ok_or_else(|| e!(DialError::InvalidTargetPort))?;
238+
let addrs = dns_resolver
239+
.resolve_host_all(url, prefer_ipv6, DNS_TIMEOUT)
240+
.await?;
241+
let mut dials = addrs
242+
.into_iter()
243+
.enumerate()
244+
.map(|(i, ip)| {
245+
let addr = SocketAddr::new(ip, port);
246+
async move {
247+
time::sleep(DIAL_STAGGER_DELAY * i as u32).await;
248+
trace!("connecting to {}", addr);
249+
let tcp_stream = time::timeout(DIAL_ENDPOINT_TIMEOUT, TcpStream::connect(addr))
250+
.await
251+
.map_err(|err| e!(DialError::Timeout, err))
252+
.and_then(|res| res.map_err(|err| e!(DialError::Io, err)))
253+
.inspect_err(|err| {
254+
debug!(%addr, "failed to connect to relay at {addr}: {err:#}");
255+
})?;
256+
tcp_stream.set_nodelay(true)?;
257+
Ok(tcp_stream)
258+
}
259+
})
260+
.into_unordered_stream();
261+
262+
let mut last_err = None;
263+
while let Some(res) = dials.next().await {
264+
match res {
265+
Ok(tcp_stream) => return Ok(tcp_stream),
266+
Err(err) => last_err = Some(err),
267+
}
268+
}
269+
270+
// If we haven't returned yet, last_err is always set unless `addrs` was empty.
271+
Err(last_err.unwrap_or_else(|| e!(DnsError::NoResponse).into()))
272+
}
273+
257274
fn url_port(url: &Url) -> Option<u16> {
258275
if let Some(port) = url.port() {
259276
return Some(port);

iroh-relay/src/defaults.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ pub mod timeouts {
3131
/// using `TcpStream::connect`
3232
pub(crate) const DIAL_ENDPOINT_TIMEOUT: Duration = Duration::from_millis(1500);
3333

34+
/// Delay before starting the next address's dial in the staggered
35+
/// "happy eyeballs" connection race.
36+
pub(crate) const DIAL_STAGGER_DELAY: Duration = Duration::from_millis(250);
37+
3438
/// Default timeout for DNS queries issued by [`DnsResolver`].
3539
///
3640
/// [`DnsResolver`]: iroh_dns::dns::DnsResolver

iroh-relay/src/server.rs

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1156,16 +1156,21 @@ impl hyper::service::Service<Request<Incoming>> for CaptivePortalService {
11561156

11571157
#[cfg(test)]
11581158
mod tests {
1159-
use std::{net::Ipv4Addr, sync::Arc, time::Duration};
1159+
use std::{
1160+
net::{Ipv4Addr, Ipv6Addr},
1161+
sync::Arc,
1162+
time::Duration,
1163+
};
11601164

11611165
use http::StatusCode;
11621166
use iroh_base::{EndpointId, RelayUrl, SecretKey};
1163-
use iroh_dns::dns::DnsResolver;
1164-
use n0_error::Result;
1165-
use n0_future::{SinkExt, StreamExt};
1167+
use iroh_dns::dns::{BoxIter, DnsError, DnsResolver, Resolver, TxtRecordData};
1168+
use n0_error::{Result, StackResultExt, StdResultExt};
1169+
use n0_future::{SinkExt, StreamExt, boxed::BoxFuture};
11661170
use n0_tracing_test::traced_test;
11671171
use rand::{RngExt, SeedableRng};
11681172
use tracing::{info, instrument};
1173+
use url::Url;
11691174

11701175
use super::{
11711176
Access, AccessControl, ClientRequest, NO_CONTENT_CHALLENGE_HEADER,
@@ -1177,7 +1182,7 @@ mod tests {
11771182
handshake,
11781183
relay::{ClientToRelayMsg, Datagrams, RelayToClientMsg},
11791184
},
1180-
tls::{CaRootsConfig, default_provider},
1185+
tls::{self, CaRootsConfig, default_provider},
11811186
};
11821187

11831188
/// An [`AccessControl`] backed by a closure, for tests.
@@ -1569,4 +1574,70 @@ mod tests {
15691574
}
15701575
Ok(())
15711576
}
1577+
1578+
/// A resolver that hands out fixed IPv4 and IPv6 addresses for every host.
1579+
#[derive(Debug, Clone)]
1580+
struct StaticResolver {
1581+
v4: Vec<Ipv4Addr>,
1582+
v6: Vec<Ipv6Addr>,
1583+
}
1584+
1585+
impl Resolver for StaticResolver {
1586+
fn lookup_ipv4(&self, _host: String) -> BoxFuture<Result<BoxIter<Ipv4Addr>, DnsError>> {
1587+
let v4 = self.v4.clone();
1588+
Box::pin(async move { Ok(Box::new(v4.into_iter()) as BoxIter<Ipv4Addr>) })
1589+
}
1590+
1591+
fn lookup_ipv6(&self, _host: String) -> BoxFuture<Result<BoxIter<Ipv6Addr>, DnsError>> {
1592+
let v6 = self.v6.clone();
1593+
Box::pin(async move { Ok(Box::new(v6.into_iter()) as BoxIter<Ipv6Addr>) })
1594+
}
1595+
1596+
fn lookup_txt(&self, _host: String) -> BoxFuture<Result<BoxIter<TxtRecordData>, DnsError>> {
1597+
Box::pin(async move { Ok(Box::new(std::iter::empty()) as BoxIter<TxtRecordData>) })
1598+
}
1599+
1600+
fn clear_cache(&self) {}
1601+
1602+
fn reset(&self) -> Box<dyn Resolver> {
1603+
Box::new(self.clone())
1604+
}
1605+
}
1606+
1607+
/// A relay client that prefers IPv6 falls back to IPv4 when the advertised IPv6
1608+
/// address is unreachable.
1609+
#[tokio::test]
1610+
#[traced_test]
1611+
async fn test_relay_client_falls_back_to_ipv4() -> Result {
1612+
// A relay reachable only over IPv4.
1613+
let mut config = ServerConfig::default();
1614+
config.relay = Some(RelayConfig::new((Ipv4Addr::LOCALHOST, 0)));
1615+
let server = Server::spawn(config).await?;
1616+
let addr = server.http_addr().expect("http relay address");
1617+
1618+
// `relay.test` resolves to the relay's real IPv4 address alongside an
1619+
// unreachable IPv6 address from the RFC 3849 documentation prefix, standing
1620+
// in for the stale AAAA record from the issue.
1621+
let resolver = DnsResolver::custom(StaticResolver {
1622+
v4: vec![Ipv4Addr::LOCALHOST],
1623+
v6: vec!["2001:db8::dead".parse().expect("valid IPv6")],
1624+
});
1625+
let url: Url = format!("http://relay.test:{}", addr.port())
1626+
.parse()
1627+
.expect("valid relay url");
1628+
1629+
// Force the IPv6 preference, as a client with working IPv6 connectivity
1630+
// would have after a net report.
1631+
let client = ClientBuilder::new(url, SecretKey::generate(), resolver)
1632+
.tls_client_config(tls::make_dangerous_client_config())
1633+
.address_family_selector(|| true);
1634+
1635+
tokio::time::timeout(Duration::from_secs(10), client.connect())
1636+
.await
1637+
.with_std_context(|_| "relay connect timed out")?
1638+
.context("relay connect")?;
1639+
1640+
server.shutdown().await.context("relay server shutdown")?;
1641+
Ok(())
1642+
}
15721643
}

0 commit comments

Comments
 (0)