diff options
| author | tv <tv@krebsco.de> | 2026-11-01 05:22:38 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-01 05:22:38 +0100 |
| commit | 6bab0c4af15988e3c0d4e38de3ee9c1f8c4a8be2 (patch) | |
| tree | 088b1c5156ce237f0be885f620b28a7d1252b333 /src/transport | |
| parent | 35eade856a9b445920ec07eacdeaf153239d8ec9 (diff) | |
libp2p: autoignore unroutable address ranges
Diffstat (limited to 'src/transport')
| -rw-r--r-- | src/transport/libp2p/book.rs | 55 | ||||
| -rw-r--r-- | src/transport/libp2p/config.rs | 4 | ||||
| -rw-r--r-- | src/transport/libp2p/driver.rs | 15 | ||||
| -rw-r--r-- | src/transport/libp2p/ignore.rs | 26 | ||||
| -rw-r--r-- | src/transport/libp2p/mod.rs | 15 | ||||
| -rw-r--r-- | src/transport/libp2p/routes.rs | 199 | ||||
| -rw-r--r-- | src/transport/libp2p/tests.rs | 49 |
7 files changed, 353 insertions, 10 deletions
diff --git a/src/transport/libp2p/book.rs b/src/transport/libp2p/book.rs index f466aef..b2ce7bb 100644 --- a/src/transport/libp2p/book.rs +++ b/src/transport/libp2p/book.rs @@ -53,6 +53,9 @@ pub(super) struct Book { pub(super) ignored: Vec<IpRange>, /// `Libp2pConfig::prefer`: addresses there first (`preference`). pub(super) preferred: Vec<IpRange>, + /// Which address ranges have a route from here (`routes`): those without are left + /// out, as if ignored. + pub(super) routes: Routes, /// `Libp2pConfig::relay`: this node relays, and says so. pub(super) serving: bool, /// Relays named to pick (configured, listed), by peer: picked first. @@ -275,7 +278,10 @@ impl Book { direct.retain(|address| !first.contains(address)); direct.sort_by_key(|address| self.preferred_range(address)); first.extend(direct); - first.retain(|address| !ignored(&self.ignored, address)); + first.retain(|address| { + !ignored(&self.ignored, address) + && ip_of(address).is_none_or(|ip| self.routes.publishable(ip)) + }); first } } @@ -464,7 +470,7 @@ impl Book { if !self.addresses.contains_key(&peer) && self.addresses.len() >= MAX_PEERS { return; } - if left_out(&self.ignored, &address) { + if self.left_out(&address) { return; } if self @@ -481,6 +487,38 @@ impl Book { addresses.truncate(ADDRESSES_PER_PEER); } + /// Whether `address` can only be reached over a range left out: ignored + /// (`left_out`), or without a route from here (`routes`), directly or through a + /// relay there. + pub(super) fn left_out(&mut self, address: &Multiaddr) -> bool { + if left_out(&self.ignored, address) { + return true; + } + let at = if is_relayed(address) { + address + .iter() + .take_while(|protocol| !matches!(protocol, Protocol::P2pCircuit)) + .collect::<Multiaddr>() + } else { + address.clone() + }; + ip_of(&at).is_some_and(|ip| !self.routes.routable(ip)) + } + + /// Asks again which ranges have a route (`Routes::recheck`); the addresses in those + /// that lost theirs go. + pub(super) fn recheck_routes(&mut self) { + let lost = self.routes.recheck(); + if lost.is_empty() { + return; + } + for known in self.addresses.values_mut() { + known.retain(|address| { + ip_of(address).is_none_or(|ip| !lost.iter().any(|range| range.contains(ip))) + }); + } + } + /// The addresses `explore` dials `peer` at, noted as dialled: a node of the swarm /// connected directly, at addresses of its not dialled so yet, and with `again` /// not only those at an IP it isn't connected at (an inbound connection's remote @@ -506,10 +544,17 @@ impl Book { .filter(|address| is_ip(address) && !left_out(&self.ignored, address)) .filter(|address| again || !used.contains(&ip_of(address))) .filter(|address| !tried.contains(*address)) - .take(EXPLORED_AT_ONCE) .cloned() .collect(); - tried.extend(candidates.iter().cloned()); + let candidates: Vec<Multiaddr> = candidates + .into_iter() + .filter(|address| !self.left_out(address)) + .take(EXPLORED_AT_ONCE) + .collect(); + self.explored + .entry(peer) + .or_default() + .extend(candidates.iter().cloned()); candidates } @@ -567,7 +612,7 @@ pub(super) fn ip_of(address: &Multiaddr) -> Option<std::net::IpAddr> { } /// Whether `ip` can be reached from the internet, as far as its range tells. -fn is_public(ip: std::net::IpAddr) -> bool { +pub(super) fn is_public(ip: std::net::IpAddr) -> bool { use std::net::IpAddr; match ip { IpAddr::V4(ip) => { diff --git a/src/transport/libp2p/config.rs b/src/transport/libp2p/config.rs index 4db8444..6d26224 100644 --- a/src/transport/libp2p/config.rs +++ b/src/transport/libp2p/config.rs @@ -25,6 +25,9 @@ pub(crate) struct Libp2pConfig { pub(crate) ignore: Vec<IpRange>, /// Address ranges that matter most, best first (`Book::preference`). pub(crate) prefer: Vec<IpRange>, + /// Hosts on the internet (names or addresses) whose routes tell whether there is + /// one to the internet at all (`Routes::internet`). + pub(crate) internet_hosts: Vec<String>, /// Use public infrastructure: the IPFS DHT, public relays. pub(crate) public: bool, /// libp2p's in-memory transport instead of TCP and QUIC: for tests. @@ -126,6 +129,7 @@ impl Libp2pConfig { upnp: settings.libp2p_upnp, ignore: parse_ranges("libp2p-ignore", &settings.libp2p_ignore)?, prefer: parse_ranges("libp2p-prefer", &settings.libp2p_prefer)?, + internet_hosts: settings.libp2p_internet_hosts.clone(), public: settings.libp2p_public, memory: false, }) diff --git a/src/transport/libp2p/driver.rs b/src/transport/libp2p/driver.rs index a0434a6..b8c8c37 100644 --- a/src/transport/libp2p/driver.rs +++ b/src/transport/libp2p/driver.rs @@ -21,6 +21,8 @@ pub(super) enum Command { /// Find out which peer relays at `address`, named without the relay's peer ID /// (`Book::finding`). FindRelay { address: Multiaddr }, + /// Where `--libp2p-internet-hosts` are now (`Routes::internet`). + Internet(Vec<std::net::IpAddr>), } /// Drives the swarm: dials what `commands` ask for and notes where we listen, until the @@ -97,6 +99,18 @@ pub(super) async fn run( } } _ = check.tick() => { + let hosts = { + let mut book = lock(&book); + book.recheck_routes(); + book.routes.internet_hosts.clone() + }; + // Names are resolved aside, not to hold up the swarm. + if !hosts.is_empty() { + let again = again.clone(); + tokio::spawn(async move { + let _ = again.send(Command::Internet(resolve_internet_hosts(&hosts).await)); + }); + } pick_relays(&mut swarm, &book, &relaying, &nat, &mut picked); retry_punches(&mut swarm, &book, relaying.local); } @@ -105,6 +119,7 @@ pub(super) async fn run( Some(Command::CheckRelays) => { pick_relays(&mut swarm, &book, &relaying, &nat, &mut picked); } + Some(Command::Internet(hosts)) => lock(&book).routes.internet(&hosts), Some(Command::Connect { peer, addresses, done }) => { if swarm.is_connected(&peer) { let _ = done.send(Ok(())); diff --git a/src/transport/libp2p/ignore.rs b/src/transport/libp2p/ignore.rs index ab59a3e..2fcbd6f 100644 --- a/src/transport/libp2p/ignore.rs +++ b/src/transport/libp2p/ignore.rs @@ -2,11 +2,13 @@ //! (`Ignoring`, a behaviour that denies connections there), nor told peers by identify //! (`Identifying`). +use std::fmt; + use super::*; /// An address range (`10.244.0.0/16`, `42::/16`; a bare address is a range of one), for /// `Libp2pConfig::ignore`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) struct IpRange { pub(super) network: std::net::IpAddr, pub(super) prefix: u8, @@ -36,7 +38,29 @@ impl FromStr for IpRange { } } +impl fmt::Display for IpRange { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}/{}", self.network, self.prefix) + } +} + impl IpRange { + /// The range of the `prefix` bits of `ip` (the rest zero). + pub(super) fn masked(ip: std::net::IpAddr, prefix: u8) -> Self { + use std::net::IpAddr; + let network = match ip { + IpAddr::V4(ip) => { + let mask = u32::MAX.checked_shl(32 - u32::from(prefix)).unwrap_or(0); + IpAddr::V4((u32::from(ip) & mask).into()) + } + IpAddr::V6(ip) => { + let mask = u128::MAX.checked_shl(128 - u32::from(prefix)).unwrap_or(0); + IpAddr::V6((u128::from(ip) & mask).into()) + } + }; + Self { network, prefix } + } + /// Whether every address of `other` is in this range too. pub(super) fn covers(&self, other: &IpRange) -> bool { self.network.is_ipv4() == other.network.is_ipv4() diff --git a/src/transport/libp2p/mod.rs b/src/transport/libp2p/mod.rs index 9d1dcc7..ace84c6 100644 --- a/src/transport/libp2p/mod.rs +++ b/src/transport/libp2p/mod.rs @@ -50,8 +50,12 @@ //! //! With `--libp2p-ignore`, address ranges are left out (VPNs): not published, not told //! peers by identify (`Identifying`), not offered for hole punching, not dialled, and -//! connections there refused (`Ignoring`). With `--libp2p-prefer`, addresses in the -//! ranges named come first, where only so many are kept (`Book::preference`). +//! connections there refused (`Ignoring`). Ranges this node has no route to are left +//! out alike, as long as it has none (`routes`): without IPv6 to the internet, peers' +//! public IPv6 addresses; and with `--libp2p-internet-hosts`, its own aren't published +//! then. With `--libp2p-prefer`, +//! addresses in the ranges named come first, where only so many are kept +//! (`Book::preference`). //! //! With `--libp2p-upnp`, a node asks its router to forward its listen ports (UPnP), and //! publishes the router's public address for them with its own (`Book::direct`): then @@ -101,6 +105,7 @@ mod driver; mod errors; mod ignore; mod relays; +mod routes; use behaviour::*; use book::*; @@ -109,6 +114,7 @@ use driver::*; use errors::*; use ignore::*; use relays::*; +use routes::*; /// The scheme of this transport's addresses (`transport::paths`). pub(crate) const SCHEME: &str = "libp2p"; @@ -251,6 +257,7 @@ impl Libp2pTransport { announced: config.announce, ignored: config.ignore.clone(), preferred: config.prefer.clone(), + routes: Routes::asking(config.internet_hosts.clone()), serving: config.relay, ..Book::default() }; @@ -288,13 +295,13 @@ impl Libp2pTransport { /// Connected to `peer`, if it can be had in time. async fn connect(&self, peer: PeerId) -> Result<()> { let addresses = { - let book = lock(&self.book); + let mut book = lock(&self.book); let mut known = book .addresses .get(&peer) .map(|known| dialable(known, self.peer_id)) .unwrap_or_default(); - known.retain(|address| !left_out(&book.ignored, address)); + known.retain(|address| !book.left_out(address)); if known.is_empty() { book.through_relays(peer, self.peer_id) } else { diff --git a/src/transport/libp2p/routes.rs b/src/transport/libp2p/routes.rs new file mode 100644 index 0000000..05e14e8 --- /dev/null +++ b/src/transport/libp2p/routes.rs @@ -0,0 +1,199 @@ +//! Addresses without a route from here, left out as if `--libp2p-ignore` named them: a +//! node without IPv6 to the internet doesn't keep, show or dial peers' public IPv6 +//! addresses; one without a route to another LAN, that LAN's. And with +//! `--libp2p-internet-hosts`, whether there's a route to the internet at all, per +//! family: a node without one doesn't publish its own public addresses of that family. +//! +//! Whether there is a route is asked of the operating system: a UDP socket "connected" +//! to the address (no packet is sent) fails where the routing table has none. Asked +//! per range (`ROUTE_PREFIX_V4`, `ROUTE_PREFIX_V6`) of the addresses heard of, and +//! asked again every `ROUTES_RECHECK`: a range gets a route (IPv6 comes up), and its +//! addresses are taken again as they're heard of next (the member list, identify); +//! one loses its route, and the addresses there are dropped. + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket}; + +use super::*; + +/// The ranges routes are asked for: an IPv4 /24 or an IPv6 /64, as far as a route +/// usually differs within them. +const ROUTE_PREFIX_V4: u8 = 24; +const ROUTE_PREFIX_V6: u8 = 64; +/// How long an answer is taken for one. +const ROUTES_RECHECK: Duration = Duration::from_secs(30); +/// Ranges remembered at most. +const MAX_RANGES: usize = 1024; +/// How long resolving `--libp2p-internet-hosts` may take, each time. +const RESOLVE_WITHIN: Duration = Duration::from_secs(5); + +/// Whether the routing table has a route to `ip`. +pub(super) fn has_route(ip: IpAddr) -> bool { + let any: SocketAddr = match ip { + IpAddr::V4(_) => (Ipv4Addr::UNSPECIFIED, 0).into(), + IpAddr::V6(_) => (Ipv6Addr::UNSPECIFIED, 0).into(), + }; + UdpSocket::bind(any) + .and_then(|socket| socket.connect((ip, 9))) + .is_ok() +} + +/// The answers, by range, and the question asked (`has_route`, or a stand-in in tests). +pub(super) struct Routes { + known: HashMap<IpRange, (bool, tokio::time::Instant)>, + /// Whether there is a route to the internet, per family (IPv4, IPv6), as last + /// asked (`internet`): own public addresses are published only then. True where + /// nobody asks (no `--libp2p-internet-hosts` of that family). + internet: [bool; 2], + /// `--libp2p-internet-hosts`: names or addresses on the internet, whose routes + /// tell whether there is one to the internet at all. + pub(super) internet_hosts: Vec<String>, + probe: fn(IpAddr) -> bool, +} + +impl Default for Routes { + fn default() -> Self { + // Tests don't depend on the host's routes; those that care set `probe`. + #[cfg(test)] + let probe: fn(IpAddr) -> bool = |_| true; + #[cfg(not(test))] + let probe: fn(IpAddr) -> bool = has_route; + Self::with_probe(probe) + } +} + +/// The range of `ip` routes are asked for. +fn range_of(ip: IpAddr) -> IpRange { + let prefix = if ip.is_ipv4() { + ROUTE_PREFIX_V4 + } else { + ROUTE_PREFIX_V6 + }; + IpRange::masked(ip, prefix) +} + +impl Routes { + /// With `--libp2p-internet-hosts` `internet_hosts`. + pub(super) fn asking(internet_hosts: Vec<String>) -> Self { + Self { + internet_hosts, + ..Self::default() + } + } + + pub(super) fn with_probe(probe: fn(IpAddr) -> bool) -> Self { + Self { + known: HashMap::new(), + internet: [true, true], + internet_hosts: Vec::new(), + probe, + } + } + + /// Whether `ip` has a route from here: asked if not known lately, told when the + /// answer is news (debug). + pub(super) fn routable(&mut self, ip: IpAddr) -> bool { + let range = range_of(ip); + let now = tokio::time::Instant::now(); + if let Some((routable, at)) = self.known.get(&range) + && now.saturating_duration_since(*at) < ROUTES_RECHECK + { + return *routable; + } + let routable = (self.probe)(ip); + let before = self.known.get(&range).map(|(routable, _)| *routable); + if before.is_none() && self.known.len() >= MAX_RANGES { + self.known.clear(); + } + self.known.insert(range, (routable, now)); + announce(range, before, routable); + routable + } + + /// Asks again for the ranges known; returns those that lost their route, whose + /// addresses are to go. + pub(super) fn recheck(&mut self) -> Vec<IpRange> { + let now = tokio::time::Instant::now(); + let mut lost = Vec::new(); + for (range, (routable, at)) in &mut self.known { + let now_routable = (self.probe)(range.network); + if *routable != now_routable { + announce(*range, Some(*routable), now_routable); + if !now_routable { + lost.push(*range); + } + } + *routable = now_routable; + *at = now; + } + lost + } + + /// Whether there's a route to the internet, per family, by the addresses + /// `--libp2p-internet-hosts` resolved to (`resolve_internet_hosts`): one of them + /// with a route is enough. A family none of them has isn't judged (taken as having + /// one). + pub(super) fn internet(&mut self, hosts: &[IpAddr]) { + for (family, name) in ["IPv4", "IPv6"].into_iter().enumerate() { + let of_family: Vec<IpAddr> = hosts + .iter() + .copied() + .filter(|ip| usize::from(ip.is_ipv6()) == family) + .collect(); + let routable = of_family.is_empty() || of_family.iter().any(|ip| (self.probe)(*ip)); + if routable != self.internet[family] { + if routable { + debug!( + family = name, + "A route to the internet again: own public addresses published again" + ); + } else { + debug!( + family = name, + "No route to the internet: own public addresses not published" + ); + } + } + self.internet[family] = routable; + } + } + + /// Whether an own address `ip` is worth publishing: not a public one of a family + /// with no route to the internet. + pub(super) fn publishable(&self, ip: IpAddr) -> bool { + !is_public(ip) || self.internet[usize::from(ip.is_ipv6())] + } +} + +fn announce(range: IpRange, before: Option<bool>, routable: bool) { + match (before, routable) { + (None | Some(true), false) => { + debug!(range = %range, "No route to an address range: its addresses left out until there is"); + } + (Some(false), true) => { + debug!(range = %range, "A route to an address range again: its addresses taken again"); + } + _ => {} + } +} + +/// The addresses `hosts` (names or addresses) resolve to, within `RESOLVE_WITHIN`; a +/// name that doesn't resolve is left out (told at debug level). +pub(super) async fn resolve_internet_hosts(hosts: &[String]) -> Vec<IpAddr> { + let mut addresses = Vec::new(); + for host in hosts { + if let Ok(ip) = host.parse::<IpAddr>() { + addresses.push(ip); + continue; + } + match tokio::time::timeout(RESOLVE_WITHIN, tokio::net::lookup_host((host.as_str(), 0))) + .await + { + Ok(Ok(found)) => addresses.extend(found.map(|address| address.ip())), + Ok(Err(error)) => { + debug!(host = %host, error = %error, "An internet host didn't resolve") + } + Err(_) => debug!(host = %host, "An internet host didn't resolve in time"), + } + } + addresses +} diff --git a/src/transport/libp2p/tests.rs b/src/transport/libp2p/tests.rs index 7311ebf..801dfd3 100644 --- a/src/transport/libp2p/tests.rs +++ b/src/transport/libp2p/tests.rs @@ -33,6 +33,7 @@ fn memory_node_with( upnp: false, ignore: Vec::new(), prefer: Vec::new(), + internet_hosts: Vec::new(), public: false, memory: true, }) @@ -181,6 +182,7 @@ async fn announced_addresses_are_published_instead() { upnp: false, ignore: Vec::new(), prefer: Vec::new(), + internet_hosts: Vec::new(), public: false, memory: true, }) @@ -421,6 +423,7 @@ async fn a_relay_that_is_no_node() { upnp: false, ignore: Vec::new(), prefer: Vec::new(), + internet_hosts: Vec::new(), public: false, memory: true, }) @@ -725,6 +728,52 @@ fn better_paths_explored() { assert_eq!(again, both); } +/// Addresses without a route from here are left out, as if ignored: not kept, nor +/// dialled; and own public ones of a family without a route to the internet (by +/// `--libp2p-internet-hosts`) aren't published. The routing table is asked for real only by `has_route`. +#[test] +fn unroutable_addresses_left_out() { + assert!(has_route("127.0.0.1".parse().unwrap())); + // No IPv6 here. + let mut book = Book { + routes: Routes::with_probe(|ip| ip.is_ipv4()), + listening: vec![ + "/ip4/192.168.1.2/tcp/9661".parse().unwrap(), + "/ip6/2a0a:4cc0::2/tcp/9661".parse().unwrap(), + ], + ..Book::default() + }; + let them = peer(); + let v4: Multiaddr = "/ip4/192.168.1.7/tcp/9661".parse().unwrap(); + let v6: Multiaddr = "/ip6/2a0a:4cc0::7/tcp/9661".parse().unwrap(); + book.identified(them, vec![v4.clone(), v6.clone()]); + assert!(book.knows(them, &[v4])); + assert!(!book.knows(them, &[v6])); + // Nobody asked whether there's a route to the internet: everything is published. + assert_eq!(book.direct().len(), 2); + book.routes.internet(&[ + "1.1.1.1".parse().unwrap(), + "2606:4700:4700::1111".parse().unwrap(), + ]); + assert_eq!( + book.direct(), + ["/ip4/192.168.1.2/tcp/9661".parse::<Multiaddr>().unwrap()] + ); +} + +/// `--libp2p-internet-hosts` takes addresses as they are (names are resolved). +#[tokio::test] +async fn internet_hosts_resolved() { + let resolved = resolve_internet_hosts(&["1.1.1.1".to_owned(), "::1".to_owned()]).await; + assert_eq!( + resolved, + [ + "1.1.1.1".parse::<std::net::IpAddr>().unwrap(), + "::1".parse().unwrap() + ] + ); +} + /// Peers nobody mentions any more are forgotten, but connected ones. #[test] fn stale_peers_are_forgotten() { |
