Skip to main content

shell_tunnel/security/
rate_limit.rs

1//! Rate limiting implementation.
2
3use std::collections::HashMap;
4use std::net::IpAddr;
5use std::sync::RwLock;
6use std::time::{Duration, Instant};
7
8use axum::{
9    extract::{ConnectInfo, Request, State},
10    http::StatusCode,
11    middleware::Next,
12    response::{IntoResponse, Response},
13};
14
15/// Rate limiter configuration.
16#[derive(Debug, Clone)]
17pub struct RateLimitConfig {
18    /// Maximum requests per window.
19    pub max_requests: u32,
20    /// Time window duration.
21    pub window: Duration,
22    /// Whether rate limiting is enabled.
23    pub enabled: bool,
24    /// Maximum number of tracked IPs (memory limit).
25    pub max_tracked_ips: usize,
26}
27
28impl Default for RateLimitConfig {
29    fn default() -> Self {
30        Self {
31            max_requests: 100,
32            window: Duration::from_secs(60),
33            enabled: true,
34            max_tracked_ips: 10000,
35        }
36    }
37}
38
39impl RateLimitConfig {
40    /// Create a disabled rate limiter config.
41    pub fn disabled() -> Self {
42        Self {
43            enabled: false,
44            ..Default::default()
45        }
46    }
47
48    /// Create a strict rate limiter (10 req/min).
49    pub fn strict() -> Self {
50        Self {
51            max_requests: 10,
52            window: Duration::from_secs(60),
53            ..Default::default()
54        }
55    }
56
57    /// Create a relaxed rate limiter (1000 req/min).
58    pub fn relaxed() -> Self {
59        Self {
60            max_requests: 1000,
61            window: Duration::from_secs(60),
62            ..Default::default()
63        }
64    }
65
66    /// Custom rate limit.
67    pub fn custom(max_requests: u32, window_secs: u64) -> Self {
68        Self {
69            max_requests,
70            window: Duration::from_secs(window_secs),
71            ..Default::default()
72        }
73    }
74}
75
76/// Request record for an IP.
77#[derive(Debug, Clone)]
78struct RequestRecord {
79    /// Timestamps of requests in the current window.
80    timestamps: Vec<Instant>,
81}
82
83impl RequestRecord {
84    fn new() -> Self {
85        Self {
86            timestamps: Vec::new(),
87        }
88    }
89
90    /// Clean up old timestamps and return current count.
91    fn clean_and_count(&mut self, window: Duration) -> u32 {
92        let now = Instant::now();
93        let cutoff = now - window;
94
95        // Remove timestamps older than the window
96        self.timestamps.retain(|&t| t > cutoff);
97
98        self.timestamps.len() as u32
99    }
100
101    /// Record a new request, returning the slot it took.
102    fn record(&mut self) -> Instant {
103        let now = Instant::now();
104        self.timestamps.push(now);
105        now
106    }
107}
108
109/// What the limiter decided about one request.
110///
111/// Three variants rather than `Result<remaining, retry_after>` because a
112/// limiter that is switched off has no remaining count to report, and folding
113/// that case into `Ok(max_requests)` is what put `X-RateLimit-Remaining: 100`
114/// on every response of a server started with `--no-rate-limit`. A limit that
115/// does not exist cannot be advertised if it cannot be represented.
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum RateLimitDecision {
118    /// No limit was applied — the limiter is disabled.
119    Unlimited,
120    /// Allowed, with this many requests left in the current window.
121    Allowed {
122        /// Requests left in the current window.
123        remaining: u32,
124        /// The slot this request took, for a handler that may refund it.
125        charge: RateLimitCharge,
126    },
127    /// Refused; the window frees up after this long.
128    Limited { retry_after: Duration },
129}
130
131impl RateLimitDecision {
132    /// Requests left in the window, where a count exists at all.
133    pub fn remaining(&self) -> Option<u32> {
134        match self {
135            Self::Allowed { remaining, .. } => Some(*remaining),
136            _ => None,
137        }
138    }
139}
140
141/// One slot in one address's window, identifying the request that took it.
142///
143/// Carried in the request's extensions so a handler can hand back the slot the
144/// middleware charged *it* — see [`RateLimiter::refund`]. Naming the slot is
145/// what keeps a refund from returning somebody else's: an opaque "give one
146/// back" cannot tell the difference once the charge it meant has aged out of
147/// the window.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub struct RateLimitCharge(Instant);
150
151/// Thread-safe rate limiter.
152#[derive(Debug)]
153pub struct RateLimiter {
154    records: RwLock<HashMap<IpAddr, RequestRecord>>,
155    config: RateLimitConfig,
156    last_cleanup: RwLock<Instant>,
157}
158
159impl RateLimiter {
160    /// Create a new rate limiter.
161    pub fn new(config: RateLimitConfig) -> Self {
162        Self {
163            records: RwLock::new(HashMap::new()),
164            config,
165            last_cleanup: RwLock::new(Instant::now()),
166        }
167    }
168
169    /// Create a disabled rate limiter.
170    pub fn disabled() -> Self {
171        Self::new(RateLimitConfig::disabled())
172    }
173
174    /// Check if rate limiting is enabled.
175    pub fn is_enabled(&self) -> bool {
176        self.config.enabled
177    }
178
179    /// Check if a request from the given IP should be allowed.
180    pub fn check(&self, ip: IpAddr) -> RateLimitDecision {
181        if !self.config.enabled {
182            return RateLimitDecision::Unlimited;
183        }
184
185        // Periodic cleanup
186        self.maybe_cleanup();
187
188        let mut records = match self.records.write() {
189            Ok(r) => r,
190            // Fail open on lock error. `Unlimited` rather than a remaining
191            // count, because no limit was applied to this request and saying
192            // otherwise would put a number on the wire that nothing counted.
193            Err(_) => return RateLimitDecision::Unlimited,
194        };
195
196        let record = records.entry(ip).or_insert_with(RequestRecord::new);
197        let current_count = record.clean_and_count(self.config.window);
198
199        if current_count >= self.config.max_requests {
200            // Calculate retry-after
201            let oldest = record.timestamps.first().copied();
202            let retry_after = oldest
203                .map(|t| self.config.window.saturating_sub(t.elapsed()))
204                .unwrap_or(self.config.window);
205            return RateLimitDecision::Limited { retry_after };
206        }
207
208        // Record this request
209        let charge = RateLimitCharge(record.record());
210        let remaining = self.config.max_requests - current_count - 1;
211
212        RateLimitDecision::Allowed { remaining, charge }
213    }
214
215    /// Give back the exact slot `charge` took.
216    ///
217    /// For a request whose legitimacy is only established *after* the limiter
218    /// has already had to decide. A device proves its enrolment token in the
219    /// first WebSocket frame, long after the middleware ran on the upgrade
220    /// request, so the choice is between not limiting those routes at all —
221    /// which is where an enrolment token could be guessed at line speed — and
222    /// charging every attempt and refunding the ones that turn out to be
223    /// authenticated. This is the second: what accumulates in the bucket is
224    /// failed and abandoned attempts, which is exactly what the limit is for.
225    ///
226    /// Removing the slot *by identity* rather than dropping the newest one is
227    /// what keeps this from handing out credit. The two differ whenever the
228    /// charge being refunded has already aged out of the window — a device may
229    /// take seconds to send its first frame — and dropping the newest would
230    /// then free a live slot belonging to whoever else is calling from that
231    /// address. A charge that is already gone refunds nothing, which is right:
232    /// the window has released it once already.
233    pub fn refund(&self, ip: IpAddr, charge: RateLimitCharge) {
234        if !self.config.enabled {
235            return;
236        }
237
238        let Ok(mut records) = self.records.write() else {
239            return;
240        };
241
242        if let Some(record) = records.get_mut(&ip) {
243            if let Some(at) = record.timestamps.iter().position(|t| *t == charge.0) {
244                record.timestamps.remove(at);
245            }
246        }
247    }
248
249    /// Perform cleanup of old records if needed.
250    fn maybe_cleanup(&self) {
251        let should_cleanup = self
252            .last_cleanup
253            .read()
254            .map(|t| t.elapsed() > self.config.window * 2)
255            .unwrap_or(false);
256
257        if !should_cleanup {
258            return;
259        }
260
261        // Try to acquire write lock for cleanup
262        if let Ok(mut last) = self.last_cleanup.write() {
263            // Double-check after acquiring lock
264            if last.elapsed() <= self.config.window * 2 {
265                return;
266            }
267
268            *last = Instant::now();
269
270            if let Ok(mut records) = self.records.write() {
271                let cutoff = Instant::now() - self.config.window * 2;
272
273                // Remove IPs with no recent activity
274                records.retain(|_, record| {
275                    record
276                        .timestamps
277                        .last()
278                        .map(|&t| t > cutoff)
279                        .unwrap_or(false)
280                });
281
282                // If still too many, remove oldest entries
283                if records.len() > self.config.max_tracked_ips {
284                    let mut entries: Vec<_> = records
285                        .iter()
286                        .map(|(ip, r)| (*ip, r.timestamps.last().copied()))
287                        .collect();
288
289                    entries.sort_by_key(|(_, t)| *t);
290
291                    let to_remove = records.len() - self.config.max_tracked_ips;
292                    for (ip, _) in entries.into_iter().take(to_remove) {
293                        records.remove(&ip);
294                    }
295                }
296            }
297        }
298    }
299
300    /// Get current stats.
301    pub fn stats(&self) -> RateLimitStats {
302        let tracked_ips = self.records.read().map(|r| r.len()).unwrap_or(0);
303        RateLimitStats {
304            tracked_ips,
305            max_requests: self.config.max_requests,
306            window_secs: self.config.window.as_secs(),
307            enabled: self.config.enabled,
308        }
309    }
310}
311
312impl Default for RateLimiter {
313    fn default() -> Self {
314        Self::new(RateLimitConfig::default())
315    }
316}
317
318/// Rate limit statistics.
319#[derive(Debug, Clone)]
320pub struct RateLimitStats {
321    pub tracked_ips: usize,
322    pub max_requests: u32,
323    pub window_secs: u64,
324    pub enabled: bool,
325}
326
327/// Rate limit middleware for axum.
328pub async fn rate_limit_middleware(
329    State(limiter): State<std::sync::Arc<RateLimiter>>,
330    ConnectInfo(addr): ConnectInfo<std::net::SocketAddr>,
331    mut request: Request,
332    next: Next,
333) -> Response {
334    // Skip rate limiting for health endpoint
335    if request.uri().path() == "/health" {
336        return next.run(request).await;
337    }
338
339    match limiter.check(addr.ip()) {
340        // Nothing counted this request, so nothing is advertised about it.
341        RateLimitDecision::Unlimited => next.run(request).await,
342        RateLimitDecision::Allowed { remaining, charge } => {
343            // A handler that can establish, later than this, that the request
344            // should not have been charged needs to name the slot to give back.
345            // Passing it down the request is the only way it can: by the time
346            // such a handler knows, this middleware has long returned.
347            request.extensions_mut().insert(charge);
348
349            let mut response = next.run(request).await;
350
351            // Only where nobody upstream already answered the question. The
352            // relay wraps this middleware around `/d/*` too, so a proxied
353            // response arrives carrying the *device's* headers — and `insert`
354            // replaced them, which is how a `429` from a device with an empty
355            // bucket reached its caller saying 92 requests remained. Whoever
356            // refused is the one whose budget the caller has to wait on.
357            //
358            // The pair moves together: a `Limit` from one limiter beside a
359            // `Remaining` from another describes no budget that exists.
360            //
361            // And never onto somebody else's refusal. A `429` that reached
362            // here is one *this* limiter allowed — a full upload table, or a
363            // device's own limit answering through a relay — so stamping a
364            // spare count on it rebuilds the contradiction the rest of this
365            // avoids: refused, with room to continue. A refusal this limiter
366            // made takes the branch below and says `0` there.
367            let refused_elsewhere = response.status() == StatusCode::TOO_MANY_REQUESTS;
368            let headers = response.headers_mut();
369            if !refused_elsewhere
370                && !headers.contains_key("X-RateLimit-Limit")
371                && !headers.contains_key("X-RateLimit-Remaining")
372            {
373                headers.insert(
374                    "X-RateLimit-Limit",
375                    limiter.config.max_requests.to_string().parse().unwrap(),
376                );
377                headers.insert(
378                    "X-RateLimit-Remaining",
379                    remaining.to_string().parse().unwrap(),
380                );
381            }
382
383            response
384        }
385        RateLimitDecision::Limited { retry_after } => {
386            let mut response = (
387                StatusCode::TOO_MANY_REQUESTS,
388                "Rate limit exceeded. Please try again later.",
389            )
390                .into_response();
391
392            response.headers_mut().insert(
393                "Retry-After",
394                retry_after.as_secs().to_string().parse().unwrap(),
395            );
396            response.headers_mut().insert(
397                "X-RateLimit-Limit",
398                limiter.config.max_requests.to_string().parse().unwrap(),
399            );
400            response
401                .headers_mut()
402                .insert("X-RateLimit-Remaining", "0".parse().unwrap());
403
404            response
405        }
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use std::net::{Ipv4Addr, Ipv6Addr};
413
414    #[test]
415    fn test_rate_limit_config_default() {
416        let config = RateLimitConfig::default();
417        assert_eq!(config.max_requests, 100);
418        assert_eq!(config.window, Duration::from_secs(60));
419        assert!(config.enabled);
420    }
421
422    #[test]
423    fn test_rate_limit_config_disabled() {
424        let config = RateLimitConfig::disabled();
425        assert!(!config.enabled);
426    }
427
428    #[test]
429    fn test_rate_limit_config_custom() {
430        let config = RateLimitConfig::custom(50, 30);
431        assert_eq!(config.max_requests, 50);
432        assert_eq!(config.window, Duration::from_secs(30));
433    }
434
435    fn allowed(decision: RateLimitDecision) -> bool {
436        matches!(decision, RateLimitDecision::Allowed { .. })
437    }
438
439    fn limited(decision: RateLimitDecision) -> bool {
440        matches!(decision, RateLimitDecision::Limited { .. })
441    }
442
443    #[test]
444    fn test_rate_limiter_allows_requests() {
445        let limiter = RateLimiter::new(RateLimitConfig::custom(5, 60));
446        let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
447
448        // First 5 requests should be allowed
449        for i in 0..5 {
450            let result = limiter.check(ip);
451            assert!(allowed(result), "Request {} should be allowed", i);
452        }
453    }
454
455    #[test]
456    fn test_rate_limiter_blocks_excess() {
457        let limiter = RateLimiter::new(RateLimitConfig::custom(3, 60));
458        let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1));
459
460        // First 3 requests allowed
461        assert!(allowed(limiter.check(ip)));
462        assert!(allowed(limiter.check(ip)));
463        assert!(allowed(limiter.check(ip)));
464
465        // 4th request should be blocked
466        assert!(limited(limiter.check(ip)));
467    }
468
469    #[test]
470    fn test_rate_limiter_different_ips() {
471        let limiter = RateLimiter::new(RateLimitConfig::custom(2, 60));
472        let ip1 = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1));
473        let ip2 = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2));
474
475        // Each IP gets its own quota
476        assert!(allowed(limiter.check(ip1)));
477        assert!(allowed(limiter.check(ip1)));
478        assert!(limited(limiter.check(ip1))); // ip1 blocked
479
480        assert!(allowed(limiter.check(ip2))); // ip2 still allowed
481        assert!(allowed(limiter.check(ip2)));
482        assert!(limited(limiter.check(ip2))); // ip2 now blocked
483    }
484
485    /// A disabled limiter reports *no limit*, never a full bucket.
486    ///
487    /// The distinction is the whole reason this is an enum: the middleware
488    /// prints whatever count it is handed, so `Allowed { remaining: 100 }`
489    /// here would advertise a 100-request budget on a server started with
490    /// `--no-rate-limit`, and a client that paces itself by the header would
491    /// throttle to a limit that does not exist.
492    #[test]
493    fn test_rate_limiter_disabled() {
494        let limiter = RateLimiter::disabled();
495        let ip = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1));
496
497        for _ in 0..100 {
498            assert_eq!(limiter.check(ip), RateLimitDecision::Unlimited);
499        }
500    }
501
502    /// The charge a decision hands back, for tests that then refund it.
503    fn charge_of(decision: RateLimitDecision) -> RateLimitCharge {
504        match decision {
505            RateLimitDecision::Allowed { charge, .. } => charge,
506            other => panic!("expected an allowed decision, got {other:?}"),
507        }
508    }
509
510    /// A refunded slot goes back into the same window it came out of.
511    #[test]
512    fn a_refund_returns_the_slot_it_was_charged() {
513        let limiter = RateLimiter::new(RateLimitConfig::custom(2, 60));
514        let ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7));
515
516        let first = limiter.check(ip);
517        assert_eq!(first.remaining(), Some(1));
518        limiter.refund(ip, charge_of(first));
519
520        assert_eq!(
521            limiter.check(ip).remaining(),
522            Some(1),
523            "the refunded slot is available again"
524        );
525
526        // And the limit still exists: two spent, no more refunds.
527        assert!(allowed(limiter.check(ip)));
528        assert!(limited(limiter.check(ip)));
529    }
530
531    /// Refunding a slot that is no longer there must not take somebody else's.
532    ///
533    /// This is the case the identity check exists for. A device can take
534    /// seconds to send the frame that proves its token, and a window can be
535    /// short enough that its charge has already expired by then — meanwhile
536    /// another caller on the same address has been charged. A refund that
537    /// simply dropped the newest entry would free *that* caller's live slot,
538    /// handing out credit nobody paid for.
539    #[test]
540    fn a_refund_of_an_expired_charge_takes_nothing_from_anyone_else() {
541        // A window short enough to roll over inside the test.
542        let limiter = RateLimiter::new(RateLimitConfig::custom(2, 1));
543        let ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 9));
544
545        let stale = charge_of(limiter.check(ip));
546        std::thread::sleep(Duration::from_millis(1100));
547
548        // A different caller on the same address, inside the fresh window.
549        assert_eq!(limiter.check(ip).remaining(), Some(1));
550
551        limiter.refund(ip, stale);
552
553        // One slot is left, not two: the expired charge refunded nothing.
554        assert!(allowed(limiter.check(ip)), "the second slot is still free");
555        assert!(
556            limited(limiter.check(ip)),
557            "an expired charge must not have bought a third"
558        );
559    }
560
561    /// Refunding what was never charged must not create credit.
562    ///
563    /// A handler refunds without knowing whether the middleware charged — a
564    /// disabled limiter and an exempt route both reach it — and a limiter that
565    /// could go negative would be a way to bank slots.
566    #[test]
567    fn a_refund_without_a_charge_creates_nothing() {
568        let limiter = RateLimiter::new(RateLimitConfig::custom(1, 60));
569        let unseen = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 8));
570        let elsewhere = charge_of(
571            RateLimiter::new(RateLimitConfig::custom(9, 60))
572                .check(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1))),
573        );
574
575        for _ in 0..5 {
576            limiter.refund(unseen, elsewhere);
577        }
578
579        assert!(allowed(limiter.check(unseen)), "one request is the budget");
580        limiter.refund(unseen, elsewhere);
581        assert!(
582            limited(limiter.check(unseen)),
583            "a charge this limiter never issued banked nothing"
584        );
585    }
586
587    #[test]
588    fn test_rate_limiter_ipv6() {
589        let limiter = RateLimiter::new(RateLimitConfig::custom(2, 60));
590        let ip = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1));
591
592        assert!(allowed(limiter.check(ip)));
593        assert!(allowed(limiter.check(ip)));
594        assert!(limited(limiter.check(ip)));
595    }
596
597    #[test]
598    fn test_rate_limiter_stats() {
599        let limiter = RateLimiter::new(RateLimitConfig::custom(10, 30));
600        let ip = IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8));
601
602        limiter.check(ip);
603
604        let stats = limiter.stats();
605        assert_eq!(stats.tracked_ips, 1);
606        assert_eq!(stats.max_requests, 10);
607        assert_eq!(stats.window_secs, 30);
608        assert!(stats.enabled);
609    }
610
611    #[test]
612    fn test_rate_limiter_remaining_count() {
613        let limiter = RateLimiter::new(RateLimitConfig::custom(5, 60));
614        let ip = IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1));
615
616        for expected in [4, 3, 2, 1, 0] {
617            assert_eq!(limiter.check(ip).remaining(), Some(expected));
618        }
619        assert!(limited(limiter.check(ip))); // Now blocked
620    }
621}