Files
buzz/crates/buzz-auth/src/rate_limit.rs
T
cls 9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

327 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Rate limiting types and interface.
//!
//! Defines the [`RateLimiter`] trait. The Redis-backed implementation lives in
//! `buzz-relay` / `buzz-pubsub`. Fixed-window counter algorithm.
//!
//! ⚠️ 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_core::TenantContext;
use nostr::PublicKey;
use serde::{Deserialize, Serialize};
use crate::error::AuthError;
/// The outcome of a rate-limit check, including counter state for response headers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RateLimitResult {
/// Whether the request is permitted (`true`) or should be rejected (`false`).
pub allowed: bool,
/// Current counter value after this increment.
pub current: u64,
/// The configured limit for this window.
pub limit: u64,
/// Seconds until the current window resets.
pub reset_in_secs: u64,
}
impl RateLimitResult {
/// Construct an **allowed** result.
pub fn allowed(current: u64, limit: u64, reset_in_secs: u64) -> Self {
Self {
allowed: true,
current,
limit,
reset_in_secs,
}
}
/// Construct a **denied** result.
pub fn denied(current: u64, limit: u64, reset_in_secs: u64) -> Self {
Self {
allowed: false,
current,
limit,
reset_in_secs,
}
}
}
/// The category of operation being rate-limited.
///
/// Each variant maps to a distinct Redis key suffix so limits are tracked
/// independently per operation type.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LimitType {
/// Nostr message events (kind:1 etc.) sent via WebSocket.
Messages,
/// HTTP REST API calls.
ApiCalls,
/// All WebSocket events (broader than `Messages`).
WsEvents,
/// Concurrent WebSocket connections from a single IP address.
IpConnections,
}
impl LimitType {
/// Short suffix used in Redis key construction (e.g. `"msg"`, `"api"`).
pub fn key_suffix(&self) -> &'static str {
match self {
Self::Messages => "msg",
Self::ApiCalls => "api",
Self::WsEvents => "ws",
Self::IpConnections => "conn",
}
}
}
/// Per-tier rate limit thresholds.
///
/// All values are counts per the relevant time window (per-minute or per-second).
/// Loaded from the relay config file; sensible defaults are provided for all fields.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RateLimitConfig {
/// Maximum messages per minute for human users. Default: 60.
#[serde(default = "default_human_msg")]
pub human_messages_per_min: u64,
/// Maximum HTTP API calls per minute for human users. Default: 300.
#[serde(default = "default_human_api")]
pub human_api_calls_per_min: u64,
/// Maximum WebSocket events per second for human users. Default: 10.
#[serde(default = "default_human_ws")]
pub human_ws_events_per_sec: u64,
/// Maximum messages per minute for standard-tier agent tokens. Default: 120.
#[serde(default = "default_agent_std_msg")]
pub agent_standard_messages_per_min: u64,
/// Maximum HTTP API calls per minute for standard-tier agent tokens. Default: 600.
#[serde(default = "default_agent_std_api")]
pub agent_standard_api_calls_per_min: u64,
/// Maximum messages per minute for elevated-tier agent tokens. Default: 300.
#[serde(default = "default_agent_elev_msg")]
pub agent_elevated_messages_per_min: u64,
/// Maximum messages per minute for platform-tier agent tokens. Default: 600.
#[serde(default = "default_agent_plat_msg")]
pub agent_platform_messages_per_min: u64,
}
fn default_human_msg() -> u64 {
60
}
fn default_human_api() -> u64 {
300
}
fn default_human_ws() -> u64 {
10
}
fn default_agent_std_msg() -> u64 {
120
}
fn default_agent_std_api() -> u64 {
600
}
fn default_agent_elev_msg() -> u64 {
300
}
fn default_agent_plat_msg() -> u64 {
600
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
human_messages_per_min: default_human_msg(),
human_api_calls_per_min: default_human_api(),
human_ws_events_per_sec: default_human_ws(),
agent_standard_messages_per_min: default_agent_std_msg(),
agent_standard_api_calls_per_min: default_agent_std_api(),
agent_elevated_messages_per_min: default_agent_elev_msg(),
agent_platform_messages_per_min: default_agent_plat_msg(),
}
}
}
/// Async rate-limiting interface.
///
/// The Redis-backed production implementation lives in `buzz-relay` / `buzz-pubsub`.
/// A no-op `AlwaysAllowRateLimiter` is provided for unit tests.
///
/// ## Tenant scoping
///
/// Pubkey-keyed limits ([`check_and_increment`]) take `&TenantContext` and the Redis
/// key is community-prefixed (`buzz:{community}:ratelimit:{pubkey}:{suffix}`). The
/// same pubkey active in two communities consumes two independent quotas — that is
/// the correct behavior under multi-tenant isolation (S1 cross-community fence).
///
/// IP-keyed limits ([`check_ip_connection`]) are **operator-global** by design. They
/// gate connection acceptance at the network edge, before host→community resolution
/// has completed (or, on resolve failure, instead of it). Threading `&TenantContext`
/// through the connection-rate fence would invert the order of operations. If
/// per-(community, IP) caps are ever needed as a tenant-fairness signal, that
/// belongs in an additive `LimitType` keyed on `(community, ip)`, not in this trait.
///
/// ⚠️ The fixed-window algorithm used by the Redis implementation allows up to 2×
/// burst at window boundaries. Upgrade to a sliding window or token bucket if strict
/// per-second limiting is required.
pub trait RateLimiter: Send + Sync {
/// Increment the per-(community, pubkey) counter for `limit_type` and return
/// whether the request is within `limit` for the given `window_secs`.
///
/// `ctx` scopes the counter to the resolved community; the same pubkey in two
/// communities is two independent quotas.
fn check_and_increment(
&self,
ctx: &TenantContext,
pubkey: &PublicKey,
limit_type: LimitType,
window_secs: u64,
limit: u64,
) -> impl std::future::Future<Output = Result<RateLimitResult, AuthError>> + Send;
/// Increment the per-IP connection counter and return whether the connection
/// is within `limit` for the given `window_secs`.
///
/// Operator-global — see trait docs. This fence runs before / outside of host
/// resolution and intentionally does not take a `TenantContext`.
fn check_ip_connection(
&self,
ip: &IpAddr,
window_secs: u64,
limit: u64,
) -> impl std::future::Future<Output = Result<RateLimitResult, AuthError>> + Send;
}
/// Redis key for pubkey-based rate limit:
/// `buzz:{community}:ratelimit:{pubkey_hex}:{suffix}`.
///
/// Community-prefixed: the same pubkey in two communities maps to two distinct
/// keys, so quotas don't bleed across the tenancy fence.
pub fn rate_limit_key(ctx: &TenantContext, pubkey: &PublicKey, limit_type: &LimitType) -> String {
format!(
"buzz:{}:ratelimit:{}:{}",
ctx.community(),
pubkey.to_hex(),
limit_type.key_suffix()
)
}
/// Redis key for IP-based rate limit: `buzz:ratelimit:ip:{ip}:conn`.
///
/// Operator-global by design — see [`RateLimiter`] docs.
pub fn ip_rate_limit_key(ip: &IpAddr) -> String {
format!("buzz:ratelimit:ip:{}:conn", ip)
}
/// Always-allow rate limiter for unit tests.
#[cfg(any(test, feature = "test-utils"))]
pub struct AlwaysAllowRateLimiter;
#[cfg(any(test, feature = "test-utils"))]
impl RateLimiter for AlwaysAllowRateLimiter {
async fn check_and_increment(
&self,
_ctx: &TenantContext,
_pubkey: &PublicKey,
_limit_type: LimitType,
window_secs: u64,
limit: u64,
) -> Result<RateLimitResult, AuthError> {
Ok(RateLimitResult::allowed(1, limit, window_secs))
}
async fn check_ip_connection(
&self,
_ip: &IpAddr,
window_secs: u64,
limit: u64,
) -> Result<RateLimitResult, AuthError> {
Ok(RateLimitResult::allowed(1, limit, window_secs))
}
}
#[cfg(test)]
mod tests {
use super::*;
use buzz_core::CommunityId;
use nostr::Keys;
use sha2::Digest;
use std::net::Ipv4Addr;
use uuid::Uuid;
fn fixture_ctx(host: &str) -> TenantContext {
// Deterministic community id from host so test assertions can name the prefix.
let bytes = sha2::Sha256::digest(host.as_bytes());
let mut uuid_bytes = [0u8; 16];
uuid_bytes.copy_from_slice(&bytes[..16]);
let id = CommunityId::from_uuid(Uuid::from_bytes(uuid_bytes));
TenantContext::resolved(id, host)
}
#[test]
fn rate_limit_key_includes_community_prefix() {
let ctx = fixture_ctx("relay-a.example");
let keys = Keys::generate();
let key = rate_limit_key(&ctx, &keys.public_key(), &LimitType::Messages);
let expected_prefix = format!("buzz:{}:ratelimit:", ctx.community());
assert!(
key.starts_with(&expected_prefix),
"key {key} should start with {expected_prefix}"
);
assert!(key.ends_with(":msg"));
}
#[test]
fn rate_limit_key_isolates_communities_for_same_pubkey() {
// The S1 cross-community isolation fence at the rate-limit key layer:
// same pubkey, two communities -> two distinct Redis keys -> independent quotas.
let keys = Keys::generate();
let ctx_a = fixture_ctx("relay-a.example");
let ctx_b = fixture_ctx("relay-b.example");
let key_a = rate_limit_key(&ctx_a, &keys.public_key(), &LimitType::Messages);
let key_b = rate_limit_key(&ctx_b, &keys.public_key(), &LimitType::Messages);
assert_ne!(
key_a, key_b,
"same pubkey in two communities must not share a rate-limit key"
);
}
#[test]
fn rate_limit_key_components_are_lowercase() {
// Stability/idempotence invariant: if pubkey hex or community Display
// ever started emitting uppercase, the same (community, pubkey) would
// produce two distinct Redis keys → effective 2× quota. Pin the
// lowercase property here so the regression surfaces in unit tests,
// not in production traffic.
let ctx = fixture_ctx("relay-a.example");
let keys = Keys::generate();
let key = rate_limit_key(&ctx, &keys.public_key(), &LimitType::Messages);
for c in key.chars() {
assert!(
!c.is_ascii_uppercase(),
"rate-limit key {key} must be all-lowercase ASCII"
);
}
}
#[test]
fn ip_rate_limit_key_format() {
// IP fence stays operator-global — no community in the key.
let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1));
assert_eq!(ip_rate_limit_key(&ip), "buzz:ratelimit:ip:192.168.1.1:conn");
}
#[tokio::test]
async fn always_allow_limiter() {
let limiter = AlwaysAllowRateLimiter;
let ctx = fixture_ctx("relay-a.example");
let keys = Keys::generate();
let result = limiter
.check_and_increment(&ctx, &keys.public_key(), LimitType::Messages, 60, 60)
.await
.unwrap();
assert!(result.allowed);
}
}