Skip to main content

openmoat_proxy/
coalesce.rs

1//! Refusals made before anyone authenticated, recorded at a bounded rate.
2//!
3//! Any local process may connect: to `openmoat proxy`'s port, and to
4//! `openmoat run`'s, which also carries the hook relay. A row for each
5//! connection it is refused would let such a process grow the audit log
6//! without limit, and a log that can no longer be written makes the proxy and
7//! the guard refuse everything. So each kind of refusal is recorded at once
8//! the first time, with what it was, and the ones after it are counted and
9//! recorded as one row per [`INTERVAL`]: when the next one of the kind comes,
10//! when any other connection comes ([`Coalesce::due`]), or when the counter's
11//! owner ends. Every refusal is still a refusal; only its row is shared.
12
13use std::sync::{Mutex, PoisonError};
14use std::time::{Duration, Instant};
15
16/// The shortest time between two rows of one kind.
17pub const INTERVAL: Duration = Duration::from_secs(10);
18
19/// Per kind: when a row was last written, and the refusals not in a row yet.
20#[derive(Debug, Default, Clone, Copy)]
21struct Kind {
22    last: Option<Instant>,
23    unrecorded: u64,
24}
25
26/// Counters for `N` kinds of refusal, each kind an index below `N`.
27#[derive(Debug)]
28pub struct Coalesce<const N: usize> {
29    kinds: Mutex<[Kind; N]>,
30}
31
32impl<const N: usize> Default for Coalesce<N> {
33    fn default() -> Self {
34        Self {
35            kinds: Mutex::new([Kind::default(); N]),
36        }
37    }
38}
39
40impl<const N: usize> Coalesce<N> {
41    /// Count one refusal of `kind` at `now`: the number of refusals the row
42    /// written now must stand for (this one included), or `None` when a row
43    /// of this kind was written less than [`INTERVAL`] ago and this one is
44    /// counted in the next. A `kind` of `N` or more is counted as the last.
45    pub fn refused(&self, kind: usize, now: Instant) -> Option<u64> {
46        let mut kinds = self.kinds.lock().unwrap_or_else(PoisonError::into_inner);
47        let slot = kinds.get_mut(kind.min(N.saturating_sub(1)))?;
48        slot.unrecorded = slot.unrecorded.saturating_add(1);
49        take_due(slot, now)
50    }
51
52    /// The counts to record now, by kind, for every kind with refusals not in
53    /// a row yet whose last row is at least [`INTERVAL`] old.
54    pub fn due(&self, now: Instant) -> Vec<(usize, u64)> {
55        let mut kinds = self.kinds.lock().unwrap_or_else(PoisonError::into_inner);
56        kinds
57            .iter_mut()
58            .enumerate()
59            .filter(|(_, k)| k.unrecorded > 0)
60            .filter_map(|(i, k)| take_due(k, now).map(|n| (i, n)))
61            .collect()
62    }
63
64    /// Every count not in a row yet, whatever its age: for the owner's end.
65    pub fn rest(&self) -> Vec<(usize, u64)> {
66        let mut kinds = self.kinds.lock().unwrap_or_else(PoisonError::into_inner);
67        kinds
68            .iter_mut()
69            .enumerate()
70            .filter(|(_, k)| k.unrecorded > 0)
71            .map(|(i, k)| (i, std::mem::take(&mut k.unrecorded)))
72            .collect()
73    }
74}
75
76/// The count to record now, if [`INTERVAL`] has passed since the last row.
77fn take_due(kind: &mut Kind, now: Instant) -> Option<u64> {
78    let due = kind
79        .last
80        .is_none_or(|last| now.saturating_duration_since(last) >= INTERVAL);
81    if !due {
82        return None;
83    }
84    kind.last = Some(now);
85    Some(std::mem::take(&mut kind.unrecorded))
86}
87
88/// The words a coalesced row starts with: "1 request", "12 requests".
89pub fn counted(count: u64, one: &str, many: &str) -> String {
90    let noun = if count == 1 { one } else { many };
91    format!("{count} {noun}")
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn the_first_of_a_kind_is_recorded_and_a_flood_is_one_row_per_interval() {
100        let counter = Coalesce::<2>::default();
101        let start = Instant::now();
102        assert_eq!(counter.refused(0, start), Some(1));
103        for i in 1..1000u32 {
104            let at = start + Duration::from_millis(u64::from(i));
105            assert_eq!(counter.refused(0, at), None, "refusal {i}");
106        }
107        // Another kind has its own first row.
108        assert_eq!(counter.refused(1, start), Some(1));
109        let later = start + INTERVAL;
110        assert_eq!(
111            counter.refused(0, later),
112            Some(1000),
113            "999 counted plus this one"
114        );
115        assert_eq!(counter.due(later), []);
116    }
117
118    #[test]
119    fn a_burst_that_ends_is_recorded_by_the_next_connection_or_the_end() {
120        let counter = Coalesce::<1>::default();
121        let start = Instant::now();
122        counter.refused(0, start);
123        counter.refused(0, start);
124        counter.refused(0, start);
125        assert!(counter.due(start).is_empty(), "within the interval");
126        assert_eq!(counter.due(start + INTERVAL), [(0, 2)]);
127        assert!(counter.due(start + INTERVAL * 3).is_empty(), "nothing left");
128        counter.refused(0, start + INTERVAL * 3);
129        counter.refused(0, start + INTERVAL * 3);
130        assert_eq!(counter.rest(), [(0, 1)]);
131        assert_eq!(counter.rest(), []);
132    }
133
134    #[test]
135    fn rows_are_bounded_by_time_not_by_refusals() {
136        let counter = Coalesce::<1>::default();
137        let start = Instant::now();
138        let mut rows = 0;
139        let mut total = 0;
140        // 100 000 refusals over one minute: at most one row per interval.
141        for i in 0..100_000u64 {
142            let at = start + Duration::from_micros(i * 600);
143            if let Some(n) = counter.refused(0, at) {
144                rows += 1;
145                total += n;
146            }
147        }
148        total += counter.rest().iter().map(|(_, n)| n).sum::<u64>();
149        assert!(rows <= 7, "{rows} rows");
150        assert_eq!(total, 100_000, "every refusal is counted");
151    }
152
153    #[test]
154    fn words() {
155        assert_eq!(counted(1, "request", "requests"), "1 request");
156        assert_eq!(counted(3, "request", "requests"), "3 requests");
157    }
158}