mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
216 lines
5.7 KiB
Rust
216 lines
5.7 KiB
Rust
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
use super::reject::{Reason, Rejection};
|
|
use crate::config::BucketConfig;
|
|
use crate::metrics::{BucketKey, Metrics};
|
|
use sha2::{Digest as _, Sha256};
|
|
use std::collections::HashMap;
|
|
use std::net::IpAddr;
|
|
use std::sync::Mutex;
|
|
use std::time::{Duration, Instant};
|
|
use tokio::sync::{Semaphore, SemaphorePermit};
|
|
|
|
const BUSY_RETRY_AFTER_SECONDS: i64 = 1;
|
|
const RATE_LIMIT_RETRY_AFTER_SECONDS: i64 = 1;
|
|
const SECONDS_PER_MINUTE: f64 = 60.0;
|
|
const KEY_BYTES: usize = 16;
|
|
const IPV4_PREFIX_BYTES: usize = 3;
|
|
const IPV6_PREFIX_BYTES: usize = 8;
|
|
const MAX_GENERATION_AGE: Duration = Duration::from_secs(300);
|
|
|
|
type Key = [u8; KEY_BYTES];
|
|
|
|
pub struct Quota {
|
|
admissions: Semaphore,
|
|
device_tokens: Buckets,
|
|
sources: Option<Buckets>,
|
|
}
|
|
|
|
impl Quota {
|
|
pub fn new(
|
|
max_concurrent: usize,
|
|
device_tokens: &BucketConfig,
|
|
sources: Option<&BucketConfig>,
|
|
) -> Self {
|
|
Self {
|
|
admissions: Semaphore::new(max_concurrent),
|
|
device_tokens: Buckets::new(device_tokens),
|
|
sources: sources.map(Buckets::new),
|
|
}
|
|
}
|
|
|
|
pub fn admit(&self) -> Result<SemaphorePermit<'_>, Rejection> {
|
|
self.admissions
|
|
.try_acquire()
|
|
.map_err(|_| Rejection::after(Reason::RelayUnavailable, BUSY_RETRY_AFTER_SECONDS))
|
|
}
|
|
|
|
pub fn take(
|
|
&self,
|
|
metrics: &Metrics,
|
|
device_token: &str,
|
|
client_ip: IpAddr,
|
|
now: Instant,
|
|
) -> Result<(), Rejection> {
|
|
self.check(
|
|
metrics,
|
|
BucketKey::DeviceToken,
|
|
&self.device_tokens,
|
|
device_token_key(device_token),
|
|
now,
|
|
)?;
|
|
let Some(sources) = self.sources.as_ref() else {
|
|
return Ok(());
|
|
};
|
|
self.check(
|
|
metrics,
|
|
BucketKey::Source,
|
|
sources,
|
|
source_key(client_ip),
|
|
now,
|
|
)
|
|
}
|
|
|
|
fn check(
|
|
&self,
|
|
metrics: &Metrics,
|
|
which: BucketKey,
|
|
buckets: &Buckets,
|
|
key: Key,
|
|
now: Instant,
|
|
) -> Result<(), Rejection> {
|
|
if buckets.take(key, now) {
|
|
return Ok(());
|
|
}
|
|
metrics.record_bucket_drop(which);
|
|
Err(Rejection::after(
|
|
Reason::RateLimited,
|
|
RATE_LIMIT_RETRY_AFTER_SECONDS,
|
|
))
|
|
}
|
|
}
|
|
|
|
pub fn device_token_key(device_token: &str) -> Key {
|
|
let digest = Sha256::digest(device_token.as_bytes());
|
|
let mut key = [0u8; KEY_BYTES];
|
|
key.copy_from_slice(&digest[..KEY_BYTES]);
|
|
key
|
|
}
|
|
|
|
pub fn source_key(ip: IpAddr) -> Key {
|
|
let mut key = [0u8; KEY_BYTES];
|
|
match ip.to_canonical() {
|
|
IpAddr::V4(ip) => {
|
|
key[..IPV4_PREFIX_BYTES].copy_from_slice(&ip.octets()[..IPV4_PREFIX_BYTES])
|
|
}
|
|
IpAddr::V6(ip) => {
|
|
key[..IPV6_PREFIX_BYTES].copy_from_slice(&ip.octets()[..IPV6_PREFIX_BYTES])
|
|
}
|
|
}
|
|
key
|
|
}
|
|
|
|
struct Bucket {
|
|
tokens: f64,
|
|
refilled_at: Instant,
|
|
}
|
|
|
|
struct Held {
|
|
live: HashMap<Key, Bucket>,
|
|
aged: HashMap<Key, Bucket>,
|
|
rotated_at: Option<Instant>,
|
|
}
|
|
|
|
pub struct Buckets {
|
|
refill_per_second: f64,
|
|
burst: f64,
|
|
entries: usize,
|
|
held: Mutex<Held>,
|
|
}
|
|
|
|
impl Buckets {
|
|
pub fn new(cfg: &BucketConfig) -> Self {
|
|
Self {
|
|
refill_per_second: f64::from(cfg.per_minute) / SECONDS_PER_MINUTE,
|
|
burst: f64::from(cfg.burst),
|
|
entries: cfg.entries,
|
|
held: Mutex::new(Held {
|
|
live: HashMap::new(),
|
|
aged: HashMap::new(),
|
|
rotated_at: None,
|
|
}),
|
|
}
|
|
}
|
|
|
|
pub fn take(&self, key: Key, now: Instant) -> bool {
|
|
let mut held = self
|
|
.held
|
|
.lock()
|
|
.expect("the relay quota lock is not poisoned");
|
|
let mut bucket = held
|
|
.live
|
|
.remove(&key)
|
|
.or_else(|| held.aged.remove(&key))
|
|
.unwrap_or(Bucket {
|
|
tokens: self.burst,
|
|
refilled_at: now,
|
|
});
|
|
|
|
let elapsed = now
|
|
.saturating_duration_since(bucket.refilled_at)
|
|
.as_secs_f64();
|
|
bucket.refilled_at = now;
|
|
bucket.tokens = (bucket.tokens + elapsed * self.refill_per_second).min(self.burst);
|
|
let allowed = bucket.tokens >= 1.0;
|
|
if allowed {
|
|
bucket.tokens -= 1.0;
|
|
}
|
|
|
|
let rotated_at = *held.rotated_at.get_or_insert(now);
|
|
let stale = now.saturating_duration_since(rotated_at) >= MAX_GENERATION_AGE;
|
|
if held.live.len() >= self.entries || stale {
|
|
held.aged = std::mem::take(&mut held.live);
|
|
held.rotated_at = Some(now);
|
|
}
|
|
held.live.insert(key, bucket);
|
|
allowed
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn config() -> BucketConfig {
|
|
BucketConfig {
|
|
entries: 1_000_000,
|
|
per_minute: 60,
|
|
burst: 20,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_generation_rotates_on_age_as_well_as_on_size() {
|
|
let buckets = Buckets::new(&config());
|
|
let start = Instant::now();
|
|
assert!(buckets.take([1u8; KEY_BYTES], start));
|
|
assert_eq!(buckets.held.lock().unwrap().live.len(), 1);
|
|
let later = start + MAX_GENERATION_AGE;
|
|
assert!(buckets.take([2u8; KEY_BYTES], later));
|
|
let held = buckets.held.lock().unwrap();
|
|
assert_eq!(held.live.len(), 1);
|
|
assert_eq!(held.aged.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn the_burst_is_spent_before_a_caller_is_refused() {
|
|
let buckets = Buckets::new(&config());
|
|
let now = Instant::now();
|
|
let key = [3u8; KEY_BYTES];
|
|
for _ in 0..20 {
|
|
assert!(buckets.take(key, now));
|
|
}
|
|
assert!(!buckets.take(key, now));
|
|
}
|
|
}
|