aboutsummaryrefslogtreecommitdiffstats
path: root/src/transport
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-01 05:22:38 +0100
committertv <tv@krebsco.de>2026-11-01 05:22:38 +0100
commit6bab0c4af15988e3c0d4e38de3ee9c1f8c4a8be2 (patch)
tree088b1c5156ce237f0be885f620b28a7d1252b333 /src/transport
parent35eade856a9b445920ec07eacdeaf153239d8ec9 (diff)
libp2p: autoignore unroutable address ranges
Diffstat (limited to 'src/transport')
-rw-r--r--src/transport/libp2p/book.rs55
-rw-r--r--src/transport/libp2p/config.rs4
-rw-r--r--src/transport/libp2p/driver.rs15
-rw-r--r--src/transport/libp2p/ignore.rs26
-rw-r--r--src/transport/libp2p/mod.rs15
-rw-r--r--src/transport/libp2p/routes.rs199
-rw-r--r--src/transport/libp2p/tests.rs49
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() {