Skip to content
Open
1 change: 1 addition & 0 deletions iroh/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ allowed_external_types = [
"tokio::*",
"url::*",
# non-1.0 crates that we decided to accept in the public API
"ipnet::*",
"rustls::*",
"futures_core::stream::Stream",
# only type alias
Expand Down
90 changes: 88 additions & 2 deletions iroh/src/endpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@
//!
//! [module docs]: crate

use std::{collections::BTreeSet, net::SocketAddr, pin::Pin, sync::Arc};
use std::{
collections::BTreeSet,
net::{IpAddr, SocketAddr},
pin::Pin,
sync::Arc,
};

#[cfg(not(wasm_browser))]
use ipnet::{Ipv4Net, Ipv6Net};
Expand Down Expand Up @@ -90,6 +95,9 @@ pub(crate) mod quic;
#[cfg(not(wasm_browser))]
pub use bind::{BindOpts, InvalidSocketAddr, ToSocketAddr};
pub use hooks::{AfterHandshakeOutcome, BeforeConnectOutcome, EndpointHooks};
/// Re-exported so callers of [`Builder::exclude_direct_addrs`] do not need to
/// depend on a matching `ipnet` themselves.
pub use ipnet::IpNet;
Comment thread
ifdario marked this conversation as resolved.
Outdated

#[cfg(feature = "qlog")]
pub use self::quic::{QlogConfig, QlogFactory, QlogFileFactory};
Expand Down Expand Up @@ -148,6 +156,29 @@ pub struct Builder {
net_report_config: NetReportConfig,
crypto_provider: Option<Arc<rustls::crypto::CryptoProvider>>,
configured_addrs: BTreeSet<SocketAddr>,
direct_addr_filter: Option<Arc<dyn DirectAddrFilter>>,
}

/// Filters the endpoint's direct (underlay) address candidates.
Comment thread
ifdario marked this conversation as resolved.
Outdated
///
/// An address for which [`Self::keeps`] returns `false` is dropped: it is not
/// stored, published, or used as a holepunch candidate. Set it with
/// [`Builder::direct_addr_filter`].
pub trait DirectAddrFilter: Send + Sync + std::fmt::Debug + 'static {
/// Returns `true` to keep `ip` as a candidate, `false` to drop it.
fn keeps(&self, ip: IpAddr) -> bool;
Comment thread
ifdario marked this conversation as resolved.
Outdated
}

/// The filter behind [`Builder::exclude_direct_addrs`]: an address contained in
/// any of the networks is dropped, everything else is kept. An empty list keeps
/// every address.
#[derive(Debug)]
struct ExcludeNets(Vec<IpNet>);

impl DirectAddrFilter for ExcludeNets {
fn keeps(&self, ip: IpAddr) -> bool {
!self.0.iter().any(|net| net.contains(&ip))
}
}

impl From<RelayMode> for Option<TransportConfig> {
Expand Down Expand Up @@ -216,6 +247,7 @@ impl Builder {
net_report_config: Default::default(),
crypto_provider: None,
configured_addrs: Default::default(),
direct_addr_filter: None,
}
}

Expand Down Expand Up @@ -279,6 +311,7 @@ impl Builder {
net_report_config: self.net_report_config,
static_config,
configured_addrs: self.configured_addrs,
direct_addr_filter: self.direct_addr_filter,
};

let inner = socket::EndpointInner::bind(sock_opts)
Expand Down Expand Up @@ -628,6 +661,40 @@ impl Builder {
self
}

/// Excludes addresses in the given networks from the endpoint's direct
/// address candidates.
Comment thread
ifdario marked this conversation as resolved.
Outdated
///
/// An address contained in any of `nets` is never stored, published, or
Comment thread
ifdario marked this conversation as resolved.
Outdated
/// offered as a holepunch / NAT-traversal candidate. This is the common case
Comment thread
ifdario marked this conversation as resolved.
Outdated
/// of [`Self::direct_addr_filter`]: excluding a virtual interface's addresses,
/// such as a VPN overlay bound on a TUN device, which peers would otherwise
/// discover and dial, looping the underlay back through the tunnel.
Comment thread
ifdario marked this conversation as resolved.
Outdated
///
/// ```no_run
/// # use iroh::{Endpoint, endpoint::IpNet, endpoint::presets};
/// # async fn wrapper() -> n0_error::Result<()> {
/// let overlay: IpNet = "100.64.0.0/10".parse().expect("valid prefix");
/// let ep = Endpoint::builder(presets::N0)
/// .exclude_direct_addrs([overlay])
/// .bind()
/// .await?;
/// # Ok(())
/// # }
Comment thread
ifdario marked this conversation as resolved.
Outdated
/// ```
pub fn exclude_direct_addrs(self, nets: impl IntoIterator<Item = IpNet>) -> Self {
self.direct_addr_filter(ExcludeNets(nets.into_iter().collect()))
}
Comment thread
ifdario marked this conversation as resolved.
Outdated

/// Sets a [`DirectAddrFilter`] that drops selected addresses from the
/// endpoint's direct address candidates.
Comment thread
ifdario marked this conversation as resolved.
Outdated
///
/// Use [`Self::exclude_direct_addrs`] instead when the addresses to drop can
/// be named as networks; this is for filters that cannot.
pub fn direct_addr_filter(mut self, filter: impl DirectAddrFilter) -> Self {
self.direct_addr_filter = Some(Arc::new(filter));
self
}

/// Sets the initial user-defined data to be published in Address Lookup's for this node.
///
/// When using Address Lookup's, this string of [`UserData`] will be published together
Expand Down Expand Up @@ -2021,7 +2088,7 @@ mod tests {
use tokio::sync::oneshot;
use tracing::{Instrument, debug_span, error_span, info, info_span, instrument};

use super::Endpoint;
use super::{DirectAddrFilter, Endpoint, ExcludeNets};
use crate::{
RelayMap, RelayMode,
address_lookup::memory::MemoryLookup,
Expand All @@ -2037,6 +2104,25 @@ mod tests {

const TEST_ALPN: &[u8] = b"n0/iroh/test";

#[test]
fn exclude_nets_drops_contained_addresses() {
let nets = ExcludeNets(vec![
"100.64.0.0/10".parse().unwrap(),
"200::/7".parse().unwrap(),
]);
// Addresses inside the excluded ranges are dropped.
assert!(!nets.keeps(IpAddr::from_str("100.64.1.2").unwrap()));
assert!(!nets.keeps(IpAddr::from_str("200::1").unwrap()));
// Everything else is kept, including addresses that only look adjacent:
// 100.128.0.0 is the first address past the /10.
assert!(nets.keeps(IpAddr::from_str("100.63.255.255").unwrap()));
assert!(nets.keeps(IpAddr::from_str("100.128.0.0").unwrap()));
assert!(nets.keeps(IpAddr::from_str("192.168.1.5").unwrap()));
assert!(nets.keeps(IpAddr::from_str("2001:db8::1").unwrap()));
// An empty list keeps every address.
assert!(ExcludeNets(Vec::new()).keeps(IpAddr::from_str("100.64.1.2").unwrap()));
}

#[tokio::test]
#[traced_test]
async fn test_connect_self() -> Result {
Expand Down
95 changes: 92 additions & 3 deletions iroh/src/socket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ use crate::{
address_lookup::{self, AddressLookupFailed, EndpointData, UserData},
defaults::timeouts::NET_REPORT_TIMEOUT,
endpoint::{
LocalTransportAddr, RelayStatus, hooks::EndpointHooksList, quic::QuicTransportConfig,
DirectAddrFilter, LocalTransportAddr, RelayStatus, hooks::EndpointHooksList,
quic::QuicTransportConfig,
},
metrics::EndpointMetrics,
net_report::{self, IfStateDetails, Report},
Expand Down Expand Up @@ -196,6 +197,9 @@ pub(crate) struct Options {

/// Explicitly configured external addresses to advertise.
pub(crate) configured_addrs: BTreeSet<SocketAddr>,

/// Optional filter dropping direct address candidates (e.g. VPN overlay IPs).
Comment thread
ifdario marked this conversation as resolved.
Outdated
pub(crate) direct_addr_filter: Option<Arc<dyn DirectAddrFilter>>,
}

/// Inner state for an iroh [`crate::Endpoint`].
Expand Down Expand Up @@ -373,6 +377,8 @@ pub(crate) struct Socket {
address_lookup_user_data: RwLock<Option<UserData>>,
/// Explicitly configured external addresses to advertise.
configured_addrs: RwLock<BTreeSet<SocketAddr>>,
/// Optional filter dropping direct address candidates (e.g. VPN overlay IPs).
Comment thread
ifdario marked this conversation as resolved.
Outdated
direct_addr_filter: Option<Arc<dyn DirectAddrFilter>>,

pub(crate) tls_config: rustls::ClientConfig,

Expand Down Expand Up @@ -887,6 +893,7 @@ impl EndpointInner {
net_report_config,
static_config,
configured_addrs,
direct_addr_filter,
} = opts;

let address_lookup = address_lookup::AddressLookupServices::default();
Expand Down Expand Up @@ -990,6 +997,7 @@ impl EndpointInner {
relay_map: relay_map.clone(),
address_lookup_user_data: RwLock::new(address_lookup_user_data),
configured_addrs: RwLock::new(configured_addrs),
direct_addr_filter,
direct_addrs,
net_report: Watchable::new((None, UpdateReason::None)),
#[cfg(not(wasm_browser))]
Expand Down Expand Up @@ -1871,6 +1879,7 @@ impl Actor {
}

// Finally create and store store all these direct addresses
let direct_addr_filter = self.sock.direct_addr_filter.as_deref();
let stored_addrs = addrs
.into_iter()
.filter_map(|(addr, (typ, flags))| {
Expand All @@ -1879,6 +1888,14 @@ impl Actor {
if is_deprecated {
return None;
}
// Drop addresses rejected by the application filter (e.g. a VPN
// overlay IP bound on a TUN device), so they are never stored,
// published, or used as a holepunch / NAT-traversal candidate.
Comment thread
ifdario marked this conversation as resolved.
Outdated
if let Some(f) = direct_addr_filter
&& !f.keeps(addr.ip())
{
return None;
}
Some(DirectAddr { addr, typ })
})
.collect();
Expand Down Expand Up @@ -2112,7 +2129,12 @@ impl Display for DirectAddrType {

#[cfg(all(test, with_crypto_provider))]
mod tests {
use std::{net::SocketAddrV4, sync::Arc, time::Duration};
use std::{
collections::BTreeSet,
net::{IpAddr, Ipv4Addr, SocketAddrV4},
sync::Arc,
time::Duration,
};

use data_encoding::HEXLOWER;
use iroh_base::{EndpointAddr, EndpointId, TransportAddr};
Expand All @@ -2130,7 +2152,7 @@ mod tests {
Endpoint, SecretKey,
address_lookup::memory::MemoryLookup,
dns::DnsResolver,
endpoint::{QuicTransportConfig, presets},
endpoint::{DirectAddrFilter, QuicTransportConfig, presets},
socket::{
EndpointInner, StaticConfig, TransportConfig,
biased_rtt_path_selector::BiasedRttPathSelector,
Expand Down Expand Up @@ -2179,6 +2201,7 @@ mod tests {
net_report_config: Default::default(),
static_config,
configured_addrs: Default::default(),
direct_addr_filter: None,
}
}

Expand Down Expand Up @@ -2549,6 +2572,71 @@ mod tests {
assert_eq!(eps0, eps1);
}

#[tokio::test]
#[traced_test]
async fn direct_addr_filter_drops_local_addresses() {
// Baseline: without a filter, local interface addresses are gathered.
let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(0u64);
let sock = EndpointInner::bind(default_options(&mut rng))
.await
.unwrap();
assert!(
!sock.ip_addrs().get().is_empty(),
"expected some local addresses without a filter"
);

// A reject-all filter drops every gathered local interface address, so
// `collect_local_addresses` contributes nothing.
#[derive(Debug)]
struct DropAll;
impl DirectAddrFilter for DropAll {
fn keeps(&self, _ip: IpAddr) -> bool {
false
}
}
let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(0u64);
let mut opts = default_options(&mut rng);
opts.direct_addr_filter = Some(Arc::new(DropAll));
let sock = EndpointInner::bind(opts).await.unwrap();
assert!(
sock.ip_addrs().get().is_empty(),
"a reject-all direct_addr_filter should drop every local address"
);
}

#[tokio::test]
#[traced_test]
async fn direct_addr_filter_keeps_unmatched_addresses() {
// A filter that rejects only a never-present address keeps everything the
// unfiltered bind gathered.
let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(0u64);
let baseline = EndpointInner::bind(default_options(&mut rng))
.await
.unwrap()
.ip_addrs()
.get();

#[derive(Debug)]
struct DropOne(IpAddr);
impl DirectAddrFilter for DropOne {
fn keeps(&self, ip: IpAddr) -> bool {
ip != self.0
}
}
// 192.0.2.1 is TEST-NET-1 (RFC 5737); it is never a real local address.
let never = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1));
let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(0u64);
let mut opts = default_options(&mut rng);
opts.direct_addr_filter = Some(Arc::new(DropOne(never)));
let sock = EndpointInner::bind(opts).await.unwrap();
// Compare by IP: each bind gets a fresh ephemeral port, so the full
// SocketAddrs differ even though the same interface addresses are kept.
let ips = |set: &BTreeSet<super::DirectAddr>| {
set.iter().map(|d| d.addr.ip()).collect::<BTreeSet<_>>()
};
assert_eq!(ips(&sock.ip_addrs().get()), ips(&baseline));
}

/// Creates a new [`noq::Endpoint`] hooked up to a [`Socket`].
///
/// This is without involving [`crate::endpoint::Endpoint`]. The socket will accept
Expand Down Expand Up @@ -2595,6 +2683,7 @@ mod tests {
net_report_config: Default::default(),
static_config,
configured_addrs: Default::default(),
direct_addr_filter: None,
};
let sock = EndpointInner::bind(opts).await?;
Ok(sock)
Expand Down
Loading