node.rs
1 //! Node information for routing table entries 2 //! 3 //! Each node in the routing table tracks more than just the address — 4 //! we maintain liveness state, failure counts, and optional tree coordinates 5 //! for hybrid routing. 6 7 use serde::{Deserialize, Serialize}; 8 use crate::key::DhtKey; 9 10 /// Node state in the routing table 11 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] 12 pub enum NodeState { 13 /// Node has responded recently 14 Good, 15 /// Node hasn't been seen recently, needs verification 16 #[default] 17 Questionable, 18 /// Node has failed multiple times, candidate for eviction 19 Bad, 20 } 21 22 /// Complete information about a known peer 23 #[derive(Debug, Clone, Serialize, Deserialize)] 24 pub struct NodeInfo { 25 /// DHT key (BLAKE3 hash of public key) 26 pub id: DhtKey, 27 28 /// Ed25519 public key 29 pub pubkey: [u8; 32], 30 31 /// Network address (transport-agnostic, encoded by transport layer) 32 /// This could be "192.168.1.1:5000" or a WebRTC peer ID 33 pub address: String, 34 35 /// Tree coordinates for hybrid routing (optional) 36 /// If known, we can use tree routing for efficiency 37 pub tree_coords: Option<Vec<u8>>, 38 39 /// Current liveness state 40 pub state: NodeState, 41 42 /// Consecutive failures (reset on success) 43 pub failures: u8, 44 45 /// Unix timestamp of last successful interaction 46 pub last_seen: u64, 47 48 /// Unix timestamp when first added 49 pub first_seen: u64, 50 51 /// Round-trip time in milliseconds (EMA) 52 pub rtt_ms: Option<u16>, 53 } 54 55 /// Maximum allowed tree coordinates length 56 /// Tree coordinates represent path in spanning tree, ~32 bytes is plenty for deep hierarchies 57 pub const MAX_TREE_COORDS_LEN: usize = 64; 58 59 impl NodeInfo { 60 /// Create a new node entry (defaults to Questionable until verified) 61 pub fn new(pubkey: [u8; 32], address: String, now: u64) -> Self { 62 Self { 63 id: crate::key::node_id(&pubkey), 64 pubkey, 65 address, 66 tree_coords: None, 67 state: NodeState::Questionable, 68 failures: 0, 69 last_seen: now, 70 first_seen: now, 71 rtt_ms: None, 72 } 73 } 74 75 /// Set tree coordinates with validation (M5 security fix) 76 /// Returns false if coords are too long or invalid 77 pub fn set_tree_coords(&mut self, coords: Option<Vec<u8>>) -> bool { 78 if let Some(ref c) = coords 79 && c.len() > MAX_TREE_COORDS_LEN { 80 return false; 81 } 82 self.tree_coords = coords; 83 true 84 } 85 86 /// Mark the node as successfully contacted 87 pub fn mark_good(&mut self, now: u64, rtt_ms: Option<u16>) { 88 self.state = NodeState::Good; 89 self.failures = 0; 90 self.last_seen = now; 91 92 // Update RTT with exponential moving average 93 if let Some(new_rtt) = rtt_ms { 94 self.rtt_ms = Some(match self.rtt_ms { 95 Some(old) => (old / 2) + (new_rtt / 2), // Simple EMA 96 None => new_rtt, 97 }); 98 } 99 } 100 101 /// Mark a failed contact attempt 102 /// 103 /// Returns `true` if the node should be evicted (too many failures) 104 pub fn mark_failed(&mut self) -> bool { 105 self.failures = self.failures.saturating_add(1); 106 107 // State transitions based on failure count 108 self.state = match self.failures { 109 0..=1 => self.state, // First failure doesn't change state 110 2..=4 => NodeState::Questionable, 111 _ => NodeState::Bad, 112 }; 113 114 // Recommend eviction after 5 failures 115 self.failures >= 5 116 } 117 118 /// Check if this node should be considered responsive 119 /// 120 /// A node is responsive if it's Good, or Questionable but seen recently 121 pub fn is_responsive(&self, now: u64, stale_after_secs: u64) -> bool { 122 match self.state { 123 NodeState::Good => true, 124 NodeState::Questionable => { 125 now.saturating_sub(self.last_seen) < stale_after_secs 126 } 127 NodeState::Bad => false, 128 } 129 } 130 131 /// Check if this node needs a liveness check 132 pub fn needs_ping(&self, now: u64, ping_interval_secs: u64) -> bool { 133 now.saturating_sub(self.last_seen) >= ping_interval_secs 134 } 135 136 /// Score for eviction priority (lower = evict first) 137 /// 138 /// This considers: 139 /// - State (Good > Questionable > Bad) 140 /// - Failure count 141 /// - Time since last seen 142 /// - RTT (faster nodes preferred) 143 pub fn eviction_score(&self, now: u64) -> u64 { 144 let state_score: u64 = match self.state { 145 NodeState::Good => 1000, 146 NodeState::Questionable => 500, 147 NodeState::Bad => 0, 148 }; 149 150 let failure_penalty = (self.failures as u64) * 100; 151 let age = now.saturating_sub(self.last_seen); 152 let age_penalty = age.min(3600) / 60; // Max 60 points for staleness 153 154 let rtt_penalty = match self.rtt_ms { 155 Some(rtt) => (rtt as u64).min(500) / 10, // Max 50 points for slow RTT 156 None => 25, // Unknown RTT = moderate penalty 157 }; 158 159 state_score 160 .saturating_sub(failure_penalty) 161 .saturating_sub(age_penalty) 162 .saturating_sub(rtt_penalty) 163 } 164 165 // ======================================================================== 166 // Sybil Defense Methods 167 // ======================================================================== 168 169 /// Check if node is eligible for routing based on age threshold 170 /// 171 /// Nodes must prove sustained network presence before being used 172 /// for routing. This prevents rapid-fire Sybil node creation. 173 pub fn is_routing_eligible(&self, now: u64, min_age_ms: u64) -> bool { 174 if min_age_ms == 0 { 175 return true; // Disabled 176 } 177 now.saturating_sub(self.first_seen) >= min_age_ms 178 } 179 180 /// Calculate routing priority score for trust-based selection 181 /// 182 /// Higher scores indicate more trustworthy nodes. Considers: 183 /// - Node age (older = more trusted) 184 /// - State (Good > Questionable > Bad) 185 /// - RTT (faster = slightly preferred) 186 /// - Failure history (penalized) 187 /// 188 /// Used to sort nodes when multiple are equidistant in XOR space. 189 pub fn routing_score(&self, now: u64) -> u64 { 190 // Base score from state 191 let state_score: u64 = match self.state { 192 NodeState::Good => 1000, 193 NodeState::Questionable => 500, 194 NodeState::Bad => 100, 195 }; 196 197 // Age bonus: 1 point per minute alive (capped at 1 day = 1440 points) 198 let age_ms = now.saturating_sub(self.first_seen); 199 let age_bonus = (age_ms / 60_000).min(1440); 200 201 // RTT bonus: faster nodes get small boost 202 let rtt_bonus = match self.rtt_ms { 203 Some(rtt) if rtt < 100 => 50, // Very fast 204 Some(rtt) if rtt < 300 => 25, // Moderately fast 205 Some(_) => 0, // Slow 206 None => 10, // Unknown 207 }; 208 209 // Failure penalty 210 let failure_penalty = (self.failures as u64) * 50; 211 212 state_score 213 .saturating_add(age_bonus) 214 .saturating_add(rtt_bonus) 215 .saturating_sub(failure_penalty) 216 } 217 218 /// Extract /24 subnet from IPv4 address string 219 /// 220 /// Returns first 3 octets as bytes for subnet tracking. 221 /// Returns None for IPv6 or malformed addresses. 222 pub fn subnet_key(&self) -> Option<[u8; 3]> { 223 // Parse address like "192.168.1.42:5000" 224 let addr_part = self.address.split(':').next()?; 225 let octets: Vec<&str> = addr_part.split('.').collect(); 226 if octets.len() != 4 { 227 return None; // Not IPv4 228 } 229 230 let a = octets[0].parse::<u8>().ok()?; 231 let b = octets[1].parse::<u8>().ok()?; 232 let c = octets[2].parse::<u8>().ok()?; 233 234 Some([a, b, c]) 235 } 236 } 237 238 impl PartialEq for NodeInfo { 239 fn eq(&self, other: &Self) -> bool { 240 self.id == other.id 241 } 242 } 243 244 impl Eq for NodeInfo {} 245 246 #[cfg(test)] 247 mod tests { 248 use super::*; 249 250 fn make_node(byte: u8, now: u64) -> NodeInfo { 251 let pubkey = [byte; 32]; 252 NodeInfo::new(pubkey, format!("addr-{byte}"), now) 253 } 254 255 #[test] 256 fn test_new_node_is_questionable() { 257 let node = make_node(0x42, 1000); 258 assert_eq!(node.state, NodeState::Questionable); 259 assert_eq!(node.failures, 0); 260 } 261 262 #[test] 263 fn test_mark_good_resets_failures() { 264 let mut node = make_node(0x42, 1000); 265 node.mark_failed(); 266 node.mark_failed(); 267 assert_eq!(node.failures, 2); 268 269 node.mark_good(1100, Some(50)); 270 assert_eq!(node.failures, 0); 271 assert_eq!(node.state, NodeState::Good); 272 assert_eq!(node.rtt_ms, Some(50)); 273 } 274 275 #[test] 276 fn test_failure_progression() { 277 let mut node = make_node(0x42, 1000); 278 node.mark_good(1000, None); 279 assert_eq!(node.state, NodeState::Good); 280 281 // First failure: no state change 282 assert!(!node.mark_failed()); 283 assert_eq!(node.state, NodeState::Good); 284 285 // Second failure: still no state change for Good 286 assert!(!node.mark_failed()); 287 assert_eq!(node.state, NodeState::Questionable); 288 289 // More failures 290 assert!(!node.mark_failed()); 291 assert!(!node.mark_failed()); 292 assert!(node.mark_failed()); // 5th failure = evict 293 assert_eq!(node.state, NodeState::Bad); 294 } 295 296 #[test] 297 fn test_eviction_score() { 298 let now = 1000; 299 300 let mut good = make_node(0x01, now); 301 good.mark_good(now, Some(20)); 302 303 let mut bad = make_node(0x02, now); 304 for _ in 0..5 { 305 bad.mark_failed(); 306 } 307 308 assert!(good.eviction_score(now) > bad.eviction_score(now)); 309 } 310 311 #[test] 312 fn test_is_responsive() { 313 let mut node = make_node(0x42, 1000); 314 node.mark_good(1000, None); 315 316 // Good node is responsive 317 assert!(node.is_responsive(1000, 600)); 318 319 // Still responsive after some time 320 assert!(node.is_responsive(1500, 600)); 321 322 // Questionable but recent is responsive 323 node.state = NodeState::Questionable; 324 assert!(node.is_responsive(1500, 600)); 325 326 // Questionable and stale is not responsive 327 assert!(!node.is_responsive(2000, 600)); 328 329 // Bad is never responsive 330 node.state = NodeState::Bad; 331 assert!(!node.is_responsive(1000, 600)); 332 } 333 }