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}