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}