Skip to main content

ethrex_p2p/discv5/
server.rs

1use crate::discovery::lookup::IterativeLookup;
2use crate::discv5::messages::Message;
3use crate::{
4    discv5::messages::Packet,
5    types::{Node, NodeRecord},
6};
7use ethrex_common::H256;
8use lru::LruCache;
9use rand::RngCore;
10use rustc_hash::{FxHashMap, FxHashSet};
11use std::{
12    net::{IpAddr, SocketAddr},
13    num::NonZero,
14    time::{Duration, Instant},
15};
16use tracing::trace;
17
18/// Maximum number of entries in the per-IP WHOAREYOU rate limit cache.
19pub const MAX_WHOAREYOU_RATE_LIMIT_ENTRIES: usize = 10_000;
20/// Time window for collecting IP votes from PONG recipient_addr.
21const IP_VOTE_WINDOW: Duration = Duration::from_secs(300);
22/// Minimum number of agreeing votes required to update external IP.
23const IP_VOTE_THRESHOLD: usize = 3;
24/// Timeout for pending messages awaiting WhoAreYou response.
25const MESSAGE_CACHE_TIMEOUT: Duration = Duration::from_secs(2);
26/// Max age of a `session_ips` entry before it is evicted. Bounds the map: it is inserted
27/// per discv5 handshake and (absent this) was never removed for nodes we don't keep as peers.
28const SESSION_TTL: Duration = Duration::from_secs(3600);
29
30/// Source IP a discv5 session was established from, paired with when it was recorded so stale
31/// entries can be evicted (see `SESSION_TTL`).
32#[derive(Debug, Clone)]
33pub struct SessionSource {
34    pub ip: IpAddr,
35    pub established_at: Instant,
36}
37
38/// Discv5-specific state held within the unified DiscoveryServer.
39#[derive(Debug)]
40pub struct Discv5State {
41    /// Outgoing message count, used for nonce generation as per the spec.
42    pub counter: u32,
43    /// Pending outgoing messages awaiting WhoAreYou response, keyed by nonce.
44    pub pending_by_nonce: FxHashMap<[u8; 12], (Node, Message, Instant)>,
45    /// Pending WhoAreYou challenges awaiting Handshake response, keyed by src_id.
46    /// Tuple: (challenge_data, timestamp, encoded_packet_bytes).
47    pub pending_challenges: FxHashMap<H256, (Vec<u8>, Instant, Vec<u8>)>,
48    /// Tracks last WHOAREYOU send time per (source IP, node ID) to prevent amplification attacks.
49    pub whoareyou_rate_limit: LruCache<(IpAddr, H256), Instant>,
50    /// Global WHOAREYOU rate limit: count of packets sent in the current second.
51    pub whoareyou_global_count: u32,
52    /// Start of the current global rate limit window.
53    pub whoareyou_global_window_start: Instant,
54    /// Tracks the source IP that each session was established from, with the insertion time
55    /// so stale entries can be evicted (see `SESSION_TTL`).
56    pub session_ips: FxHashMap<H256, SessionSource>,
57    /// Collects recipient_addr IPs from PONGs for external IP detection via majority voting.
58    pub ip_votes: FxHashMap<IpAddr, FxHashSet<H256>>,
59    /// When the current IP voting period started. None if no votes received yet.
60    pub ip_vote_period_start: Option<Instant>,
61    /// Whether the first (fast) voting round has completed.
62    pub first_ip_vote_round_completed: bool,
63    /// Currently active iterative lookups.
64    pub active_lookups: Vec<IterativeLookup>,
65}
66
67impl Default for Discv5State {
68    fn default() -> Self {
69        Self {
70            counter: 0,
71            pending_by_nonce: Default::default(),
72            pending_challenges: Default::default(),
73            whoareyou_rate_limit: LruCache::new(
74                NonZero::new(MAX_WHOAREYOU_RATE_LIMIT_ENTRIES)
75                    .expect("MAX_WHOAREYOU_RATE_LIMIT_ENTRIES must be non-zero"),
76            ),
77            whoareyou_global_count: 0,
78            whoareyou_global_window_start: Instant::now(),
79            session_ips: Default::default(),
80            ip_votes: Default::default(),
81            ip_vote_period_start: None,
82            first_ip_vote_round_completed: false,
83            active_lookups: Vec::new(),
84        }
85    }
86}
87
88impl Discv5State {
89    /// Generates a 96-bit AES-GCM nonce.
90    /// Encodes the current outgoing message count into the first 32 bits
91    /// and fills the remaining 64 bits with random data.
92    pub fn next_nonce<R: RngCore>(&mut self, rng: &mut R) -> [u8; 12] {
93        let counter = self.counter;
94        self.counter = self.counter.wrapping_add(1);
95
96        let mut nonce = [0u8; 12];
97        nonce[..4].copy_from_slice(&counter.to_be_bytes());
98        rng.fill_bytes(&mut nonce[4..]);
99        nonce
100    }
101
102    /// Remove stale entries from caches.
103    /// Returns `Some(ip)` if a timed-out IP voting round produced a winning IP to apply.
104    pub fn cleanup_stale_entries(&mut self) -> Option<IpAddr> {
105        let now = Instant::now();
106
107        let before_messages = self.pending_by_nonce.len();
108        self.pending_by_nonce
109            .retain(|_nonce, (_node, _message, timestamp)| {
110                now.duration_since(*timestamp) < MESSAGE_CACHE_TIMEOUT
111            });
112        let removed_messages = before_messages - self.pending_by_nonce.len();
113
114        let before_challenges = self.pending_challenges.len();
115        self.pending_challenges
116            .retain(|_src_id, (_challenge_data, timestamp, _raw)| {
117                now.duration_since(*timestamp) < MESSAGE_CACHE_TIMEOUT
118            });
119        let removed_challenges = before_challenges - self.pending_challenges.len();
120
121        let before_sessions = self.session_ips.len();
122        self.session_ips
123            .retain(|_node_id, source| now.duration_since(source.established_at) < SESSION_TTL);
124        let removed_sessions = before_sessions - self.session_ips.len();
125
126        let total_removed = removed_messages + removed_challenges + removed_sessions;
127        if total_removed > 0 {
128            trace!(
129                protocol = "discv5",
130                "Cleaned up {} stale entries ({} messages, {} challenges)",
131                total_removed,
132                removed_messages,
133                removed_challenges,
134            );
135        }
136
137        if let Some(start) = self.ip_vote_period_start
138            && now.duration_since(start) >= IP_VOTE_WINDOW
139        {
140            return self.finalize_ip_vote_round();
141        }
142        None
143    }
144
145    /// Records an IP vote from a PONG recipient_addr.
146    /// Returns `Some(ip)` if the voting round ended with a winning IP to apply.
147    pub fn record_ip_vote(&mut self, reported_ip: IpAddr, voter_id: H256) -> Option<IpAddr> {
148        if Self::is_private_ip(reported_ip) {
149            return None;
150        }
151
152        let now = Instant::now();
153
154        if self.ip_vote_period_start.is_none() {
155            self.ip_vote_period_start = Some(now);
156        }
157
158        self.ip_votes
159            .entry(reported_ip)
160            .or_default()
161            .insert(voter_id);
162
163        let total_votes: usize = self.ip_votes.values().map(|v| v.len()).sum();
164        let round_ended = if !self.first_ip_vote_round_completed {
165            total_votes >= IP_VOTE_THRESHOLD
166        } else {
167            self.ip_vote_period_start
168                .is_some_and(|start| now.duration_since(start) >= IP_VOTE_WINDOW)
169        };
170
171        if round_ended {
172            return self.finalize_ip_vote_round();
173        }
174        None
175    }
176
177    /// Finalizes the current voting round.
178    /// Returns `Some(winning_ip)` if a winner reached the threshold and should be applied.
179    fn finalize_ip_vote_round(&mut self) -> Option<IpAddr> {
180        let winner = self
181            .ip_votes
182            .iter()
183            .map(|(ip, voters)| (*ip, voters.len()))
184            .max_by_key(|(_, count)| *count);
185
186        let result = winner.and_then(|(winning_ip, vote_count)| {
187            (vote_count >= IP_VOTE_THRESHOLD).then_some(winning_ip)
188        });
189
190        self.ip_votes.clear();
191        self.ip_vote_period_start = Some(Instant::now());
192        self.first_ip_vote_round_completed = true;
193
194        result
195    }
196
197    /// Returns true if the IP is private/local (not useful for external connectivity).
198    pub fn is_private_ip(ip: IpAddr) -> bool {
199        match ip {
200            IpAddr::V4(v4) => v4.is_private() || v4.is_loopback() || v4.is_link_local(),
201            IpAddr::V6(v6) => {
202                v6.is_loopback()
203                    || v6.is_unspecified()
204                    // unique local (fc00::/7)
205                    || (v6.segments()[0] & 0xfe00) == 0xfc00
206                    // link-local (fe80::/10)
207                    || (v6.segments()[0] & 0xffc0) == 0xfe80
208            }
209        }
210    }
211}
212
213/// Updates local node IP and re-signs the ENR with incremented seq.
214pub(crate) fn update_local_ip(
215    local_node: &mut Node,
216    local_node_record: &mut NodeRecord,
217    signer: &secp256k1::SecretKey,
218    new_ip: IpAddr,
219) {
220    let mut updated_node = local_node.clone();
221    updated_node.ip = new_ip;
222    let new_seq = local_node_record.seq + 1;
223    let Ok(mut new_record) = NodeRecord::from_node(&updated_node, new_seq, signer) else {
224        tracing::error!(%new_ip, "Failed to create new ENR for IP update");
225        return;
226    };
227    if let Some(fork_id) = local_node_record.get_fork_id().cloned()
228        && new_record.set_fork_id(fork_id, signer).is_err()
229    {
230        tracing::error!(%new_ip, "Failed to set fork_id in new ENR, aborting IP update");
231        return;
232    }
233    local_node.ip = new_ip;
234    *local_node_record = new_record;
235}
236
237#[derive(Debug, Clone)]
238pub struct Discv5Message {
239    pub(crate) from: SocketAddr,
240    pub(crate) packet: Packet,
241}
242
243impl Discv5Message {
244    pub fn from(packet: Packet, from: SocketAddr) -> Self {
245        Self { from, packet }
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use rand::{SeedableRng, rngs::StdRng};
253
254    fn make_test_state() -> Discv5State {
255        Discv5State::default()
256    }
257
258    #[tokio::test]
259    async fn test_next_nonce_counter() {
260        let mut rng = StdRng::seed_from_u64(7);
261        let mut state = make_test_state();
262
263        let n1 = state.next_nonce(&mut rng);
264        let n2 = state.next_nonce(&mut rng);
265
266        assert_eq!(&n1[..4], &[0, 0, 0, 0]);
267        assert_eq!(&n2[..4], &[0, 0, 0, 1]);
268        assert_ne!(&n1[4..], &n2[4..]);
269    }
270
271    #[tokio::test]
272    async fn test_ip_voting_returns_winning_ip() {
273        let mut state = make_test_state();
274
275        let new_ip: IpAddr = "203.0.113.50".parse().unwrap();
276        let voter1 = H256::from_low_u64_be(1);
277        let voter2 = H256::from_low_u64_be(2);
278        let voter3 = H256::from_low_u64_be(3);
279
280        assert_eq!(state.record_ip_vote(new_ip, voter1), None);
281        assert_eq!(state.record_ip_vote(new_ip, voter2), None);
282        // Third vote triggers round end, returns the winning IP
283        assert_eq!(state.record_ip_vote(new_ip, voter3), Some(new_ip));
284        assert!(state.ip_votes.is_empty());
285    }
286
287    #[tokio::test]
288    async fn test_ip_voting_same_peer_votes_once() {
289        let mut state = make_test_state();
290
291        let new_ip: IpAddr = "203.0.113.50".parse().unwrap();
292        let same_voter = H256::from_low_u64_be(1);
293
294        state.record_ip_vote(new_ip, same_voter);
295        state.record_ip_vote(new_ip, same_voter);
296        state.record_ip_vote(new_ip, same_voter);
297
298        assert_eq!(state.ip_votes.get(&new_ip).map(|v| v.len()), Some(1));
299    }
300
301    #[tokio::test]
302    async fn test_ip_voting_ignores_private_ips() {
303        let mut state = make_test_state();
304
305        let voter1 = H256::from_low_u64_be(1);
306
307        let private_ip: IpAddr = "192.168.1.100".parse().unwrap();
308        state.record_ip_vote(private_ip, voter1);
309        assert!(state.ip_votes.is_empty());
310
311        let loopback: IpAddr = "127.0.0.1".parse().unwrap();
312        state.record_ip_vote(loopback, voter1);
313        assert!(state.ip_votes.is_empty());
314
315        let public_ip: IpAddr = "203.0.113.50".parse().unwrap();
316        state.record_ip_vote(public_ip, voter1);
317        assert_eq!(state.ip_votes.get(&public_ip).map(|v| v.len()), Some(1));
318    }
319
320    #[tokio::test]
321    async fn test_ip_voting_split_votes_no_winner() {
322        let mut state = make_test_state();
323
324        let ip1: IpAddr = "203.0.113.50".parse().unwrap();
325        let ip2: IpAddr = "203.0.113.51".parse().unwrap();
326        let voter1 = H256::from_low_u64_be(1);
327        let voter2 = H256::from_low_u64_be(2);
328        let voter3 = H256::from_low_u64_be(3);
329
330        state.record_ip_vote(ip1, voter1);
331        state.record_ip_vote(ip2, voter2);
332        // ip1 has 2 votes, ip2 has 1 — ip1 wins but only has 2 < threshold 3
333        assert_eq!(state.record_ip_vote(ip1, voter3), None);
334        assert!(state.ip_votes.is_empty());
335        assert!(state.first_ip_vote_round_completed);
336    }
337
338    #[tokio::test]
339    async fn test_ip_vote_cleanup() {
340        let mut state = make_test_state();
341
342        let ip: IpAddr = "203.0.113.50".parse().unwrap();
343        let voter1 = H256::from_low_u64_be(1);
344
345        let mut voters = FxHashSet::default();
346        voters.insert(voter1);
347        state.ip_votes.insert(ip, voters);
348        state.ip_vote_period_start = Some(Instant::now());
349        assert_eq!(state.ip_votes.len(), 1);
350
351        // Cleanup should retain votes (round hasn't timed out yet)
352        assert_eq!(state.cleanup_stale_entries(), None);
353        assert_eq!(state.ip_votes.len(), 1);
354        assert!(!state.first_ip_vote_round_completed);
355    }
356}