/ abzu-dht / src / routing / node.rs
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  }