mirror of
https://github.com/block/buzz.git
synced 2026-11-15 13:51:12 +00:00
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Quinn <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: Sami <sami@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
122 lines
3.9 KiB
Rust
122 lines
3.9 KiB
Rust
//! Redis-backed rate limiter using atomic Lua script (INCR + EXPIRE).
|
||
//!
|
||
//! Implements the [`RateLimiter`] trait from `buzz-auth`.
|
||
//! Uses a single Lua script to atomically INCR and conditionally EXPIRE,
|
||
//! eliminating the crash window where a key could exist without a TTL.
|
||
//!
|
||
//! ⚠️ Fixed windows allow up to 2× burst at boundaries. Upgrade to sliding
|
||
//! window or token bucket for strict limiting.
|
||
|
||
use std::net::IpAddr;
|
||
|
||
use buzz_auth::{
|
||
error::AuthError,
|
||
rate_limit::{LimitType, RateLimitResult, RateLimiter},
|
||
};
|
||
use buzz_core::TenantContext;
|
||
use nostr::PublicKey;
|
||
use redis::Script;
|
||
|
||
/// Atomically INCR the key, set EXPIRE on first call, and return (count, ttl).
|
||
///
|
||
/// Using a Lua script ensures INCR and EXPIRE are executed atomically —
|
||
/// a crash between them can no longer leave a key without a TTL.
|
||
const RATE_LIMIT_SCRIPT: &str = r#"
|
||
local count = redis.call('INCR', KEYS[1])
|
||
if count == 1 then
|
||
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
||
end
|
||
local ttl = redis.call('TTL', KEYS[1])
|
||
return {count, ttl}
|
||
"#;
|
||
|
||
/// Run the atomic rate-limit Lua script against `key` and return a
|
||
/// [`RateLimitResult`].
|
||
///
|
||
/// If the TTL comes back negative (key exists without expiry — broken state
|
||
/// from a prior crash), the key is repaired with a fresh EXPIRE and a warning
|
||
/// is logged.
|
||
async fn run_rate_limit(
|
||
pool: &deadpool_redis::Pool,
|
||
key: &str,
|
||
window_secs: u64,
|
||
limit: u64,
|
||
) -> Result<RateLimitResult, AuthError> {
|
||
let mut conn = pool
|
||
.get()
|
||
.await
|
||
.map_err(|e| AuthError::Internal(format!("Redis pool: {e}")))?;
|
||
|
||
let script = Script::new(RATE_LIMIT_SCRIPT);
|
||
let (count, ttl): (u64, i64) = script
|
||
.key(key)
|
||
.arg(window_secs as i64)
|
||
.invoke_async(&mut *conn)
|
||
.await
|
||
.map_err(|e| AuthError::Internal(format!("Redis rate limit script: {e}")))?;
|
||
|
||
// ttl == -1 means the key exists but has no expiry — broken state from a
|
||
// prior crash between INCR and EXPIRE. Repair it now.
|
||
let reset_in_secs = if ttl < 0 {
|
||
tracing::warn!(key = %key, "rate limit key has no TTL — repairing");
|
||
let _: () = redis::cmd("EXPIRE")
|
||
.arg(key)
|
||
.arg(window_secs as i64)
|
||
.query_async(&mut *conn)
|
||
.await
|
||
.map_err(|e| AuthError::Internal(format!("Redis EXPIRE repair: {e}")))?;
|
||
// After repair, the window resets to the full duration.
|
||
window_secs
|
||
} else {
|
||
ttl.max(0) as u64
|
||
};
|
||
|
||
if count <= limit {
|
||
Ok(RateLimitResult::allowed(count, limit, reset_in_secs))
|
||
} else {
|
||
Ok(RateLimitResult::denied(count, limit, reset_in_secs))
|
||
}
|
||
}
|
||
|
||
/// Redis-backed rate limiter using fixed-window counters.
|
||
///
|
||
/// Pubkey keys are community-scoped via `&TenantContext`:
|
||
/// `buzz:{community}:ratelimit:{pubkey_hex}:{suffix}`. IP keys remain
|
||
/// operator-global: `buzz:ratelimit:ip:{ip}:conn`. The counter and its TTL are
|
||
/// managed atomically via a Lua script to prevent keys from persisting without
|
||
/// expiry.
|
||
pub struct RedisRateLimiter {
|
||
pool: deadpool_redis::Pool,
|
||
}
|
||
|
||
impl RedisRateLimiter {
|
||
/// Create a new `RedisRateLimiter` backed by the given connection pool.
|
||
pub fn new(pool: deadpool_redis::Pool) -> Self {
|
||
Self { pool }
|
||
}
|
||
}
|
||
|
||
impl RateLimiter for RedisRateLimiter {
|
||
async fn check_and_increment(
|
||
&self,
|
||
ctx: &TenantContext,
|
||
pubkey: &PublicKey,
|
||
limit_type: LimitType,
|
||
window_secs: u64,
|
||
limit: u64,
|
||
) -> Result<RateLimitResult, AuthError> {
|
||
let key = buzz_auth::rate_limit::rate_limit_key(ctx, pubkey, &limit_type);
|
||
run_rate_limit(&self.pool, &key, window_secs, limit).await
|
||
}
|
||
|
||
async fn check_ip_connection(
|
||
&self,
|
||
ip: &IpAddr,
|
||
window_secs: u64,
|
||
limit: u64,
|
||
) -> Result<RateLimitResult, AuthError> {
|
||
let key = buzz_auth::rate_limit::ip_rate_limit_key(ip);
|
||
run_rate_limit(&self.pool, &key, window_secs, limit).await
|
||
}
|
||
}
|