| 1 | //! Per-source-IP connection limits (spec §9). | |
| 2 | //! | |
| 3 | //! > **SSH.** Key auth only. […] Rate-limit connections per source IP. | |
| 4 | //! | |
| 5 | //! SSH is the cheapest thing on this host to attack: a connection costs the | |
| 6 | //! client one TCP handshake and costs the server a key exchange, a database | |
| 7 | //! lookup, and an Argon2-free but still real authentication path. Without a | |
| 8 | //! limit, one source can occupy every connection slot. | |
| 9 | //! | |
| 10 | //! Two limits, for the two shapes that takes: | |
| 11 | //! | |
| 12 | //! * a **rate** limit, so a source cannot open connections in a tight loop, and | |
| 13 | //! * a **concurrency** limit, so it cannot hold many open at once. | |
| 14 | //! | |
| 15 | //! Enforced at `new_client`, before the key exchange — the earliest point at | |
| 16 | //! which the peer is known, and before any work has been done on its behalf. | |
| 17 | ||
| 18 | use std::collections::HashMap; | |
| 19 | use std::net::IpAddr; | |
| 20 | use std::sync::{Arc, Mutex}; | |
| 21 | use std::time::{Duration, Instant}; | |
| 22 | ||
| 23 | /// Sustained new connections per second, per source. | |
| 24 | /// | |
| 25 | /// A `jj git push` opens one connection. Even a script fetching every | |
| 26 | /// repository in a loop stays well under this. | |
| 27 | const REFILL_PER_SEC: f64 = 0.5; | |
| 28 | ||
| 29 | /// Burst above the sustained rate, for a client that clones several | |
| 30 | /// repositories at once. | |
| 31 | const BURST: f64 = 10.0; | |
| 32 | ||
| 33 | /// Simultaneous connections from one source. | |
| 34 | const MAX_CONCURRENT: u32 = 8; | |
| 35 | ||
| 36 | /// Sources idle longer than this are forgotten. | |
| 37 | const IDLE_EVICT: Duration = Duration::from_secs(600); | |
| 38 | ||
| 39 | /// Distinct sources tracked before new ones are refused. Reached only under a | |
| 40 | /// distributed flood. | |
| 41 | const MAX_TRACKED: usize = 20_000; | |
| 42 | ||
| 43 | #[derive(Clone, Default)] | |
| 44 | pub struct ConnectionLimits(Arc<Mutex<State>>); | |
| 45 | ||
| 46 | #[derive(Default)] | |
| 47 | struct State { | |
| 48 | sources: HashMap<IpAddr, Source>, | |
| 49 | last_sweep: Option<Instant>, | |
| 50 | } | |
| 51 | ||
| 52 | struct Source { | |
| 53 | tokens: f64, | |
| 54 | open: u32, | |
| 55 | last: Instant, | |
| 56 | } | |
| 57 | ||
| 58 | impl ConnectionLimits { | |
| 59 | pub fn new() -> Self { | |
| 60 | Self::default() | |
| 61 | } | |
| 62 | ||
| 63 | /// Admit a connection from `peer`, or refuse it. | |
| 64 | /// | |
| 65 | /// The returned guard releases the concurrency slot when the connection's | |
| 66 | /// handler is dropped — which happens however the connection ends, so a | |
| 67 | /// client that disappears mid-session does not leak a slot. | |
| 68 | pub fn admit(&self, peer: Option<std::net::SocketAddr>) -> Option<ConnectionGuard> { | |
| 69 | // A connection whose peer cannot be determined is not attributable to a | |
| 70 | // source, so it is admitted without a slot rather than refused: in | |
| 71 | // practice this does not happen, and failing closed here would break | |
| 72 | // every connection if it ever did. | |
| 73 | let ip = peer?.ip(); | |
| 74 | self.admit_at(ip, Instant::now()) | |
| 75 | } | |
| 76 | ||
| 77 | fn admit_at(&self, ip: IpAddr, now: Instant) -> Option<ConnectionGuard> { | |
| 78 | let mut state = self.0.lock().ok()?; | |
| 79 | state.sweep(now); | |
| 80 | ||
| 81 | if state.sources.len() >= MAX_TRACKED && !state.sources.contains_key(&ip) { | |
| 82 | return None; | |
| 83 | } | |
| 84 | ||
| 85 | let source = state.sources.entry(ip).or_insert(Source { | |
| 86 | tokens: BURST, | |
| 87 | open: 0, | |
| 88 | last: now, | |
| 89 | }); | |
| 90 | ||
| 91 | let elapsed = now.saturating_duration_since(source.last).as_secs_f64(); | |
| 92 | source.tokens = (source.tokens + elapsed * REFILL_PER_SEC).min(BURST); | |
| 93 | source.last = now; | |
| 94 | ||
| 95 | if source.open >= MAX_CONCURRENT || source.tokens < 1.0 { | |
| 96 | return None; | |
| 97 | } | |
| 98 | ||
| 99 | source.tokens -= 1.0; | |
| 100 | source.open += 1; | |
| 101 | ||
| 102 | Some(ConnectionGuard { limits: self.clone(), ip }) | |
| 103 | } | |
| 104 | ||
| 105 | fn release(&self, ip: IpAddr) { | |
| 106 | if let Ok(mut state) = self.0.lock() { | |
| 107 | if let Some(s) = state.sources.get_mut(&ip) { | |
| 108 | s.open = s.open.saturating_sub(1); | |
| 109 | } | |
| 110 | } | |
| 111 | } | |
| 112 | ||
| 113 | #[cfg(test)] | |
| 114 | fn tracked(&self) -> usize { | |
| 115 | self.0.lock().unwrap().sources.len() | |
| 116 | } | |
| 117 | } | |
| 118 | ||
| 119 | impl State { | |
| 120 | fn sweep(&mut self, now: Instant) { | |
| 121 | let due = self | |
| 122 | .last_sweep | |
| 123 | .is_none_or(|t| now.saturating_duration_since(t) >= Duration::from_secs(60)); | |
| 124 | if !due { | |
| 125 | return; | |
| 126 | } | |
| 127 | self.last_sweep = Some(now); | |
| 128 | // Never evict a source with a connection open; that would lose the count | |
| 129 | // and let the concurrency cap be bypassed by long-lived sessions. | |
| 130 | self.sources | |
| 131 | .retain(|_, s| s.open > 0 || now.saturating_duration_since(s.last) < IDLE_EVICT); | |
| 132 | } | |
| 133 | } | |
| 134 | ||
| 135 | /// Holds one connection slot for a source. | |
| 136 | pub struct ConnectionGuard { | |
| 137 | limits: ConnectionLimits, | |
| 138 | ip: IpAddr, | |
| 139 | } | |
| 140 | ||
| 141 | impl Drop for ConnectionGuard { | |
| 142 | fn drop(&mut self) { | |
| 143 | self.limits.release(self.ip); | |
| 144 | } | |
| 145 | } | |
| 146 | ||
| 147 | #[cfg(test)] | |
| 148 | mod tests { | |
| 149 | use super::*; | |
| 150 | ||
| 151 | fn ip(s: &str) -> IpAddr { | |
| 152 | s.parse().unwrap() | |
| 153 | } | |
| 154 | ||
| 155 | #[test] | |
| 156 | fn concurrent_connections_are_capped() { | |
| 157 | let l = ConnectionLimits::new(); | |
| 158 | let now = Instant::now(); | |
| 159 | ||
| 160 | let mut held = Vec::new(); | |
| 161 | for i in 0..MAX_CONCURRENT { | |
| 162 | held.push(l.admit_at(ip("10.0.0.1"), now).expect_and_count(i)); | |
| 163 | } | |
| 164 | assert!( | |
| 165 | l.admit_at(ip("10.0.0.1"), now).is_none(), | |
| 166 | "the concurrency cap must refuse the next connection" | |
| 167 | ); | |
| 168 | ||
| 169 | drop(held.pop()); | |
| 170 | assert!( | |
| 171 | l.admit_at(ip("10.0.0.1"), now).is_some(), | |
| 172 | "closing a connection frees its slot" | |
| 173 | ); | |
| 174 | } | |
| 175 | ||
| 176 | #[test] | |
| 177 | fn the_burst_is_spent_and_then_refills() { | |
| 178 | let l = ConnectionLimits::new(); | |
| 179 | let start = Instant::now(); | |
| 180 | ||
| 181 | // Guards dropped immediately, so only the token bucket applies. | |
| 182 | for _ in 0..BURST as usize { | |
| 183 | assert!(l.admit_at(ip("10.0.0.2"), start).is_some()); | |
| 184 | } | |
| 185 | assert!(l.admit_at(ip("10.0.0.2"), start).is_none(), "burst spent"); | |
| 186 | ||
| 187 | let later = start + Duration::from_secs(4); | |
| 188 | assert!( | |
| 189 | l.admit_at(ip("10.0.0.2"), later).is_some(), | |
| 190 | "{REFILL_PER_SEC}/s should have refilled by then" | |
| 191 | ); | |
| 192 | } | |
| 193 | ||
| 194 | #[test] | |
| 195 | fn sources_do_not_share_a_budget() { | |
| 196 | let l = ConnectionLimits::new(); | |
| 197 | let now = Instant::now(); | |
| 198 | for _ in 0..BURST as usize { | |
| 199 | let _ = l.admit_at(ip("10.0.0.3"), now); | |
| 200 | } | |
| 201 | assert!(l.admit_at(ip("10.0.0.3"), now).is_none()); | |
| 202 | assert!( | |
| 203 | l.admit_at(ip("10.0.0.4"), now).is_some(), | |
| 204 | "one source must not exhaust another's budget" | |
| 205 | ); | |
| 206 | } | |
| 207 | ||
| 208 | #[test] | |
| 209 | fn a_peerless_connection_is_admitted_without_a_slot() { | |
| 210 | let l = ConnectionLimits::new(); | |
| 211 | assert!( | |
| 212 | l.admit(None).is_none(), | |
| 213 | "no peer means no guard — the connection proceeds unmetered" | |
| 214 | ); | |
| 215 | } | |
| 216 | ||
| 217 | #[test] | |
| 218 | fn idle_sources_are_forgotten() { | |
| 219 | let l = ConnectionLimits::new(); | |
| 220 | let start = Instant::now(); | |
| 221 | drop(l.admit_at(ip("10.0.0.5"), start)); | |
| 222 | assert_eq!(l.tracked(), 1); | |
| 223 | ||
| 224 | let later = start + IDLE_EVICT + Duration::from_secs(1); | |
| 225 | let _g = l.admit_at(ip("10.0.0.6"), later); | |
| 226 | assert_eq!(l.tracked(), 1, "the idle source should have been swept"); | |
| 227 | } | |
| 228 | ||
| 229 | #[test] | |
| 230 | fn a_backwards_clock_does_not_mint_tokens() { | |
| 231 | let l = ConnectionLimits::new(); | |
| 232 | let now = Instant::now(); | |
| 233 | for _ in 0..BURST as usize { | |
| 234 | let _ = l.admit_at(ip("10.0.0.7"), now); | |
| 235 | } | |
| 236 | let earlier = now.checked_sub(Duration::from_secs(600)).unwrap_or(now); | |
| 237 | assert!(l.admit_at(ip("10.0.0.7"), earlier).is_none()); | |
| 238 | } | |
| 239 | ||
| 240 | /// Small helper so the concurrency test reads clearly when it fails. | |
| 241 | trait ExpectAndCount { | |
| 242 | fn expect_and_count(self, i: u32) -> ConnectionGuard; | |
| 243 | } | |
| 244 | impl ExpectAndCount for Option<ConnectionGuard> { | |
| 245 | fn expect_and_count(self, i: u32) -> ConnectionGuard { | |
| 246 | self.unwrap_or_else(|| panic!("connection {i} should have been admitted")) | |
| 247 | } | |
| 248 | } | |
| 249 | } |
249 lines · Rust