From e36d7c78565560af223950186256896d05f58010 Mon Sep 17 00:00:00 2001 From: tv Date: Thu, 29 Oct 2026 05:32:49 +0100 Subject: libp2p: add ignore option --- src/config/mod.rs | 10 ++ src/main.rs | 5 + src/simtest/identity.rs | 1 + src/transport/libp2p.rs | 243 +++++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 248 insertions(+), 11 deletions(-) diff --git a/src/config/mod.rs b/src/config/mod.rs index cb4bf19..85d71d2 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -270,6 +270,13 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ None, "With the libp2p transport: the addresses to publish in the member list instead of those listened on, e.g. a public address forwarded to this node's listen port. Without, the listen addresses are published, loopback and link-local ones left out.", ), + option( + "libp2p-ignore", + Kind::List, + "RANGE,[RANGE...]", + None, + "With the libp2p transport: address ranges libp2p leaves out, e.g. 10.244.0.0/16 and 42::/16 (VPNs): addresses there are neither published (but announced ones) nor offered for hole punching nor dialled, and connections there are refused, whoever dials.", + ), option( "libp2p-relay", Kind::Flag, @@ -468,6 +475,9 @@ pub(crate) struct Settings { pub(crate) libp2p_upnp: bool, #[serde(default)] #[cfg_attr(not(feature = "libp2p"), allow(dead_code))] + pub(crate) libp2p_ignore: Vec, + #[serde(default)] + #[cfg_attr(not(feature = "libp2p"), allow(dead_code))] pub(crate) libp2p_public: bool, #[cfg_attr(not(feature = "libp2p"), allow(dead_code))] pub(crate) libp2p_key_file: Option, diff --git a/src/main.rs b/src/main.rs index 1803f15..1cfd173 100644 --- a/src/main.rs +++ b/src/main.rs @@ -392,6 +392,11 @@ fn libp2p_config( continuum: !relay_only, mdns: args.libp2p_mdns, upnp: args.libp2p_upnp, + ignore: args + .libp2p_ignore + .iter() + .map(|range| range.parse().context("libp2p-ignore")) + .collect::>>()?, public: args.libp2p_public, memory: false, }) diff --git a/src/simtest/identity.rs b/src/simtest/identity.rs index 13c596a..e0dd6f2 100644 --- a/src/simtest/identity.rs +++ b/src/simtest/identity.rs @@ -222,6 +222,7 @@ async fn nodes_over_libp2p() { continuum: true, mdns: false, upnp: false, + ignore: Vec::new(), public: false, memory: true, }) diff --git a/src/transport/libp2p.rs b/src/transport/libp2p.rs index 2808fae..5419f46 100644 --- a/src/transport/libp2p.rs +++ b/src/transport/libp2p.rs @@ -40,6 +40,9 @@ //! AutoNAT asks connected peers (nodes of the swarm) to dial it back. Once AutoNAT says //! it can be dialled, it lets the relays it picked go. //! +//! With `--libp2p-ignore`, address ranges are left out (VPNs): not published, not +//! offered for hole punching, not dialled, and connections there refused (`Ignoring`). +//! //! 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 //! it can be dialled from elsewhere, whatever the other side's NAT. @@ -179,6 +182,8 @@ pub(crate) struct Libp2pConfig { pub(crate) mdns: bool, /// Ask the router to forward our listen ports (UPnP), and publish where. pub(crate) upnp: bool, + /// Address ranges neither published nor dialled, nor connected over (`Ignoring`). + pub(crate) ignore: Vec, /// Use public infrastructure: the IPFS DHT, public relays. pub(crate) public: bool, /// libp2p's in-memory transport instead of TCP and QUIC: for tests. @@ -265,6 +270,8 @@ struct Book { mapped: Vec, /// `Libp2pConfig::announce`. announced: Vec, + /// `Libp2pConfig::ignore`: not published (but announced), not dialled. + ignored: Vec, /// `Libp2pConfig::relay`: this node relays, and says so. serving: bool, /// Relays named to pick (configured, listed), by peer: picked first. @@ -610,6 +617,7 @@ impl Book { } direct.retain(|address| !first.contains(address)); first.extend(direct); + first.retain(|address| !ignored(&self.ignored, address)); first } } @@ -822,6 +830,148 @@ fn relay_of(address: &Multiaddr) -> Option { /// The addresses of `known` this node can dial: not those through itself as the relay /// (a relay reaches the nodes it relays for only while they are connected to it). +/// 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)] +pub(crate) struct IpRange { + network: std::net::IpAddr, + prefix: u8, +} + +impl FromStr for IpRange { + type Err = anyhow::Error; + + fn from_str(text: &str) -> Result { + let (address, prefix) = match text.split_once('/') { + Some((address, prefix)) => (address, Some(prefix)), + None => (text, None), + }; + let network: std::net::IpAddr = address + .parse() + .with_context(|| format!("{text:?} is no address range (e.g. 10.244.0.0/16)"))?; + let bits = if network.is_ipv4() { 32 } else { 128 }; + let prefix = match prefix { + Some(prefix) => prefix + .parse::() + .ok() + .filter(|prefix| *prefix <= bits) + .with_context(|| format!("{text:?}: the prefix is 0 to {bits}"))?, + None => bits, + }; + Ok(Self { network, prefix }) + } +} + +impl IpRange { + fn contains(&self, ip: std::net::IpAddr) -> bool { + use std::net::IpAddr; + match (self.network, ip) { + (IpAddr::V4(network), IpAddr::V4(ip)) => { + let mask = u32::MAX + .checked_shl(32 - u32::from(self.prefix)) + .unwrap_or(0); + u32::from(network) & mask == u32::from(ip) & mask + } + (IpAddr::V6(network), IpAddr::V6(ip)) => { + let mask = u128::MAX + .checked_shl(128 - u32::from(self.prefix)) + .unwrap_or(0); + u128::from(network) & mask == u128::from(ip) & mask + } + _ => false, + } + } +} + +/// Whether `address` is in one of `ranges`: a direct address there (one through a relay +/// is judged by the connection to the relay). +fn ignored(ranges: &[IpRange], address: &Multiaddr) -> bool { + if ranges.is_empty() || is_relayed(address) { + return false; + } + let ip = match address.iter().next() { + Some(Protocol::Ip4(ip)) => std::net::IpAddr::V4(ip), + Some(Protocol::Ip6(ip)) => std::net::IpAddr::V6(ip), + _ => return false, + }; + ranges.iter().any(|range| range.contains(ip)) +} + +/// Keeps connections off the address ranges `libp2p-ignore` names, whoever dialled: +/// hole punching and identify bring addresses there too, and a peer may dial us there. +struct Ignoring { + ranges: Vec, +} + +#[derive(Debug, thiserror::Error)] +#[error("{0} is in a range left out (libp2p-ignore)")] +struct IgnoredAddress(Multiaddr); + +impl ::libp2p::swarm::NetworkBehaviour for Ignoring { + type ConnectionHandler = ::libp2p::swarm::dummy::ConnectionHandler; + type ToSwarm = std::convert::Infallible; + + fn handle_pending_inbound_connection( + &mut self, + _connection_id: ConnectionId, + _local_addr: &Multiaddr, + remote_addr: &Multiaddr, + ) -> Result<(), ::libp2p::swarm::ConnectionDenied> { + if ignored(&self.ranges, remote_addr) { + return Err(::libp2p::swarm::ConnectionDenied::new(IgnoredAddress( + remote_addr.clone(), + ))); + } + Ok(()) + } + + fn handle_established_inbound_connection( + &mut self, + _connection_id: ConnectionId, + _peer: PeerId, + _local_addr: &Multiaddr, + _remote_addr: &Multiaddr, + ) -> Result<::libp2p::swarm::THandler, ::libp2p::swarm::ConnectionDenied> { + Ok(::libp2p::swarm::dummy::ConnectionHandler) + } + + fn handle_established_outbound_connection( + &mut self, + _connection_id: ConnectionId, + _peer: PeerId, + addr: &Multiaddr, + _role_override: ::libp2p::core::Endpoint, + _port_use: ::libp2p::core::transport::PortUse, + ) -> Result<::libp2p::swarm::THandler, ::libp2p::swarm::ConnectionDenied> { + if ignored(&self.ranges, addr) { + return Err(::libp2p::swarm::ConnectionDenied::new(IgnoredAddress( + addr.clone(), + ))); + } + Ok(::libp2p::swarm::dummy::ConnectionHandler) + } + + fn on_swarm_event(&mut self, _event: ::libp2p::swarm::FromSwarm) {} + + fn on_connection_handler_event( + &mut self, + _peer_id: PeerId, + _connection_id: ConnectionId, + event: ::libp2p::swarm::THandlerOutEvent, + ) { + match event {} + } + + fn poll( + &mut self, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll< + ::libp2p::swarm::ToSwarm>, + > { + std::task::Poll::Pending + } +} + fn dialable(known: &[Multiaddr], local: PeerId) -> Vec { known .iter() @@ -858,9 +1008,9 @@ impl Libp2pTransport { let peer_id = config.key.public().to_peer_id(); let features = Features::of(&config); let mut swarm = if config.memory { - memory_swarm(config.key, features)? + memory_swarm(config.key, features, config.ignore.clone())? } else { - network_swarm(config.key, features)? + network_swarm(config.key, features, config.ignore.clone())? }; for address in config.listen { swarm @@ -884,6 +1034,7 @@ impl Libp2pTransport { let (commands, receiver) = mpsc::unbounded_channel(); let mut book = Book { announced: config.announce, + ignored: config.ignore.clone(), serving: config.relay, ..Book::default() }; @@ -922,11 +1073,12 @@ impl Libp2pTransport { async fn connect(&self, peer: PeerId) -> Result<()> { let addresses = { let book = lock(&self.book); - let known = book + let mut known = book .addresses .get(&peer) .map(|known| dialable(known, self.peer_id)) .unwrap_or_default(); + known.retain(|address| !ignored(&book.ignored, address)); if known.is_empty() { book.through_relays(peer, self.peer_id) } else { @@ -955,6 +1107,8 @@ const IDENTIFY_VERSION: &str = "/continuum/1"; #[derive(::libp2p::swarm::NetworkBehaviour)] struct Behaviour { + /// First: connections it denies need nothing from the others. + ignoring: Ignoring, stream: libp2p_stream::Behaviour, identify: ::libp2p::identify::Behaviour, relay_client: ::libp2p::relay::client::Behaviour, @@ -986,6 +1140,8 @@ struct HolePunching { inner: ::libp2p::dcutr::Behaviour, /// Where we listen (`offered`). listening: Vec, + /// `Libp2pConfig::ignore`: not offered. + ignored: Vec, } /// Whether DCUtR offers our listen address `address` too: neither loopback nor @@ -995,10 +1151,11 @@ fn offered(address: &Multiaddr) -> bool { } impl HolePunching { - fn new(local: PeerId) -> Self { + fn new(local: PeerId, ignored: Vec) -> Self { Self { inner: ::libp2p::dcutr::Behaviour::new(local), listening: Vec::new(), + ignored, } } @@ -1086,7 +1243,9 @@ impl ::libp2p::swarm::NetworkBehaviour for HolePunching { use ::libp2p::swarm::FromSwarm; self.inner.on_swarm_event(event); match event { - FromSwarm::NewListenAddr(new) if offered(new.addr) => { + FromSwarm::NewListenAddr(new) + if offered(new.addr) && !ignored(&self.ignored, new.addr) => + { if !self.listening.contains(new.addr) { debug!(address = %new.addr, "Offering where we listen for hole punching"); self.listening.push(new.addr.clone()); @@ -1158,6 +1317,7 @@ impl Behaviour { key: &Keypair, relay_client: ::libp2p::relay::client::Behaviour, features: Features, + ignored: Vec, ) -> std::io::Result { let Features { relay, @@ -1203,11 +1363,14 @@ impl Behaviour { false => None, }; Ok(Self { + ignoring: Ignoring { + ranges: ignored.clone(), + }, stream: libp2p_stream::Behaviour::new(), identify: ::libp2p::identify::Behaviour::new(identify), relay_client, relay: relay.into(), - dcutr: HolePunching::new(peer_id), + dcutr: HolePunching::new(peer_id, ignored), ping: ::libp2p::ping::Behaviour::new(::libp2p::ping::Config::new()), autonat: ::libp2p::autonat::Behaviour::new( peer_id, @@ -1224,7 +1387,11 @@ impl Behaviour { } } -fn network_swarm(key: Keypair, features: Features) -> Result> { +fn network_swarm( + key: Keypair, + features: Features, + ignored: Vec, +) -> Result> { Ok(::libp2p::SwarmBuilder::with_existing_identity(key) .with_tokio() .with_tcp( @@ -1239,7 +1406,7 @@ fn network_swarm(key: Keypair, features: Features) -> Result> { ::libp2p::yamux::Config::default, )? .with_behaviour(|key, relay_client| { - Behaviour::new(key, relay_client, features) + Behaviour::new(key, relay_client, features, ignored.clone()) .map_err(|error| Box::new(error) as Box) })? .with_swarm_config(|config| swarm_config(config, features)) @@ -1263,7 +1430,11 @@ fn swarm_config(config: ::libp2p::swarm::Config, features: Features) -> ::libp2p } } -fn memory_swarm(key: Keypair, features: Features) -> Result> { +fn memory_swarm( + key: Keypair, + features: Features, + ignored: Vec, +) -> Result> { use ::libp2p::Transport as _; use ::libp2p::core::transport::{MemoryTransport, OrTransport}; use ::libp2p::core::upgrade::Version; @@ -1279,7 +1450,7 @@ fn memory_swarm(key: Keypair, features: Features) -> Result> { .boxed(); Ok(Swarm::new( transport, - Behaviour::new(&key, relay_client, features)?, + Behaviour::new(&key, relay_client, features, ignored)?, peer_id, swarm_config(::libp2p::swarm::Config::with_tokio_executor(), features), )) @@ -1414,7 +1585,7 @@ fn upgrade(swarm: &mut Swarm, book: &Mutex, peer: PeerId, local .map(|known| dialable(known, local)) .unwrap_or_default() .into_iter() - .filter(|address| !is_relayed(address)) + .filter(|address| !is_relayed(address) && !ignored(&book.ignored, address)) .collect(); if direct.is_empty() { return; @@ -2303,6 +2474,7 @@ mod tests { continuum: true, mdns: false, upnp: false, + ignore: Vec::new(), public: false, memory: true, }) @@ -2450,6 +2622,7 @@ mod tests { continuum: true, mdns: false, upnp: false, + ignore: Vec::new(), public: false, memory: true, }) @@ -2460,6 +2633,53 @@ mod tests { ); } + #[test] + fn address_ranges() { + let range = |text: &str| text.parse::().unwrap(); + let address = |text: &str| text.parse::().unwrap(); + let ranges = [range("10.244.0.0/16"), range("42::/16"), range("192.0.2.7")]; + for left_out in [ + "/ip4/10.244.3.2/udp/9661/quic-v1", + "/ip6/42:1:a46e:be4a:cb58:e460:bf0:142c/tcp/9661", + "/ip4/192.0.2.7/tcp/1", + ] { + assert!(ignored(&ranges, &address(left_out)), "{left_out}"); + } + for kept in [ + "/ip4/10.23.1.144/udp/9661/quic-v1", + "/ip4/10.245.0.1/tcp/1", + "/ip6/2a0a:4cc0:c1:5eb0::1/udp/9661/quic-v1", + "/ip4/192.0.2.8/tcp/1", + "/dns4/example.org/tcp/1", + ] { + assert!(!ignored(&ranges, &address(kept)), "{kept}"); + } + // Through a relay: judged by the connection to the relay, not here. + let relayed = format!("/ip4/10.244.0.1/tcp/1/p2p/{}/p2p-circuit", peer()); + assert!(!ignored(&ranges, &address(&relayed))); + assert!(range("0.0.0.0/0").contains("203.0.113.1".parse().unwrap())); + assert!(!range("0.0.0.0/0").contains("::1".parse().unwrap())); + for wrong in ["10.244.0.0/33", "42::/129", "nowhere", "10.0.0.0/x"] { + assert!(wrong.parse::().is_err(), "{wrong}"); + } + } + + /// Ranges left out aren't published; announced addresses are, whatever they are. + #[test] + fn ignored_addresses_are_not_published() { + let parse = |text: &str| text.parse::().unwrap(); + let lan = parse("/ip4/10.23.1.5/udp/9663/quic-v1"); + let vpn = parse("/ip4/10.244.3.2/udp/9663/quic-v1"); + let mut book = Book { + listening: vec![lan.clone(), vpn.clone()], + ignored: vec!["10.244.0.0/16".parse().unwrap()], + ..Book::default() + }; + assert_eq!(book.direct(), [lan]); + book.announced = vec![vpn.clone()]; + assert_eq!(book.direct(), [vpn]); + } + /// A port forwarded by hand: once AutoNAT found the node dialable at the router's /// address, that is published with the listen addresses; announced ones still win. #[test] @@ -2607,6 +2827,7 @@ mod tests { continuum: false, mdns: false, upnp: false, + ignore: Vec::new(), public: false, memory: true, }) -- cgit v1.3.1