mumble_server_runtime_gateway/
limits.rs

1//! Data-plane limits (spec 15.7).
2//!
3//! Two protect the voice path itself: a hard cap on datagram size, and a
4//! per-connection packet budget. That budget is not an optimisation, it is what
5//! keeps one connection from turning into N encryptions per packet for every
6//! other connection.
7//!
8//! One protects the control path: a leaky bucket in front of `TextMessage`,
9//! which is the only thing a client can type as fast as it likes. The rest of
10//! the rate-limit matrix of 22.5 - authentication, blobs, context actions - is
11//! still Phase 9.
12
13use std::time::{Duration, Instant};
14
15/// Largest voice datagram the server accepts, in bytes.
16///
17/// REF: references/mumble/src/MumbleProtocol.h : `MAX_UDP_PACKET_SIZE = 1024`.
18///   The real server drops tunnelled audio below 2 bytes or above this, so
19///   matching it keeps both ingress paths on the same rule.
20pub const MAX_UDP_PACKET_SIZE: usize = 1024;
21
22/// Smallest datagram that can carry anything meaningful (a type byte plus at
23/// least one payload byte).
24/// REF: references/mumble/src/murmur/Server.cpp : the `UDPTunnel` branch drops
25///   `len < 2`.
26pub const MIN_UDP_PACKET_SIZE: usize = 2;
27
28/// Sustained voice packets per second allowed from one connection.
29///
30/// A client at the usual 10 ms framing emits 100 packets per second, and 20 ms
31/// framing halves that. 200 leaves generous headroom for a fast codec setting
32/// while still bounding a flood to twice the legitimate worst case.
33const PACKETS_PER_SECOND: u32 = 200;
34
35/// How many packets may arrive back to back before the sustained rate applies.
36/// A burst absorbs normal jitter and scheduler hiccups without ever letting the
37/// long-run average exceed [`PACKETS_PER_SECOND`].
38const BURST: u32 = 400;
39
40/// What the reference server adds to a voice payload to account for what the
41/// network actually carried.
42///
43/// REF: references/mumble/src/murmur/Server.cpp : `processMsg` bills
44///   `20 + 8 + 4 + payload` - IP, UDP, crypt, data - against the bandwidth
45///   record.
46pub const PACKET_OVERHEAD: usize = 20 + 8 + 4;
47
48/// How the throughput window is bucketed, and over what span.
49///
50/// Ten buckets of 100 ms. The reference server keeps 360 individually timed
51/// frames per connection, which is roughly 4 KB of ring per user to answer one
52/// line in a dialog; bucketing gives the same figure to a tenth of a second for
53/// fifty bytes. The deviation is deliberate and this is the only place it shows.
54const BANDWIDTH_BUCKETS: usize = 10;
55const BUCKET: Duration = Duration::from_millis(100);
56const WINDOW: Duration = Duration::from_millis(1000);
57
58/// One connection's voice traffic account.
59///
60/// Three things that share a clock and a lock: the token bucket that decides
61/// admission, the one-second window that measures throughput, and the last sign
62/// of life that is not a keepalive. Keeping them together is what makes the hot
63/// path take one lock rather than three.
64///
65/// The clock is passed in rather than read here: the packet path already knows
66/// what time it is, and a test that has to sleep to observe a rate limit is a
67/// test that will be flaky by Friday.
68#[derive(Debug)]
69pub struct VoiceBudget {
70    tokens: u32,
71    last_refill: Instant,
72    /// Bytes billed to each bucket, the current one at `bucket`.
73    billed: [u32; BANDWIDTH_BUCKETS],
74    bucket: usize,
75    bucket_started: Instant,
76    /// The last thing this connection did that counts as being there.
77    last_active: Instant,
78}
79
80impl VoiceBudget {
81    pub fn new(now: Instant) -> Self {
82        Self {
83            tokens: BURST,
84            last_refill: now,
85            billed: [0; BANDWIDTH_BUCKETS],
86            bucket: 0,
87            bucket_started: now,
88            last_active: now,
89        }
90    }
91
92    /// Take one packet's worth of budget, refilling for elapsed time first, and
93    /// bill what it carried.
94    ///
95    /// A refused packet is billed nothing, like the reference server's own
96    /// record: `addFrame` is both the limiter and the meter, and a frame it
97    /// rejects never enters the window.
98    ///
99    /// `bytes` is the decoded packet, to which the network overhead is added.
100    /// `false` means the caller must drop the packet.
101    pub fn allow(&mut self, now: Instant, bytes: usize) -> bool {
102        self.refill(now);
103        if self.tokens == 0 {
104            return false;
105        }
106        self.tokens -= 1;
107
108        self.roll(now);
109        let billed = u32::try_from(bytes.saturating_add(PACKET_OVERHEAD)).unwrap_or(u32::MAX);
110        if let Some(bucket) = self.billed.get_mut(self.bucket) {
111            *bucket = bucket.saturating_add(billed);
112        }
113        self.last_active = now;
114        true
115    }
116
117    /// Note that the connection did something that is not a keepalive.
118    pub fn touch(&mut self, now: Instant) {
119        self.last_active = now;
120    }
121
122    /// Voice throughput over the last second, in **bytes per second**.
123    ///
124    /// The unit is the client's: it divides by 125 to print kbit/s.
125    ///
126    /// REF: references/mumble/src/murmur/ServerUser.cpp : `bandwidth()` sums the
127    ///   frames of the last second and divides by the elapsed time.
128    /// REF: references/mumble/src/mumble/UserInformation.cpp : the dialog prints
129    ///   `msg.bandwidth() / 125.0` as kbit/s.
130    pub fn bandwidth(&mut self, now: Instant) -> u32 {
131        self.roll(now);
132        let total: u32 = self.billed.iter().copied().fold(0, u32::saturating_add);
133        // The window is a whole second by construction, so the sum already is a
134        // per-second figure.
135        total
136    }
137
138    /// How long this connection has been doing nothing.
139    ///
140    /// REF: references/mumble/src/murmur/ServerUser.cpp : `idleSeconds()` takes
141    ///   the shorter of "since the last voice frame" and "since the last control
142    ///   message that unidles".
143    pub fn idle(&self, now: Instant) -> Duration {
144        now.saturating_duration_since(self.last_active)
145    }
146
147    /// Advance the window to `now`, clearing whatever it moved past.
148    fn roll(&mut self, now: Instant) {
149        let elapsed = now.saturating_duration_since(self.bucket_started);
150        let steps = usize::try_from(elapsed.as_millis() / BUCKET.as_millis()).unwrap_or(usize::MAX);
151        if steps == 0 {
152            return;
153        }
154
155        if steps >= BANDWIDTH_BUCKETS {
156            // Nothing in the window is still within the last second.
157            self.billed = [0; BANDWIDTH_BUCKETS];
158            self.bucket = 0;
159        } else {
160            for step in 1..=steps {
161                let index = (self.bucket + step) % BANDWIDTH_BUCKETS;
162                if let Some(bucket) = self.billed.get_mut(index) {
163                    *bucket = 0;
164                }
165            }
166            self.bucket = (self.bucket + steps) % BANDWIDTH_BUCKETS;
167        }
168        // Anchored on whole buckets so a burst of packets cannot keep dragging
169        // the boundary forward and stretch the window past a second.
170        self.bucket_started += BUCKET.saturating_mul(u32::try_from(steps).unwrap_or(u32::MAX));
171        if now.saturating_duration_since(self.bucket_started) > WINDOW {
172            self.bucket_started = now;
173        }
174    }
175
176    fn refill(&mut self, now: Instant) {
177        let elapsed = now.saturating_duration_since(self.last_refill);
178        if elapsed < Duration::from_millis(1) {
179            return;
180        }
181
182        // Whole tokens only; the remainder stays on the clock so a stream of
183        // sub-millisecond gaps still accrues budget instead of losing it.
184        let earned = elapsed
185            .as_millis()
186            .saturating_mul(u128::from(PACKETS_PER_SECOND))
187            / 1000;
188        let earned = u32::try_from(earned).unwrap_or(u32::MAX);
189        if earned == 0 {
190            return;
191        }
192
193        self.tokens = self.tokens.saturating_add(earned).min(BURST);
194        self.last_refill = now;
195    }
196}
197
198/// Whether a datagram is within the accepted size band (spec 15.7).
199pub fn is_acceptable_size(len: usize) -> bool {
200    (MIN_UDP_PACKET_SIZE..=MAX_UDP_PACKET_SIZE).contains(&len)
201}
202
203/// Sustained text messages per second allowed from one connection, and how many
204/// may arrive back to back.
205///
206/// REF: references/mumble/src/murmur/Meta.cpp : `iMessageLimit = 1`,
207///   `iMessageBurst = 5`.
208const MESSAGES_PER_SECOND: u32 = 1;
209const MESSAGE_BURST: u32 = 5;
210
211/// One connection's allowance for the things it types.
212///
213/// The same leaky bucket the reference server puts in front of `TextMessage`,
214/// and it is worth having here rather than in the shard: a flood refused at the
215/// socket never crosses a mailbox nor wakes a shard task.
216///
217/// The clock is passed in for the same reason [`VoiceBudget`] does it: a test
218/// that has to sleep to observe a rate limit is a test that will be flaky.
219///
220/// REF: references/mumble/src/murmur/Messages.cpp : the `RATELIMIT` macro
221///   returns from `msgTextMessage` **without** answering the client.
222#[derive(Debug)]
223pub struct TextBudget {
224    tokens: u32,
225    last_refill: Instant,
226}
227
228impl TextBudget {
229    #[must_use]
230    pub fn new(now: Instant) -> TextBudget {
231        TextBudget {
232            tokens: MESSAGE_BURST,
233            last_refill: now,
234        }
235    }
236
237    /// Take one message's worth of budget. `false` means drop it.
238    pub fn allow(&mut self, now: Instant) -> bool {
239        let elapsed = now.saturating_duration_since(self.last_refill);
240        // Whole tokens only; the remainder stays on the clock so a stream of
241        // short gaps still accrues budget instead of losing it.
242        let earned = elapsed
243            .as_millis()
244            .saturating_mul(u128::from(MESSAGES_PER_SECOND))
245            / 1000;
246        if let Ok(earned) = u32::try_from(earned)
247            && earned > 0
248        {
249            self.tokens = self.tokens.saturating_add(earned).min(MESSAGE_BURST);
250            self.last_refill = now;
251        }
252
253        if self.tokens == 0 {
254            return false;
255        }
256        self.tokens -= 1;
257        true
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn a_typist_gets_a_burst_then_one_message_a_second() {
267        let start = Instant::now();
268        let mut budget = TextBudget::new(start);
269
270        for message in 0..MESSAGE_BURST {
271            assert!(budget.allow(start), "message {message} is within the burst");
272        }
273        assert!(!budget.allow(start), "the burst is spent");
274
275        assert!(
276            budget.allow(start + Duration::from_millis(1000)),
277            "a second of silence buys one message"
278        );
279        assert!(
280            !budget.allow(start + Duration::from_millis(1000)),
281            "and only one"
282        );
283    }
284
285    #[test]
286    fn a_burst_is_allowed_then_the_sustained_rate_applies() {
287        let start = Instant::now();
288        let mut budget = VoiceBudget::new(start);
289
290        for packet in 0..BURST {
291            assert!(
292                budget.allow(start, 0),
293                "packet {packet} of the burst refused"
294            );
295        }
296        assert!(!budget.allow(start, 0), "the burst is not bounded");
297
298        // A second of silence refills to the cap, not beyond it.
299        let later = start + Duration::from_secs(10);
300        assert!(budget.allow(later, 0));
301        for _ in 0..BURST {
302            budget.allow(later, 0);
303        }
304        assert!(!budget.allow(later, 0), "refill exceeded the burst ceiling");
305    }
306
307    #[test]
308    fn budget_accrues_with_elapsed_time() {
309        let start = Instant::now();
310        let mut budget = VoiceBudget::new(start);
311        for _ in 0..BURST {
312            budget.allow(start, 0);
313        }
314        assert!(!budget.allow(start, 0));
315
316        // 100 ms buys PACKETS_PER_SECOND/10 packets.
317        let later = start + Duration::from_millis(100);
318        for packet in 0..(PACKETS_PER_SECOND / 10) {
319            assert!(
320                budget.allow(later, 0),
321                "packet {packet} after refill refused"
322            );
323        }
324        assert!(!budget.allow(later, 0), "refill was too generous");
325    }
326
327    #[test]
328    fn throughput_is_measured_over_the_last_second_and_then_forgotten() {
329        let start = Instant::now();
330        let mut budget = VoiceBudget::new(start);
331
332        // Ten packets of 100 payload bytes, spread across the whole window.
333        for step in 0..10 {
334            let now = start + Duration::from_millis(step * 100);
335            assert!(budget.allow(now, 100));
336        }
337
338        let billed = 10 * (100 + PACKET_OVERHEAD);
339        let measured = budget.bandwidth(start + Duration::from_millis(999));
340        assert_eq!(
341            u32::try_from(billed).unwrap_or(u32::MAX),
342            measured,
343            "the window must bill the payload plus the network overhead"
344        );
345
346        // A second of silence, and the whole window has moved past them.
347        assert_eq!(
348            budget.bandwidth(start + Duration::from_secs(3)),
349            0,
350            "throughput is a rate, not a total"
351        );
352    }
353
354    #[test]
355    fn a_refused_packet_is_billed_nothing() {
356        let start = Instant::now();
357        let mut budget = VoiceBudget::new(start);
358        for _ in 0..BURST {
359            budget.allow(start, 10);
360        }
361        let admitted = budget.bandwidth(start);
362
363        assert!(!budget.allow(start, 10_000), "the burst is not bounded");
364        assert_eq!(
365            budget.bandwidth(start),
366            admitted,
367            "a packet the server dropped never crossed the network for this user"
368        );
369    }
370
371    #[test]
372    fn idling_is_the_time_since_the_last_thing_that_counted() {
373        let start = Instant::now();
374        let mut budget = VoiceBudget::new(start);
375        assert_eq!(
376            budget.idle(start + Duration::from_secs(5)),
377            Duration::from_secs(5)
378        );
379
380        budget.allow(start + Duration::from_secs(5), 100);
381        assert_eq!(
382            budget.idle(start + Duration::from_secs(6)),
383            Duration::from_secs(1)
384        );
385
386        // A control message that unidles resets it just the same.
387        budget.touch(start + Duration::from_secs(6));
388        assert_eq!(budget.idle(start + Duration::from_secs(6)), Duration::ZERO);
389    }
390
391    #[test]
392    fn the_accepted_size_band_matches_the_reference() {
393        assert!(!is_acceptable_size(0));
394        assert!(!is_acceptable_size(1));
395        assert!(is_acceptable_size(2));
396        assert!(is_acceptable_size(MAX_UDP_PACKET_SIZE));
397        assert!(!is_acceptable_size(MAX_UDP_PACKET_SIZE + 1));
398    }
399}