This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knot2 / crates / knot-resource / src / admission.rs
19 kB 585 lines
1use std::collections::HashMap; 2use std::net::IpAddr; 3use std::sync::{Arc, Mutex}; 4 5use knot_types::UnixMicros; 6 7const MAX_TRACKED_PEERS: usize = 100_000; 8 9const SWEEP_INTERVAL_MICROS: u64 = 1_000_000; 10 11knot_types::scalar_newtype! { 12 pub struct Burst(u32); 13 pub struct RefillMicros(u64); 14 pub struct PerPeerInflight(usize); 15 pub struct GlobalInflight(usize); 16} 17 18#[derive(Debug, Clone, Copy, PartialEq, Eq)] 19pub struct RateLimit { 20 pub burst: Burst, 21 pub refill: RefillMicros, 22} 23 24impl RateLimit { 25 const fn interval(self) -> u64 { 26 match self.refill.get() { 27 0 => 1, 28 micros => micros, 29 } 30 } 31} 32 33#[derive(Debug, Clone, Copy)] 34pub struct LimitConfig { 35 pub rate: Option<RateLimit>, 36 pub per_peer_inflight: Option<PerPeerInflight>, 37 pub global_inflight: Option<GlobalInflight>, 38} 39 40impl Default for LimitConfig { 41 fn default() -> Self { 42 Self { 43 rate: Some(RateLimit { 44 burst: Burst::new(20), 45 refill: RefillMicros::new(100_000), 46 }), 47 per_peer_inflight: Some(PerPeerInflight::new(8)), 48 global_inflight: Some(GlobalInflight::new(64)), 49 } 50 } 51} 52 53impl LimitConfig { 54 pub const fn per_peer_only(per_peer_inflight: PerPeerInflight) -> Self { 55 Self { 56 rate: None, 57 per_peer_inflight: Some(per_peer_inflight), 58 global_inflight: None, 59 } 60 } 61 62 pub const fn unmetered() -> Self { 63 Self { 64 rate: None, 65 per_peer_inflight: None, 66 global_inflight: None, 67 } 68 } 69} 70 71struct Bucket { 72 tokens: u32, 73 last_refill: UnixMicros, 74} 75 76impl Bucket { 77 fn new(rate: RateLimit, now: UnixMicros) -> Self { 78 Self { 79 tokens: rate.burst.get(), 80 last_refill: now, 81 } 82 } 83 84 fn tokens_at(&self, rate: RateLimit, now: UnixMicros) -> u32 { 85 let elapsed = now.get().saturating_sub(self.last_refill.get()); 86 let gained = (elapsed / rate.interval()).min(u64::from(rate.burst.get())) as u32; 87 self.tokens.saturating_add(gained).min(rate.burst.get()) 88 } 89 90 fn replenish(&mut self, rate: RateLimit, now: UnixMicros) -> bool { 91 if now.get().saturating_sub(self.last_refill.get()) >= rate.interval() { 92 self.tokens = self.tokens_at(rate, now); 93 self.last_refill = now; 94 } 95 self.tokens > 0 96 } 97 98 fn full(&self, rate: RateLimit, now: UnixMicros) -> bool { 99 self.tokens_at(rate, now) >= rate.burst.get() 100 } 101} 102 103struct PeerState { 104 bucket: Option<Bucket>, 105 inflight: usize, 106} 107 108impl PeerState { 109 fn forgettable(&self) -> bool { 110 self.inflight == 0 && self.bucket.is_none() 111 } 112 113 fn worth_tracking(&self, rate: Option<RateLimit>, now: UnixMicros) -> bool { 114 match (self.inflight, &self.bucket, rate) { 115 (0, Some(bucket), Some(rate)) => !bucket.full(rate, now), 116 (0, _, _) => false, 117 _ => true, 118 } 119 } 120} 121 122const CONCENTRATED_REFUSALS: u32 = 1_024; 123 124#[derive(Default)] 125struct RefusalMajority { 126 peer: Option<IpAddr>, 127 votes: u32, 128 reported: bool, 129} 130 131impl RefusalMajority { 132 fn observe(&mut self, peer: IpAddr) -> Option<IpAddr> { 133 match (self.peer == Some(peer), self.votes) { 134 (true, _) => self.votes = self.votes.saturating_add(1), 135 (false, 0) => { 136 self.peer = Some(peer); 137 self.votes = 1; 138 } 139 (false, _) => self.votes -= 1, 140 } 141 let crossed = self.votes >= CONCENTRATED_REFUSALS && !self.reported; 142 self.reported |= crossed; 143 crossed.then_some(peer) 144 } 145} 146 147struct Inner { 148 peers: HashMap<Option<IpAddr>, PeerState>, 149 global_inflight: usize, 150 last_sweep: UnixMicros, 151 refusal_majority: RefusalMajority, 152} 153 154impl Inner { 155 fn has_room_for( 156 &mut self, 157 peer: Option<IpAddr>, 158 rate: Option<RateLimit>, 159 now: UnixMicros, 160 ) -> bool { 161 if rate.is_none() || self.peers.contains_key(&peer) || self.peers.len() < MAX_TRACKED_PEERS 162 { 163 return true; 164 } 165 if now.get().saturating_sub(self.last_sweep.get()) >= SWEEP_INTERVAL_MICROS { 166 self.last_sweep = now; 167 self.peers 168 .retain(|_, state| state.worth_tracking(rate, now)); 169 } 170 self.peers.len() < MAX_TRACKED_PEERS 171 } 172} 173 174pub struct PreAuthLimiter { 175 config: LimitConfig, 176 inner: Mutex<Inner>, 177} 178 179#[derive(Debug, Clone, Copy, PartialEq, Eq)] 180pub enum Refusal { 181 RateLimited, 182 Saturated, 183} 184 185impl Default for PreAuthLimiter { 186 fn default() -> Self { 187 Self::with_config(LimitConfig::default()) 188 } 189} 190 191impl PreAuthLimiter { 192 pub fn with_config(config: LimitConfig) -> Self { 193 Self { 194 config, 195 inner: Mutex::new(Inner { 196 peers: HashMap::new(), 197 global_inflight: 0, 198 last_sweep: UnixMicros::new(0), 199 refusal_majority: RefusalMajority::default(), 200 }), 201 } 202 } 203 204 fn lock(&self) -> std::sync::MutexGuard<'_, Inner> { 205 self.inner 206 .lock() 207 .unwrap_or_else(|poisoned| poisoned.into_inner()) 208 } 209 210 pub fn admit( 211 self: &Arc<Self>, 212 peer: Option<IpAddr>, 213 now: UnixMicros, 214 ) -> Result<AdmitGuard, Refusal> { 215 let mut inner = self.lock(); 216 let rate = self.config.rate; 217 let over_global = self 218 .config 219 .global_inflight 220 .is_some_and(|limit| inner.global_inflight >= limit.get()); 221 222 let per_peer = self.config.per_peer_inflight; 223 let decision = match inner.has_room_for(peer, rate, now) { 224 false => Err(Refusal::Saturated), 225 true => { 226 let state = inner.peers.entry(peer).or_insert_with(|| PeerState { 227 bucket: rate.map(|rate| Bucket::new(rate, now)), 228 inflight: 0, 229 }); 230 let ready = match (rate, state.bucket.as_mut()) { 231 (Some(rate), Some(bucket)) => bucket.replenish(rate, now), 232 _ => true, 233 }; 234 let over_peer = per_peer.is_some_and(|limit| state.inflight >= limit.get()); 235 match (ready, over_peer || over_global) { 236 (false, _) => Err(Refusal::RateLimited), 237 (_, true) => Err(Refusal::Saturated), 238 (true, false) => { 239 if let Some(bucket) = state.bucket.as_mut() { 240 bucket.tokens -= 1; 241 } 242 state.inflight += 1; 243 Ok(()) 244 } 245 } 246 } 247 }; 248 match decision { 249 Err(refusal) => { 250 if inner.peers.get(&peer).is_some_and(PeerState::forgettable) { 251 inner.peers.remove(&peer); 252 } 253 let concentrated = peer.and_then(|peer| inner.refusal_majority.observe(peer)); 254 drop(inner); 255 if let Some(peer) = concentrated { 256 tracing::warn!( 257 %peer, 258 "one address has taken {CONCENTRATED_REFUSALS} more of the pre-authentication limiter's refusals than every other address combined. If this address is a proxy, set xrpc.trusted_proxy_header to the header it forwards the client address in and add the address to xrpc.trusted_proxies, since every client behind a proxy will share its one rate-limit bucket. This warning reports the first such address only." 259 ); 260 } 261 Err(refusal) 262 } 263 Ok(()) => { 264 inner.global_inflight += 1; 265 Ok(AdmitGuard { 266 limiter: Arc::clone(self), 267 peer, 268 }) 269 } 270 } 271 } 272 273 fn leave(&self, peer: Option<IpAddr>) { 274 let mut inner = self.lock(); 275 inner.global_inflight = inner.global_inflight.saturating_sub(1); 276 let Some(state) = inner.peers.get_mut(&peer) else { 277 return; 278 }; 279 state.inflight = state.inflight.saturating_sub(1); 280 if state.forgettable() { 281 inner.peers.remove(&peer); 282 } 283 } 284 285 fn repay(&self, peer: Option<IpAddr>) { 286 let Some(rate) = self.config.rate else { 287 return; 288 }; 289 let mut inner = self.lock(); 290 if let Some(bucket) = inner 291 .peers 292 .get_mut(&peer) 293 .and_then(|state| state.bucket.as_mut()) 294 { 295 bucket.tokens = bucket.tokens.saturating_add(1).min(rate.burst.get()); 296 } 297 } 298} 299 300pub struct AdmitGuard { 301 limiter: Arc<PreAuthLimiter>, 302 peer: Option<IpAddr>, 303} 304 305impl AdmitGuard { 306 pub fn refund(self) { 307 self.limiter.repay(self.peer); 308 } 309} 310 311impl Drop for AdmitGuard { 312 fn drop(&mut self) { 313 self.limiter.leave(self.peer); 314 } 315} 316 317#[cfg(test)] 318mod tests { 319 use super::*; 320 use std::net::Ipv4Addr; 321 322 fn limiter(config: LimitConfig) -> Arc<PreAuthLimiter> { 323 Arc::new(PreAuthLimiter::with_config(config)) 324 } 325 326 fn rate(burst: u32, refill_micros: u64) -> Option<RateLimit> { 327 Some(RateLimit { 328 burst: Burst::new(burst), 329 refill: RefillMicros::new(refill_micros), 330 }) 331 } 332 333 fn peer(last: u8) -> Option<IpAddr> { 334 Some(ip(last)) 335 } 336 337 fn rotating(index: u64) -> Option<IpAddr> { 338 let octets = (index as u32).to_be_bytes(); 339 Some(IpAddr::V4(Ipv4Addr::new( 340 1, octets[1], octets[2], octets[3], 341 ))) 342 } 343 344 fn at(micros: u64) -> UnixMicros { 345 UnixMicros::new(micros) 346 } 347 348 fn tracked(limiter: &Arc<PreAuthLimiter>) -> usize { 349 limiter.lock().peers.len() 350 } 351 352 fn ip(last: u8) -> IpAddr { 353 IpAddr::V4(Ipv4Addr::new(127, 0, 0, last)) 354 } 355 356 #[test] 357 fn only_an_address_taking_most_of_the_refusals_is_reported_and_only_once() { 358 let reported = |rounds, peer: fn(u32) -> u8| { 359 let mut majority = RefusalMajority::default(); 360 (0..rounds) 361 .filter_map(|round| majority.observe(ip(peer(round)))) 362 .collect::<Vec<IpAddr>>() 363 }; 364 [ 365 (CONCENTRATED_REFUSALS * 3, (|round| (round % 4 == 0) as u8) as fn(u32) -> u8, vec![ip(0)], 366 "the warning reports that address once, since three refusals in every four come from it"), 367 (CONCENTRATED_REFUSALS * 8, |round| (round % 251) as u8, vec![], 368 "a knot under scattered load doesn't have a proxy to point the operator at"), 369 (CONCENTRATED_REFUSALS - 1, |_| 1, vec![], 370 "a burst under the threshold is ordinary rate limiting and doesn't point at a proxy"), 371 (CONCENTRATED_REFUSALS, |_| 1, vec![ip(1)], 372 "the threshold itself is where an address earns the warning"), 373 ] 374 .into_iter() 375 .for_each(|(rounds, peer, expected, why)| { 376 assert_eq!(reported(rounds, peer), expected, "{why}"); 377 }); 378 } 379 380 #[test] 381 fn a_rate_budget_refuses_a_flood_refills_over_time_and_stays_per_peer() { 382 let limiter = limiter(LimitConfig { 383 rate: rate(2, 1_000), 384 per_peer_inflight: Some(PerPeerInflight::new(100)), 385 global_inflight: Some(GlobalInflight::new(100)), 386 }); 387 assert!(limiter.admit(peer(1), at(0)).is_ok()); 388 assert!(limiter.admit(peer(1), at(0)).is_ok()); 389 assert_eq!( 390 limiter.admit(peer(1), at(0)).err(), 391 Some(Refusal::RateLimited), 392 "the limiter refuses a third request inside the same instant even when nothing is in flight" 393 ); 394 assert!( 395 limiter.admit(peer(2), at(0)).is_ok(), 396 "a second peer keeps its own rate budget" 397 ); 398 assert_eq!( 399 limiter.admit(peer(1), at(600)).err(), 400 Some(Refusal::RateLimited) 401 ); 402 assert!( 403 limiter.admit(peer(1), at(1_000)).is_ok(), 404 "a refusal partway through the interval mustn't reset the clock the refill measures from" 405 ); 406 } 407 408 #[test] 409 fn inflight_shedding_frees_on_drop_and_spends_no_rate_budget() { 410 let limiter = limiter(LimitConfig { 411 rate: rate(4, 1_000_000), 412 per_peer_inflight: Some(PerPeerInflight::new(1)), 413 global_inflight: Some(GlobalInflight::new(2)), 414 }); 415 let held = limiter 416 .admit(peer(1), at(0)) 417 .expect("the limiter admits the first request"); 418 let other = limiter 419 .admit(peer(2), at(0)) 420 .expect("a second peer fills the global budget"); 421 assert_eq!( 422 limiter.admit(peer(1), at(0)).err(), 423 Some(Refusal::Saturated), 424 "the limiter sheds a second concurrent request from one peer" 425 ); 426 assert_eq!( 427 limiter.admit(peer(3), at(0)).err(), 428 Some(Refusal::Saturated), 429 "the limiter sheds a third peer once the global in-flight limit is reached" 430 ); 431 drop(other); 432 drop( 433 limiter 434 .admit(peer(3), at(0)) 435 .expect("freeing a global slot admits the peer that was shed"), 436 ); 437 drop(held); 438 (0..50).for_each(|_| { 439 limiter 440 .admit(peer(1), at(0)) 441 .expect("a refunded admission must leave the full budget available") 442 .refund(); 443 }); 444 (0..3).for_each(|_| { 445 limiter 446 .admit(peer(1), at(0)) 447 .expect("a shed request mustn't spend the rate budget it never used"); 448 }); 449 assert_eq!( 450 limiter.admit(peer(1), at(0)).err(), 451 Some(Refusal::RateLimited), 452 "a dropped guard without a refund keeps its token spent" 453 ); 454 } 455 456 #[test] 457 fn a_budget_without_a_rate_never_rate_limits_and_keeps_no_idle_state() { 458 let overflowing = MAX_TRACKED_PEERS as u64 + 1_000; 459 460 let per_peer = limiter(LimitConfig::per_peer_only(PerPeerInflight::new(2))); 461 (0..1_000).for_each(|_| { 462 per_peer 463 .admit(peer(1), at(0)) 464 .expect("a budget with no rate has nothing for a sequential flood to exhaust"); 465 }); 466 let concurrent: Vec<AdmitGuard> = (0..2) 467 .map(|_| { 468 per_peer 469 .admit(peer(1), at(0)) 470 .expect("the limiter admits both concurrent operations from one peer") 471 }) 472 .collect(); 473 assert_eq!( 474 per_peer.admit(peer(1), at(0)).err(), 475 Some(Refusal::Saturated), 476 "only the per-peer count refuses a request in this budget" 477 ); 478 drop(concurrent); 479 let held: Vec<AdmitGuard> = (0..overflowing) 480 .map(|index| { 481 per_peer 482 .admit(rotating(index), at(0)) 483 .expect("a budget that keeps no idle state has no table to overflow") 484 }) 485 .collect(); 486 assert_eq!(tracked(&per_peer), held.len()); 487 drop(held); 488 assert_eq!( 489 tracked(&per_peer), 490 0, 491 "with no tokens to remember, an idle peer leaves no entry, \ 492 so address rotation mustn't fill the table and start shedding newcomers" 493 ); 494 495 let global = limiter(LimitConfig { 496 rate: None, 497 per_peer_inflight: None, 498 global_inflight: Some(GlobalInflight::new(1)), 499 }); 500 let _saturating = global 501 .admit(peer(1), at(0)) 502 .expect("the limiter admits the first peer"); 503 (0..overflowing).for_each(|index| { 504 assert_eq!( 505 global.admit(rotating(index), at(0)).err(), 506 Some(Refusal::Saturated) 507 ); 508 }); 509 assert_eq!( 510 tracked(&global), 511 1, 512 "a refusal returns no guard, so an entry it left behind would never be freed, \ 513 and the sweep that bounds the table only reclaims idle rate state" 514 ); 515 516 let unmetered = limiter(LimitConfig::unmetered()); 517 let guards: Vec<AdmitGuard> = (0..512) 518 .map(|_| { 519 unmetered 520 .admit(peer(1), at(0)) 521 .expect("an unmetered budget admits every request from every peer") 522 }) 523 .collect(); 524 drop(guards); 525 assert_eq!(tracked(&unmetered), 0); 526 } 527 528 #[test] 529 fn a_rate_budget_bounds_its_peer_map_and_sweeps_at_most_once_per_interval() { 530 let limiter = limiter(LimitConfig { 531 rate: rate(1, 1_000), 532 per_peer_inflight: Some(PerPeerInflight::new(8)), 533 global_inflight: Some(GlobalInflight::new(64)), 534 }); 535 (0..MAX_TRACKED_PEERS as u64).for_each(|index| { 536 let _ = limiter.admit(rotating(index), at(0)); 537 }); 538 assert_eq!( 539 limiter 540 .admit(peer(201), at(SWEEP_INTERVAL_MICROS - 1)) 541 .err(), 542 Some(Refusal::Saturated), 543 "inside the sweep interval a full map sheds unseen peers without rescanning" 544 ); 545 assert!( 546 limiter.admit(peer(202), at(SWEEP_INTERVAL_MICROS)).is_ok(), 547 "once the interval elapses the sweep evicts replenished entries and admits the newcomer" 548 ); 549 (0..(MAX_TRACKED_PEERS as u64 + 50_000)).for_each(|index| { 550 let _ = limiter.admit( 551 rotating(MAX_TRACKED_PEERS as u64 + index), 552 at(SWEEP_INTERVAL_MICROS + index), 553 ); 554 }); 555 let tracked = tracked(&limiter); 556 assert!( 557 tracked <= MAX_TRACKED_PEERS, 558 "a flood of distinct source addresses mustn't grow the peer map past its limit, saw {tracked}" 559 ); 560 } 561 562 #[test] 563 fn the_majority_counter_sees_a_refusal_from_a_full_peer_table() { 564 let limiter = limiter(LimitConfig { 565 rate: rate(1, 1_000), 566 per_peer_inflight: Some(PerPeerInflight::new(8)), 567 global_inflight: Some(GlobalInflight::new(64)), 568 }); 569 (0..MAX_TRACKED_PEERS as u64).for_each(|index| { 570 let _ = limiter.admit(rotating(index), at(0)); 571 }); 572 assert_eq!( 573 limiter 574 .admit(peer(201), at(SWEEP_INTERVAL_MICROS - 1)) 575 .err(), 576 Some(Refusal::Saturated) 577 ); 578 let inner = limiter.lock(); 579 assert_eq!( 580 (inner.refusal_majority.peer, inner.refusal_majority.votes), 581 (peer(201), 1), 582 "that refusal has to reach the counter like every other refusal, since a peer shed by a full table is what the warning most needs to report" 583 ); 584 } 585}