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

469 lines
14 KiB
Rust

//! Eclipse attack mitigations.
//!
//! Eclipse attacks attempt to isolate a node by controlling all its peer connections.
//! This module implements several mitigations:
//!
//! - **Subnet diversity**: Limit peers from the same /16 subnet
//! - **Anchor connections**: Maintain persistent connections to trusted peers
//! - **Outbound ratio**: Ensure minimum outbound connections
//! - **Random rotation**: Periodically rotate a subset of peers
//! - **New peer trials**: Test new peers before fully trusting them
use std::collections::{HashMap, HashSet};
use std::net::IpAddr;
use std::time::{Duration, Instant};
use libp2p::PeerId;
/// Configuration for eclipse attack protection.
#[derive(Clone, Debug)]
pub struct EclipseConfig {
/// Maximum peers from the same /16 subnet.
pub max_peers_per_subnet: usize,
/// Minimum outbound connections (must be maintained).
pub min_outbound: usize,
/// Number of anchor (persistent) connections.
pub anchor_count: usize,
/// How often to rotate peers (0 = never).
pub rotation_interval: Duration,
/// Percentage of peers to rotate each interval.
pub rotation_percentage: f32,
/// Trial period for new peers before full trust.
pub trial_period: Duration,
/// Maximum trial peers at once.
pub max_trial_peers: usize,
}
impl Default for EclipseConfig {
fn default() -> Self {
EclipseConfig {
max_peers_per_subnet: 2,
min_outbound: 8,
anchor_count: 4,
rotation_interval: Duration::from_secs(3600), // 1 hour
rotation_percentage: 0.1, // 10%
trial_period: Duration::from_secs(300), // 5 minutes
max_trial_peers: 5,
}
}
}
/// Peer connection type.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PeerType {
/// Outbound connection we initiated.
Outbound,
/// Inbound connection peer initiated.
Inbound,
/// Anchor peer (persistent, trusted).
Anchor,
/// Trial peer (new, not yet trusted).
Trial,
}
/// Information about a connected peer for eclipse protection.
#[derive(Clone, Debug)]
pub struct PeerInfo {
/// Peer ID.
pub peer_id: PeerId,
/// IP address.
pub ip: Option<IpAddr>,
/// Connection type.
pub peer_type: PeerType,
/// When the peer connected.
pub connected_at: Instant,
/// /16 subnet identifier.
pub subnet: Option<u32>,
}
impl PeerInfo {
/// Creates new peer info.
pub fn new(peer_id: PeerId, ip: Option<IpAddr>, peer_type: PeerType) -> Self {
let subnet = ip.and_then(|addr| Self::extract_subnet(&addr));
PeerInfo {
peer_id,
ip,
peer_type,
connected_at: Instant::now(),
subnet,
}
}
/// Extracts /16 subnet identifier from IP.
fn extract_subnet(ip: &IpAddr) -> Option<u32> {
match ip {
IpAddr::V4(addr) => {
let octets = addr.octets();
Some(((octets[0] as u32) << 8) | (octets[1] as u32))
}
IpAddr::V6(addr) => {
// Use first 32 bits for IPv6 subnet grouping
let segments = addr.segments();
Some(((segments[0] as u32) << 16) | (segments[1] as u32))
}
}
}
/// Checks if peer is in trial period.
pub fn is_trial(&self, trial_period: Duration) -> bool {
self.peer_type == PeerType::Trial || self.connected_at.elapsed() < trial_period
}
}
/// Eclipse protection manager.
pub struct EclipseProtection {
/// Configuration.
config: EclipseConfig,
/// Connected peers.
peers: HashMap<PeerId, PeerInfo>,
/// Peers per subnet.
subnet_counts: HashMap<u32, usize>,
/// Anchor peer IDs (persistent connections).
anchors: HashSet<PeerId>,
/// Last rotation time.
last_rotation: Instant,
/// Denied peer IDs (temporarily rejected).
denied: HashMap<PeerId, Instant>,
}
impl EclipseProtection {
/// Creates a new eclipse protection manager.
pub fn new(config: EclipseConfig) -> Self {
EclipseProtection {
config,
peers: HashMap::new(),
subnet_counts: HashMap::new(),
anchors: HashSet::new(),
last_rotation: Instant::now(),
denied: HashMap::new(),
}
}
/// Checks if a new connection should be allowed.
///
/// Returns `Ok(())` if allowed, `Err(reason)` if denied.
pub fn check_connection(
&self,
peer_id: &PeerId,
ip: Option<IpAddr>,
is_outbound: bool,
) -> Result<(), EclipseDenialReason> {
// Check if peer is temporarily denied
if let Some(denied_until) = self.denied.get(peer_id) {
if denied_until.elapsed() < Duration::from_secs(300) {
return Err(EclipseDenialReason::TemporarilyDenied);
}
}
// Check subnet diversity for inbound connections
if !is_outbound {
if let Some(ip) = ip {
if let Some(subnet) = PeerInfo::extract_subnet(&ip) {
let current_count = self.subnet_counts.get(&subnet).copied().unwrap_or(0);
if current_count >= self.config.max_peers_per_subnet {
return Err(EclipseDenialReason::SubnetLimitReached);
}
}
}
}
// Check trial peer limit
let trial_count = self.count_trial_peers();
if trial_count >= self.config.max_trial_peers && !self.anchors.contains(peer_id) {
return Err(EclipseDenialReason::TooManyTrialPeers);
}
Ok(())
}
/// Registers a new peer connection.
pub fn register_peer(&mut self, peer_id: PeerId, ip: Option<IpAddr>, is_outbound: bool) {
let peer_type = if self.anchors.contains(&peer_id) {
PeerType::Anchor
} else if is_outbound {
PeerType::Outbound
} else {
PeerType::Trial // Inbound connections start as trial
};
let info = PeerInfo::new(peer_id, ip, peer_type);
// Update subnet count
if let Some(subnet) = info.subnet {
*self.subnet_counts.entry(subnet).or_insert(0) += 1;
}
self.peers.insert(peer_id, info);
}
/// Removes a peer from tracking.
pub fn remove_peer(&mut self, peer_id: &PeerId) {
if let Some(info) = self.peers.remove(peer_id) {
if let Some(subnet) = info.subnet {
if let Some(count) = self.subnet_counts.get_mut(&subnet) {
*count = count.saturating_sub(1);
if *count == 0 {
self.subnet_counts.remove(&subnet);
}
}
}
}
}
/// Promotes a trial peer to regular status.
pub fn promote_peer(&mut self, peer_id: &PeerId) {
if let Some(info) = self.peers.get_mut(peer_id) {
if info.peer_type == PeerType::Trial {
info.peer_type = PeerType::Inbound;
}
}
}
/// Adds a peer to the anchor set.
pub fn add_anchor(&mut self, peer_id: PeerId) {
self.anchors.insert(peer_id);
if let Some(info) = self.peers.get_mut(&peer_id) {
info.peer_type = PeerType::Anchor;
}
}
/// Removes a peer from the anchor set.
pub fn remove_anchor(&mut self, peer_id: &PeerId) {
self.anchors.remove(peer_id);
}
/// Gets peers that should be rotated.
pub fn get_rotation_candidates(&mut self) -> Vec<PeerId> {
if self.last_rotation.elapsed() < self.config.rotation_interval {
return Vec::new();
}
self.last_rotation = Instant::now();
// Only rotate non-anchor, non-outbound peers
let candidates: Vec<_> = self
.peers
.iter()
.filter(|(_, info)| {
info.peer_type != PeerType::Anchor
&& info.peer_type != PeerType::Outbound
&& info.connected_at.elapsed() > self.config.trial_period
})
.map(|(id, _)| *id)
.collect();
let rotate_count =
(candidates.len() as f32 * self.config.rotation_percentage).ceil() as usize;
// Return random subset (simplified: just take first N)
candidates.into_iter().take(rotate_count).collect()
}
/// Checks if we need more outbound connections.
pub fn needs_outbound(&self) -> bool {
let outbound_count = self
.peers
.values()
.filter(|p| p.peer_type == PeerType::Outbound || p.peer_type == PeerType::Anchor)
.count();
outbound_count < self.config.min_outbound
}
/// Returns the number of outbound connections needed.
pub fn outbound_deficit(&self) -> usize {
let outbound_count = self
.peers
.values()
.filter(|p| p.peer_type == PeerType::Outbound || p.peer_type == PeerType::Anchor)
.count();
self.config.min_outbound.saturating_sub(outbound_count)
}
/// Temporarily denies a peer.
pub fn deny_peer(&mut self, peer_id: PeerId, _reason: &str) {
self.denied.insert(peer_id, Instant::now());
}
/// Cleans up expired denials.
pub fn cleanup_denials(&mut self) {
let expiry = Duration::from_secs(300);
self.denied.retain(|_, time| time.elapsed() < expiry);
}
/// Counts trial peers.
fn count_trial_peers(&self) -> usize {
self.peers
.values()
.filter(|p| p.is_trial(self.config.trial_period))
.count()
}
/// Gets connection statistics.
pub fn stats(&self) -> EclipseStats {
let mut inbound = 0;
let mut outbound = 0;
let mut anchor = 0;
let mut trial = 0;
for info in self.peers.values() {
match info.peer_type {
PeerType::Inbound => inbound += 1,
PeerType::Outbound => outbound += 1,
PeerType::Anchor => anchor += 1,
PeerType::Trial => trial += 1,
}
}
EclipseStats {
total_peers: self.peers.len(),
inbound,
outbound,
anchor,
trial,
unique_subnets: self.subnet_counts.len(),
}
}
}
/// Reason for denying a connection.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EclipseDenialReason {
/// Too many peers from the same subnet.
SubnetLimitReached,
/// Peer is temporarily denied.
TemporarilyDenied,
/// Too many trial peers.
TooManyTrialPeers,
}
impl std::fmt::Display for EclipseDenialReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::SubnetLimitReached => write!(f, "subnet limit reached"),
Self::TemporarilyDenied => write!(f, "temporarily denied"),
Self::TooManyTrialPeers => write!(f, "too many trial peers"),
}
}
}
/// Eclipse protection statistics.
#[derive(Clone, Debug, Default)]
pub struct EclipseStats {
/// Total connected peers.
pub total_peers: usize,
/// Inbound connections.
pub inbound: usize,
/// Outbound connections.
pub outbound: usize,
/// Anchor connections.
pub anchor: usize,
/// Trial connections.
pub trial: usize,
/// Unique subnets represented.
pub unique_subnets: usize,
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::Ipv4Addr;
fn random_peer() -> PeerId {
PeerId::random()
}
#[test]
fn test_subnet_extraction() {
let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1));
let subnet = PeerInfo::extract_subnet(&ip).unwrap();
// 192.168.x.x -> (192 << 8) | 168 = 49320
assert_eq!(subnet, (192 << 8) | 168);
let ip2 = IpAddr::V4(Ipv4Addr::new(192, 168, 2, 100));
let subnet2 = PeerInfo::extract_subnet(&ip2).unwrap();
assert_eq!(subnet, subnet2); // Same /16 subnet
}
#[test]
fn test_subnet_diversity() {
let config = EclipseConfig {
max_peers_per_subnet: 2,
..Default::default()
};
let mut protection = EclipseProtection::new(config);
let ip = Some(IpAddr::V4(Ipv4Addr::new(10, 0, 1, 1)));
// First two should be allowed
let peer1 = random_peer();
assert!(protection.check_connection(&peer1, ip, false).is_ok());
protection.register_peer(peer1, ip, false);
let peer2 = random_peer();
assert!(protection.check_connection(&peer2, ip, false).is_ok());
protection.register_peer(peer2, ip, false);
// Third should be denied (same subnet)
let peer3 = random_peer();
assert_eq!(
protection.check_connection(&peer3, ip, false),
Err(EclipseDenialReason::SubnetLimitReached)
);
// Different subnet should be allowed
let different_ip = Some(IpAddr::V4(Ipv4Addr::new(10, 1, 1, 1)));
assert!(protection
.check_connection(&peer3, different_ip, false)
.is_ok());
}
#[test]
fn test_outbound_tracking() {
let config = EclipseConfig {
min_outbound: 4,
..Default::default()
};
let mut protection = EclipseProtection::new(config);
assert!(protection.needs_outbound());
assert_eq!(protection.outbound_deficit(), 4);
// Add outbound peers
for _ in 0..4 {
let peer = random_peer();
protection.register_peer(peer, None, true);
}
assert!(!protection.needs_outbound());
assert_eq!(protection.outbound_deficit(), 0);
}
#[test]
fn test_anchor_peers() {
let config = EclipseConfig::default();
let mut protection = EclipseProtection::new(config);
let anchor = random_peer();
protection.add_anchor(anchor);
protection.register_peer(anchor, None, true);
let stats = protection.stats();
assert_eq!(stats.anchor, 1);
}
#[test]
fn test_peer_denial() {
let config = EclipseConfig::default();
let mut protection = EclipseProtection::new(config);
let peer = random_peer();
protection.deny_peer(peer, "bad behavior");
assert_eq!(
protection.check_connection(&peer, None, false),
Err(EclipseDenialReason::TemporarilyDenied)
);
}
}