zebra_network/ban_list.rs
1//! Peer misbehavior reports, and the set of banned peer groups.
2
3use std::{collections::HashMap, net::IpAddr, sync::Arc};
4
5use tokio::time::Instant;
6
7use crate::{constants, protocol::external::connection_limit_key, PeerSocketAddr};
8
9#[cfg(test)]
10mod tests;
11
12/// A peer misbehavior report, sent to the address book.
13#[derive(Clone, Debug, PartialEq, Eq)]
14#[non_exhaustive]
15pub struct MisbehaviorReport {
16 /// The peer that misbehaved.
17 pub addr: PeerSocketAddr,
18
19 /// The misbehavior score to add to the peer's group.
20 pub score: u32,
21
22 /// Why the peer was scored, logged if this report bans the peer's group.
23 pub reason: String,
24}
25
26impl MisbehaviorReport {
27 /// Returns a new report that adds `score` to `addr`'s peer group because of `reason`.
28 pub fn new(addr: PeerSocketAddr, score: u32, reason: impl Into<String>) -> Self {
29 Self {
30 addr,
31 score,
32 reason: reason.into(),
33 }
34 }
35}
36
37/// The peer groups Zebra has banned for misbehaviour, and when each was banned.
38///
39/// Entries are keyed by peer group — one IPv4 address, or one IPv6 `/64` subnet
40/// — so a peer cannot dodge its ban by reconnecting from another address it
41/// already controls. Bans lapse after
42/// [`BAN_DURATION`](constants::BAN_DURATION).
43///
44/// # Security
45///
46/// This type owns both of those rules. It deliberately does not expose the
47/// underlying map: a caller doing its own lookup would have to remember to map
48/// the address to its peer group *and* to check the ban's age, and getting
49/// either wrong silently stops bans being enforced. Query it with
50/// [`BanList::is_banned`].
51///
52/// # Correctness
53///
54/// Cloning is cheap, and a clone is a snapshot: the map is shared behind an
55/// [`Arc`] and copied only when a ban is added. Snapshots stay correct as bans
56/// lapse, because [`BanList::is_banned`] checks each entry's age when it is
57/// queried, so holders don't need to be sent a new snapshot.
58#[derive(Clone, Debug, Default, PartialEq, Eq)]
59pub struct BanList {
60 /// The time each banned peer group was banned.
61 banned_at: Arc<HashMap<IpAddr, Instant>>,
62}
63
64impl BanList {
65 /// Returns `true` if `ip`'s peer group is banned, and the ban has not
66 /// lapsed.
67 pub fn is_banned(&self, ip: IpAddr) -> bool {
68 self.banned_at
69 .get(&connection_limit_key(ip))
70 .is_some_and(|banned_at| !Self::has_lapsed(*banned_at, Instant::now()))
71 }
72
73 /// Bans `ip`'s peer group, starting now.
74 ///
75 /// Re-banning an already-banned group extends its ban for another full
76 /// [`BAN_DURATION`](constants::BAN_DURATION).
77 pub(crate) fn ban(&mut self, ip: IpAddr) {
78 let now = Instant::now();
79 let banned_at = Arc::make_mut(&mut self.banned_at);
80
81 // Drop lapsed bans, so they don't occupy the slots that active bans
82 // need, and so snapshots stay small.
83 banned_at.retain(|_group, entry| !Self::has_lapsed(*entry, now));
84
85 // Inserting an already-banned group overwrites its ban time, which is
86 // exactly the refresh we want.
87 banned_at.insert(connection_limit_key(ip), now);
88
89 while banned_at.len() > constants::MAX_BANNED_IPS {
90 let oldest = banned_at
91 .iter()
92 .min_by_key(|(_group, entry)| **entry)
93 .map(|(group, _entry)| *group)
94 .expect("the map is over the limit, so it is not empty");
95 banned_at.remove(&oldest);
96 }
97 }
98
99 /// Returns `true` if a ban applied at `banned_at` has lapsed by `now`.
100 fn has_lapsed(banned_at: Instant, now: Instant) -> bool {
101 // Instants are monotonic, so `now` is normally at or after `banned_at`.
102 // Saturating to zero treats a clock oddity as "just banned" rather than
103 // "lapsed", which fails closed.
104 now.saturating_duration_since(banned_at) >= constants::BAN_DURATION
105 }
106
107 /// Returns the number of banned peer groups, including any whose bans have
108 /// lapsed but have not been pruned yet.
109 pub fn len(&self) -> usize {
110 self.banned_at.len()
111 }
112
113 /// Returns `true` if no peer group is banned.
114 pub fn is_empty(&self) -> bool {
115 self.banned_at.is_empty()
116 }
117}