Skip to main content

zakura_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;
12use tokio::{
13    sync::broadcast,
14    time::{self, Instant},
15};
16use tokio_stream::wrappers::{errors::BroadcastStreamRecvError, BroadcastStream, IntervalStream};
17
18use zakura_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    /// Stream of incoming inventory statuses to register.
102    inv_stream: Pin<
103        Box<dyn Stream<Item = Result<InventoryChange, BroadcastStreamRecvError>> + Send + 'static>,
104    >,
105
106    /// Interval tracking when we should next rotate our maps.
107    interval: IntervalStream,
108
109    /// Whether peer address labels in logs are unredacted.
110    expose_peer_addresses: bool,
111}
112
113impl std::fmt::Debug for InventoryRegistry {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        f.debug_struct("InventoryRegistry")
116            .field("current", &self.current)
117            .field("prev", &self.prev)
118            .finish()
119    }
120}
121
122impl InventoryChange {
123    /// Returns a new available inventory change from a single hash.
124    pub fn new_available(hash: InventoryHash, peer: PeerSocketAddr) -> Self {
125        let bv = AtLeastOne::from_vec(vec![hash]).expect("bounded vec must fit");
126        InventoryStatus::Available((bv, peer))
127    }
128
129    /// Returns a new missing inventory change from a single hash.
130    #[allow(dead_code)]
131    pub fn new_missing(hash: InventoryHash, peer: PeerSocketAddr) -> Self {
132        let bv = AtLeastOne::from_vec(vec![hash]).expect("bounded vec must fit");
133        InventoryStatus::Missing((bv, peer))
134    }
135
136    /// Returns a new available multiple inventory change, if `hashes` contains at least one change.
137    pub fn new_available_multi<'a>(
138        hashes: impl IntoIterator<Item = &'a InventoryHash>,
139        peer: PeerSocketAddr,
140    ) -> Option<Self> {
141        let mut hashes: Vec<InventoryHash> = hashes.into_iter().copied().collect();
142
143        // # Security
144        //
145        // Don't send more hashes than we're going to store.
146        // It doesn't matter which hashes we choose, because this is an efficiency optimisation.
147        //
148        //  This limits known memory denial of service attacks to:
149        // `1000 hashes * 200 peers/channel capacity * 32-64 bytes = up to 12 MB`
150        hashes.truncate(MAX_INV_PER_MAP);
151
152        let hashes = hashes.try_into().ok();
153
154        hashes.map(|hashes| InventoryStatus::Available((hashes, peer)))
155    }
156
157    /// Returns a new missing multiple inventory change, if `hashes` contains at least one change.
158    pub fn new_missing_multi<'a>(
159        hashes: impl IntoIterator<Item = &'a InventoryHash>,
160        peer: PeerSocketAddr,
161    ) -> Option<Self> {
162        let mut hashes: Vec<InventoryHash> = hashes.into_iter().copied().collect();
163
164        // # Security
165        //
166        // Don't send more hashes than we're going to store.
167        // It doesn't matter which hashes we choose, because this is an efficiency optimisation.
168        hashes.truncate(MAX_INV_PER_MAP);
169
170        let hashes = hashes.try_into().ok();
171
172        hashes.map(|hashes| InventoryStatus::Missing((hashes, peer)))
173    }
174}
175
176impl<T> InventoryStatus<T> {
177    /// Get a marker for the status, without any associated data.
178    pub fn marker(&self) -> InventoryMarker {
179        self.as_ref().map(|_inner| ())
180    }
181
182    /// Maps an `InventoryStatus<T>` to `InventoryStatus<U>` by applying a function to a contained value.
183    pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> InventoryStatus<U> {
184        // Based on Option::map from https://doc.rust-lang.org/src/core/option.rs.html#844
185        match self {
186            Available(item) => Available(f(item)),
187            Missing(item) => Missing(f(item)),
188        }
189    }
190}
191
192impl<T: Clone> InventoryStatus<T> {
193    /// Returns a clone of the inner item, regardless of status.
194    pub fn to_inner(&self) -> T {
195        match self {
196            Available(item) | Missing(item) => item.clone(),
197        }
198    }
199}
200
201impl InventoryRegistry {
202    /// Returns a new Inventory Registry for `inv_stream` and the configured address policy.
203    pub fn new(
204        inv_stream: broadcast::Receiver<InventoryChange>,
205        expose_peer_addresses: bool,
206    ) -> Self {
207        let interval = INVENTORY_ROTATION_INTERVAL;
208
209        // Don't do an immediate rotation, current and prev are already empty.
210        let mut interval = tokio::time::interval_at(Instant::now() + interval, interval);
211        // # Security
212        //
213        // If the rotation time is late, execute as many ticks as needed to catch up.
214        // This is a tradeoff between memory usage and quickly accessing remote data
215        // under heavy load. Bursting prioritises lower memory usage.
216        //
217        // Skipping or delaying could keep peer inventory in memory for a longer time,
218        // further increasing memory load or delays due to virtual memory swapping.
219        interval.set_missed_tick_behavior(time::MissedTickBehavior::Burst);
220
221        Self {
222            current: Default::default(),
223            prev: Default::default(),
224            inv_stream: BroadcastStream::new(inv_stream).boxed(),
225            interval: IntervalStream::new(interval),
226            expose_peer_addresses,
227        }
228    }
229
230    /// Returns an iterator over addrs of peers that have recently advertised `hash` in their inventory.
231    pub fn advertising_peers(&self, hash: InventoryHash) -> impl Iterator<Item = &PeerSocketAddr> {
232        self.status_peers(hash)
233            .filter_map(|addr_status| addr_status.available())
234    }
235
236    /// Returns an iterator over addrs of peers that have recently missed `hash` in their inventory.
237    #[allow(dead_code)]
238    pub fn missing_peers(&self, hash: InventoryHash) -> impl Iterator<Item = &PeerSocketAddr> {
239        self.status_peers(hash)
240            .filter_map(|addr_status| addr_status.missing())
241    }
242
243    /// Returns an iterator over peer inventory statuses for `hash`.
244    ///
245    /// Prefers current statuses to previously rotated statuses for the same peer.
246    pub fn status_peers(
247        &self,
248        hash: InventoryHash,
249    ) -> impl Iterator<Item = InventoryStatus<&PeerSocketAddr>> {
250        let prev = self.prev.get(&hash);
251        let current = self.current.get(&hash);
252
253        // # Security
254        //
255        // Prefer `current` statuses for the same peer over previously rotated statuses.
256        // This makes sure Zebra is using the most up-to-date network information.
257        let prev = prev
258            .into_iter()
259            .flatten()
260            .filter(move |(addr, _status)| !self.has_current_status(hash, **addr));
261        let current = current.into_iter().flatten();
262
263        current
264            .chain(prev)
265            .map(|(addr, status)| status.map(|()| addr))
266    }
267
268    /// Returns true if there is a current status entry for `hash` and `addr`.
269    pub fn has_current_status(&self, hash: InventoryHash, addr: PeerSocketAddr) -> bool {
270        self.current
271            .get(&hash)
272            .and_then(|current| current.get(&addr))
273            .is_some()
274    }
275
276    /// Returns an iterator over peer inventory status hashes.
277    ///
278    /// Yields current statuses first, then previously rotated statuses.
279    /// This can include multiple statuses for the same hash.
280    #[allow(dead_code)]
281    pub fn status_hashes(
282        &self,
283    ) -> impl Iterator<Item = (&InventoryHash, &IndexMap<PeerSocketAddr, InventoryMarker>)> {
284        self.current.iter().chain(self.prev.iter())
285    }
286
287    /// Returns a future that waits for new registry updates.
288    #[allow(dead_code)]
289    pub fn update(&mut self) -> Update<'_> {
290        Update::new(self)
291    }
292
293    /// Drive periodic inventory tasks.
294    ///
295    /// Rotates the inventory HashMaps on every timer tick.
296    /// Drains the inv_stream channel and registers all advertised inventory.
297    ///
298    /// Returns an error if the inventory channel is closed.
299    ///
300    /// Otherwise, returns `Ok` if it performed at least one update or rotation, or `Poll::Pending`
301    /// if there was no inventory change. Always registers a wakeup for the next inventory update
302    /// or rotation, even when it returns `Ok`.
303    pub fn poll_inventory(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), BoxError>> {
304        let mut result = Poll::Pending;
305
306        // # Correctness
307        //
308        // Registers the current task for wakeup when the timer next becomes ready.
309        // (But doesn't return, because we also want to register the task for wakeup when more
310        // inventory arrives.)
311        //
312        // # Security
313        //
314        // Only rotate one inventory per peer request, to give the next inventory
315        // time to gather some peer advertisements. This is a tradeoff between
316        // memory usage and quickly accessing remote data under heavy load.
317        //
318        // This prevents a burst edge case where all inventory is emptied after
319        // two interval ticks are delayed.
320        if Pin::new(&mut self.interval).poll_next(cx).is_ready() {
321            self.rotate();
322            result = Poll::Ready(Ok(()));
323        }
324
325        // This module uses a broadcast channel instead of an mpsc channel, even
326        // though there's a single consumer of inventory advertisements, because
327        // the broadcast channel has ring-buffer behavior: when the channel is
328        // full, sending a new message displaces the oldest message in the
329        // channel.
330        //
331        // This is the behavior we want for inventory advertisements, because we
332        // want to have a bounded buffer of unprocessed advertisements, and we
333        // want to prioritize new inventory (which is likely only at a specific
334        // peer) over old inventory (which is likely more widely distributed).
335        //
336        // The broadcast channel reports dropped messages by returning
337        // `RecvError::Lagged`. It's crucial that we handle that error here
338        // rather than propagating it through the peer set's Service::poll_ready
339        // implementation, where reporting a failure means reporting a permanent
340        // failure of the peer set.
341
342        // Returns Pending if all messages are processed, but the channel could get more.
343        loop {
344            let channel_result = self.inv_stream.next().poll_unpin(cx);
345
346            match channel_result {
347                Poll::Ready(Some(Ok(change))) => {
348                    self.register(change);
349                    result = Poll::Ready(Ok(()));
350                }
351                Poll::Ready(Some(Err(BroadcastStreamRecvError::Lagged(count)))) => {
352                    // This isn't a fatal inventory error, it's expected behaviour when Zebra is
353                    // under load from peers.
354                    metrics::counter!("pool.inventory.dropped").increment(1);
355                    metrics::counter!("pool.inventory.dropped.messages").increment(count);
356
357                    // If this message happens a lot, we should improve inventory registry
358                    // performance, or poll the registry or peer set in a separate task.
359                    info!(count, "dropped lagged inventory advertisements");
360                }
361                Poll::Ready(None) => {
362                    // If the channel is empty and returns None, all senders, including the one in
363                    // the handshaker, have been dropped, which really is a permanent failure.
364                    result = Poll::Ready(Err(broadcast::error::RecvError::Closed.into()));
365                }
366                Poll::Pending => {
367                    break;
368                }
369            }
370        }
371
372        result
373    }
374
375    /// Record the given inventory `change` for the peer `addr`.
376    ///
377    /// `Missing` markers are not updated until the registry rotates, for security reasons.
378    fn register(&mut self, change: InventoryChange) {
379        let new_status = change.marker();
380        let (invs, addr) = change.to_inner();
381        let addr_label = addr.addr_label(self.expose_peer_addresses);
382
383        for inv in invs {
384            use InventoryHash::*;
385            assert!(
386                matches!(inv, Block(_) | Tx(_) | Wtx(_)),
387                "unexpected inventory type: {inv:?} from peer: {addr:?}",
388            );
389
390            let hash_peers = self.current.entry(inv).or_default();
391
392            // # Security
393            //
394            // Prefer `missing` over `advertised`, so malicious peers can't reset their own entries,
395            // and funnel multiple failing requests to themselves.
396            if let Some(old_status) = hash_peers.get(&addr) {
397                if old_status.is_missing() && new_status.is_available() {
398                    debug!(
399                        ?new_status,
400                        ?old_status,
401                        peer = %addr_label,
402                        ?inv,
403                        "skipping new status"
404                    );
405                    continue;
406                }
407
408                debug!(
409                    ?new_status,
410                    ?old_status,
411                    peer = %addr_label,
412                    ?inv,
413                    "keeping both new and old status"
414                );
415            }
416
417            let replaced_status = hash_peers.insert(addr, new_status);
418
419            debug!(
420                ?new_status,
421                ?replaced_status,
422                peer = %addr_label,
423                ?inv,
424                "inserted new status"
425            );
426
427            // # Security
428            //
429            // Limit the number of stored peers per hash, removing the oldest entries,
430            // because newer entries are likely to be more relevant.
431            //
432            // TODO: do random or weighted random eviction instead?
433            if hash_peers.len() > MAX_PEERS_PER_INV {
434                // Performance: `MAX_PEERS_PER_INV` is small, so O(n) performance is acceptable.
435                hash_peers.shift_remove_index(0);
436            }
437
438            // # Security
439            //
440            // Limit the number of stored inventory hashes, removing the oldest entries,
441            // because newer entries are likely to be more relevant.
442            //
443            // TODO: do random or weighted random eviction instead?
444            if self.current.len() > MAX_INV_PER_MAP {
445                // Performance: `MAX_INV_PER_MAP` is small, so O(n) performance is acceptable.
446                self.current.shift_remove_index(0);
447            }
448        }
449    }
450
451    /// Replace the prev HashMap with current's and replace current with an empty
452    /// HashMap
453    fn rotate(&mut self) {
454        self.prev = std::mem::take(&mut self.current);
455    }
456}