iuna

iuna - experimental devnet protocol
git clone https://iuna.jhx.app/git/iuna.git
Log | Files | Refs | README | LICENSE

commit b36e3d432a418c6d57b6f458355eb1d6e20b53b1
parent 3304118d4b193f37d4a05fe0b73c889927627f75
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Wed, 29 Jul 2026 21:24:04 +0200

Limit inbound P2P connections

Diffstat:
Msrc/adapters/http.rs | 1+
Msrc/adapters/p2p.rs | 254++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 253 insertions(+), 2 deletions(-)

diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -2611,6 +2611,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <h2>Metrics</h2> <div class="grid"> <div class="metric"><div class="label">Inbound Sessions</div><div class="value" x-text="p2pMetrics.inbound_sessions_started ?? 0"></div></div> + <div class="metric"><div class="label">Inbound Rejects</div><div class="value" x-text="p2pMetrics.inbound_sessions_rejected ?? 0"></div></div> <div class="metric"><div class="label">Outbound Attempts</div><div class="value" x-text="p2pMetrics.outbound_connect_attempts ?? 0"></div></div> <div class="metric"><div class="label">Connect Failures</div><div class="value" x-text="p2pMetrics.outbound_connect_failures ?? 0"></div></div> <div class="metric"><div class="label">Session Failures</div><div class="value" x-text="p2pMetrics.session_failures ?? 0"></div></div> diff --git a/src/adapters/p2p.rs b/src/adapters/p2p.rs @@ -1,5 +1,5 @@ use std::{ - collections::{BTreeMap, BTreeSet}, + collections::{BTreeMap, BTreeSet, VecDeque}, io::ErrorKind, net::{IpAddr, SocketAddr}, sync::{ @@ -36,6 +36,10 @@ const MAX_INVENTORY_ITEMS: usize = 512; const MAX_PEER_LIST: usize = 128; const MAX_SNAPSHOT_BLOCKS: usize = 10_000; const MAX_GOSSIP_LINE_BYTES: usize = 8 * 1024 * 1024; +const MAX_INBOUND_SESSIONS: usize = 64; +const MAX_INBOUND_SESSIONS_PER_IP: usize = 8; +const MAX_INBOUND_ACCEPTS_PER_IP_PER_WINDOW: usize = 24; +const INBOUND_ACCEPT_RATE_WINDOW_MS: u64 = 10_000; const PEER_QUEUE_SIZE: usize = 256; const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5); @@ -138,6 +142,7 @@ struct GossipNetworkInner { node_id: String, sessions: Mutex<BTreeMap<String, mpsc::Sender<OutboundBatch>>>, tx_delivery: Mutex<BTreeMap<String, PeerTransactionDelivery>>, + inbound_limiter: Arc<StdMutex<InboundConnectionLimiter>>, metrics: P2pMetricsCounters, } @@ -149,8 +154,104 @@ struct PeerTransactionDelivery { } #[derive(Default)] +struct InboundConnectionLimiter { + active: usize, + peers: BTreeMap<IpAddr, InboundPeerLimit>, +} + +#[derive(Default)] +struct InboundPeerLimit { + active: usize, + accepted_at_ms: VecDeque<u64>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum InboundSessionRejection { + GlobalActive, + PeerActive, + PeerRate, +} + +impl InboundSessionRejection { + fn label(self) -> &'static str { + match self { + Self::GlobalActive => "global active inbound session limit", + Self::PeerActive => "per-IP active inbound session limit", + Self::PeerRate => "per-IP inbound accept rate limit", + } + } +} + +struct InboundSessionPermit { + limiter: Arc<StdMutex<InboundConnectionLimiter>>, + ip: IpAddr, +} + +impl Drop for InboundSessionPermit { + fn drop(&mut self) { + if let Ok(mut limiter) = self.limiter.lock() { + limiter.release(self.ip); + } + } +} + +impl InboundConnectionLimiter { + fn try_acquire( + &mut self, + ip: IpAddr, + now_ms: u64, + ) -> std::result::Result<(), InboundSessionRejection> { + self.prune_stale_accepts(now_ms); + if self.active >= MAX_INBOUND_SESSIONS { + return Err(InboundSessionRejection::GlobalActive); + } + + let peer = self.peers.entry(ip).or_default(); + prune_peer_accepts(peer, now_ms); + if peer.active >= MAX_INBOUND_SESSIONS_PER_IP { + return Err(InboundSessionRejection::PeerActive); + } + if peer.accepted_at_ms.len() >= MAX_INBOUND_ACCEPTS_PER_IP_PER_WINDOW { + return Err(InboundSessionRejection::PeerRate); + } + + peer.active += 1; + peer.accepted_at_ms.push_back(now_ms); + self.active += 1; + Ok(()) + } + + fn release(&mut self, ip: IpAddr) { + if self.active > 0 { + self.active -= 1; + } + if let Some(peer) = self.peers.get_mut(&ip) { + if peer.active > 0 { + peer.active -= 1; + } + } + } + + fn prune_stale_accepts(&mut self, now_ms: u64) { + self.peers.retain(|_, peer| { + prune_peer_accepts(peer, now_ms); + peer.active > 0 || !peer.accepted_at_ms.is_empty() + }); + } +} + +fn prune_peer_accepts(peer: &mut InboundPeerLimit, now_ms: u64) { + while peer.accepted_at_ms.front().is_some_and(|accepted_ms| { + now_ms.saturating_sub(*accepted_ms) >= INBOUND_ACCEPT_RATE_WINDOW_MS + }) { + peer.accepted_at_ms.pop_front(); + } +} + +#[derive(Default)] struct P2pMetricsCounters { inbound_sessions_started: AtomicU64, + inbound_sessions_rejected: AtomicU64, outbound_connect_attempts: AtomicU64, outbound_connect_successes: AtomicU64, outbound_connect_failures: AtomicU64, @@ -191,6 +292,7 @@ struct P2pMetricsCounters { #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)] pub struct P2pMetrics { pub inbound_sessions_started: u64, + pub inbound_sessions_rejected: u64, pub outbound_connect_attempts: u64, pub outbound_connect_successes: u64, pub outbound_connect_failures: u64, @@ -247,6 +349,7 @@ impl P2pMetricsCounters { fn snapshot(&self) -> P2pMetrics { P2pMetrics { inbound_sessions_started: self.inbound_sessions_started.load(Ordering::Relaxed), + inbound_sessions_rejected: self.inbound_sessions_rejected.load(Ordering::Relaxed), outbound_connect_attempts: self.outbound_connect_attempts.load(Ordering::Relaxed), outbound_connect_successes: self.outbound_connect_successes.load(Ordering::Relaxed), outbound_connect_failures: self.outbound_connect_failures.load(Ordering::Relaxed), @@ -326,6 +429,7 @@ impl GossipNetwork { node_id: new_node_id(), sessions: Mutex::new(BTreeMap::new()), tx_delivery: Mutex::new(BTreeMap::new()), + inbound_limiter: Arc::new(StdMutex::new(InboundConnectionLimiter::default())), metrics: P2pMetricsCounters::default(), }), } @@ -343,6 +447,7 @@ impl GossipNetwork { node_id: new_node_id(), sessions: Mutex::new(BTreeMap::new()), tx_delivery: Mutex::new(BTreeMap::new()), + inbound_limiter: Arc::new(StdMutex::new(InboundConnectionLimiter::default())), metrics: P2pMetricsCounters::default(), }), }; @@ -369,6 +474,21 @@ impl GossipNetwork { metrics } + fn try_acquire_inbound_session( + &self, + ip: IpAddr, + ) -> std::result::Result<InboundSessionPermit, InboundSessionRejection> { + self.inner + .inbound_limiter + .lock() + .expect("inbound limiter mutex poisoned") + .try_acquire(ip, now_ms())?; + Ok(InboundSessionPermit { + limiter: Arc::clone(&self.inner.inbound_limiter), + ip, + }) + } + pub async fn broadcast(&self, envelopes: Vec<GossipEnvelope>) -> Result<()> { let envelopes = self.prepare_gossip(envelopes).await; if envelopes.is_empty() { @@ -621,8 +741,27 @@ async fn accept_loop(network: GossipNetwork, listener: TcpListener) { match listener.accept().await { Ok((stream, remote_addr)) => { let network = network.clone(); + let permit = match network.try_acquire_inbound_session(remote_addr.ip()) { + Ok(permit) => permit, + Err(rejection) => { + P2pMetricsCounters::inc(&network.inner.metrics.inbound_sessions_rejected); + P2pMetricsCounters::set_last( + &network.inner.metrics.last_session_failure, + format!("{remote_addr}: {}", rejection.label()), + ); + if debug_logging_enabled() { + eprintln!( + "p2p inbound connection from {remote_addr} rejected: {}", + rejection.label() + ); + } + drop(stream); + continue; + } + }; P2pMetricsCounters::inc(&network.inner.metrics.inbound_sessions_started); tokio::spawn(async move { + let _permit = permit; let result = session_loop( network.clone(), stream, @@ -2294,7 +2433,11 @@ fn is_possible_fork_error(error: &anyhow::Error) -> bool { #[cfg(test)] mod tests { - use std::{collections::BTreeMap, net::SocketAddr, sync::Arc}; + use std::{ + collections::BTreeMap, + net::SocketAddr, + sync::{Arc, Mutex as StdMutex}, + }; use crate::{ app::{ @@ -2306,6 +2449,8 @@ mod tests { use tokio::io::AsyncWriteExt; use super::{ + INBOUND_ACCEPT_RATE_WINDOW_MS, InboundConnectionLimiter, InboundSessionRejection, + MAX_INBOUND_ACCEPTS_PER_IP_PER_WINDOW, MAX_INBOUND_SESSIONS, MAX_INBOUND_SESSIONS_PER_IP, MAX_INVENTORY_ITEMS, MAX_OBJECT_REQUESTS, next_reconnect_delay, parse_envelope, reachable_advertised_addr, validate_envelope_limits, }; @@ -2559,6 +2704,57 @@ mod tests { } #[test] + fn inbound_limiter_enforces_per_ip_active_limit() { + let ip = "203.0.113.10".parse().unwrap(); + let mut limiter = InboundConnectionLimiter::default(); + for _ in 0..MAX_INBOUND_SESSIONS_PER_IP { + limiter.try_acquire(ip, 1_000).unwrap(); + } + + assert_eq!( + limiter.try_acquire(ip, 1_000).unwrap_err(), + InboundSessionRejection::PeerActive + ); + + limiter.release(ip); + limiter.try_acquire(ip, 1_000).unwrap(); + } + + #[test] + fn inbound_limiter_enforces_global_active_limit() { + let mut limiter = InboundConnectionLimiter::default(); + for index in 0..MAX_INBOUND_SESSIONS { + let ip = format!("198.51.100.{index}").parse().unwrap(); + limiter.try_acquire(ip, 1_000).unwrap(); + } + + assert_eq!( + limiter + .try_acquire("203.0.113.200".parse().unwrap(), 1_000) + .unwrap_err(), + InboundSessionRejection::GlobalActive + ); + } + + #[test] + fn inbound_limiter_enforces_per_ip_accept_rate() { + let ip = "203.0.113.20".parse().unwrap(); + let mut limiter = InboundConnectionLimiter::default(); + for _ in 0..MAX_INBOUND_ACCEPTS_PER_IP_PER_WINDOW { + limiter.try_acquire(ip, 1_000).unwrap(); + limiter.release(ip); + } + + assert_eq!( + limiter.try_acquire(ip, 1_000).unwrap_err(), + InboundSessionRejection::PeerRate + ); + limiter + .try_acquire(ip, 1_000 + INBOUND_ACCEPT_RATE_WINDOW_MS) + .unwrap(); + } + + #[test] fn reconnect_backoff_is_capped() { assert_eq!( next_reconnect_delay(super::INITIAL_RECONNECT_DELAY), @@ -2615,6 +2811,9 @@ mod tests { node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), + inbound_limiter: Arc::new( + StdMutex::new(super::InboundConnectionLimiter::default()), + ), metrics: super::P2pMetricsCounters::default(), }), }; @@ -2674,6 +2873,9 @@ mod tests { node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), + inbound_limiter: Arc::new( + StdMutex::new(super::InboundConnectionLimiter::default()), + ), metrics: super::P2pMetricsCounters::default(), }), }; @@ -2717,6 +2919,9 @@ mod tests { node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), + inbound_limiter: Arc::new( + StdMutex::new(super::InboundConnectionLimiter::default()), + ), metrics: super::P2pMetricsCounters::default(), }), }; @@ -2928,6 +3133,9 @@ mod tests { node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), + inbound_limiter: Arc::new( + StdMutex::new(super::InboundConnectionLimiter::default()), + ), metrics: super::P2pMetricsCounters::default(), }), }; @@ -3031,6 +3239,9 @@ mod tests { node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), + inbound_limiter: Arc::new( + StdMutex::new(super::InboundConnectionLimiter::default()), + ), metrics: super::P2pMetricsCounters::default(), }), }; @@ -3091,6 +3302,9 @@ mod tests { node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), + inbound_limiter: Arc::new( + StdMutex::new(super::InboundConnectionLimiter::default()), + ), metrics: super::P2pMetricsCounters::default(), }), }; @@ -3171,6 +3385,9 @@ mod tests { node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), + inbound_limiter: Arc::new( + StdMutex::new(super::InboundConnectionLimiter::default()), + ), metrics: super::P2pMetricsCounters::default(), }), }; @@ -3218,6 +3435,9 @@ mod tests { node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), + inbound_limiter: Arc::new( + StdMutex::new(super::InboundConnectionLimiter::default()), + ), metrics: super::P2pMetricsCounters::default(), }), }; @@ -3261,6 +3481,9 @@ mod tests { node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), + inbound_limiter: Arc::new( + StdMutex::new(super::InboundConnectionLimiter::default()), + ), metrics: super::P2pMetricsCounters::default(), }), }; @@ -3330,6 +3553,9 @@ mod tests { node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), + inbound_limiter: Arc::new( + StdMutex::new(super::InboundConnectionLimiter::default()), + ), metrics: super::P2pMetricsCounters::default(), }), }; @@ -3357,6 +3583,9 @@ mod tests { node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), + inbound_limiter: Arc::new( + StdMutex::new(super::InboundConnectionLimiter::default()), + ), metrics: super::P2pMetricsCounters::default(), }), }; @@ -3388,6 +3617,9 @@ mod tests { node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), + inbound_limiter: Arc::new( + StdMutex::new(super::InboundConnectionLimiter::default()), + ), metrics: super::P2pMetricsCounters::default(), }), }; @@ -3422,6 +3654,9 @@ mod tests { node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), + inbound_limiter: Arc::new( + StdMutex::new(super::InboundConnectionLimiter::default()), + ), metrics: super::P2pMetricsCounters::default(), }), }; @@ -3457,6 +3692,9 @@ mod tests { node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), + inbound_limiter: Arc::new( + StdMutex::new(super::InboundConnectionLimiter::default()), + ), metrics: super::P2pMetricsCounters::default(), }), }; @@ -3482,6 +3720,9 @@ mod tests { node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), + inbound_limiter: Arc::new( + StdMutex::new(super::InboundConnectionLimiter::default()), + ), metrics: super::P2pMetricsCounters::default(), }), }; @@ -3514,6 +3755,9 @@ mod tests { node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), + inbound_limiter: Arc::new( + StdMutex::new(super::InboundConnectionLimiter::default()), + ), metrics: super::P2pMetricsCounters::default(), }), }; @@ -3567,6 +3811,9 @@ mod tests { node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), + inbound_limiter: Arc::new( + StdMutex::new(super::InboundConnectionLimiter::default()), + ), metrics: super::P2pMetricsCounters::default(), }), }; @@ -3619,6 +3866,9 @@ mod tests { node_id: super::new_node_id(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), + inbound_limiter: Arc::new( + StdMutex::new(super::InboundConnectionLimiter::default()), + ), metrics: super::P2pMetricsCounters::default(), }), };