synor/crates/synor-network/src/reputation.rs
2026-01-08 05:22:24 +05:30

844 lines
26 KiB
Rust

//! Peer reputation and banning system for Synor network.
//!
//! This module provides a comprehensive reputation system to track peer behavior
//! and automatically ban misbehaving peers. The system tracks:
//! - Reputation scores (0-100)
//! - Violation history
//! - Ban status and expiry
//! - Gradual score recovery over time
//!
//! # Score Mechanics
//!
//! - New peers start with a score of 50
//! - Successful interactions increase score (up to 100)
//! - Violations decrease score based on severity
//! - Peers are auto-banned when score drops below 20
//! - Scores gradually recover over time when peer behaves well
use hashbrown::HashMap;
use libp2p::PeerId;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant};
/// Types of violations that affect peer reputation.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ViolationType {
/// Peer sent an invalid or malformed message.
InvalidMessage,
/// Peer responded too slowly or timed out.
SlowResponse,
/// Peer is sending too many requests (spam).
Spam,
/// Peer violated the protocol rules.
ProtocolViolation,
/// Peer sent duplicate data.
DuplicateData,
/// Peer provided incorrect data (e.g., bad block).
InvalidData,
/// Peer disconnected unexpectedly.
UnexpectedDisconnect,
/// Peer failed to respond to a request.
NoResponse,
}
impl ViolationType {
/// Returns the penalty for this violation type.
pub fn penalty(&self) -> u8 {
match self {
ViolationType::InvalidMessage => 15,
ViolationType::SlowResponse => 5,
ViolationType::Spam => 20,
ViolationType::ProtocolViolation => 25,
ViolationType::DuplicateData => 3,
ViolationType::InvalidData => 30,
ViolationType::UnexpectedDisconnect => 5,
ViolationType::NoResponse => 10,
}
}
/// Returns the description of this violation type.
pub fn description(&self) -> &'static str {
match self {
ViolationType::InvalidMessage => "sent invalid or malformed message",
ViolationType::SlowResponse => "responded too slowly",
ViolationType::Spam => "sending excessive requests",
ViolationType::ProtocolViolation => "violated protocol rules",
ViolationType::DuplicateData => "sent duplicate data",
ViolationType::InvalidData => "provided invalid data",
ViolationType::UnexpectedDisconnect => "disconnected unexpectedly",
ViolationType::NoResponse => "failed to respond",
}
}
}
/// A record of a single violation.
#[derive(Clone, Debug)]
pub struct ViolationRecord {
/// Type of violation.
pub violation_type: ViolationType,
/// When the violation occurred.
pub timestamp: Instant,
/// Optional context about the violation.
pub context: Option<String>,
}
impl ViolationRecord {
/// Creates a new violation record.
pub fn new(violation_type: ViolationType) -> Self {
ViolationRecord {
violation_type,
timestamp: Instant::now(),
context: None,
}
}
/// Creates a new violation record with context.
pub fn with_context(violation_type: ViolationType, context: String) -> Self {
ViolationRecord {
violation_type,
timestamp: Instant::now(),
context: Some(context),
}
}
}
/// Reputation tracking for a single peer.
#[derive(Clone, Debug)]
pub struct PeerReputation {
/// Peer identifier.
pub peer_id: PeerId,
/// Current reputation score (0-100).
pub score: u8,
/// When the peer was last seen active.
pub last_seen: Instant,
/// History of violations.
pub violations: Vec<ViolationRecord>,
/// When the ban expires (None if not banned).
pub ban_until: Option<Instant>,
/// Total successful interactions.
pub successes: u64,
/// Total failed interactions.
pub failures: u64,
/// Last time the score was recovered.
last_recovery: Instant,
}
impl PeerReputation {
/// Initial reputation score for new peers.
pub const INITIAL_SCORE: u8 = 50;
/// Maximum reputation score.
pub const MAX_SCORE: u8 = 100;
/// Minimum reputation score (below this, peer is banned).
pub const BAN_THRESHOLD: u8 = 20;
/// Score increment for successful interactions.
pub const SUCCESS_BONUS: u8 = 1;
/// How often scores can recover (in seconds).
pub const RECOVERY_INTERVAL_SECS: u64 = 300; // 5 minutes
/// How much score recovers each interval.
pub const RECOVERY_AMOUNT: u8 = 2;
/// Creates a new peer reputation tracker.
pub fn new(peer_id: PeerId) -> Self {
let now = Instant::now();
PeerReputation {
peer_id,
score: Self::INITIAL_SCORE,
last_seen: now,
violations: Vec::new(),
ban_until: None,
successes: 0,
failures: 0,
last_recovery: now,
}
}
/// Returns true if the peer is currently banned.
pub fn is_banned(&self) -> bool {
if let Some(ban_until) = self.ban_until {
Instant::now() < ban_until
} else {
false
}
}
/// Returns the remaining ban duration, if any.
pub fn ban_remaining(&self) -> Option<Duration> {
self.ban_until.and_then(|until| {
let now = Instant::now();
if now < until {
Some(until - now)
} else {
None
}
})
}
/// Records a successful interaction.
pub fn record_success(&mut self) {
self.successes += 1;
self.last_seen = Instant::now();
self.score = self
.score
.saturating_add(Self::SUCCESS_BONUS)
.min(Self::MAX_SCORE);
}
/// Records a violation and returns true if the peer should be banned.
pub fn record_violation(&mut self, violation: ViolationType) -> bool {
self.record_violation_with_context(violation, None)
}
/// Records a violation with context and returns true if the peer should be banned.
pub fn record_violation_with_context(
&mut self,
violation: ViolationType,
context: Option<String>,
) -> bool {
self.failures += 1;
self.last_seen = Instant::now();
let record = if let Some(ctx) = context {
ViolationRecord::with_context(violation, ctx)
} else {
ViolationRecord::new(violation)
};
self.violations.push(record);
// Apply penalty
let penalty = violation.penalty();
self.score = self.score.saturating_sub(penalty);
// Check if should be banned
self.score < Self::BAN_THRESHOLD
}
/// Applies gradual score recovery over time.
/// Call this periodically to allow good peers to recover reputation.
pub fn apply_recovery(&mut self) {
if self.is_banned() {
return;
}
let now = Instant::now();
let elapsed = now.duration_since(self.last_recovery);
let intervals = elapsed.as_secs() / Self::RECOVERY_INTERVAL_SECS;
if intervals > 0 {
let recovery = (intervals as u8).saturating_mul(Self::RECOVERY_AMOUNT);
self.score = self.score.saturating_add(recovery).min(Self::MAX_SCORE);
self.last_recovery = now;
}
}
/// Returns the success rate as a percentage (0-100).
pub fn success_rate(&self) -> f64 {
let total = self.successes + self.failures;
if total == 0 {
100.0
} else {
(self.successes as f64 / total as f64) * 100.0
}
}
/// Returns the number of recent violations (within the last hour).
pub fn recent_violation_count(&self) -> usize {
let one_hour_ago = Instant::now() - Duration::from_secs(3600);
self.violations
.iter()
.filter(|v| v.timestamp > one_hour_ago)
.count()
}
/// Clears violation history older than the specified duration.
pub fn clear_old_violations(&mut self, max_age: Duration) {
let cutoff = Instant::now() - max_age;
self.violations.retain(|v| v.timestamp > cutoff);
}
/// Manually bans the peer for the specified duration.
pub fn ban(&mut self, duration: Duration) {
self.ban_until = Some(Instant::now() + duration);
self.score = 0;
}
/// Unbans the peer and resets their score to a low starting point.
pub fn unban(&mut self) {
self.ban_until = None;
// Don't fully restore reputation - they need to earn it back
self.score = Self::BAN_THRESHOLD;
}
}
/// Configuration for the reputation manager.
#[derive(Clone, Debug)]
pub struct ReputationConfig {
/// Default ban duration.
pub default_ban_duration: Duration,
/// Minimum ban duration.
pub min_ban_duration: Duration,
/// Maximum ban duration.
pub max_ban_duration: Duration,
/// How often to run cleanup of old data.
pub cleanup_interval: Duration,
/// Maximum age of violation records to keep.
pub max_violation_age: Duration,
/// Whether to enable gradual score recovery.
pub enable_recovery: bool,
}
impl Default for ReputationConfig {
fn default() -> Self {
ReputationConfig {
default_ban_duration: Duration::from_secs(3600), // 1 hour
min_ban_duration: Duration::from_secs(300), // 5 minutes
max_ban_duration: Duration::from_secs(86400), // 24 hours
cleanup_interval: Duration::from_secs(600), // 10 minutes
max_violation_age: Duration::from_secs(86400), // 24 hours
enable_recovery: true,
}
}
}
/// Manages reputation for all peers.
pub struct ReputationManager {
/// Per-peer reputation data.
reputations: RwLock<HashMap<PeerId, PeerReputation>>,
/// Configuration.
config: ReputationConfig,
/// Last cleanup time.
last_cleanup: RwLock<Instant>,
}
impl ReputationManager {
/// Creates a new reputation manager with default configuration.
pub fn new() -> Self {
Self::with_config(ReputationConfig::default())
}
/// Creates a new reputation manager with custom configuration.
pub fn with_config(config: ReputationConfig) -> Self {
ReputationManager {
reputations: RwLock::new(HashMap::new()),
config,
last_cleanup: RwLock::new(Instant::now()),
}
}
/// Gets or creates reputation data for a peer.
fn get_or_create(&self, peer_id: &PeerId) -> PeerReputation {
let reputations = self.reputations.read();
if let Some(rep) = reputations.get(peer_id) {
rep.clone()
} else {
drop(reputations);
let rep = PeerReputation::new(*peer_id);
self.reputations.write().insert(*peer_id, rep.clone());
rep
}
}
/// Records a successful interaction with a peer.
pub fn record_success(&self, peer_id: &PeerId) {
let mut reputations = self.reputations.write();
let rep = reputations
.entry(*peer_id)
.or_insert_with(|| PeerReputation::new(*peer_id));
if self.config.enable_recovery {
rep.apply_recovery();
}
rep.record_success();
}
/// Records a violation by a peer.
/// Returns true if the peer was auto-banned as a result.
pub fn record_violation(&self, peer_id: &PeerId, violation: ViolationType) -> bool {
self.record_violation_with_context(peer_id, violation, None)
}
/// Records a violation by a peer with additional context.
/// Returns true if the peer was auto-banned as a result.
pub fn record_violation_with_context(
&self,
peer_id: &PeerId,
violation: ViolationType,
context: Option<String>,
) -> bool {
let mut reputations = self.reputations.write();
let rep = reputations
.entry(*peer_id)
.or_insert_with(|| PeerReputation::new(*peer_id));
let should_ban = rep.record_violation_with_context(violation, context);
if should_ban && rep.ban_until.is_none() {
// Calculate ban duration based on violation history
let ban_duration = self.calculate_ban_duration(rep);
rep.ban(ban_duration);
true
} else {
false
}
}
/// Calculates ban duration based on peer's violation history.
fn calculate_ban_duration(&self, rep: &PeerReputation) -> Duration {
let recent_violations = rep.recent_violation_count();
// Exponential backoff: longer bans for repeat offenders
let multiplier = match recent_violations {
0..=2 => 1,
3..=5 => 2,
6..=10 => 4,
_ => 8,
};
let duration = self.config.default_ban_duration * multiplier;
duration.clamp(self.config.min_ban_duration, self.config.max_ban_duration)
}
/// Checks if a peer is currently banned.
pub fn is_banned(&self, peer_id: &PeerId) -> bool {
let reputations = self.reputations.read();
if let Some(rep) = reputations.get(peer_id) {
rep.is_banned()
} else {
false
}
}
/// Gets the reputation score for a peer.
/// Returns None if the peer is unknown.
pub fn get_score(&self, peer_id: &PeerId) -> Option<u8> {
let mut reputations = self.reputations.write();
if let Some(rep) = reputations.get_mut(peer_id) {
if self.config.enable_recovery {
rep.apply_recovery();
}
Some(rep.score)
} else {
None
}
}
/// Gets the full reputation data for a peer.
pub fn get_reputation(&self, peer_id: &PeerId) -> Option<PeerReputation> {
let mut reputations = self.reputations.write();
if let Some(rep) = reputations.get_mut(peer_id) {
if self.config.enable_recovery {
rep.apply_recovery();
}
Some(rep.clone())
} else {
None
}
}
/// Manually bans a peer for the default duration.
pub fn ban_peer(&self, peer_id: &PeerId) {
self.ban_peer_for(peer_id, self.config.default_ban_duration);
}
/// Manually bans a peer for a specific duration.
pub fn ban_peer_for(&self, peer_id: &PeerId, duration: Duration) {
let mut reputations = self.reputations.write();
let rep = reputations
.entry(*peer_id)
.or_insert_with(|| PeerReputation::new(*peer_id));
rep.ban(duration);
}
/// Unbans a peer.
pub fn unban_peer(&self, peer_id: &PeerId) {
let mut reputations = self.reputations.write();
if let Some(rep) = reputations.get_mut(peer_id) {
rep.unban();
}
}
/// Gets all currently banned peers.
pub fn banned_peers(&self) -> Vec<PeerId> {
self.reputations
.read()
.iter()
.filter(|(_, rep)| rep.is_banned())
.map(|(id, _)| *id)
.collect()
}
/// Gets peers with low reputation (at risk of being banned).
pub fn low_reputation_peers(&self, threshold: u8) -> Vec<(PeerId, u8)> {
self.reputations
.read()
.iter()
.filter(|(_, rep)| rep.score < threshold && !rep.is_banned())
.map(|(id, rep)| (*id, rep.score))
.collect()
}
/// Gets peers sorted by reputation score (highest first).
pub fn peers_by_reputation(&self) -> Vec<(PeerId, u8)> {
let mut peers: Vec<_> = self
.reputations
.read()
.iter()
.filter(|(_, rep)| !rep.is_banned())
.map(|(id, rep)| (*id, rep.score))
.collect();
peers.sort_by(|a, b| b.1.cmp(&a.1));
peers
}
/// Removes reputation data for a peer.
pub fn remove_peer(&self, peer_id: &PeerId) {
self.reputations.write().remove(peer_id);
}
/// Runs periodic cleanup of old data.
pub fn cleanup(&self) {
let now = Instant::now();
// Check if cleanup is needed
{
let last_cleanup = self.last_cleanup.read();
if now.duration_since(*last_cleanup) < self.config.cleanup_interval {
return;
}
}
// Update last cleanup time
*self.last_cleanup.write() = now;
// Clean up old violations and expired bans
let mut reputations = self.reputations.write();
for rep in reputations.values_mut() {
rep.clear_old_violations(self.config.max_violation_age);
// Clear expired bans
if let Some(ban_until) = rep.ban_until {
if now >= ban_until {
rep.ban_until = None;
// Restore to ban threshold score after ban expires
rep.score = PeerReputation::BAN_THRESHOLD;
}
}
// Apply recovery
if self.config.enable_recovery {
rep.apply_recovery();
}
}
}
/// Returns statistics about peer reputations.
pub fn stats(&self) -> ReputationStats {
let reputations = self.reputations.read();
let total = reputations.len();
let banned = reputations.values().filter(|r| r.is_banned()).count();
let low_rep = reputations
.values()
.filter(|r| r.score < 30 && !r.is_banned())
.count();
let high_rep = reputations.values().filter(|r| r.score >= 80).count();
let avg_score = if total > 0 {
reputations.values().map(|r| r.score as u64).sum::<u64>() / total as u64
} else {
50
} as u8;
ReputationStats {
total_peers: total,
banned_peers: banned,
low_reputation_peers: low_rep,
high_reputation_peers: high_rep,
average_score: avg_score,
}
}
}
impl Default for ReputationManager {
fn default() -> Self {
Self::new()
}
}
/// Statistics about peer reputations.
#[derive(Clone, Debug)]
pub struct ReputationStats {
/// Total number of tracked peers.
pub total_peers: usize,
/// Number of currently banned peers.
pub banned_peers: usize,
/// Number of peers with low reputation.
pub low_reputation_peers: usize,
/// Number of peers with high reputation.
pub high_reputation_peers: usize,
/// Average reputation score.
pub average_score: u8,
}
#[cfg(test)]
mod tests {
use super::*;
fn random_peer_id() -> PeerId {
PeerId::random()
}
#[test]
fn test_peer_reputation_new() {
let peer_id = random_peer_id();
let rep = PeerReputation::new(peer_id);
assert_eq!(rep.score, PeerReputation::INITIAL_SCORE);
assert!(!rep.is_banned());
assert_eq!(rep.successes, 0);
assert_eq!(rep.failures, 0);
}
#[test]
fn test_record_success() {
let peer_id = random_peer_id();
let mut rep = PeerReputation::new(peer_id);
rep.record_success();
assert_eq!(rep.successes, 1);
assert_eq!(
rep.score,
PeerReputation::INITIAL_SCORE + PeerReputation::SUCCESS_BONUS
);
}
#[test]
fn test_record_violation() {
let peer_id = random_peer_id();
let mut rep = PeerReputation::new(peer_id);
let penalty = ViolationType::InvalidMessage.penalty();
let should_ban = rep.record_violation(ViolationType::InvalidMessage);
assert!(!should_ban); // Score is still above threshold
assert_eq!(rep.failures, 1);
assert_eq!(rep.violations.len(), 1);
assert_eq!(rep.score, PeerReputation::INITIAL_SCORE - penalty);
}
#[test]
fn test_auto_ban_on_low_score() {
let peer_id = random_peer_id();
let mut rep = PeerReputation::new(peer_id);
// Apply multiple severe violations to trigger ban
let should_ban1 = rep.record_violation(ViolationType::ProtocolViolation); // -25
let should_ban2 = rep.record_violation(ViolationType::InvalidData); // -30
// First violation shouldn't ban (50 - 25 = 25)
assert!(!should_ban1);
// Second violation should trigger ban (25 - 30 = 0)
assert!(should_ban2);
assert!(rep.score < PeerReputation::BAN_THRESHOLD);
}
#[test]
fn test_score_max_limit() {
let peer_id = random_peer_id();
let mut rep = PeerReputation::new(peer_id);
rep.score = 99;
rep.record_success();
rep.record_success();
assert_eq!(rep.score, PeerReputation::MAX_SCORE);
}
#[test]
fn test_score_min_limit() {
let peer_id = random_peer_id();
let mut rep = PeerReputation::new(peer_id);
rep.score = 5;
rep.record_violation(ViolationType::InvalidData); // -30
assert_eq!(rep.score, 0);
}
#[test]
fn test_manual_ban() {
let peer_id = random_peer_id();
let mut rep = PeerReputation::new(peer_id);
rep.ban(Duration::from_secs(3600));
assert!(rep.is_banned());
assert!(rep.ban_remaining().is_some());
}
#[test]
fn test_unban() {
let peer_id = random_peer_id();
let mut rep = PeerReputation::new(peer_id);
rep.ban(Duration::from_secs(3600));
assert!(rep.is_banned());
rep.unban();
assert!(!rep.is_banned());
assert_eq!(rep.score, PeerReputation::BAN_THRESHOLD);
}
#[test]
fn test_reputation_manager_record_success() {
let manager = ReputationManager::new();
let peer_id = random_peer_id();
manager.record_success(&peer_id);
let score = manager.get_score(&peer_id).unwrap();
assert_eq!(
score,
PeerReputation::INITIAL_SCORE + PeerReputation::SUCCESS_BONUS
);
}
#[test]
fn test_reputation_manager_record_violation() {
let manager = ReputationManager::new();
let peer_id = random_peer_id();
let banned = manager.record_violation(&peer_id, ViolationType::SlowResponse);
assert!(!banned);
let score = manager.get_score(&peer_id).unwrap();
assert_eq!(
score,
PeerReputation::INITIAL_SCORE - ViolationType::SlowResponse.penalty()
);
}
#[test]
fn test_reputation_manager_auto_ban() {
let manager = ReputationManager::new();
let peer_id = random_peer_id();
// Apply severe violations to trigger auto-ban
manager.record_violation(&peer_id, ViolationType::InvalidData); // -30
let banned = manager.record_violation(&peer_id, ViolationType::InvalidData); // -30
assert!(banned);
assert!(manager.is_banned(&peer_id));
}
#[test]
fn test_reputation_manager_manual_ban() {
let manager = ReputationManager::new();
let peer_id = random_peer_id();
manager.ban_peer(&peer_id);
assert!(manager.is_banned(&peer_id));
}
#[test]
fn test_reputation_manager_unban() {
let manager = ReputationManager::new();
let peer_id = random_peer_id();
manager.ban_peer(&peer_id);
assert!(manager.is_banned(&peer_id));
manager.unban_peer(&peer_id);
assert!(!manager.is_banned(&peer_id));
}
#[test]
fn test_banned_peers_list() {
let manager = ReputationManager::new();
let peer1 = random_peer_id();
let peer2 = random_peer_id();
let peer3 = random_peer_id();
manager.ban_peer(&peer1);
manager.ban_peer(&peer2);
manager.record_success(&peer3);
let banned = manager.banned_peers();
assert_eq!(banned.len(), 2);
assert!(banned.contains(&peer1));
assert!(banned.contains(&peer2));
assert!(!banned.contains(&peer3));
}
#[test]
fn test_peers_by_reputation() {
let manager = ReputationManager::new();
let peer1 = random_peer_id();
let peer2 = random_peer_id();
// Give peer1 higher reputation
for _ in 0..10 {
manager.record_success(&peer1);
}
manager.record_violation(&peer2, ViolationType::SlowResponse);
let peers = manager.peers_by_reputation();
assert!(!peers.is_empty());
// peer1 should be first (higher score)
if peers.len() >= 2 {
assert!(peers[0].1 > peers[1].1);
}
}
#[test]
fn test_reputation_stats() {
let manager = ReputationManager::new();
let peer1 = random_peer_id();
let peer2 = random_peer_id();
manager.record_success(&peer1);
manager.ban_peer(&peer2);
let stats = manager.stats();
assert_eq!(stats.total_peers, 2);
assert_eq!(stats.banned_peers, 1);
}
#[test]
fn test_violation_types() {
// Test all violation types have penalties
let violations = [
ViolationType::InvalidMessage,
ViolationType::SlowResponse,
ViolationType::Spam,
ViolationType::ProtocolViolation,
ViolationType::DuplicateData,
ViolationType::InvalidData,
ViolationType::UnexpectedDisconnect,
ViolationType::NoResponse,
];
for v in violations {
assert!(v.penalty() > 0);
assert!(!v.description().is_empty());
}
}
#[test]
fn test_success_rate() {
let peer_id = random_peer_id();
let mut rep = PeerReputation::new(peer_id);
// 100% when no interactions
assert_eq!(rep.success_rate(), 100.0);
// 75% with 3 successes and 1 failure
rep.record_success();
rep.record_success();
rep.record_success();
rep.record_violation(ViolationType::SlowResponse);
assert!((rep.success_rate() - 75.0).abs() < 0.01);
}
}