tenzro_network/peer_manager.rs
1//! Peer tracking and reputation management for Tenzro Network
2//!
3//! This module implements comprehensive peer management including:
4//!
5//! - **Reputation scoring**: Peers earn reputation from +100 to -100 based on behavior
6//! - **Rate limiting**: Per-peer sliding window rate limiting (default: 100 msgs/10s)
7//! - **Automatic banning**: Peers are auto-banned when reputation drops below -50
8//! - **Ban management**: Configurable ban duration (default: 1 hour) with automatic expiry
9//! - **Connection tracking**: Failed connection attempts trigger auto-banning after 5 failures
10//!
11//! Rate limiting is implemented using a sliding window approach with timestamps stored
12//! in a VecDeque. The `check_rate_limit()` method should be called for each incoming
13//! message to enforce limits.
14
15use dashmap::{DashMap, DashSet};
16use governor::{
17 clock::DefaultClock,
18 state::{InMemoryState, NotKeyed},
19 Quota, RateLimiter,
20};
21use libp2p::{Multiaddr, PeerId};
22use std::collections::{HashSet, VecDeque};
23use std::net::IpAddr;
24use std::num::NonZeroU32;
25use std::sync::Arc;
26use std::time::{Duration, Instant};
27use tenzro_types::network::{NetworkRole, PeerStatus};
28
29/// Type alias for a keyed rate limiter over IP addresses.
30/// Each IP gets its own token bucket; eviction is handled by governor internally.
31type IpRateLimiter = RateLimiter<IpAddr, dashmap::DashMap<IpAddr, governor::state::InMemoryState>, DefaultClock>;
32
33/// Type alias for a global (non-keyed) rate limiter.
34type GlobalRateLimiter = RateLimiter<NotKeyed, InMemoryState, DefaultClock>;
35
36/// Trait for checking validator set membership.
37///
38/// Implemented by the node to provide the network layer with access to the
39/// current validator set without creating a circular dependency on tenzro-consensus.
40pub trait ValidatorRegistry: Send + Sync {
41 /// Returns true if the given PeerId is a known, active validator.
42 fn is_validator(&self, peer_id: &PeerId) -> bool;
43
44 /// Returns the set of all active validator PeerIds.
45 fn validator_peer_ids(&self) -> HashSet<PeerId>;
46
47 /// Attempt to dynamically register this peer as a validator after the
48 /// identify handshake has confirmed it speaks a trusted Tenzro protocol.
49 ///
50 /// Default implementation: no-op. The node-level registry overrides this
51 /// to admit peers so that long-running validator topics (consensus,
52 /// attestations) don't cause mutual peer-score decay & gossipsub bans
53 /// when a new validator joins after the boot set has been wired.
54 fn try_add_validator(&self, _peer_id: &PeerId) {}
55
56 /// Remove a peer from the validator set.
57 ///
58 /// Called by the peer manager when a peer is banned for misbehavior —
59 /// a banned peer must not continue to be authorized for validator-only
60 /// gossipsub topics (consensus, attestations). This is the symmetric
61 /// counterpart to `try_add_validator` and ensures the validator set
62 /// stays consistent with the connected/admissible peer set.
63 ///
64 /// Default implementation: no-op. The node-level registry overrides this.
65 fn try_remove_validator(&self, _peer_id: &PeerId) {}
66}
67
68/// Topics that require validator authorization for message publishing.
69/// Non-validators can subscribe and receive these topics (light clients)
70/// but cannot publish to them.
71/// NOTE: "blocks" is intentionally excluded — block announcements must propagate
72/// to ALL nodes including RPC nodes and nodes not yet in the local validator registry.
73/// Only consensus voting and attestation messages are restricted to known validators.
74pub const VALIDATOR_ONLY_TOPICS: &[&str] = &[
75 "consensus",
76 "attestations",
77];
78
79/// Peer information tracked by the manager
80#[derive(Debug, Clone)]
81pub struct ManagedPeer {
82 /// Peer ID
83 pub peer_id: PeerId,
84 /// Peer role
85 pub role: Option<NetworkRole>,
86 /// Reputation score (-100 to 100)
87 pub reputation: i32,
88 /// Connection status
89 pub status: PeerStatus,
90 /// Last seen timestamp
91 pub last_seen: Instant,
92 /// Number of failed connections
93 pub failed_connections: u32,
94 /// Ban expiration time (if banned)
95 pub ban_until: Option<Instant>,
96 /// Protocol version
97 pub protocol_version: Option<String>,
98 /// Message timestamps for rate limiting (sliding window)
99 pub message_timestamps: VecDeque<Instant>,
100 /// Last observed remote multiaddr for the peer's primary connection.
101 /// Used to detect address migrations (e.g. QUIC path migration, mobile
102 /// network switches, NAT rebinding). When the remote endpoint of a newly
103 /// established connection differs from this value, a migration counter is
104 /// incremented at the service layer.
105 pub last_endpoint: Option<Multiaddr>,
106}
107
108impl ManagedPeer {
109 /// Creates a new peer entry
110 pub fn new(peer_id: PeerId) -> Self {
111 Self {
112 peer_id,
113 role: None,
114 reputation: 0,
115 status: PeerStatus::Disconnected,
116 last_seen: Instant::now(),
117 failed_connections: 0,
118 ban_until: None,
119 protocol_version: None,
120 message_timestamps: VecDeque::new(),
121 last_endpoint: None,
122 }
123 }
124
125 /// Checks if the peer is banned
126 pub fn is_banned(&self) -> bool {
127 if let Some(until) = self.ban_until {
128 Instant::now() < until
129 } else {
130 false
131 }
132 }
133
134 /// Increases reputation
135 pub fn increase_reputation(&mut self, amount: i32) {
136 self.reputation = (self.reputation + amount).min(100);
137 }
138
139 /// Decreases reputation
140 pub fn decrease_reputation(&mut self, amount: i32) {
141 self.reputation = (self.reputation - amount).max(-100);
142 }
143
144 /// Updates last seen time
145 pub fn update_last_seen(&mut self) {
146 self.last_seen = Instant::now();
147 }
148}
149
150/// Peer manager for tracking and managing peers
151pub struct PeerManager {
152 /// Map of peer ID to peer info
153 peers: DashMap<PeerId, ManagedPeer>,
154 /// Maximum allowed peers
155 max_peers: usize,
156 /// Ban duration for misbehaving peers
157 ban_duration: Duration,
158 /// Reputation threshold for banning
159 ban_threshold: i32,
160 /// Maximum messages per peer per rate limit window
161 rate_limit_max_messages: u32,
162 /// Rate limit sliding window duration
163 rate_limit_window: Duration,
164 /// Optional validator registry for peer authorization
165 validator_registry: Option<Arc<dyn ValidatorRegistry>>,
166 /// Boot-node peers that should never be banned.
167 /// These are critical infrastructure peers (e.g., boot nodes from --boot-nodes CLI).
168 /// They may trigger rate limits due to high block production rates, but banning them
169 /// would isolate the node from the network entirely.
170 /// Peers protected from auto-ban. Boot nodes are added at startup; any
171 /// peer that completes a libp2p Identify handshake announcing a
172 /// `tenzro/` protocol is promoted here, so cluster validators can never
173 /// ban each other via reputation decay or rate-limit penalties.
174 ///
175 /// Stored as a lock-free `DashSet` so Identify-time promotion can mutate
176 /// it from the `&self` swarm event path (see
177 /// `try_register_validator_on_identify`).
178 protected_peers: DashSet<PeerId>,
179 /// Per-IP dial rate limiter (Sybil / eclipse attack defence).
180 ///
181 /// Limits inbound dial attempts from a single IP to prevent an attacker
182 /// from consuming all 32 pending-inbound slots by repeatedly reconnecting.
183 /// Default quota: 10 dials/minute per IP with burst of 5.
184 ip_dial_limiter: Arc<IpRateLimiter>,
185 /// Global dial rate limiter — protects against aggregate dial floods.
186 /// Default: 200 dials/minute with burst of 20.
187 global_dial_limiter: Arc<GlobalRateLimiter>,
188}
189
190impl PeerManager {
191 /// Creates a new peer manager
192 pub fn new(max_peers: usize) -> Self {
193 // Per-IP dial limit: 10/min, burst 5. Blocks Sybil-by-reconnect where
194 // an attacker tries to fill our 32 pending-inbound slots by repeatedly
195 // dialling from one IP. Legitimate boot-node reconnects use ≤1 dial/min.
196 let ip_quota = Quota::per_minute(NonZeroU32::new(10).unwrap())
197 .allow_burst(NonZeroU32::new(5).unwrap());
198 let ip_dial_limiter = Arc::new(RateLimiter::dashmap_with_clock(ip_quota, DefaultClock::default()));
199
200 // Global dial limit: 200/min, burst 20. Aggregate protection against
201 // dial floods spread across many IPs (distributed Sybil).
202 let global_quota = Quota::per_minute(NonZeroU32::new(200).unwrap())
203 .allow_burst(NonZeroU32::new(20).unwrap());
204 let global_dial_limiter = Arc::new(RateLimiter::direct_with_clock(global_quota, DefaultClock::default()));
205
206 Self {
207 peers: DashMap::new(),
208 max_peers,
209 ban_duration: Duration::from_secs(300), // 5 minutes (was 1 hour — too aggressive)
210 ban_threshold: -50,
211 rate_limit_max_messages: 1000, // 1000 messages per window (high to accommodate block production)
212 rate_limit_window: Duration::from_secs(10), // 10 second window
213 validator_registry: None,
214 protected_peers: DashSet::new(),
215 ip_dial_limiter,
216 global_dial_limiter,
217 }
218 }
219
220 /// Checks if a dial from the given IP should be allowed.
221 ///
222 /// Consults two rate limiters:
223 /// 1. Per-IP bucket — blocks Sybil-by-reconnect from a single source
224 /// 2. Global bucket — blocks aggregate dial floods across many IPs
225 ///
226 /// Returns `true` if the dial is within both rate limits, `false` if
227 /// either limit has been exceeded. The caller (service.rs swarm dial
228 /// handler) should reject the connection and increment
229 /// `DIAL_REJECTED_RATE_LIMIT` on a `false` result.
230 pub fn check_dial_rate_limit(&self, remote_ip: IpAddr) -> bool {
231 if self.global_dial_limiter.check().is_err() {
232 tracing::warn!("Global dial rate limit exceeded; rejecting connection");
233 return false;
234 }
235 if self.ip_dial_limiter.check_key(&remote_ip).is_err() {
236 tracing::warn!(ip = %remote_ip, "Per-IP dial rate limit exceeded; rejecting connection");
237 return false;
238 }
239 true
240 }
241
242 /// Returns the gossipsub application-specific score for a peer.
243 ///
244 /// Maps our internal reputation (-100 .. +100) onto the f64 range used
245 /// by libp2p gossipsub's P5 factor. Gossipsub combines this with P1-P4
246 /// and P6-P7 via weighted sum. Typical mapping:
247 ///
248 /// - Reputation +100 → P5 score +32.0 (capped at topic_score_cap)
249 /// - Reputation 0 → P5 score 0.0
250 /// - Reputation -100 → P5 score -32.0
251 ///
252 /// Peers not tracked return 0.0 (neutral). This method is designed to
253 /// be hot-path safe — no allocations, single DashMap lookup.
254 pub fn app_specific_score(&self, peer_id: &PeerId) -> f64 {
255 self.peers
256 .get(peer_id)
257 .map(|p| (p.reputation as f64) * 0.32)
258 .unwrap_or(0.0)
259 }
260
261 /// Creates a new peer manager with custom rate limiting
262 pub fn with_rate_limit(max_peers: usize, max_messages: u32, window_secs: u64) -> Self {
263 Self {
264 rate_limit_max_messages: max_messages,
265 rate_limit_window: Duration::from_secs(window_secs),
266 ..Self::new(max_peers)
267 }
268 }
269
270 /// Sets the validator registry for peer authorization
271 pub fn set_validator_registry(&mut self, registry: Arc<dyn ValidatorRegistry>) {
272 self.validator_registry = Some(registry);
273 }
274
275 /// Returns a clone of the installed validator registry, if any.
276 ///
277 /// Used by the network service's admitted-mesh gate to intersect the
278 /// gossipsub mesh peer set with the validator-admitted set, so that the
279 /// first consensus publish only fires after enough mesh peers have been
280 /// admitted via identify (closing the race where messages arrive before
281 /// `try_register_validator_on_identify` has fired and get silently dropped
282 /// by `authorize_peer_for_topic`).
283 pub fn validator_registry(&self) -> Option<Arc<dyn ValidatorRegistry>> {
284 self.validator_registry.clone()
285 }
286
287 /// Checks if a peer is authorized to publish on a validator-only topic.
288 ///
289 /// Returns `true` if:
290 /// - The topic is not validator-only (e.g., transactions, status, inference), OR
291 /// - No validator registry is set (permissive mode), OR
292 /// - The peer is a known validator in the current validator set
293 ///
294 /// Returns `false` if the topic requires validator status and the peer is not a validator.
295 pub fn authorize_peer_for_topic(&self, peer_id: &PeerId, topic_name: &str) -> bool {
296 // Check if this topic requires validator authorization
297 let is_validator_topic = VALIDATOR_ONLY_TOPICS.iter().any(|t| topic_name.contains(t));
298
299 if !is_validator_topic {
300 // Non-validator topics (transactions, models, inference, status) are open to all
301 return true;
302 }
303
304 // Validator-only topic — check registry
305 match &self.validator_registry {
306 Some(registry) => {
307 let authorized = registry.is_validator(peer_id);
308 if !authorized {
309 tracing::warn!(
310 peer = %peer_id,
311 topic = topic_name,
312 "Rejected message from non-validator on validator-only topic"
313 );
314 }
315 authorized
316 }
317 None => {
318 // No registry configured — permissive mode (pre-genesis or single-node)
319 true
320 }
321 }
322 }
323
324 /// Dynamically admit a peer as a validator after the identify handshake
325 /// confirms it speaks a trusted Tenzro protocol.
326 ///
327 /// This closes the "mutual ban" gap where a peer that joins AFTER the
328 /// static boot-node list was wired would never be admitted to validator
329 /// topics, causing its consensus/attestation messages to be rejected and
330 /// its peer score to decay below thresholds.
331 ///
332 /// Only protocol versions starting with `"tenzro/"` are admitted. All
333 /// other identify payloads are ignored (no-op).
334 pub fn try_register_validator_on_identify(&self, peer_id: &PeerId, protocol_version: &str) {
335 if !protocol_version.starts_with("tenzro/") {
336 return;
337 }
338
339 // Promote to protected so cluster validators can never mutually ban
340 // each other via reputation decay / rate-limit penalties. This is
341 // the load-bearing fix for the "all blocks empty; peers perpetually
342 // banned" class of outage on the live testnet.
343 self.add_protected_peer(*peer_id);
344
345 if let Some(registry) = &self.validator_registry
346 && !registry.is_validator(peer_id)
347 {
348 tracing::info!(
349 peer = %peer_id,
350 protocol = protocol_version,
351 "Dynamically admitting peer as validator via identify handshake"
352 );
353 registry.try_add_validator(peer_id);
354 }
355 }
356
357 /// Adds a new peer or updates existing peer
358 pub fn add_peer(&self, peer_id: PeerId) {
359 self.peers.entry(peer_id).or_insert_with(|| ManagedPeer::new(peer_id));
360 }
361
362 /// Removes a peer
363 pub fn remove_peer(&self, peer_id: &PeerId) {
364 self.peers.remove(peer_id);
365 }
366
367 /// Gets peer information
368 pub fn get_peer(&self, peer_id: &PeerId) -> Option<ManagedPeer> {
369 self.peers.get(peer_id).map(|p| p.clone())
370 }
371
372 /// Updates peer status
373 pub fn update_status(&self, peer_id: &PeerId, status: PeerStatus) {
374 if let Some(mut peer) = self.peers.get_mut(peer_id) {
375 peer.status = status;
376 peer.update_last_seen();
377 }
378 }
379
380 /// Records the observed remote multiaddr for a peer's newly established
381 /// connection. Returns `true` if the previous `last_endpoint` was set and
382 /// differed from `addr` — i.e. an address migration was observed
383 /// (QUIC path migration, mobile network switch, NAT rebinding). Returns
384 /// `false` on first observation or when the address is unchanged.
385 pub fn update_endpoint(&self, peer_id: &PeerId, addr: Multiaddr) -> bool {
386 let mut migrated = false;
387 if let Some(mut peer) = self.peers.get_mut(peer_id) {
388 match &peer.last_endpoint {
389 Some(prev) if prev != &addr => {
390 migrated = true;
391 }
392 _ => {}
393 }
394 peer.last_endpoint = Some(addr);
395 }
396 migrated
397 }
398
399 /// Updates peer role
400 pub fn update_role(&self, peer_id: &PeerId, role: NetworkRole) {
401 if let Some(mut peer) = self.peers.get_mut(peer_id) {
402 peer.role = Some(role);
403 }
404 }
405
406 /// Updates peer protocol version
407 pub fn update_protocol_version(&self, peer_id: &PeerId, version: String) {
408 if let Some(mut peer) = self.peers.get_mut(peer_id) {
409 peer.protocol_version = Some(version);
410 }
411 }
412
413 /// Increases a peer's reputation
414 pub fn increase_reputation(&self, peer_id: &PeerId, amount: i32) {
415 if let Some(mut peer) = self.peers.get_mut(peer_id) {
416 peer.increase_reputation(amount);
417 tracing::debug!(
418 "Increased reputation for peer {} by {} (new: {})",
419 peer_id,
420 amount,
421 peer.reputation
422 );
423 }
424 }
425
426 /// Decreases a peer's reputation and potentially bans them.
427 ///
428 /// Protected peers (boot nodes, peers that completed a `tenzro/`
429 /// Identify handshake) are skipped entirely — neither their reputation
430 /// nor their ban state is touched. This is deliberate: on a small
431 /// validator set (e.g. 3 validators on the live testnet), HotStuff-2
432 /// consensus bursts plus any single malformed gossip frame would
433 /// otherwise drive mutual reputation below the ban threshold within
434 /// seconds and wedge the chain in empty-block mode.
435 pub fn decrease_reputation(&self, peer_id: &PeerId, amount: i32) {
436 if self.protected_peers.contains(peer_id) {
437 tracing::trace!(
438 "Skipping reputation decrease for protected peer {} (amount={})",
439 peer_id,
440 amount
441 );
442 return;
443 }
444 if let Some(mut peer) = self.peers.get_mut(peer_id) {
445 peer.decrease_reputation(amount);
446 tracing::debug!(
447 "Decreased reputation for peer {} by {} (new: {})",
448 peer_id,
449 amount,
450 peer.reputation
451 );
452
453 // Auto-ban if reputation drops too low
454 if peer.reputation <= self.ban_threshold {
455 self.ban_peer_internal(&mut peer);
456 }
457 }
458 }
459
460 /// Bans a peer for misbehavior
461 pub fn ban_peer(&self, peer_id: &PeerId) {
462 if let Some(mut peer) = self.peers.get_mut(peer_id) {
463 self.ban_peer_internal(&mut peer);
464 }
465 }
466
467 /// Marks a peer as a protected peer (e.g., boot node).
468 /// Protected peers will never be auto-banned, even if they trigger rate limits.
469 ///
470 /// Takes `&self` because `protected_peers` is a lock-free `DashSet`.
471 /// The previous `&mut self` signature prevented Identify-time promotion
472 /// from the swarm event loop (which holds only `&self` on PeerManager).
473 pub fn add_protected_peer(&self, peer_id: PeerId) {
474 if self.protected_peers.insert(peer_id) {
475 tracing::info!("Added protected peer {}", peer_id);
476 }
477 }
478
479 /// Checks if a peer is protected (boot node or critical infrastructure)
480 pub fn is_protected(&self, peer_id: &PeerId) -> bool {
481 self.protected_peers.contains(peer_id)
482 }
483
484 /// Internal ban implementation
485 fn ban_peer_internal(&self, peer: &mut ManagedPeer) {
486 if self.protected_peers.contains(&peer.peer_id) {
487 tracing::debug!(
488 "Skipping ban for protected peer {} (reputation: {})",
489 peer.peer_id,
490 peer.reputation
491 );
492 // Reset reputation to 0 instead of banning — prevents runaway negative scores
493 peer.reputation = 0;
494 return;
495 }
496 peer.status = PeerStatus::Banned;
497 peer.ban_until = Some(Instant::now() + self.ban_duration);
498 tracing::warn!("Banned peer {} for {} seconds", peer.peer_id, self.ban_duration.as_secs());
499
500 // A banned peer must also be removed from the validator set so it
501 // cannot continue to be authorized for validator-only gossipsub topics
502 // (consensus, attestations). This keeps validator authorization
503 // consistent with peer admission state.
504 if let Some(registry) = &self.validator_registry
505 && registry.is_validator(&peer.peer_id)
506 {
507 registry.try_remove_validator(&peer.peer_id);
508 }
509 }
510
511 /// Unbans a peer
512 pub fn unban_peer(&self, peer_id: &PeerId) {
513 if let Some(mut peer) = self.peers.get_mut(peer_id) {
514 peer.ban_until = None;
515 peer.status = PeerStatus::Disconnected;
516 peer.reputation = 0; // Reset reputation
517 tracing::info!("Unbanned peer {}", peer_id);
518 }
519 }
520
521 /// Checks if a peer is banned
522 pub fn is_banned(&self, peer_id: &PeerId) -> bool {
523 // Protected peers are never considered banned, even if a previous
524 // (pre-protection) ban is still lingering on their ManagedPeer record.
525 // Clear the ban_until stamp on first read to keep downstream paths
526 // (ban duration metrics, reconnect logic) consistent.
527 if self.protected_peers.contains(peer_id) {
528 if let Some(mut peer) = self.peers.get_mut(peer_id)
529 && peer.is_banned()
530 {
531 tracing::info!(
532 peer = %peer_id,
533 "Clearing lingering ban on now-protected peer"
534 );
535 peer.ban_until = None;
536 peer.reputation = peer.reputation.max(0);
537 }
538 return false;
539 }
540 self.peers
541 .get(peer_id)
542 .map(|p| p.is_banned())
543 .unwrap_or(false)
544 }
545
546 /// Gets all connected peers
547 pub fn connected_peers(&self) -> Vec<PeerId> {
548 self.peers
549 .iter()
550 .filter(|entry| entry.status == PeerStatus::Connected)
551 .map(|entry| entry.peer_id)
552 .collect()
553 }
554
555 /// Gets peers by role
556 pub fn peers_by_role(&self, role: NetworkRole) -> Vec<PeerId> {
557 self.peers
558 .iter()
559 .filter(|entry| entry.role == Some(role))
560 .map(|entry| entry.peer_id)
561 .collect()
562 }
563
564 /// Gets the number of connected peers
565 pub fn connected_count(&self) -> usize {
566 self.peers
567 .iter()
568 .filter(|entry| entry.status == PeerStatus::Connected)
569 .count()
570 }
571
572 /// Checks if we can accept more peers
573 pub fn can_accept_peer(&self) -> bool {
574 self.connected_count() < self.max_peers
575 }
576
577 /// Checks if a peer is rate limited and records the message.
578 ///
579 /// Returns `true` if the peer is within rate limits (message allowed),
580 /// `false` if the peer has exceeded the rate limit (message should be dropped).
581 /// Automatically decreases reputation for rate-limited peers.
582 pub fn check_rate_limit(&self, peer_id: &PeerId) -> bool {
583 // Protected peers (boot nodes + identified tenzro/ peers) bypass
584 // the rate limiter entirely. HotStuff-2 + gossip amplification can
585 // legitimately exceed 1000 msgs/10s on a small cluster during
586 // view changes; dropping those messages stalls consensus.
587 if self.protected_peers.contains(peer_id) {
588 return true;
589 }
590
591 if let Some(mut peer) = self.peers.get_mut(peer_id) {
592 let now = Instant::now();
593 let window_start = now - self.rate_limit_window;
594
595 // Evict timestamps outside the sliding window
596 while peer.message_timestamps.front().is_some_and(|&t| t < window_start) {
597 peer.message_timestamps.pop_front();
598 }
599
600 // Check if we're over the limit
601 if peer.message_timestamps.len() >= self.rate_limit_max_messages as usize {
602 // Rate limited — penalize reputation
603 peer.decrease_reputation(5);
604 tracing::warn!(
605 "Rate limited peer {} ({} msgs in {}s window)",
606 peer_id,
607 peer.message_timestamps.len(),
608 self.rate_limit_window.as_secs()
609 );
610
611 // Auto-ban if reputation drops too low
612 if peer.reputation <= self.ban_threshold {
613 self.ban_peer_internal(&mut peer);
614 }
615
616 return false;
617 }
618
619 // Record this message
620 peer.message_timestamps.push_back(now);
621 true
622 } else {
623 // Unknown peer — allow but don't track
624 true
625 }
626 }
627
628 /// Records a failed connection attempt.
629 ///
630 /// In a K8s environment, outgoing connection errors are common during pod startup
631 /// (e.g., when validators 1/2 try to dial validator-0 before it's fully ready).
632 /// These are transient and should NOT trigger banning — only actual misbehavior
633 /// (equivocation, spam, invalid messages) warrants a ban. We still track the
634 /// failure count for diagnostics.
635 pub fn record_failed_connection(&self, peer_id: &PeerId) {
636 if let Some(mut peer) = self.peers.get_mut(peer_id) {
637 peer.failed_connections += 1;
638 tracing::debug!(
639 "Recorded failed connection for peer {} (total: {})",
640 peer_id,
641 peer.failed_connections
642 );
643 // NOTE: Intentionally NOT auto-banning on connection failures.
644 // libp2p will retry with exponential backoff; banning here causes
645 // permanent validator isolation after just a few startup glitches.
646 }
647 }
648
649 /// Cleans up stale peers (not seen for a long time)
650 pub fn cleanup_stale_peers(&self, max_age: Duration) {
651 let now = Instant::now();
652 let stale_peers: Vec<PeerId> = self
653 .peers
654 .iter()
655 .filter(|entry| {
656 entry.status == PeerStatus::Disconnected
657 && now.duration_since(entry.last_seen) > max_age
658 })
659 .map(|entry| entry.peer_id)
660 .collect();
661
662 for peer_id in stale_peers {
663 self.remove_peer(&peer_id);
664 tracing::debug!("Removed stale peer {}", peer_id);
665 }
666 }
667
668 /// Cleans up expired bans
669 pub fn cleanup_expired_bans(&self) {
670 let now = Instant::now();
671 for mut entry in self.peers.iter_mut() {
672 if let Some(until) = entry.ban_until
673 && now >= until
674 {
675 entry.ban_until = None;
676 entry.status = PeerStatus::Disconnected;
677 entry.reputation = 0;
678 tracing::info!("Ban expired for peer {}", entry.peer_id);
679 }
680 }
681 }
682
683 /// Gets statistics about peers
684 pub fn stats(&self) -> PeerManagerStats {
685 let mut stats = PeerManagerStats::default();
686
687 for peer in self.peers.iter() {
688 match peer.status {
689 PeerStatus::Connected => stats.connected += 1,
690 PeerStatus::Connecting => stats.connecting += 1,
691 PeerStatus::Disconnected => stats.disconnected += 1,
692 PeerStatus::Failed => stats.failed += 1,
693 PeerStatus::Banned => stats.banned += 1,
694 }
695 }
696
697 stats.total = self.peers.len();
698 stats
699 }
700}
701
702/// Peer manager statistics
703#[derive(Debug, Default, Clone, Copy)]
704pub struct PeerManagerStats {
705 pub total: usize,
706 pub connected: usize,
707 pub connecting: usize,
708 pub disconnected: usize,
709 pub failed: usize,
710 pub banned: usize,
711}
712
713#[cfg(test)]
714mod tests {
715 use super::*;
716
717 /// Test implementation of ValidatorRegistry
718 struct TestValidatorRegistry {
719 validators: HashSet<PeerId>,
720 }
721
722 impl TestValidatorRegistry {
723 fn new(validators: Vec<PeerId>) -> Self {
724 Self {
725 validators: validators.into_iter().collect(),
726 }
727 }
728 }
729
730 impl ValidatorRegistry for TestValidatorRegistry {
731 fn is_validator(&self, peer_id: &PeerId) -> bool {
732 self.validators.contains(peer_id)
733 }
734
735 fn validator_peer_ids(&self) -> HashSet<PeerId> {
736 self.validators.clone()
737 }
738 }
739
740 #[test]
741 fn test_peer_reputation() {
742 let manager = PeerManager::new(10);
743 let peer_id = PeerId::random();
744
745 manager.add_peer(peer_id);
746
747 manager.increase_reputation(&peer_id, 10);
748 let peer = manager.get_peer(&peer_id).unwrap();
749 assert_eq!(peer.reputation, 10);
750
751 manager.decrease_reputation(&peer_id, 5);
752 let peer = manager.get_peer(&peer_id).unwrap();
753 assert_eq!(peer.reputation, 5);
754 }
755
756 #[test]
757 fn test_peer_banning() {
758 let manager = PeerManager::new(10);
759 let peer_id = PeerId::random();
760
761 manager.add_peer(peer_id);
762 assert!(!manager.is_banned(&peer_id));
763
764 manager.ban_peer(&peer_id);
765 assert!(manager.is_banned(&peer_id));
766
767 manager.unban_peer(&peer_id);
768 assert!(!manager.is_banned(&peer_id));
769 }
770
771 #[test]
772 fn test_auto_ban_on_low_reputation() {
773 let manager = PeerManager::new(10);
774 let peer_id = PeerId::random();
775
776 manager.add_peer(peer_id);
777 manager.decrease_reputation(&peer_id, 160); // 100 - 160 = -60, below -50 threshold
778
779 assert!(manager.is_banned(&peer_id));
780 }
781
782 #[test]
783 fn test_rate_limiting() {
784 // Allow only 5 messages in a 10 second window
785 let manager = PeerManager::with_rate_limit(10, 5, 10);
786 let peer_id = PeerId::random();
787
788 manager.add_peer(peer_id);
789
790 // First 5 messages should pass
791 for _ in 0..5 {
792 assert!(manager.check_rate_limit(&peer_id));
793 }
794
795 // 6th message should be rate limited
796 assert!(!manager.check_rate_limit(&peer_id));
797
798 // Reputation should have decreased
799 let peer = manager.get_peer(&peer_id).unwrap();
800 assert!(peer.reputation < 0);
801 }
802
803 #[test]
804 fn test_validator_authorization_no_registry() {
805 // Without a registry, all peers are allowed (permissive mode)
806 let manager = PeerManager::new(10);
807 let peer_id = PeerId::random();
808
809 assert!(manager.authorize_peer_for_topic(&peer_id, "tenzro/blocks"));
810 assert!(manager.authorize_peer_for_topic(&peer_id, "tenzro/consensus"));
811 assert!(manager.authorize_peer_for_topic(&peer_id, "tenzro/transactions"));
812 }
813
814 #[test]
815 fn test_validator_authorization_with_registry() {
816 let validator = PeerId::random();
817 let non_validator = PeerId::random();
818
819 let registry = Arc::new(TestValidatorRegistry::new(vec![validator]));
820
821 let mut manager = PeerManager::new(10);
822 manager.set_validator_registry(registry);
823
824 // Validator should be allowed on all topics
825 assert!(manager.authorize_peer_for_topic(&validator, "tenzro/blocks"));
826 assert!(manager.authorize_peer_for_topic(&validator, "tenzro/consensus"));
827 assert!(manager.authorize_peer_for_topic(&validator, "tenzro/attestations"));
828 assert!(manager.authorize_peer_for_topic(&validator, "tenzro/transactions"));
829
830 // Non-validator should be blocked on validator-only topics (consensus, attestations)
831 // NOTE: "tenzro/blocks" is intentionally open to all peers so that
832 // RPC nodes and model providers can receive block gossip for chain sync.
833 assert!(manager.authorize_peer_for_topic(&non_validator, "tenzro/blocks"));
834 assert!(!manager.authorize_peer_for_topic(&non_validator, "tenzro/consensus"));
835 assert!(!manager.authorize_peer_for_topic(&non_validator, "tenzro/attestations"));
836
837 // Non-validator should be allowed on open topics
838 assert!(manager.authorize_peer_for_topic(&non_validator, "tenzro/transactions"));
839 assert!(manager.authorize_peer_for_topic(&non_validator, "tenzro/status"));
840 assert!(manager.authorize_peer_for_topic(&non_validator, "tenzro/inference"));
841 assert!(manager.authorize_peer_for_topic(&non_validator, "tenzro/models"));
842 }
843
844 #[test]
845 fn test_protected_peer_not_banned() {
846 let manager = PeerManager::new(10);
847 let protected = PeerId::random();
848 let normal = PeerId::random();
849
850 // Register protected peer
851 manager.add_protected_peer(protected);
852 assert!(manager.is_protected(&protected));
853 assert!(!manager.is_protected(&normal));
854
855 // Add both peers
856 manager.add_peer(protected);
857 manager.add_peer(normal);
858
859 // Tank both peers' reputation below ban threshold (-50)
860 manager.decrease_reputation(&protected, 160);
861 manager.decrease_reputation(&normal, 160);
862
863 // Normal peer should be banned
864 assert!(manager.is_banned(&normal));
865
866 // Protected peer should NOT be banned, and reputation reset to 0
867 assert!(!manager.is_banned(&protected));
868 let peer = manager.get_peer(&protected).unwrap();
869 assert_eq!(peer.reputation, 0);
870 }
871}