Skip to main content

pb_mapper_protocol/secure/
limiter.rs

1//! Cardinality-bounded suppression for repeated authentication failure logs.
2//!
3//! ```text
4//! (peer IP, key id, reason) -> per-window counter -> emit first / suppress repeats
5//!                too many distinct keys ---------> shared overflow bucket
6//! ```
7//!
8//! This limits log amplification from the public relay port without changing protocol
9//! decisions: every authentication failure is still rejected, only duplicate logging
10//! is coalesced.
11
12use pb_mapper_auth::KeyId;
13
14#[derive(Clone, Copy, Debug)]
15pub struct FailureLogDecision {
16    pub emit: bool,
17    pub suppressed: u64,
18}
19
20pub(super) struct FailureLogEntry {
21    window_started_at: u64,
22    emitted: u8,
23    suppressed: u64,
24}
25
26#[derive(Default)]
27pub(super) struct FailureLogLimiter {
28    pub(super) entries:
29        std::collections::HashMap<(std::net::IpAddr, KeyId, String), FailureLogEntry>,
30    pub(super) overflow: Option<FailureLogEntry>,
31}
32
33impl FailureLogLimiter {
34    pub(super) fn record(
35        &mut self,
36        peer_ip: std::net::IpAddr,
37        key_id: KeyId,
38        reason: &str,
39        now: u64,
40    ) -> FailureLogDecision {
41        let key = (peer_ip, key_id, reason.to_string());
42        if !self.entries.contains_key(&key) && self.entries.len() >= 4096 {
43            self.entries
44                .retain(|_, entry| now.saturating_sub(entry.window_started_at) < 120);
45            if self.entries.len() >= 4096 {
46                let entry = self.overflow.get_or_insert(FailureLogEntry {
47                    window_started_at: now,
48                    emitted: 0,
49                    suppressed: 0,
50                });
51                return record_failure_entry(entry, now);
52            }
53        }
54        let entry = self.entries.entry(key).or_insert(FailureLogEntry {
55            window_started_at: now,
56            emitted: 0,
57            suppressed: 0,
58        });
59        record_failure_entry(entry, now)
60    }
61}
62
63fn record_failure_entry(entry: &mut FailureLogEntry, now: u64) -> FailureLogDecision {
64    if now.saturating_sub(entry.window_started_at) >= 60 {
65        let suppressed = entry.suppressed;
66        *entry = FailureLogEntry {
67            window_started_at: now,
68            emitted: 1,
69            suppressed: 0,
70        };
71        return FailureLogDecision {
72            emit: true,
73            suppressed,
74        };
75    }
76    if entry.emitted < 5 {
77        entry.emitted += 1;
78        FailureLogDecision {
79            emit: true,
80            suppressed: 0,
81        }
82    } else {
83        entry.suppressed = entry.suppressed.saturating_add(1);
84        FailureLogDecision {
85            emit: false,
86            suppressed: 0,
87        }
88    }
89}