Skip to main content

zebra_network/peer_set/
inventory_registry.rs

1//! Inventory Registry Implementation
2//!
3//! [RFC]: https://zebra.zfnd.org/dev/rfcs/0003-inventory-tracking.html
4
5use std::{
6    pin::Pin,
7    task::{Context, Poll},
8};
9
10use futures::{FutureExt, Stream, StreamExt};
11use indexmap::{IndexMap, IndexSet};
12use tokio::{
13    sync::broadcast,
14    time::{self, Instant},
15};
16use tokio_stream::wrappers::{errors::BroadcastStreamRecvError, BroadcastStream, IntervalStream};
17
18use zebra_chain::serialization::AtLeastOne;
19
20use crate::{
21    constants::INVENTORY_ROTATION_INTERVAL,
22    protocol::{external::InventoryHash, internal::InventoryResponse},
23    BoxError, PeerSocketAddr,
24};
25
26use self::update::Update;
27
28/// Underlying type for the alias InventoryStatus::*
29use InventoryResponse::*;
30
31pub mod update;
32
33#[cfg(test)]
34mod tests;
35
36/// The maximum number of inventory hashes we will track from a single peer.
37///
38/// # Security
39///
40/// This limits known memory denial of service attacks like <https://invdos.net/> to a total of:
41/// ```text
42/// 1000 inventory * 2 maps * 32-64 bytes per inventory = less than 1 MB
43/// 1000 inventory * 70 peers * 2 maps * 6-18 bytes per address = up to 3 MB
44/// ```
45///
46/// Since the inventory registry is an efficiency optimisation, which falls back to a
47/// random peer, we only need to track a small number of hashes for available inventory.
48///
49/// But we want to be able to track a significant amount of missing inventory,
50/// to limit queries for globally missing inventory.
51//
52// TODO: split this into available (25) and missing (1000 or more?)
53pub const MAX_INV_PER_MAP: usize = 1000;
54
55/// The maximum number of peers we will track inventory for.
56///
57/// # Security
58///
59/// This limits known memory denial of service attacks. See [`MAX_INV_PER_MAP`] for details.
60///
61/// Since the inventory registry is an efficiency optimisation, which falls back to a
62/// random peer, we only need to track a small number of peers per inv for available inventory.
63///
64/// But we want to be able to track missing inventory for almost all our peers,
65/// so we only query a few peers for inventory that is genuinely missing from the network.
66//
67// TODO: split this into available (25) and missing (70)
68pub const MAX_PEERS_PER_INV: usize = 70;
69
70/// A peer inventory status, which tracks a hash for both available and missing inventory.
71pub type InventoryStatus<T> = InventoryResponse<T, T>;
72
73/// A peer inventory status change, used in the inventory status channel.
74///
75/// For performance reasons, advertisements should only be tracked
76/// for hashes that are rare on the network.
77/// So Zebra only tracks single-block inventory messages.
78///
79/// For security reasons, all `notfound` rejections should be tracked.
80/// This also helps with performance, if the hash is rare on the network.
81pub type InventoryChange = InventoryStatus<(AtLeastOne<InventoryHash>, PeerSocketAddr)>;
82
83/// An internal marker used in inventory status hash maps.
84type InventoryMarker = InventoryStatus<()>;
85
86/// An Inventory Registry for tracking recent inventory advertisements and missing inventory.
87///
88/// For more details please refer to the [RFC].
89///
90/// [RFC]: https://zebra.zfnd.org/dev/rfcs/0003-inventory-tracking.html
91pub struct InventoryRegistry {
92    /// Map tracking the latest inventory status from the current interval
93    /// period.
94    //
95    // TODO: split maps into available and missing, so we can limit them separately.
96    current: IndexMap<InventoryHash, IndexMap<PeerSocketAddr, InventoryMarker>>,
97
98    /// Map tracking inventory statuses from the previous interval period.
99    prev: IndexMap<InventoryHash, IndexMap<PeerSocketAddr, InventoryMarker>>,
100
101    /// Map tracking peers that failed to return inventory in the current interval period.
102    ///
103    /// A failure is a completed peer error or response timeout, not caller cancellation or a
104    /// `notfound` response. Missing inventory is tracked separately; failed peers might still have it.
105    ///
106    /// Failures are tracked separately from inventory statuses, so a peer can't clear its failure
107    /// by advertising the inventory again.
108    ///
109    /// # Security
110    ///
111    /// This map is limited like the status maps, using [`MAX_INV_PER_MAP`] and
112    /// [`MAX_PEERS_PER_INV`]. So it uses up to 3 MB for both intervals.
113    failed_current: IndexMap<InventoryHash, IndexSet<PeerSocketAddr>>,
114
115    /// Map tracking peers that failed to return inventory in the previous interval period.
116    failed_prev: IndexMap<InventoryHash, IndexSet<PeerSocketAddr>>,
117
118    /// Stream of incoming inventory statuses to register.
119    inv_stream: Pin<
120        Box<dyn Stream<Item = Result<InventoryChange, BroadcastStreamRecvError>> + Send + 'static>,
121    >,
122
123    /// Interval tracking when we should next rotate our maps.
124    interval: IntervalStream,
125}
126
127impl std::fmt::Debug for InventoryRegistry {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        f.debug_struct("InventoryRegistry")
130            .field("current", &self.current)
131            .field("prev", &self.prev)
132            .finish()
133    }
134}
135
136impl InventoryChange {
137    /// Returns a new available inventory change from a single hash.
138    pub fn new_available(hash: InventoryHash, peer: PeerSocketAddr) -> Self {
139        let bv = AtLeastOne::from_vec(vec![hash]).expect("bounded vec must fit");
140        InventoryStatus::Available((bv, peer))
141    }
142
143    /// Returns a new missing inventory change from a single hash.
144    #[allow(dead_code)]
145    pub fn new_missing(hash: InventoryHash, peer: PeerSocketAddr) -> Self {
146        let bv = AtLeastOne::from_vec(vec![hash]).expect("bounded vec must fit");
147        InventoryStatus::Missing((bv, peer))
148    }
149
150    /// Returns a new available multiple inventory change, if `hashes` contains at least one change.
151    pub fn new_available_multi<'a>(
152        hashes: impl IntoIterator<Item = &'a InventoryHash>,
153        peer: PeerSocketAddr,
154    ) -> Option<Self> {
155        let mut hashes: Vec<InventoryHash> = hashes.into_iter().copied().collect();
156
157        // # Security
158        //
159        // Don't send more hashes than we're going to store.
160        // It doesn't matter which hashes we choose, because this is an efficiency optimisation.
161        //
162        //  This limits known memory denial of service attacks to:
163        // `1000 hashes * 200 peers/channel capacity * 32-64 bytes = up to 12 MB`
164        hashes.truncate(MAX_INV_PER_MAP);
165
166        let hashes = hashes.try_into().ok();
167
168        hashes.map(|hashes| InventoryStatus::Available((hashes, peer)))
169    }
170
171    /// Returns a new missing multiple inventory change, if `hashes` contains at least one change.
172    pub fn new_missing_multi<'a>(
173        hashes: impl IntoIterator<Item = &'a InventoryHash>,
174        peer: PeerSocketAddr,
175    ) -> Option<Self> {
176        let mut hashes: Vec<InventoryHash> = hashes.into_iter().copied().collect();
177
178        // # Security
179        //
180        // Don't send more hashes than we're going to store.
181        // It doesn't matter which hashes we choose, because this is an efficiency optimisation.
182        hashes.truncate(MAX_INV_PER_MAP);
183
184        let hashes = hashes.try_into().ok();
185
186        hashes.map(|hashes| InventoryStatus::Missing((hashes, peer)))
187    }
188}
189
190impl<T> InventoryStatus<T> {
191    /// Get a marker for the status, without any associated data.
192    pub fn marker(&self) -> InventoryMarker {
193        self.as_ref().map(|_inner| ())
194    }
195
196    /// Maps an `InventoryStatus<T>` to `InventoryStatus<U>` by applying a function to a contained value.
197    pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> InventoryStatus<U> {
198        // Based on Option::map from https://doc.rust-lang.org/src/core/option.rs.html#844
199        match self {
200            Available(item) => Available(f(item)),
201            Missing(item) => Missing(f(item)),
202        }
203    }
204}
205
206impl<T: Clone> InventoryStatus<T> {
207    /// Returns a clone of the inner item, regardless of status.
208    pub fn to_inner(&self) -> T {
209        match self {
210            Available(item) | Missing(item) => item.clone(),
211        }
212    }
213}
214
215impl InventoryRegistry {
216    /// Returns a new Inventory Registry for `inv_stream`.
217    pub fn new(inv_stream: broadcast::Receiver<InventoryChange>) -> Self {
218        let interval = INVENTORY_ROTATION_INTERVAL;
219
220        // Don't do an immediate rotation, current and prev are already empty.
221        let mut interval = tokio::time::interval_at(Instant::now() + interval, interval);
222        // # Security
223        //
224        // Delay missed ticks so newly registered inventory survives a full interval.
225        // Per-map caps bound memory use even when polling is delayed.
226        interval.set_missed_tick_behavior(time::MissedTickBehavior::Delay);
227
228        Self {
229            current: Default::default(),
230            prev: Default::default(),
231            failed_current: Default::default(),
232            failed_prev: Default::default(),
233            inv_stream: BroadcastStream::new(inv_stream).boxed(),
234            interval: IntervalStream::new(interval),
235        }
236    }
237
238    /// Returns an iterator over addrs of peers that have recently advertised `hash` in their inventory.
239    pub fn advertising_peers(&self, hash: InventoryHash) -> impl Iterator<Item = &PeerSocketAddr> {
240        self.status_peers(hash)
241            .filter_map(|addr_status| addr_status.available())
242    }
243
244    /// Returns an iterator over addrs of peers that have recently missed `hash` in their inventory.
245    pub fn missing_peers(&self, hash: InventoryHash) -> impl Iterator<Item = &PeerSocketAddr> {
246        self.status_peers(hash)
247            .filter_map(|addr_status| addr_status.missing())
248    }
249
250    /// Returns an iterator over addrs of peers that recently failed to return `hash`.
251    ///
252    /// Can include the same peer twice, if it failed in the current and previous interval.
253    pub fn failed_peers(&self, hash: InventoryHash) -> impl Iterator<Item = &PeerSocketAddr> {
254        self.failed_current
255            .get(&hash)
256            .into_iter()
257            .chain(self.failed_prev.get(&hash))
258            .flatten()
259    }
260
261    /// Records that `peer` failed to return `hash`.
262    ///
263    /// Failures expire after the registry rotates twice, like inventory statuses.
264    pub fn register_failed(&mut self, hash: InventoryHash, peer: PeerSocketAddr) {
265        if let Some(index) = self.failed_current.get_index_of(&hash) {
266            self.failed_current
267                .move_index(index, self.failed_current.len() - 1);
268        }
269        let failed_peers = self.failed_current.entry(hash).or_default();
270        let (index, _) = failed_peers.insert_full(peer);
271        failed_peers.move_index(index, failed_peers.len() - 1);
272
273        // # Security
274        //
275        // Limit the number of stored peers and hashes, removing the oldest entries, like
276        // `register()`.
277        if failed_peers.len() > MAX_PEERS_PER_INV {
278            // Performance: `MAX_PEERS_PER_INV` is small, so O(n) performance is acceptable.
279            failed_peers.shift_remove_index(0);
280        }
281        if self.failed_current.len() > MAX_INV_PER_MAP {
282            // Performance: `MAX_INV_PER_MAP` is small, so O(n) performance is acceptable.
283            self.failed_current.shift_remove_index(0);
284        }
285    }
286
287    /// Clears failures from previous connections that used `peer`'s transient address.
288    pub fn clear_peer_failures(&mut self, peer: PeerSocketAddr) {
289        for failures in [&mut self.failed_current, &mut self.failed_prev] {
290            failures.retain(|_, peers| {
291                peers.shift_remove(&peer);
292                !peers.is_empty()
293            });
294        }
295    }
296
297    /// Returns an iterator over peer inventory statuses for `hash`.
298    ///
299    /// Prefers current statuses to previously rotated statuses for the same peer.
300    pub fn status_peers(
301        &self,
302        hash: InventoryHash,
303    ) -> impl Iterator<Item = InventoryStatus<&PeerSocketAddr>> {
304        let prev = self.prev.get(&hash);
305        let current = self.current.get(&hash);
306
307        // # Security
308        //
309        // Prefer `current` statuses for the same peer over previously rotated statuses.
310        // This makes sure Zebra is using the most up-to-date network information.
311        let prev = prev
312            .into_iter()
313            .flatten()
314            .filter(move |(addr, _status)| !self.has_current_status(hash, **addr));
315        let current = current.into_iter().flatten();
316
317        current
318            .chain(prev)
319            .map(|(addr, status)| status.map(|()| addr))
320    }
321
322    /// Returns true if there is a current status entry for `hash` and `addr`.
323    pub fn has_current_status(&self, hash: InventoryHash, addr: PeerSocketAddr) -> bool {
324        self.current
325            .get(&hash)
326            .and_then(|current| current.get(&addr))
327            .is_some()
328    }
329
330    /// Returns an iterator over peer inventory status hashes.
331    ///
332    /// Yields current statuses first, then previously rotated statuses.
333    /// This can include multiple statuses for the same hash.
334    #[allow(dead_code)]
335    pub fn status_hashes(
336        &self,
337    ) -> impl Iterator<Item = (&InventoryHash, &IndexMap<PeerSocketAddr, InventoryMarker>)> {
338        self.current.iter().chain(self.prev.iter())
339    }
340
341    /// Returns a future that waits for new registry updates.
342    #[allow(dead_code)]
343    pub fn update(&mut self) -> Update<'_> {
344        Update::new(self)
345    }
346
347    /// Drive periodic inventory tasks.
348    ///
349    /// Rotates the inventory HashMaps on every timer tick.
350    /// Drains the inv_stream channel and registers all advertised inventory.
351    ///
352    /// Returns an error if the inventory channel is closed.
353    ///
354    /// Otherwise, returns `Ok` if it performed at least one update or rotation, or `Poll::Pending`
355    /// if there was no inventory change. Always registers a wakeup for the next inventory update
356    /// or rotation, even when it returns `Ok`.
357    pub fn poll_inventory(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), BoxError>> {
358        let mut result = Poll::Pending;
359
360        // # Correctness
361        //
362        // Registers the current task for wakeup when the timer next becomes ready.
363        // (But doesn't return, because we also want to register the task for wakeup when more
364        // inventory arrives.)
365        //
366        // # Security
367        //
368        // Rotate before registering new inventory. Missed ticks restart the interval, so
369        // repeated polls cannot immediately age out new advertisements or failures.
370        if Pin::new(&mut self.interval).poll_next(cx).is_ready() {
371            self.rotate();
372            result = Poll::Ready(Ok(()));
373        }
374
375        // This module uses a broadcast channel instead of an mpsc channel, even
376        // though there's a single consumer of inventory advertisements, because
377        // the broadcast channel has ring-buffer behavior: when the channel is
378        // full, sending a new message displaces the oldest message in the
379        // channel.
380        //
381        // This is the behavior we want for inventory advertisements, because we
382        // want to have a bounded buffer of unprocessed advertisements, and we
383        // want to prioritize new inventory (which is likely only at a specific
384        // peer) over old inventory (which is likely more widely distributed).
385        //
386        // The broadcast channel reports dropped messages by returning
387        // `RecvError::Lagged`. It's crucial that we handle that error here
388        // rather than propagating it through the peer set's Service::poll_ready
389        // implementation, where reporting a failure means reporting a permanent
390        // failure of the peer set.
391
392        // Returns Pending if all messages are processed, but the channel could get more.
393        loop {
394            let channel_result = self.inv_stream.next().poll_unpin(cx);
395
396            match channel_result {
397                Poll::Ready(Some(Ok(change))) => {
398                    self.register(change);
399                    result = Poll::Ready(Ok(()));
400                }
401                Poll::Ready(Some(Err(BroadcastStreamRecvError::Lagged(count)))) => {
402                    // This isn't a fatal inventory error, it's expected behaviour when Zebra is
403                    // under load from peers.
404                    metrics::counter!("pool.inventory.dropped").increment(1);
405                    metrics::counter!("pool.inventory.dropped.messages").increment(count);
406
407                    // If this message happens a lot, we should improve inventory registry
408                    // performance, or poll the registry or peer set in a separate task.
409                    info!(count, "dropped lagged inventory advertisements");
410                }
411                Poll::Ready(None) => {
412                    // If the channel is empty and returns None, all senders, including the one in
413                    // the handshaker, have been dropped, which really is a permanent failure.
414                    result = Poll::Ready(Err(broadcast::error::RecvError::Closed.into()));
415                }
416                Poll::Pending => {
417                    break;
418                }
419            }
420        }
421
422        result
423    }
424
425    /// Record the given inventory `change` for the peer `addr`.
426    ///
427    /// `Missing` markers are not updated until the registry rotates, for security reasons.
428    fn register(&mut self, change: InventoryChange) {
429        let new_status = change.marker();
430        let (invs, addr) = change.to_inner();
431
432        for inv in invs {
433            use InventoryHash::*;
434            assert!(
435                matches!(inv, Block(_) | Tx(_) | Wtx(_)),
436                "unexpected inventory type: {inv:?} from peer: {addr:?}",
437            );
438
439            let hash_peers = self.current.entry(inv).or_default();
440
441            // # Security
442            //
443            // Prefer `missing` over `advertised`, so malicious peers can't reset their own entries,
444            // and funnel multiple failing requests to themselves.
445            if let Some(old_status) = hash_peers.get(&addr) {
446                if old_status.is_missing() && new_status.is_available() {
447                    debug!(?new_status, ?old_status, ?addr, ?inv, "skipping new status");
448                    continue;
449                }
450
451                debug!(
452                    ?new_status,
453                    ?old_status,
454                    ?addr,
455                    ?inv,
456                    "keeping both new and old status"
457                );
458            }
459
460            let replaced_status = hash_peers.insert(addr, new_status);
461
462            debug!(
463                ?new_status,
464                ?replaced_status,
465                ?addr,
466                ?inv,
467                "inserted new status"
468            );
469
470            // # Security
471            //
472            // Limit the number of stored peers per hash, removing the oldest entries,
473            // because newer entries are likely to be more relevant.
474            //
475            // TODO: do random or weighted random eviction instead?
476            if hash_peers.len() > MAX_PEERS_PER_INV {
477                // Performance: `MAX_PEERS_PER_INV` is small, so O(n) performance is acceptable.
478                hash_peers.shift_remove_index(0);
479            }
480
481            // # Security
482            //
483            // Limit the number of stored inventory hashes, removing the oldest entries,
484            // because newer entries are likely to be more relevant.
485            //
486            // TODO: do random or weighted random eviction instead?
487            if self.current.len() > MAX_INV_PER_MAP {
488                // Performance: `MAX_INV_PER_MAP` is small, so O(n) performance is acceptable.
489                self.current.shift_remove_index(0);
490            }
491        }
492    }
493
494    /// Replace the prev HashMap with current's and replace current with an empty
495    /// HashMap
496    fn rotate(&mut self) {
497        self.prev = std::mem::take(&mut self.current);
498        self.failed_prev = std::mem::take(&mut self.failed_current);
499    }
500}