Skip to main content

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}