From b5a56a6a4e9a147e919e25b275374511fad7e786 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:49:26 +0100 Subject: [PATCH] perf: Use governor for rate limiting (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes: #19 Signed-off-by: Jagoda Ślązak --- filtermail/Cargo.lock | 283 +++++++++++++++++++++++++++++++++ filtermail/Cargo.toml | 1 + filtermail/src/config.rs | 3 +- filtermail/src/main.rs | 1 - filtermail/src/outbound.rs | 29 +++- filtermail/src/rate_limiter.rs | 35 ---- 6 files changed, 307 insertions(+), 45 deletions(-) delete mode 100644 filtermail/src/rate_limiter.rs diff --git a/filtermail/Cargo.lock b/filtermail/Cargo.lock index c94f5fe1..3db62d88 100644 --- a/filtermail/Cargo.lock +++ b/filtermail/Cargo.lock @@ -111,6 +111,12 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + [[package]] name = "bytes" version = "1.11.0" @@ -159,6 +165,26 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.10.0" @@ -237,6 +263,7 @@ dependencies = [ "async-trait", "base64", "env_logger", + "governor", "lettre", "log", "mailparse", @@ -254,6 +281,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -286,6 +319,12 @@ dependencies = [ "syn", ] +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + [[package]] name = "futures-task" version = "0.3.31" @@ -307,6 +346,7 @@ dependencies = [ "futures-core", "futures-io", "futures-macro", + "futures-sink", "futures-task", "memchr", "pin-project-lite", @@ -314,12 +354,49 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + [[package]] name = "glob" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "governor" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" +dependencies = [ + "cfg-if", + "dashmap", + "futures-sink", + "futures-timer", + "futures-util", + "getrandom", + "hashbrown 0.16.1", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand", + "smallvec", + "spinning_top", + "web-time", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -335,6 +412,11 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "icu_collections" @@ -478,6 +560,16 @@ dependencies = [ "syn", ] +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "lettre" version = "0.11.19" @@ -562,6 +654,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + [[package]] name = "object" version = "0.32.2" @@ -648,6 +746,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -676,6 +783,21 @@ dependencies = [ "cc", ] +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + [[package]] name = "quote" version = "1.0.43" @@ -691,6 +813,50 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -773,6 +939,12 @@ dependencies = [ "semver", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "scopeguard" version = "1.2.0" @@ -863,6 +1035,15 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1040,6 +1221,102 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" @@ -1211,6 +1488,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + [[package]] name = "writeable" version = "0.6.2" diff --git a/filtermail/Cargo.toml b/filtermail/Cargo.toml index 12bb72ee..a4ed8596 100644 --- a/filtermail/Cargo.toml +++ b/filtermail/Cargo.toml @@ -22,6 +22,7 @@ lettre = { version = "0.11.19", default-features = false, features = [ ] } log = "0.4.29" env_logger = "0.11.8" +governor = "0.10.4" [dev-dependencies] rstest = "0.26.1" diff --git a/filtermail/src/config.rs b/filtermail/src/config.rs index 24224ed2..04ef6997 100644 --- a/filtermail/src/config.rs +++ b/filtermail/src/config.rs @@ -1,6 +1,7 @@ //! Configuration file handling for filtermail. use serde::{Deserialize, Deserializer}; +use std::num::NonZeroU32; use std::path::{Path, PathBuf}; /// Chatmail configuration subset used by filtermail. @@ -16,7 +17,7 @@ pub struct Config { pub postfix_reinject_port_incoming: u16, #[serde(default = "Config::default_max_message_size")] pub max_message_size: usize, - pub max_user_send_per_minute: usize, + pub max_user_send_per_minute: NonZeroU32, #[serde(default, deserialize_with = "deserialize_sequence")] pub passthrough_senders: Vec, #[serde(default, deserialize_with = "deserialize_sequence")] diff --git a/filtermail/src/main.rs b/filtermail/src/main.rs index b81dbb3d..12b2048d 100644 --- a/filtermail/src/main.rs +++ b/filtermail/src/main.rs @@ -30,7 +30,6 @@ pub(crate) mod inbound; pub(crate) mod message; pub(crate) mod openpgp; pub(crate) mod outbound; -pub(crate) mod rate_limiter; pub(crate) mod smtp_server; pub(crate) mod utils; diff --git a/filtermail/src/outbound.rs b/filtermail/src/outbound.rs index 663c3891..2d67c33d 100644 --- a/filtermail/src/outbound.rs +++ b/filtermail/src/outbound.rs @@ -3,11 +3,11 @@ use crate::ENCRYPTION_NEEDED_523; use crate::config::Config; use crate::message::{check_encrypted, is_securejoin, recipient_matches_passthrough}; -use crate::rate_limiter::SendRateLimiter; pub use crate::smtp_server::Envelope; use crate::smtp_server::SmtpHandler; use crate::utils::{extract_address, format_smtp_error}; use async_trait::async_trait; +use governor::{DefaultKeyedRateLimiter, Quota, RateLimiter}; use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor}; use mailparse::{MailHeaderMap, parse_mail}; use std::sync::{Arc, Mutex}; @@ -15,14 +15,15 @@ use std::sync::{Arc, Mutex}; /// Handler for outgoing SMTP messages. pub struct OutgoingBeforeQueueHandler { config: Arc, - send_rate_limiter: Arc>, + send_rate_limiter: Arc>>, } impl OutgoingBeforeQueueHandler { pub fn new(config: Config) -> Self { + let quota = Quota::per_minute(config.max_user_send_per_minute); Self { config: Arc::new(config), - send_rate_limiter: Arc::new(Mutex::new(SendRateLimiter::default())), + send_rate_limiter: Arc::new(Mutex::new(RateLimiter::keyed(quota))), } } } @@ -37,13 +38,25 @@ impl SmtpHandler for OutgoingBeforeQueueHandler { return Err(format!("500 Invalid from address <{}>", address)); } - let max_sent = self.config.max_user_send_per_minute; - let mut limiter = self.send_rate_limiter.lock().unwrap(); - if !limiter.is_sending_allowed(address, max_sent) { - log::debug!("Rate limit exceeded for {address}"); - return Err(format!("450 4.7.1: Too much mail from {address}")); + let Ok(limiter) = self.send_rate_limiter.lock() else { + log::error!("send_rate_limiter lock panicked!"); + return Err("451 Temporary server error".to_string()); + }; + if let Err(e) = limiter.check_key(&address.to_string()) { + // " rate limited until: ..." + log::debug!("<{address}> {e}"); + return Err(format!("450 4.7.1: Too much mail from <{address}>, {e}")); } + // Cleanup + // + // This is only called after a successful check, + // so a spam of mails from the same user will not cause calling this repeatedly. + // In the future, in case of higher traffic this can be further optimized by e.g. calling it + // every N messages or in a separate task every N minutes. + // Time complexity is O(n) where n is the number of unique senders in the last minute. + limiter.retain_recent(); + Ok(()) } diff --git a/filtermail/src/rate_limiter.rs b/filtermail/src/rate_limiter.rs deleted file mode 100644 index 00bbb13a..00000000 --- a/filtermail/src/rate_limiter.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! Module for rate limiting. - -use std::collections::HashMap; -use std::time::{Duration, SystemTime}; - -const ONE_MINUTE: Duration = Duration::from_secs(60); - -/// A rate limiter tracking send timestamps per address. -#[derive(Debug, Default)] -pub struct SendRateLimiter { - address_timestamps: HashMap>, -} - -impl SendRateLimiter { - pub fn is_sending_allowed(&mut self, mail_from: &str, max_send_per_minute: usize) -> bool { - self.address_timestamps.retain(|_, timestamps| { - timestamps - .last() - .map(|t| t.elapsed().unwrap_or_default() <= ONE_MINUTE) - .unwrap_or(false) - }); - - let last = self - .address_timestamps - .entry(mail_from.to_string()) - .or_default(); - last.retain(|&send_time| send_time.elapsed().unwrap_or_default() <= ONE_MINUTE); - if last.len() <= max_send_per_minute { - last.push(SystemTime::now()); - true - } else { - false - } - } -}