From 308b4a82a774b832bca414d8a0e295df26a94e65 Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Wed, 29 Apr 2026 12:37:20 +0200 Subject: [PATCH 1/2] feat!: remove BootstrapManager and bootstrap_cache wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persistent bootstrap cache was poisoning routing-table population across networks (mainnet, testnets) because saorsa-transport's on-disk cache had no per-network namespacing. The whole BootstrapManager / BootstrapConfig / BootstrapStats wrapper that delegated to that cache is removed; the close-group cache (separate, intentionally persistent, namespaced by NodeConfig::close_group_cache_dir) stays. NodeConfig loses its bootstrap_cache_config field; Network drops the bootstrap_manager handle, the get_bootstrap_cache_stats / cached_peer_count public methods, the add_discovered_peer and update_peer_metrics bookkeeping, and the Priority-2 "Added N cached bootstrap peers (supplementing CLI peers)" branch of connect_bootstrap_peers. Bootstrap is now exclusively close-group cache (Priority 0) plus configured CLI peers (Priority 1). JoinRateLimiter and BootstrapIpLimiter were also orphaned — their only consumer was BootstrapManager — so they go too. The generic rate_limit::Engine, IPDiversityConfig, IP_EXACT_LIMIT, canonicalize_ip, and ip_subnet_limit (still used by DhtCoreEngine for routing-table diversity) stay. BREAKING CHANGE: BootstrapManager, BootstrapConfig, BootstrapStats, JoinRateLimiter, JoinRateLimiterConfig, BootstrapIpLimiter, NodeConfig::bootstrap_cache_config, P2PNode::get_bootstrap_cache_stats, P2PNode::cached_peer_count, P2PNode::add_discovered_peer, and P2PNode::update_peer_metrics are removed. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/bootstrap/manager.rs | 581 --------------------------------------- src/bootstrap/mod.rs | 33 +-- src/lib.rs | 3 +- src/network.rs | 201 +------------- src/rate_limit.rs | 317 --------------------- src/security.rs | 449 ++---------------------------- 6 files changed, 26 insertions(+), 1558 deletions(-) delete mode 100644 src/bootstrap/manager.rs diff --git a/src/bootstrap/manager.rs b/src/bootstrap/manager.rs deleted file mode 100644 index c4ca0988..00000000 --- a/src/bootstrap/manager.rs +++ /dev/null @@ -1,581 +0,0 @@ -// Copyright 2024 Saorsa Labs Limited -// -// This software is dual-licensed under: -// - GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later) -// - Commercial License -// -// For AGPL-3.0 license, see LICENSE-AGPL-3.0 -// For commercial licensing, contact: david@saorsalabs.com -// -// Unless required by applicable law or agreed to in writing, software -// distributed under these licenses is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - -//! Simplified Bootstrap Manager -//! -//! Thin wrapper around saorsa-transport's BootstrapCache that adds: -//! - IP diversity enforcement (Sybil protection) -//! - Rate limiting (temporal Sybil protection) -//! - Four-word address encoding -//! -//! All core caching functionality is delegated to saorsa-transport. - -use crate::error::BootstrapError; -use crate::network::DHTConfig; -use crate::rate_limit::{JoinRateLimiter, JoinRateLimiterConfig}; -use crate::security::{BootstrapIpLimiter, IPDiversityConfig}; -use crate::{P2PError, Result}; -use parking_lot::Mutex; -use saorsa_transport::bootstrap_cache::{ - BootstrapCache as AntBootstrapCache, BootstrapCacheConfig, CachedPeer, PeerCapabilities, -}; -use std::net::SocketAddr; -use std::path::PathBuf; -use std::sync::Arc; -use tokio::task::JoinHandle; -use tracing::{info, warn}; - -/// Configuration for the bootstrap manager -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct BootstrapConfig { - /// Directory for cache files - pub cache_dir: PathBuf, - /// Maximum number of peers to cache - pub max_peers: usize, - /// Epsilon for exploration rate (0.0-1.0) - pub epsilon: f64, - /// Rate limiting configuration - pub rate_limit: JoinRateLimiterConfig, - /// IP diversity configuration - pub diversity: IPDiversityConfig, -} - -impl Default for BootstrapConfig { - fn default() -> Self { - Self { - cache_dir: default_cache_dir(), - max_peers: 20_000, - epsilon: 0.1, - rate_limit: JoinRateLimiterConfig::default(), - diversity: IPDiversityConfig::default(), - } - } -} - -/// Simplified bootstrap manager wrapping saorsa-transport's cache -/// -/// Provides Sybil protection via rate limiting and IP diversity enforcement -/// while delegating core caching to saorsa-transport's proven implementation. -pub struct BootstrapManager { - cache: Arc, - rate_limiter: JoinRateLimiter, - ip_limiter: Mutex, - diversity_config: IPDiversityConfig, - maintenance_handle: Option>, -} - -impl BootstrapManager { - async fn with_config_loopback_and_k( - config: BootstrapConfig, - allow_loopback: bool, - k_value: usize, - ) -> Result { - let ant_config = BootstrapCacheConfig::builder() - .cache_dir(&config.cache_dir) - .max_peers(config.max_peers) - .epsilon(config.epsilon) - .build(); - - let cache = AntBootstrapCache::open(ant_config).await.map_err(|e| { - P2PError::Bootstrap(BootstrapError::CacheError( - format!("Failed to open bootstrap cache: {e}").into(), - )) - })?; - - Ok(Self { - cache: Arc::new(cache), - rate_limiter: JoinRateLimiter::new(config.rate_limit), - ip_limiter: Mutex::new(BootstrapIpLimiter::with_loopback_and_k( - config.diversity.clone(), - allow_loopback, - k_value, - )), - diversity_config: config.diversity, - maintenance_handle: None, - }) - } - - /// Create a new bootstrap manager with default configuration - pub async fn new() -> Result { - Self::with_config(BootstrapConfig::default()).await - } - - /// Create a new bootstrap manager with custom configuration - pub async fn with_config(config: BootstrapConfig) -> Result { - Self::with_config_loopback_and_k(config, false, DHTConfig::DEFAULT_K_VALUE).await - } - - /// Create a new bootstrap manager from a `BootstrapConfig` and a `NodeConfig`. - /// - /// Derives the loopback policy from `node_config.allow_loopback` and merges - /// the node-level `diversity_config` (if set) so the transport and bootstrap - /// layers stay consistent. Passes `k_value` through so bootstrap subnet - /// limits match the routing table. - pub async fn with_node_config( - mut config: BootstrapConfig, - node_config: &crate::network::NodeConfig, - ) -> Result { - if let Some(ref diversity) = node_config.diversity_config { - config.diversity = diversity.clone(); - } - Self::with_config_loopback_and_k( - config, - node_config.allow_loopback, - node_config.dht_config.k_value, - ) - .await - } - - /// Start background maintenance tasks (delegated to saorsa-transport) - pub fn start_maintenance(&mut self) -> Result<()> { - if self.maintenance_handle.is_some() { - return Ok(()); // Already started - } - - let handle = self.cache.clone().start_maintenance(); - self.maintenance_handle = Some(handle); - info!("Started bootstrap cache maintenance tasks"); - Ok(()) - } - - /// Add a peer to the cache with Sybil protection - /// - /// Enforces: - /// 1. Rate limiting (per-subnet temporal limits) - /// 2. IP diversity (geographic/ASN limits) - pub async fn add_peer(&self, addr: &SocketAddr, addresses: Vec) -> Result<()> { - if addresses.is_empty() { - return Err(P2PError::Bootstrap(BootstrapError::InvalidData( - "No addresses provided".to_string().into(), - ))); - } - - let ip = addr.ip(); - - // Rate limiting check - self.rate_limiter.check_join_allowed(&ip).map_err(|e| { - warn!("Rate limit exceeded for {}: {}", ip, e); - P2PError::Bootstrap(BootstrapError::RateLimited(e.to_string().into())) - })?; - - // IP diversity check (scoped to avoid holding lock across await) - { - let mut diversity = self.ip_limiter.lock(); - if !diversity.can_accept(ip) { - warn!("IP diversity limit exceeded for {}", ip); - return Err(P2PError::Bootstrap(BootstrapError::RateLimited( - "IP diversity limits exceeded".to_string().into(), - ))); - } - - // Track in diversity enforcer - if let Err(e) = diversity.track(ip) { - warn!("Failed to track IP diversity for {}: {}", ip, e); - } - } // Lock released here before await - - // Add to cache keyed by primary address - self.cache.add_seed(*addr, addresses).await; - - Ok(()) - } - - /// Add a trusted peer bypassing Sybil protection - /// - /// Use only for well-known bootstrap nodes or admin-approved peers. - pub async fn add_peer_trusted(&self, addr: &SocketAddr, addresses: Vec) { - self.cache.add_seed(*addr, addresses).await; - } - - /// Record a successful connection - pub async fn record_success(&self, addr: &SocketAddr, rtt_ms: u32) { - self.cache.record_success(addr, rtt_ms).await; - } - - /// Record a failed connection - pub async fn record_failure(&self, addr: &SocketAddr) { - self.cache.record_failure(addr).await; - } - - /// Select peers for bootstrap using epsilon-greedy strategy - pub async fn select_peers(&self, count: usize) -> Vec { - self.cache.select_peers(count).await - } - - /// Select peers that support relay functionality - pub async fn select_relay_peers(&self, count: usize) -> Vec { - self.cache.select_relay_peers(count).await - } - - /// Select peers that support NAT coordination - pub async fn select_coordinators(&self, count: usize) -> Vec { - self.cache.select_coordinators(count).await - } - - /// Get cache statistics - pub async fn stats(&self) -> BootstrapStats { - let ant_stats = self.cache.stats().await; - BootstrapStats { - total_peers: ant_stats.total_peers, - relay_peers: ant_stats.relay_peers, - coordinator_peers: ant_stats.coordinator_peers, - average_quality: ant_stats.average_quality, - untested_peers: ant_stats.untested_peers, - } - } - - /// Get the number of cached peers - pub async fn peer_count(&self) -> usize { - self.cache.peer_count().await - } - - /// Save cache to disk - pub async fn save(&self) -> Result<()> { - self.cache.save().await.map_err(|e| { - P2PError::Bootstrap(BootstrapError::CacheError( - format!("Failed to save cache: {e}").into(), - )) - }) - } - - /// Update peer capabilities - pub async fn update_capabilities(&self, addr: &SocketAddr, capabilities: PeerCapabilities) { - self.cache.update_capabilities(addr, capabilities).await; - } - - /// Check if a peer exists in the cache - pub async fn contains(&self, addr: &SocketAddr) -> bool { - self.cache.contains(addr).await - } - - /// Get a specific peer from the cache - pub async fn get_peer(&self, addr: &SocketAddr) -> Option { - self.cache.get(addr).await - } - - /// Get the diversity config - pub fn diversity_config(&self) -> &IPDiversityConfig { - &self.diversity_config - } -} - -/// Bootstrap cache statistics -#[derive(Debug, Clone, Default)] -pub struct BootstrapStats { - /// Total number of cached peers - pub total_peers: usize, - /// Peers that support relay - pub relay_peers: usize, - /// Peers that support NAT coordination - pub coordinator_peers: usize, - /// Average quality score across all peers - pub average_quality: f64, - /// Number of untested peers - pub untested_peers: usize, -} - -/// Get the default cache directory -fn default_cache_dir() -> PathBuf { - if let Some(cache_dir) = dirs::cache_dir() { - cache_dir.join("saorsa").join("bootstrap") - } else if let Some(home) = dirs::home_dir() { - home.join(".cache").join("saorsa").join("bootstrap") - } else { - PathBuf::from(".saorsa-bootstrap-cache") - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - /// Helper to create a test configuration - fn test_config(temp_dir: &TempDir) -> BootstrapConfig { - BootstrapConfig { - cache_dir: temp_dir.path().to_path_buf(), - max_peers: 100, - epsilon: 0.0, // Pure exploitation for predictable tests - rate_limit: JoinRateLimiterConfig::default(), - diversity: IPDiversityConfig::default(), - } - } - - #[tokio::test] - async fn test_manager_creation() { - let temp_dir = TempDir::new().unwrap(); - let config = test_config(&temp_dir); - - let manager = BootstrapManager::with_config(config).await; - assert!(manager.is_ok()); - - let manager = manager.unwrap(); - assert_eq!(manager.peer_count().await, 0); - } - - #[tokio::test] - async fn test_add_and_get_peer() { - let temp_dir = TempDir::new().unwrap(); - let config = test_config(&temp_dir); - let manager = BootstrapManager::with_config(config).await.unwrap(); - - // Use a non-loopback address — loopback is rejected when allow_loopback=false - let addr: SocketAddr = "10.0.0.1:9000".parse().unwrap(); - - // Add peer - let result = manager.add_peer(&addr, vec![addr]).await; - assert!(result.is_ok()); - - // Verify it was added - assert_eq!(manager.peer_count().await, 1); - assert!(manager.contains(&addr).await); - } - - #[tokio::test] - async fn test_add_peer_no_addresses_fails() { - let temp_dir = TempDir::new().unwrap(); - let config = test_config(&temp_dir); - let manager = BootstrapManager::with_config(config).await.unwrap(); - - let addr: SocketAddr = "10.0.0.1:9000".parse().unwrap(); - let result = manager.add_peer(&addr, vec![]).await; - - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - P2PError::Bootstrap(BootstrapError::InvalidData(_)) - )); - } - - #[tokio::test] - async fn test_add_trusted_peer_bypasses_checks() { - let temp_dir = TempDir::new().unwrap(); - let config = test_config(&temp_dir); - let manager = BootstrapManager::with_config(config).await.unwrap(); - - let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap(); - - // Trusted add doesn't return Result, always succeeds - manager.add_peer_trusted(&addr, vec![addr]).await; - - assert_eq!(manager.peer_count().await, 1); - assert!(manager.contains(&addr).await); - } - - #[tokio::test] - async fn test_record_success_updates_quality() { - let temp_dir = TempDir::new().unwrap(); - let config = test_config(&temp_dir); - let manager = BootstrapManager::with_config(config).await.unwrap(); - - let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap(); - manager.add_peer_trusted(&addr, vec![addr]).await; - - // Get initial quality - let initial_peer = manager.get_peer(&addr).await.unwrap(); - let initial_quality = initial_peer.quality_score; - - // Record multiple successes - for _ in 0..5 { - manager.record_success(&addr, 50).await; - } - - // Quality should improve - let updated_peer = manager.get_peer(&addr).await.unwrap(); - assert!( - updated_peer.quality_score >= initial_quality, - "Quality should improve after successes" - ); - } - - #[tokio::test] - async fn test_record_failure_decreases_quality() { - let temp_dir = TempDir::new().unwrap(); - let config = test_config(&temp_dir); - let manager = BootstrapManager::with_config(config).await.unwrap(); - - let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap(); - manager.add_peer_trusted(&addr, vec![addr]).await; - - // Record successes first to establish baseline - for _ in 0..3 { - manager.record_success(&addr, 50).await; - } - let good_peer = manager.get_peer(&addr).await.unwrap(); - let good_quality = good_peer.quality_score; - - // Record failures - for _ in 0..5 { - manager.record_failure(&addr).await; - } - - // Quality should decrease - let bad_peer = manager.get_peer(&addr).await.unwrap(); - assert!( - bad_peer.quality_score < good_quality, - "Quality should decrease after failures" - ); - } - - #[tokio::test] - async fn test_select_peers_returns_best() { - let temp_dir = TempDir::new().unwrap(); - let config = test_config(&temp_dir); - let manager = BootstrapManager::with_config(config).await.unwrap(); - - // Add multiple peers with different quality - for i in 0..10 { - let addr: SocketAddr = format!("127.0.0.1:{}", 9000 + i).parse().unwrap(); - manager.add_peer_trusted(&addr, vec![addr]).await; - - // Make some peers better than others - for _ in 0..i { - manager.record_success(&addr, 50).await; - } - } - - // Select top 5 - let selected = manager.select_peers(5).await; - assert_eq!(selected.len(), 5); - - // With epsilon=0, should be sorted by quality (best first) - for i in 0..4 { - assert!( - selected[i].quality_score >= selected[i + 1].quality_score, - "Peers should be sorted by quality" - ); - } - } - - #[tokio::test] - async fn test_stats() { - let temp_dir = TempDir::new().unwrap(); - let config = test_config(&temp_dir); - let manager = BootstrapManager::with_config(config).await.unwrap(); - - // Add some peers - for i in 0..5 { - let addr: SocketAddr = format!("127.0.0.1:{}", 9000 + i).parse().unwrap(); - manager.add_peer_trusted(&addr, vec![addr]).await; - } - - let stats = manager.stats().await; - assert_eq!(stats.total_peers, 5); - assert_eq!(stats.untested_peers, 5); // All untested initially - } - - #[tokio::test] - async fn test_persistence() { - let temp_dir = TempDir::new().unwrap(); - let cache_path = temp_dir.path().to_path_buf(); - - // Create manager and add peers - { - let config = BootstrapConfig { - cache_dir: cache_path.clone(), - max_peers: 100, - epsilon: 0.0, - rate_limit: JoinRateLimiterConfig::default(), - diversity: IPDiversityConfig::default(), - }; - let manager = BootstrapManager::with_config(config).await.unwrap(); - let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap(); - manager.add_peer_trusted(&addr, vec![addr]).await; - - // Verify peer was added - let count_before = manager.peer_count().await; - assert_eq!(count_before, 1, "Peer should be in cache before save"); - - // Explicitly save - manager.save().await.unwrap(); - - // Small delay to ensure file is written - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - } - - // Reopen and verify - { - let config = BootstrapConfig { - cache_dir: cache_path, - max_peers: 100, - epsilon: 0.0, - rate_limit: JoinRateLimiterConfig::default(), - diversity: IPDiversityConfig::default(), - }; - let manager = BootstrapManager::with_config(config).await.unwrap(); - let count = manager.peer_count().await; - - // saorsa-transport may use different persistence mechanics - // If persistence isn't working, this is informative - if count == 0 { - // This might be expected if saorsa-transport doesn't persist immediately - // or uses a different persistence model - eprintln!( - "Note: saorsa-transport BootstrapCache may have different persistence behavior" - ); - } - // For now, we just verify the cache can be reopened without error - // The actual persistence behavior depends on saorsa-transport implementation - } - } - - #[tokio::test] - async fn test_rate_limiting() { - let temp_dir = TempDir::new().unwrap(); - - // Very restrictive rate limiting - only 2 joins per /24 subnet per hour - // Use permissive diversity config to isolate rate limiting behavior - let diversity_config = IPDiversityConfig { - max_per_ip: Some(usize::MAX), - max_per_subnet: Some(usize::MAX), - }; - - let config = BootstrapConfig { - cache_dir: temp_dir.path().to_path_buf(), - max_peers: 100, - epsilon: 0.0, - rate_limit: JoinRateLimiterConfig { - max_joins_per_64_per_hour: 100, // IPv6 /64 limit - max_joins_per_48_per_hour: 100, // IPv6 /48 limit - max_joins_per_24_per_hour: 2, // IPv4 /24 limit - restrictive - max_global_joins_per_minute: 100, - global_burst_size: 10, - }, - diversity: diversity_config, - }; - - let manager = BootstrapManager::with_config(config).await.unwrap(); - - // Add first two peers from same /24 - should succeed - for i in 0..2 { - let addr: SocketAddr = format!("192.168.1.{}:{}", 10 + i, 9000 + i) - .parse() - .unwrap(); - let result = manager.add_peer(&addr, vec![addr]).await; - assert!( - result.is_ok(), - "First 2 peers should be allowed: {:?}", - result - ); - } - - // Third peer from same /24 subnet - should fail rate limiting - let addr: SocketAddr = "192.168.1.100:9100".parse().unwrap(); - let result = manager.add_peer(&addr, vec![addr]).await; - assert!(result.is_err(), "Third peer should be rate limited"); - assert!(matches!( - result.unwrap_err(), - P2PError::Bootstrap(BootstrapError::RateLimited(_)) - )); - } -} diff --git a/src/bootstrap/mod.rs b/src/bootstrap/mod.rs index f38518f0..7675a977 100644 --- a/src/bootstrap/mod.rs +++ b/src/bootstrap/mod.rs @@ -11,39 +11,8 @@ // distributed under these licenses is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -//! Bootstrap Cache System -//! -//! Provides decentralized peer discovery through local caching of known contacts. -//! Uses saorsa-transport's BootstrapCache internally with additional Sybil protection -//! via rate limiting and IP diversity enforcement. +//! Close-group cache for warm-loading trusted peers across restarts. pub mod cache; -pub mod manager; -// Re-export the primary BootstrapManager (wraps saorsa-transport) -pub use manager::BootstrapManager; -pub use manager::{BootstrapConfig, BootstrapStats}; - -// Re-export close group cache types pub use cache::{CachedCloseGroupPeer, CloseGroupCache}; - -#[cfg(test)] -mod tests { - use super::*; - use crate::network::NodeConfig; - use tempfile::TempDir; - - #[tokio::test] - async fn test_bootstrap_manager_creation() { - let temp_dir = TempDir::new().unwrap(); - let config = BootstrapConfig { - cache_dir: temp_dir.path().to_path_buf(), - max_peers: 1000, - ..BootstrapConfig::default() - }; - let node_config = NodeConfig::default(); - - let manager = BootstrapManager::with_node_config(config, &node_config).await; - assert!(manager.is_ok()); - } -} diff --git a/src/lib.rs b/src/lib.rs index ead0e671..7eaf14f7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -65,8 +65,7 @@ pub use network::{NodeConfig, NodeMode, P2PEvent, P2PNode}; pub use dht::Key; pub use dht_network_manager::{DHTNode, DhtNetworkEvent}; -// Bootstrap -pub use bootstrap::{BootstrapConfig, BootstrapManager, BootstrapStats}; +// Close-group cache pub use bootstrap::{CachedCloseGroupPeer, CloseGroupCache}; // Trust & Adaptive DHT diff --git a/src/network.rs b/src/network.rs index e72d183e..04a8dd63 100644 --- a/src/network.rs +++ b/src/network.rs @@ -20,7 +20,6 @@ use crate::PeerId; use crate::adaptive::trust::{TrustRecord, TrustSnapshot}; use crate::adaptive::{AdaptiveDHT, AdaptiveDhtConfig, TrustEngine, TrustEvent}; use crate::bootstrap::cache::{CachedCloseGroupPeer, CloseGroupCache}; -use crate::bootstrap::{BootstrapConfig, BootstrapManager}; use crate::dht_network_manager::{ DhtNetworkConfig, DhtNetworkEvent, DhtNetworkManager, IDENTITY_EXCHANGE_TIMEOUT, }; @@ -135,9 +134,6 @@ const DEFAULT_MAX_CONNECTIONS: usize = 10_000; /// 25s provides margin for handshake jitter. const DEFAULT_CONNECTION_TIMEOUT_SECS: u64 = 25; -/// Number of cached bootstrap peers to retrieve. -const BOOTSTRAP_PEER_BATCH_SIZE: usize = 20; - /// Timeout in seconds for waiting on a bootstrap peer's identity exchange. /// /// Tighter than the post-bootstrap budget (`IDENTITY_EXCHANGE_TIMEOUT`, @@ -211,9 +207,6 @@ pub struct NodeConfig { /// DHT configuration pub dht_config: DHTConfig, - /// Bootstrap cache configuration - pub bootstrap_cache_config: Option, - /// Optional IP diversity configuration for Sybil protection tuning. /// /// When set, this configuration is used by bootstrap peer discovery and @@ -568,7 +561,6 @@ impl NodeConfigBuilder { .unwrap_or(Duration::from_secs(DEFAULT_CONNECTION_TIMEOUT_SECS)), max_connections: self.max_connections.unwrap_or(DEFAULT_MAX_CONNECTIONS), dht_config: self.dht_config.unwrap_or_default(), - bootstrap_cache_config: None, diversity_config: None, max_message_size: self.max_message_size, node_identity: None, @@ -591,7 +583,6 @@ impl Default for NodeConfig { connection_timeout: Duration::from_secs(DEFAULT_CONNECTION_TIMEOUT_SECS), max_connections: DEFAULT_MAX_CONNECTIONS, dht_config: DHTConfig::default(), - bootstrap_cache_config: None, diversity_config: None, max_message_size: None, node_identity: None, @@ -786,9 +777,6 @@ pub struct P2PNode { /// All DHT operations and trust signals go through this component. adaptive_dht: AdaptiveDHT, - /// Bootstrap cache manager for peer discovery - bootstrap_manager: Option>>, - /// Bootstrap state tracking - indicates whether peer discovery has completed is_bootstrapped: Arc, @@ -871,17 +859,6 @@ impl P2PNode { .map_err(|e| P2PError::Validation(format!("IP diversity config: {e}").into()))?; } - // Initialize bootstrap cache manager - let bootstrap_config = config.bootstrap_cache_config.clone().unwrap_or_default(); - let bootstrap_manager = - match BootstrapManager::with_node_config(bootstrap_config, &config).await { - Ok(manager) => Some(Arc::new(RwLock::new(manager))), - Err(e) => { - warn!("Failed to initialize bootstrap manager: {e}, continuing without cache"); - None - } - }; - // Build transport handle with all transport-level concerns let transport_config = crate::transport_handle::TransportConfig::from_node_config( &config, @@ -914,7 +891,6 @@ impl P2PNode { start_time: Instant::now(), shutdown: CancellationToken::new(), adaptive_dht, - bootstrap_manager, is_bootstrapped: Arc::new(AtomicBool::new(false)), is_started: Arc::new(AtomicBool::new(false)), reconnect_locks: ParkingMutex::new(HashMap::new()), @@ -1098,15 +1074,6 @@ impl P2PNode { pub async fn start(&self) -> Result<()> { info!("Starting P2P node..."); - // Start bootstrap manager background tasks - if let Some(ref bootstrap_manager) = self.bootstrap_manager { - let mut manager = bootstrap_manager.write().await; - manager - .start_maintenance() - .map_err(|e| protocol_error(format!("Failed to start bootstrap manager: {e}")))?; - info!("Bootstrap cache manager started"); - } - // Start transport listeners and message receiving self.transport.start_network_listeners().await?; @@ -1879,74 +1846,6 @@ impl P2PNode { self.dht_manager() } - /// Add a discovered peer to the bootstrap cache - pub async fn add_discovered_peer( - &self, - _peer_id: PeerId, - addresses: Vec, - ) -> Result<()> { - if let Some(ref bootstrap_manager) = self.bootstrap_manager { - let manager = bootstrap_manager.read().await; - let socket_addresses: Vec = addresses - .iter() - .filter_map(|addr| addr.socket_addr()) - .collect(); - if let Some(&primary) = socket_addresses.first() { - manager - .add_peer(&primary, socket_addresses) - .await - .map_err(|e| { - protocol_error(format!("Failed to add peer to bootstrap cache: {e}")) - })?; - } - } - Ok(()) - } - - /// Update connection metrics for a peer in the bootstrap cache - pub async fn update_peer_metrics( - &self, - addr: &MultiAddr, - success: bool, - latency_ms: Option, - _error: Option, - ) -> Result<()> { - if let Some(ref bootstrap_manager) = self.bootstrap_manager - && let Some(sa) = addr.socket_addr() - { - let manager = bootstrap_manager.read().await; - if success { - let rtt_ms = latency_ms.unwrap_or(0) as u32; - manager.record_success(&sa, rtt_ms).await; - } else { - manager.record_failure(&sa).await; - } - } - Ok(()) - } - - /// Get bootstrap cache statistics - pub async fn get_bootstrap_cache_stats( - &self, - ) -> Result> { - if let Some(ref bootstrap_manager) = self.bootstrap_manager { - let manager = bootstrap_manager.read().await; - Ok(Some(manager.stats().await)) - } else { - Ok(None) - } - } - - /// Get the number of cached bootstrap peers - pub async fn cached_peer_count(&self) -> usize { - if let Some(ref _bootstrap_manager) = self.bootstrap_manager - && let Ok(Some(stats)) = self.get_bootstrap_cache_stats().await - { - return stats.total_peers; - } - 0 - } - /// Connect to bootstrap peers and perform initial peer discovery. /// /// If a `close_group_cache` was loaded on startup, its peers are injected @@ -1963,7 +1862,6 @@ impl P2PNode { // latency when some peers are slow or dead. let mut serial_addr_sets: Vec> = Vec::new(); let mut parallel_addr_sets: Vec> = Vec::new(); - let mut used_cache = false; let mut seen_addresses = std::collections::HashSet::new(); // Priority 0: Cached close group peers (pre-trusted, highest priority). @@ -2040,44 +1938,8 @@ impl P2PNode { } } - // Supplement with cached bootstrap peers (after CLI peers) - if let Some(ref bootstrap_manager) = self.bootstrap_manager { - let manager = bootstrap_manager.read().await; - let cached_peers = manager.select_peers(BOOTSTRAP_PEER_BATCH_SIZE).await; - if !cached_peers.is_empty() { - let mut added_from_cache = 0; - for cached in cached_peers { - let mut addrs = vec![cached.primary_address]; - addrs.extend(cached.addresses); - // Only add addresses we haven't seen from CLI peers - let new_addresses: Vec = addrs - .into_iter() - .filter(|a| !seen_addresses.contains(a)) - .map(MultiAddr::quic) - .collect(); - - if !new_addresses.is_empty() { - for addr in &new_addresses { - if let Some(sa) = addr.socket_addr() { - seen_addresses.insert(sa); - } - } - parallel_addr_sets.push(new_addresses); - added_from_cache += 1; - } - } - if added_from_cache > 0 { - info!( - "Added {} cached bootstrap peers (supplementing CLI peers)", - added_from_cache - ); - used_cache = true; - } - } - } - if serial_addr_sets.is_empty() && parallel_addr_sets.is_empty() { - info!("No bootstrap peers configured and no cached peers available"); + info!("No bootstrap peers configured"); return Ok(()); } @@ -2090,10 +1952,7 @@ impl P2PNode { // Phase A: serial close-group dials to preserve trust-priority ordering. let client_mode = matches!(self.config.mode, NodeMode::Client); for addrs in &serial_addr_sets { - if let Some(peer_id) = self - .dial_bootstrap_addr_set(addrs, used_cache, identity_timeout) - .await - { + if let Some(peer_id) = self.dial_bootstrap_addr_set(addrs, identity_timeout).await { successful_connections += 1; connected_peer_ids.push(peer_id); if client_mode && successful_connections >= CLIENT_BOOTSTRAP_TARGET { @@ -2105,15 +1964,14 @@ impl P2PNode { } } - // Phase B: concurrent dials of CLI + cached bootstrap peers, bounded - // by `MAX_CONCURRENT_BOOTSTRAP_DIALS` to cap simultaneous QUIC+PQC + // Phase B: concurrent dials of CLI bootstrap peers, bounded by + // `MAX_CONCURRENT_BOOTSTRAP_DIALS` to cap simultaneous QUIC+PQC // handshakes. Skipped entirely when a client has already hit its // target during Phase A. if !client_mode || successful_connections < CLIENT_BOOTSTRAP_TARGET { let mut parallel_stream = futures::stream::iter(parallel_addr_sets.into_iter().map(|addrs| async move { - self.dial_bootstrap_addr_set(&addrs, used_cache, identity_timeout) - .await + self.dial_bootstrap_addr_set(&addrs, identity_timeout).await })) .buffer_unordered(MAX_CONCURRENT_BOOTSTRAP_DIALS); while let Some(result) = parallel_stream.next().await { @@ -2147,9 +2005,7 @@ impl P2PNode { connected_peer_ids = transport_peers; successful_connections = connected_peer_ids.len(); } else { - if !used_cache { - warn!("Failed to connect to any bootstrap peers"); - } + warn!("Failed to connect to any bootstrap peers"); // Starting a node should not be gated on immediate bootstrap connectivity. // Keep running and allow background discovery / retries to populate peers later. return Ok(()); @@ -2220,7 +2076,6 @@ impl P2PNode { async fn dial_bootstrap_addr_set( &self, addrs: &[MultiAddr], - used_cache: bool, identity_timeout: Duration, ) -> Option { for addr in addrs { @@ -2231,12 +2086,6 @@ impl P2PNode { .await { Ok(real_peer_id) => { - if let Some(ref bootstrap_manager) = self.bootstrap_manager { - let manager = bootstrap_manager.read().await; - if let Some(sa) = addr.socket_addr() { - manager.record_success(&sa, 100).await; - } - } return Some(real_peer_id); } Err(e) => { @@ -2250,12 +2099,6 @@ impl P2PNode { }, Err(e) => { warn!("Failed to connect to bootstrap peer {}: {}", addr, e); - if used_cache && let Some(ref bootstrap_manager) = self.bootstrap_manager { - let manager = bootstrap_manager.read().await; - if let Some(sa) = addr.socket_addr() { - manager.record_failure(&sa).await; - } - } } } } @@ -2334,37 +2177,6 @@ pub trait NetworkSender: Send + Sync { // P2PNetworkSender removed — NetworkSender is now implemented directly on TransportHandle. // NodeBuilder removed — use NodeConfigBuilder + P2PNode::new() instead. -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used)] -mod diversity_tests { - use super::*; - use crate::security::IPDiversityConfig; - - async fn build_bootstrap_manager_like_prod(config: &NodeConfig) -> BootstrapManager { - // Use a temp dir to avoid conflicts with cached files from old format - let temp_dir = tempfile::TempDir::new().expect("temp dir"); - let mut bootstrap_config = config.bootstrap_cache_config.clone().unwrap_or_default(); - bootstrap_config.cache_dir = temp_dir.path().to_path_buf(); - - BootstrapManager::with_node_config(bootstrap_config, config) - .await - .expect("bootstrap manager") - } - - #[tokio::test] - async fn test_nodeconfig_diversity_config_used_for_bootstrap() { - let config = NodeConfig { - diversity_config: Some(IPDiversityConfig::testnet()), - ..Default::default() - }; - - let manager = build_bootstrap_manager_like_prod(&config).await; - // Verify testnet config has permissive IP limits - assert_eq!(manager.diversity_config().max_per_ip, Some(usize::MAX)); - assert_eq!(manager.diversity_config().max_per_subnet, Some(usize::MAX)); - } -} - /// Helper function to register a new channel. /// /// Sync because the underlying map is a sharded `DashMap` — no `.await` is @@ -2412,7 +2224,6 @@ mod tests { connection_timeout: Duration::from_secs(2), max_connections: 100, dht_config: DHTConfig::default(), - bootstrap_cache_config: None, diversity_config: None, max_message_size: None, node_identity: None, diff --git a/src/rate_limit.rs b/src/rate_limit.rs index 112851e2..c8848316 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -101,320 +101,3 @@ impl Engine { } pub type SharedEngine = Arc>; - -// ============================================================================ -// Join Rate Limiting for Sybil Protection -// ============================================================================ - -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; -use thiserror::Error; - -/// Error types for join rate limiting -#[derive(Debug, Error)] -#[allow(clippy::enum_variant_names)] -pub enum JoinRateLimitError { - /// Global join limit exceeded (network is under high load) - #[error("global join rate limit exceeded: max {max_per_minute} joins per minute")] - GlobalLimitExceeded { max_per_minute: u32 }, - - /// Per-subnet /64 limit exceeded (potential Sybil attack) - #[error("subnet /64 join rate limit exceeded: max {max_per_hour} joins per hour from this /64")] - Subnet64LimitExceeded { max_per_hour: u32 }, - - /// Per-subnet /48 limit exceeded (potential coordinated attack) - #[error("subnet /48 join rate limit exceeded: max {max_per_hour} joins per hour from this /48")] - Subnet48LimitExceeded { max_per_hour: u32 }, - - /// Per-subnet /24 limit exceeded (IPv4 Sybil attack) - #[error("subnet /24 join rate limit exceeded: max {max_per_hour} joins per hour from this /24")] - Subnet24LimitExceeded { max_per_hour: u32 }, -} - -/// Configuration for join rate limiting -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct JoinRateLimiterConfig { - /// Maximum joins per /64 subnet per hour (default: 1) - /// This is the strictest limit to prevent Sybil attacks - pub max_joins_per_64_per_hour: u32, - - /// Maximum joins per /48 subnet per hour (default: 5) - pub max_joins_per_48_per_hour: u32, - - /// Maximum joins per /24 subnet per hour for IPv4 (default: 3) - pub max_joins_per_24_per_hour: u32, - - /// Maximum global joins per minute (default: 100) - /// This protects against network-wide flooding - pub max_global_joins_per_minute: u32, - - /// Burst allowance for global limit (default: 10) - pub global_burst_size: u32, -} - -impl Default for JoinRateLimiterConfig { - fn default() -> Self { - Self { - max_joins_per_64_per_hour: 10_000, - max_joins_per_48_per_hour: 10_000, - max_joins_per_24_per_hour: 10_000, - max_global_joins_per_minute: 10_000, - global_burst_size: 10_000, - } - } -} - -/// Join rate limiter for Sybil attack protection -/// -/// Implements multi-level rate limiting to prevent attackers from flooding -/// the network with Sybil identities: -/// -/// - **Global limit**: Protects against network-wide flooding attacks -/// - **Per-subnet /64 limit**: Prevents single residential/small org Sybil attacks -/// - **Per-subnet /48 limit**: Prevents coordinated attacks from larger organizations -/// - **Per-subnet /24 limit**: IPv4-specific protection -/// -/// # Example -/// -/// ```rust,ignore -/// use saorsa_core::rate_limit::{JoinRateLimiter, JoinRateLimiterConfig}; -/// use std::net::IpAddr; -/// -/// let limiter = JoinRateLimiter::new(JoinRateLimiterConfig::default()); -/// -/// let ip: IpAddr = "2001:db8::1".parse().unwrap(); -/// match limiter.check_join_allowed(&ip) { -/// Ok(()) => println!("Join allowed"), -/// Err(e) => println!("Join denied: {}", e), -/// } -/// ``` -#[derive(Debug)] -pub struct JoinRateLimiter { - config: JoinRateLimiterConfig, - /// Per /64 subnet rate limiter (1 hour window) - per_subnet_64: Engine, - /// Per /48 subnet rate limiter (1 hour window) - per_subnet_48: Engine, - /// Per /24 subnet rate limiter for IPv4 (1 hour window) - per_subnet_24: Engine, - /// Global rate limiter (1 minute window) - uses u8 key with constant 0 - global: Engine, -} - -impl JoinRateLimiter { - /// Create a new join rate limiter with the given configuration - pub fn new(config: JoinRateLimiterConfig) -> Self { - // /64 subnet limiter: max_joins_per_64_per_hour over 1 hour - let subnet_64_config = EngineConfig { - window: Duration::from_secs(3600), // 1 hour - max_requests: config.max_joins_per_64_per_hour, - burst_size: config.max_joins_per_64_per_hour, // Allow configured limit as burst - }; - - // /48 subnet limiter: max_joins_per_48_per_hour over 1 hour - let subnet_48_config = EngineConfig { - window: Duration::from_secs(3600), // 1 hour - max_requests: config.max_joins_per_48_per_hour, - burst_size: config.max_joins_per_48_per_hour, // Allow configured limit as burst - }; - - // /24 subnet limiter for IPv4 - let subnet_24_config = EngineConfig { - window: Duration::from_secs(3600), // 1 hour - max_requests: config.max_joins_per_24_per_hour, - burst_size: config.max_joins_per_24_per_hour, // Allow full burst up to limit - }; - - // Global limiter: max_global_joins_per_minute over 1 minute - let global_config = EngineConfig { - window: Duration::from_secs(60), // 1 minute - max_requests: config.max_global_joins_per_minute, - burst_size: config.global_burst_size, - }; - - Self { - config, - per_subnet_64: Engine::new(subnet_64_config), - per_subnet_48: Engine::new(subnet_48_config), - per_subnet_24: Engine::new(subnet_24_config), - global: Engine::new(global_config), - } - } - - /// Check if a join request from the given IP is allowed - /// - /// Returns `Ok(())` if the join is allowed, or `Err(JoinRateLimitError)` - /// if any rate limit is exceeded. - /// - /// # Rate Limit Checks (in order) - /// - /// 1. Global rate limit (protects against network flooding) - /// 2. Per-subnet limits based on IP version: - /// - IPv6: /64 and /48 subnet limits - /// - IPv4: /24 subnet limit - pub fn check_join_allowed(&self, ip: &IpAddr) -> Result<(), JoinRateLimitError> { - // 1. Check global limit first (uses constant key 0) - if !self.global.try_consume_key(&0u8) { - return Err(JoinRateLimitError::GlobalLimitExceeded { - max_per_minute: self.config.max_global_joins_per_minute, - }); - } - - // 2. Check per-subnet limits based on IP version - match ip { - IpAddr::V6(ipv6) => { - // Check /64 subnet limit (strictest for Sybil protection) - let subnet_64 = extract_ipv6_subnet_64(ipv6); - if !self.per_subnet_64.try_consume_key(&subnet_64) { - return Err(JoinRateLimitError::Subnet64LimitExceeded { - max_per_hour: self.config.max_joins_per_64_per_hour, - }); - } - - // Check /48 subnet limit - let subnet_48 = extract_ipv6_subnet_48(ipv6); - if !self.per_subnet_48.try_consume_key(&subnet_48) { - return Err(JoinRateLimitError::Subnet48LimitExceeded { - max_per_hour: self.config.max_joins_per_48_per_hour, - }); - } - } - IpAddr::V4(ipv4) => { - // Check /24 subnet limit for IPv4 - let subnet_24 = extract_ipv4_subnet_24(ipv4); - if !self.per_subnet_24.try_consume_key(&subnet_24) { - return Err(JoinRateLimitError::Subnet24LimitExceeded { - max_per_hour: self.config.max_joins_per_24_per_hour, - }); - } - } - } - - Ok(()) - } -} - -/// Extract /64 subnet prefix from an IPv6 address -/// -/// Returns an IPv6 address with only the first 64 bits preserved (network portion), -/// with the remaining 64 bits zeroed (interface identifier). -#[inline] -pub fn extract_ipv6_subnet_64(addr: &Ipv6Addr) -> Ipv6Addr { - let octets = addr.octets(); - let mut subnet = [0u8; 16]; - subnet[..8].copy_from_slice(&octets[..8]); // Keep first 64 bits - Ipv6Addr::from(subnet) -} - -/// Extract /48 subnet prefix from an IPv6 address -/// -/// Returns an IPv6 address with only the first 48 bits preserved. -#[inline] -pub fn extract_ipv6_subnet_48(addr: &Ipv6Addr) -> Ipv6Addr { - let octets = addr.octets(); - let mut subnet = [0u8; 16]; - subnet[..6].copy_from_slice(&octets[..6]); // Keep first 48 bits - Ipv6Addr::from(subnet) -} - -/// Extract /24 subnet prefix from an IPv4 address -/// -/// Returns an IPv4 address with only the first 24 bits preserved. -#[inline] -pub fn extract_ipv4_subnet_24(addr: &Ipv4Addr) -> Ipv4Addr { - let octets = addr.octets(); - Ipv4Addr::new(octets[0], octets[1], octets[2], 0) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_extract_ipv6_subnet_64() { - let addr: Ipv6Addr = "2001:db8:85a3:1234:8a2e:370:7334:1234".parse().unwrap(); - let subnet = extract_ipv6_subnet_64(&addr); - assert_eq!(subnet.to_string(), "2001:db8:85a3:1234::"); - } - - #[test] - fn test_extract_ipv6_subnet_48() { - let addr: Ipv6Addr = "2001:db8:85a3:1234:8a2e:370:7334:1234".parse().unwrap(); - let subnet = extract_ipv6_subnet_48(&addr); - assert_eq!(subnet.to_string(), "2001:db8:85a3::"); - } - - #[test] - fn test_extract_ipv4_subnet_24() { - let addr: Ipv4Addr = "192.168.1.100".parse().unwrap(); - let subnet = extract_ipv4_subnet_24(&addr); - assert_eq!(subnet.to_string(), "192.168.1.0"); - } - - #[test] - fn test_join_rate_limiter_allows_first_join() { - let limiter = JoinRateLimiter::new(JoinRateLimiterConfig::default()); - let ip: IpAddr = "2001:db8::1".parse().unwrap(); - assert!(limiter.check_join_allowed(&ip).is_ok()); - } - - #[test] - fn test_join_rate_limiter_blocks_second_from_same_64() { - let config = JoinRateLimiterConfig { - max_joins_per_64_per_hour: 1, - ..Default::default() - }; - let limiter = JoinRateLimiter::new(config); - - // First join should succeed - let ip1: IpAddr = "2001:db8::1".parse().unwrap(); - assert!(limiter.check_join_allowed(&ip1).is_ok()); - - // Second join from same /64 should fail - let ip2: IpAddr = "2001:db8::2".parse().unwrap(); - let result = limiter.check_join_allowed(&ip2); - assert!(matches!( - result, - Err(JoinRateLimitError::Subnet64LimitExceeded { .. }) - )); - } - - #[test] - fn test_join_rate_limiter_allows_different_subnets() { - let config = JoinRateLimiterConfig { - max_joins_per_64_per_hour: 1, - ..Default::default() - }; - let limiter = JoinRateLimiter::new(config); - - // First join from one /64 - let ip1: IpAddr = "2001:db8:1::1".parse().unwrap(); - assert!(limiter.check_join_allowed(&ip1).is_ok()); - - // Second join from different /64 should succeed - let ip2: IpAddr = "2001:db8:2::1".parse().unwrap(); - assert!(limiter.check_join_allowed(&ip2).is_ok()); - } - - #[test] - fn test_join_rate_limiter_ipv4() { - let config = JoinRateLimiterConfig { - max_joins_per_24_per_hour: 2, - ..Default::default() - }; - let limiter = JoinRateLimiter::new(config); - - // First two joins should succeed - let ip1: IpAddr = "192.168.1.1".parse().unwrap(); - let ip2: IpAddr = "192.168.1.2".parse().unwrap(); - assert!(limiter.check_join_allowed(&ip1).is_ok()); - assert!(limiter.check_join_allowed(&ip2).is_ok()); - - // Third join from same /24 should fail - let ip3: IpAddr = "192.168.1.3".parse().unwrap(); - let result = limiter.check_join_allowed(&ip3); - assert!(matches!( - result, - Err(JoinRateLimitError::Subnet24LimitExceeded { .. }) - )); - } -} diff --git a/src/security.rs b/src/security.rs index 0732cccb..2a09b1f1 100644 --- a/src/security.rs +++ b/src/security.rs @@ -13,29 +13,17 @@ //! Security module //! -//! This module provides Sybil protection for the P2P network via IP diversity -//! enforcement to prevent large-scale Sybil attacks while maintaining network -//! openness. +//! IP diversity configuration and helpers used by the DHT routing-table +//! Sybil defenses. -use anyhow::{Result, anyhow}; -use lru::LruCache; +use anyhow::Result; use serde::{Deserialize, Serialize}; -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; -use std::num::NonZeroUsize; - -/// Maximum subnet tracking entries before evicting oldest (prevents memory DoS) -const BOOTSTRAP_MAX_TRACKED_SUBNETS: usize = 50_000; +use std::net::{IpAddr, Ipv6Addr}; /// Max nodes sharing an exact IP address per bucket/close-group. -/// Used by both `DhtCoreEngine` and `BootstrapIpLimiter` when -/// `IPDiversityConfig::max_per_ip` is `None`. +/// Used by `DhtCoreEngine` when `IPDiversityConfig::max_per_ip` is `None`. pub const IP_EXACT_LIMIT: usize = 2; -/// Default K value for `BootstrapIpLimiter` when the actual K is not known -/// (e.g. standalone test construction). Matches `DHTConfig::DEFAULT_K_VALUE`. -#[cfg(test)] -const DEFAULT_K_VALUE: usize = 20; - /// Canonicalize an IP address: map IPv4-mapped IPv6 (`::ffff:a.b.c.d`) to /// its IPv4 equivalent so that diversity limits are enforced uniformly /// regardless of which address family the transport layer reports. @@ -112,7 +100,7 @@ impl IPDiversityConfig { } } - /// Validate IP diversity parameter safety constraints (Section 4 points 1-2). + /// Validate IP diversity parameter safety constraints. /// /// Returns `Err` if any explicit limit is less than 1. pub fn validate(&self) -> Result<()> { @@ -130,174 +118,6 @@ impl IPDiversityConfig { } } -/// IP diversity enforcement system -/// -/// Tracks per-IP and per-subnet counts to prevent Sybil attacks. -/// Uses simple 2-tier limits: exact IP and subnet (/24 IPv4, /48 IPv6). -#[derive(Debug)] -pub struct BootstrapIpLimiter { - config: IPDiversityConfig, - /// Allow loopback addresses (127.0.0.1, ::1) to bypass diversity checks. - /// - /// This flag is intentionally separate from `IPDiversityConfig` so that it - /// has a single source of truth in the owning component (`NodeConfig`, - /// `BootstrapManager`, etc.) rather than being copied into every config. - allow_loopback: bool, - /// K value from DHT config, used to derive subnet limits consistent with - /// the routing table's `ip_subnet_limit(k)`. - k_value: usize, - /// Count of nodes per exact IP address - ip_counts: LruCache, - /// Count of nodes per subnet (/24 IPv4, /48 IPv6) - subnet_counts: LruCache, -} - -impl BootstrapIpLimiter { - /// Create a new IP diversity enforcer with loopback disabled and default K. - /// - /// Uses [`DEFAULT_K_VALUE`] — production code should prefer - /// [`with_loopback_and_k`](Self::with_loopback_and_k) to stay consistent - /// with the configured bucket size. - #[cfg(test)] - pub fn new(config: IPDiversityConfig) -> Self { - Self::with_loopback(config, false) - } - - /// Create a new IP diversity enforcer with explicit loopback setting and - /// default K value. - /// - /// Uses [`DEFAULT_K_VALUE`] — production code should prefer - /// [`with_loopback_and_k`](Self::with_loopback_and_k) to stay consistent - /// with the configured bucket size. - #[cfg(test)] - pub fn with_loopback(config: IPDiversityConfig, allow_loopback: bool) -> Self { - Self::with_loopback_and_k(config, allow_loopback, DEFAULT_K_VALUE) - } - - /// Create a new IP diversity enforcer with explicit loopback setting and K value. - /// - /// The `k_value` is used to derive the subnet limit (`k/4`) so that bootstrap - /// and routing table diversity limits stay consistent. - pub fn with_loopback_and_k( - config: IPDiversityConfig, - allow_loopback: bool, - k_value: usize, - ) -> Self { - let cache_size = - NonZeroUsize::new(BOOTSTRAP_MAX_TRACKED_SUBNETS).unwrap_or(NonZeroUsize::MIN); - Self { - config, - allow_loopback, - k_value, - ip_counts: LruCache::new(cache_size), - subnet_counts: LruCache::new(cache_size), - } - } - - /// Mask an IP to its subnet prefix (/24 for IPv4, /48 for IPv6). - fn subnet_key(ip: IpAddr) -> IpAddr { - match ip { - IpAddr::V4(v4) => { - let o = v4.octets(); - IpAddr::V4(Ipv4Addr::new(o[0], o[1], o[2], 0)) - } - IpAddr::V6(v6) => { - let mut o = v6.octets(); - // Zero out bytes 6-15 (host portion of /48) - for b in &mut o[6..] { - *b = 0; - } - IpAddr::V6(Ipv6Addr::from(o)) - } - } - } - - /// Check if a new node with the given IP can be accepted under diversity limits. - pub fn can_accept(&self, ip: IpAddr) -> bool { - let ip = canonicalize_ip(ip); - - // Loopback: bypass all checks when allowed, reject outright when not. - if ip.is_loopback() { - return self.allow_loopback; - } - - // Reject addresses that are never valid peer endpoints. - if ip.is_unspecified() || ip.is_multicast() { - return false; - } - - let ip_limit = self.config.max_per_ip.unwrap_or(IP_EXACT_LIMIT); - let subnet_limit = self - .config - .max_per_subnet - .unwrap_or(ip_subnet_limit(self.k_value)); - - // Check exact IP limit - if let Some(&count) = self.ip_counts.peek(&ip) - && count >= ip_limit - { - return false; - } - - // Check subnet limit - let subnet = Self::subnet_key(ip); - if let Some(&count) = self.subnet_counts.peek(&subnet) - && count >= subnet_limit - { - return false; - } - - true - } - - /// Track a new node's IP address in the diversity enforcer. - /// - /// Returns an error if the IP would exceed diversity limits. - pub fn track(&mut self, ip: IpAddr) -> Result<()> { - let ip = canonicalize_ip(ip); - if !self.can_accept(ip) { - return Err(anyhow!("IP diversity limits exceeded")); - } - - let count = self.ip_counts.get(&ip).copied().unwrap_or(0) + 1; - self.ip_counts.put(ip, count); - - let subnet = Self::subnet_key(ip); - let count = self.subnet_counts.get(&subnet).copied().unwrap_or(0) + 1; - self.subnet_counts.put(subnet, count); - - Ok(()) - } - - /// Remove a tracked IP address from the diversity enforcer. - #[allow(dead_code)] - pub fn untrack(&mut self, ip: IpAddr) { - let ip = canonicalize_ip(ip); - if let Some(count) = self.ip_counts.peek_mut(&ip) { - *count = count.saturating_sub(1); - if *count == 0 { - self.ip_counts.pop(&ip); - } - } - - let subnet = Self::subnet_key(ip); - if let Some(count) = self.subnet_counts.peek_mut(&subnet) { - *count = count.saturating_sub(1); - if *count == 0 { - self.subnet_counts.pop(&subnet); - } - } - } -} - -#[cfg(test)] -impl BootstrapIpLimiter { - #[allow(dead_code)] - pub fn config(&self) -> &IPDiversityConfig { - &self.config - } -} - /// GeoIP/ASN provider trait. /// /// Used by `BgpGeoProvider` in the transport layer; kept here so it can be @@ -322,8 +142,6 @@ pub struct GeoInfo { pub is_vpn_provider: bool, } -// Ed25519 compatibility removed - #[cfg(test)] mod tests { use super::*; @@ -331,260 +149,29 @@ mod tests { #[test] fn test_ip_diversity_config_default() { let config = IPDiversityConfig::default(); - assert!(config.max_per_ip.is_none()); assert!(config.max_per_subnet.is_none()); } #[test] - fn test_bootstrap_ip_limiter_creation() { - let config = IPDiversityConfig { - max_per_ip: None, - max_per_subnet: Some(1), - }; - let enforcer = BootstrapIpLimiter::with_loopback(config.clone(), true); - - assert_eq!(enforcer.config.max_per_subnet, config.max_per_subnet); - } - - #[test] - fn test_can_accept_basic() { - let config = IPDiversityConfig::default(); - let enforcer = BootstrapIpLimiter::new(config); - - let ip: IpAddr = "192.168.1.1".parse().unwrap(); - assert!(enforcer.can_accept(ip)); - } - - #[test] - fn test_ip_limit_enforcement() { - let config = IPDiversityConfig { - max_per_ip: Some(1), - max_per_subnet: Some(usize::MAX), - }; - let mut enforcer = BootstrapIpLimiter::new(config); - - let ip: IpAddr = "10.0.0.1".parse().unwrap(); - - // First node should be accepted - assert!(enforcer.can_accept(ip)); - enforcer.track(ip).unwrap(); - - // Second node with same IP should be rejected - assert!(!enforcer.can_accept(ip)); - assert!(enforcer.track(ip).is_err()); - } - - #[test] - fn test_subnet_limit_enforcement_ipv4() { - let config = IPDiversityConfig { - max_per_ip: Some(usize::MAX), - max_per_subnet: Some(2), - }; - let mut enforcer = BootstrapIpLimiter::new(config); - - // Two IPs in same /24 subnet - let ip1: IpAddr = "10.0.1.1".parse().unwrap(); - let ip2: IpAddr = "10.0.1.2".parse().unwrap(); - let ip3: IpAddr = "10.0.1.3".parse().unwrap(); - - enforcer.track(ip1).unwrap(); - enforcer.track(ip2).unwrap(); - - // Third in same /24 should be rejected - assert!(!enforcer.can_accept(ip3)); - assert!(enforcer.track(ip3).is_err()); - - // Different /24 should still be accepted - let ip_other: IpAddr = "10.0.2.1".parse().unwrap(); - assert!(enforcer.can_accept(ip_other)); - } - - #[test] - fn test_subnet_limit_enforcement_ipv6() { - let config = IPDiversityConfig { - max_per_ip: Some(usize::MAX), - max_per_subnet: Some(1), - }; - let mut enforcer = BootstrapIpLimiter::new(config); - - // Two IPs in same /48 subnet - let ip1: IpAddr = "2001:db8:85a3:1234::1".parse().unwrap(); - let ip2: IpAddr = "2001:db8:85a3:5678::2".parse().unwrap(); - - enforcer.track(ip1).unwrap(); - - // Second in same /48 should be rejected - assert!(!enforcer.can_accept(ip2)); - - // Different /48 should be accepted - let ip_other: IpAddr = "2001:db8:aaaa::1".parse().unwrap(); - assert!(enforcer.can_accept(ip_other)); - } - - #[test] - fn test_track_and_untrack() { - let config = IPDiversityConfig { - max_per_ip: Some(1), - max_per_subnet: Some(usize::MAX), - }; - let mut enforcer = BootstrapIpLimiter::new(config); - - let ip: IpAddr = "10.0.0.1".parse().unwrap(); - - // Track - enforcer.track(ip).unwrap(); - assert!(!enforcer.can_accept(ip)); - - // Untrack - enforcer.untrack(ip); - assert!(enforcer.can_accept(ip)); - - // Can track again after untrack - enforcer.track(ip).unwrap(); - assert!(!enforcer.can_accept(ip)); - } - - #[test] - fn test_loopback_bypass() { - let config = IPDiversityConfig { - max_per_ip: Some(1), - max_per_subnet: Some(1), - }; - - // With loopback enabled - let enforcer = BootstrapIpLimiter::with_loopback(config.clone(), true); - let loopback_v4: IpAddr = "127.0.0.1".parse().unwrap(); - let loopback_v6: IpAddr = "::1".parse().unwrap(); - assert!(enforcer.can_accept(loopback_v4)); - assert!(enforcer.can_accept(loopback_v6)); - - // With loopback disabled (default) — rejected outright, not tracked - let enforcer_no_lb = BootstrapIpLimiter::new(config); - assert!( - !enforcer_no_lb.can_accept(loopback_v4), - "loopback should be rejected when allow_loopback=false" - ); - assert!( - !enforcer_no_lb.can_accept(loopback_v6), - "loopback IPv6 should be rejected when allow_loopback=false" - ); - } - - #[test] - fn test_subnet_key_ipv4() { - let ip: IpAddr = "192.168.42.100".parse().unwrap(); - let subnet = BootstrapIpLimiter::subnet_key(ip); - let expected: IpAddr = "192.168.42.0".parse().unwrap(); - assert_eq!(subnet, expected); - } - - #[test] - fn test_subnet_key_ipv6() { - let ip: IpAddr = "2001:db8:85a3:1234:5678:8a2e:0370:7334".parse().unwrap(); - let subnet = BootstrapIpLimiter::subnet_key(ip); - let expected: IpAddr = "2001:db8:85a3::".parse().unwrap(); - assert_eq!(subnet, expected); - } - - #[test] - fn test_default_ip_limit_is_two() { - let config = IPDiversityConfig::default(); - let mut enforcer = BootstrapIpLimiter::new(config); - - let ip1: IpAddr = "10.0.0.1".parse().unwrap(); - - // Default IP limit is 2, so two tracks should succeed - enforcer.track(ip1).unwrap(); - enforcer.track(ip1).unwrap(); - - // Third should fail - assert!(!enforcer.can_accept(ip1)); - } - - #[test] - fn test_default_subnet_limit_matches_k() { - // With default K=20, subnet limit should be K/4 = 5 - let config = IPDiversityConfig::default(); - let mut enforcer = BootstrapIpLimiter::new(config); - - // Track 5 IPs in the same /24 subnet — all should succeed - for i in 1..=5 { - let ip: IpAddr = format!("10.0.1.{i}").parse().unwrap(); - enforcer.track(ip).unwrap(); - } - - // 6th in same subnet should be rejected - let ip6: IpAddr = "10.0.1.6".parse().unwrap(); - assert!( - !enforcer.can_accept(ip6), - "6th peer in same /24 should exceed K/4=5 subnet limit" - ); - } - - #[test] - fn test_ipv4_mapped_ipv6_counts_as_ipv4() { - let config = IPDiversityConfig { - max_per_ip: Some(1), - max_per_subnet: Some(usize::MAX), - }; - let mut enforcer = BootstrapIpLimiter::new(config); - - // Track using native IPv4 - let ipv4: IpAddr = "10.0.0.1".parse().unwrap(); - enforcer.track(ipv4).unwrap(); - - // IPv4-mapped IPv6 form of the same address should be rejected + fn test_canonicalize_ipv4_mapped() { let mapped: IpAddr = "::ffff:10.0.0.1".parse().unwrap(); - assert!( - !enforcer.can_accept(mapped), - "IPv4-mapped IPv6 should be canonicalized and hit the IPv4 limit" - ); + let canonical = canonicalize_ip(mapped); + let expected: IpAddr = "10.0.0.1".parse().unwrap(); + assert_eq!(canonical, expected); } #[test] - fn test_multicast_rejected() { - let config = IPDiversityConfig::default(); - let enforcer = BootstrapIpLimiter::new(config); - - let multicast_v4: IpAddr = "224.0.0.1".parse().unwrap(); - assert!(!enforcer.can_accept(multicast_v4)); - - let multicast_v6: IpAddr = "ff02::1".parse().unwrap(); - assert!(!enforcer.can_accept(multicast_v6)); + fn test_canonicalize_native_ipv6_unchanged() { + let v6: IpAddr = "2001:db8::1".parse().unwrap(); + assert_eq!(canonicalize_ip(v6), v6); } #[test] - fn test_unspecified_rejected() { - let config = IPDiversityConfig::default(); - let enforcer = BootstrapIpLimiter::new(config); - - let unspec_v4: IpAddr = "0.0.0.0".parse().unwrap(); - assert!(!enforcer.can_accept(unspec_v4)); - - let unspec_v6: IpAddr = "::".parse().unwrap(); - assert!(!enforcer.can_accept(unspec_v6)); - } - - #[test] - fn test_untrack_ipv4_mapped_ipv6() { - let config = IPDiversityConfig { - max_per_ip: Some(1), - max_per_subnet: Some(usize::MAX), - }; - let mut enforcer = BootstrapIpLimiter::new(config); - - // Track using native IPv4 - let ipv4: IpAddr = "10.0.0.1".parse().unwrap(); - enforcer.track(ipv4).unwrap(); - assert!(!enforcer.can_accept(ipv4)); - - // Untrack using the IPv4-mapped IPv6 form — should still decrement - let mapped: IpAddr = "::ffff:10.0.0.1".parse().unwrap(); - enforcer.untrack(mapped); - assert!( - enforcer.can_accept(ipv4), - "untrack via mapped form should decrement the IPv4 counter" - ); + fn test_ip_subnet_limit() { + assert_eq!(ip_subnet_limit(20), 5); + assert_eq!(ip_subnet_limit(8), 2); + assert_eq!(ip_subnet_limit(1), 1); + assert_eq!(ip_subnet_limit(0), 1); } } From 37d13d8b1f447f3dcb35995e9d7ccd68b6eb54f7 Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Wed, 29 Apr 2026 15:30:59 +0200 Subject: [PATCH 2/2] chore(deps): point saorsa-transport at mick/remove-bootstrap-cache branch Pulls in the matching saorsa-transport branch where the bootstrap cache module was deleted. Required for this saorsa-core branch to build, since the prior public surface (BootstrapCache, BootstrapCacheConfig, BootstrapTokenStore, etc.) has been removed. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 3 +-- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cba9d56d..d1345270 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2587,8 +2587,7 @@ dependencies = [ [[package]] name = "saorsa-transport" version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9df373519d88540825f435bf3c5c6e300377871b28f16cdd461db3c3f4bb5323" +source = "git+https://github.com/saorsa-labs/saorsa-transport.git?branch=mick%2Fremove-bootstrap-cache#f3ad915d1395755675f41108b1391ea346267ef2" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 416e2c56..798d6c62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,7 +64,7 @@ once_cell = "1.21" dashmap = "6" # Networking -saorsa-transport = "0.33.0" +saorsa-transport = { git = "https://github.com/saorsa-labs/saorsa-transport.git", branch = "mick/remove-bootstrap-cache" } # Core-specific dependencies dirs = "6.0"