mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
[codex] Enforce shared relay admission limits (BUZZ-SEC-019) (#1917)
This commit is contained in:
@@ -51,6 +51,16 @@ RELAY_URL=ws://localhost:3000
|
||||
# (use `just web` for Vite HMR instead).
|
||||
# BUZZ_WEB_DIR=./web/dist
|
||||
|
||||
# Shared Redis-backed admission limits. Defaults shown below; each value must
|
||||
# be a positive integer.
|
||||
# BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=60
|
||||
# BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN=300
|
||||
# BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC=10
|
||||
# BUZZ_RATE_LIMIT_AGENT_STANDARD_MESSAGES_PER_MIN=120
|
||||
# BUZZ_RATE_LIMIT_AGENT_STANDARD_API_CALLS_PER_MIN=600
|
||||
# BUZZ_RATE_LIMIT_AGENT_ELEVATED_MESSAGES_PER_MIN=300
|
||||
# BUZZ_RATE_LIMIT_AGENT_PLATFORM_MESSAGES_PER_MIN=600
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Git (NIP-34 bare repositories)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -459,6 +459,9 @@ jobs:
|
||||
BUZZ_BIND_ADDR=0.0.0.0:3000 \
|
||||
BUZZ_REQUIRE_AUTH_TOKEN=false \
|
||||
BUZZ_RECONCILE_CHANNELS=true \
|
||||
BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=100000 \
|
||||
BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN=100000 \
|
||||
BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC=10000 \
|
||||
BUZZ_GIT_PROBE_WRITERS=8 \
|
||||
SPROUT_REMINDER_SCHEDULER_INTERVAL_SECS=1 \
|
||||
./target/ci/buzz-relay > /tmp/buzz-relay.log 2>&1 &
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
use buzz_auth::{LimitType, RateLimiter};
|
||||
use buzz_core::TenantContext;
|
||||
use nostr::PublicKey;
|
||||
|
||||
// Desktop startup establishes several independent live subscriptions at once.
|
||||
// Preserve the configured average rate while allowing that bounded burst. This
|
||||
// is still a fixed-window limiter, so a Redis-backed token bucket would be a
|
||||
// better long-term fit for smoother refill behavior.
|
||||
const WS_BURST_WINDOW_SECS: u64 = 5;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum AdmissionError {
|
||||
Exceeded { reset_in_secs: u64 },
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
pub(crate) async fn check_principal<L: RateLimiter>(
|
||||
limiter: &L,
|
||||
tenant: &TenantContext,
|
||||
pubkey: &PublicKey,
|
||||
limit_type: LimitType,
|
||||
window_secs: u64,
|
||||
limit: u64,
|
||||
) -> Result<(), AdmissionError> {
|
||||
match limiter
|
||||
.check_and_increment(tenant, pubkey, limit_type, window_secs, limit)
|
||||
.await
|
||||
{
|
||||
Ok(result) if result.allowed => Ok(()),
|
||||
Ok(result) => Err(AdmissionError::Exceeded {
|
||||
reset_in_secs: result.reset_in_secs,
|
||||
}),
|
||||
Err(error) => {
|
||||
tracing::warn!(error = %error, "shared rate-limit admission unavailable");
|
||||
Err(AdmissionError::Unavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ws_admission_budget(per_second_limit: u64) -> (u64, u64) {
|
||||
(
|
||||
WS_BURST_WINDOW_SECS,
|
||||
per_second_limit.saturating_mul(WS_BURST_WINDOW_SECS),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::IpAddr;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use buzz_auth::{AuthError, RateLimitResult, RateLimiter};
|
||||
use buzz_core::CommunityId;
|
||||
use nostr::Keys;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::*;
|
||||
|
||||
enum StubOutcome {
|
||||
Denied,
|
||||
Failed,
|
||||
}
|
||||
|
||||
struct StubLimiter {
|
||||
outcome: StubOutcome,
|
||||
calls: AtomicUsize,
|
||||
}
|
||||
|
||||
impl RateLimiter for StubLimiter {
|
||||
async fn check_and_increment(
|
||||
&self,
|
||||
_ctx: &TenantContext,
|
||||
_pubkey: &PublicKey,
|
||||
_limit_type: LimitType,
|
||||
_window_secs: u64,
|
||||
_limit: u64,
|
||||
) -> Result<RateLimitResult, AuthError> {
|
||||
self.calls.fetch_add(1, Ordering::Relaxed);
|
||||
match self.outcome {
|
||||
StubOutcome::Denied => Ok(RateLimitResult::denied(11, 10, 1)),
|
||||
StubOutcome::Failed => Err(AuthError::Internal("redis unavailable".to_owned())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_ip_connection(
|
||||
&self,
|
||||
_ip: &IpAddr,
|
||||
_window_secs: u64,
|
||||
_limit: u64,
|
||||
) -> Result<RateLimitResult, AuthError> {
|
||||
match self.outcome {
|
||||
StubOutcome::Denied => Ok(RateLimitResult::denied(11, 10, 1)),
|
||||
StubOutcome::Failed => Err(AuthError::Internal("redis unavailable".to_owned())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tenant() -> TenantContext {
|
||||
TenantContext::resolved(
|
||||
CommunityId::from_uuid(Uuid::from_u128(1)),
|
||||
"relay.example.com",
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_budget_preserves_rate_with_a_bounded_burst() {
|
||||
assert_eq!(ws_admission_budget(10), (5, 50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_budget_saturates_on_overflow() {
|
||||
assert_eq!(ws_admission_budget(u64::MAX), (5, u64::MAX));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn denied_shared_counter_rejects_admission() {
|
||||
let limiter = StubLimiter {
|
||||
outcome: StubOutcome::Denied,
|
||||
calls: AtomicUsize::new(0),
|
||||
};
|
||||
let keys = Keys::generate();
|
||||
|
||||
let result = check_principal(
|
||||
&limiter,
|
||||
&tenant(),
|
||||
&keys.public_key(),
|
||||
LimitType::WsEvents,
|
||||
1,
|
||||
10,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(result, Err(AdmissionError::Exceeded { reset_in_secs: 1 }));
|
||||
assert_eq!(limiter.calls.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_counter_failure_rejects_admission() {
|
||||
let limiter = StubLimiter {
|
||||
outcome: StubOutcome::Failed,
|
||||
calls: AtomicUsize::new(0),
|
||||
};
|
||||
let keys = Keys::generate();
|
||||
|
||||
let result = check_principal(
|
||||
&limiter,
|
||||
&tenant(),
|
||||
&keys.public_key(),
|
||||
LimitType::ApiCalls,
|
||||
60,
|
||||
300,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(result, Err(AdmissionError::Unavailable));
|
||||
assert_eq!(limiter.calls.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ use axum::{
|
||||
use base64::Engine;
|
||||
use serde_json::Value;
|
||||
|
||||
use buzz_auth::{Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS};
|
||||
use buzz_auth::{LimitType, Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS};
|
||||
use buzz_core::TenantContext;
|
||||
|
||||
use crate::handlers::ingest::{IngestAuth, IngestError};
|
||||
@@ -21,6 +21,40 @@ use crate::state::AppState;
|
||||
|
||||
use super::{api_error, internal_error, not_found};
|
||||
|
||||
async fn enforce_http_admission(
|
||||
state: &AppState,
|
||||
tenant: &TenantContext,
|
||||
pubkey: &nostr::PublicKey,
|
||||
) -> Result<(), (StatusCode, Json<Value>)> {
|
||||
let limit = state.auth.config().rate_limits.human_api_calls_per_min;
|
||||
match crate::admission::check_principal(
|
||||
state.admission_rate_limiter.as_ref(),
|
||||
tenant,
|
||||
pubkey,
|
||||
LimitType::ApiCalls,
|
||||
60,
|
||||
limit,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Ok(()),
|
||||
Err(crate::admission::AdmissionError::Exceeded { reset_in_secs }) => {
|
||||
metrics::counter!("buzz_admission_rejections_total", "transport" => "http", "reason" => "quota").increment(1);
|
||||
Err(api_error(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
&format!("rate-limited: quota exceeded; retry in {reset_in_secs}s"),
|
||||
))
|
||||
}
|
||||
Err(crate::admission::AdmissionError::Unavailable) => {
|
||||
metrics::counter!("buzz_admission_rejections_total", "transport" => "http", "reason" => "unavailable").increment(1);
|
||||
Err(api_error(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"rate-limited: shared admission unavailable",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify bridge auth: NIP-98 (production) or X-Pubkey (dev mode).
|
||||
///
|
||||
/// Returns the authenticated public key and an event ID for replay detection.
|
||||
@@ -584,6 +618,7 @@ pub async fn submit_event(
|
||||
Some(&body),
|
||||
state.config.require_auth_token,
|
||||
)?;
|
||||
enforce_http_admission(&state, &tenant, &pubkey).await?;
|
||||
check_nip98_replay(&state, &tenant, event_id_bytes).await?;
|
||||
let pubkey_bytes = pubkey.to_bytes().to_vec();
|
||||
|
||||
@@ -653,6 +688,7 @@ pub async fn query_events(
|
||||
Some(&body),
|
||||
state.config.require_auth_token,
|
||||
)?;
|
||||
enforce_http_admission(&state, &tenant, &pubkey).await?;
|
||||
check_nip98_replay(&state, &tenant, event_id_bytes).await?;
|
||||
let pubkey_bytes = pubkey.to_bytes().to_vec();
|
||||
|
||||
@@ -1036,6 +1072,7 @@ pub async fn count_events(
|
||||
Some(&body),
|
||||
state.config.require_auth_token,
|
||||
)?;
|
||||
enforce_http_admission(&state, &tenant, &pubkey).await?;
|
||||
check_nip98_replay(&state, &tenant, event_id_bytes).await?;
|
||||
let pubkey_bytes = pubkey.to_bytes().to_vec();
|
||||
|
||||
|
||||
@@ -233,6 +233,54 @@ fn parse_bind_addr(raw: &str) -> Result<SocketAddr, ConfigError> {
|
||||
.map_err(|e| ConfigError::InvalidBindAddr(e.to_string()))
|
||||
}
|
||||
|
||||
fn positive_u64_from_env(name: &str, default: u64) -> Result<u64, ConfigError> {
|
||||
match std::env::var(name) {
|
||||
Ok(raw) => raw
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.filter(|value| *value > 0)
|
||||
.ok_or_else(|| ConfigError::InvalidValue(format!("{name} must be a positive integer"))),
|
||||
Err(std::env::VarError::NotPresent) => Ok(default),
|
||||
Err(std::env::VarError::NotUnicode(_)) => Err(ConfigError::InvalidValue(format!(
|
||||
"{name} must be valid Unicode"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn rate_limit_config_from_env() -> Result<buzz_auth::RateLimitConfig, ConfigError> {
|
||||
let defaults = buzz_auth::RateLimitConfig::default();
|
||||
Ok(buzz_auth::RateLimitConfig {
|
||||
human_messages_per_min: positive_u64_from_env(
|
||||
"BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN",
|
||||
defaults.human_messages_per_min,
|
||||
)?,
|
||||
human_api_calls_per_min: positive_u64_from_env(
|
||||
"BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN",
|
||||
defaults.human_api_calls_per_min,
|
||||
)?,
|
||||
human_ws_events_per_sec: positive_u64_from_env(
|
||||
"BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC",
|
||||
defaults.human_ws_events_per_sec,
|
||||
)?,
|
||||
agent_standard_messages_per_min: positive_u64_from_env(
|
||||
"BUZZ_RATE_LIMIT_AGENT_STANDARD_MESSAGES_PER_MIN",
|
||||
defaults.agent_standard_messages_per_min,
|
||||
)?,
|
||||
agent_standard_api_calls_per_min: positive_u64_from_env(
|
||||
"BUZZ_RATE_LIMIT_AGENT_STANDARD_API_CALLS_PER_MIN",
|
||||
defaults.agent_standard_api_calls_per_min,
|
||||
)?,
|
||||
agent_elevated_messages_per_min: positive_u64_from_env(
|
||||
"BUZZ_RATE_LIMIT_AGENT_ELEVATED_MESSAGES_PER_MIN",
|
||||
defaults.agent_elevated_messages_per_min,
|
||||
)?,
|
||||
agent_platform_messages_per_min: positive_u64_from_env(
|
||||
"BUZZ_RATE_LIMIT_AGENT_PLATFORM_MESSAGES_PER_MIN",
|
||||
defaults.agent_platform_messages_per_min,
|
||||
)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_operator_api_origin(raw: &str) -> Result<String, ConfigError> {
|
||||
let raw = raw.trim();
|
||||
let url = url::Url::parse(raw).map_err(|e| {
|
||||
@@ -477,7 +525,9 @@ impl Config {
|
||||
));
|
||||
}
|
||||
|
||||
let auth = buzz_auth::AuthConfig::default();
|
||||
let auth = buzz_auth::AuthConfig {
|
||||
rate_limits: rate_limit_config_from_env()?,
|
||||
};
|
||||
|
||||
if !require_auth_token {
|
||||
warn!(
|
||||
@@ -842,6 +892,37 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limits_can_be_overridden() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
std::env::set_var("BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN", "1001");
|
||||
std::env::set_var("BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN", "1002");
|
||||
std::env::set_var("BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC", "1003");
|
||||
|
||||
let config = Config::from_env().expect("config");
|
||||
|
||||
std::env::remove_var("BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN");
|
||||
std::env::remove_var("BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN");
|
||||
std::env::remove_var("BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC");
|
||||
assert_eq!(config.auth.rate_limits.human_messages_per_min, 1001);
|
||||
assert_eq!(config.auth.rate_limits.human_api_calls_per_min, 1002);
|
||||
assert_eq!(config.auth.rate_limits.human_ws_events_per_sec, 1003);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limit_overrides_reject_zero() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
std::env::set_var("BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC", "0");
|
||||
let result = Config::from_env();
|
||||
std::env::remove_var("BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC");
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(ConfigError::InvalidValue(ref message))
|
||||
if message.contains("BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_operator_pubkeys_parse_dedupe_and_normalize() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
|
||||
@@ -14,7 +14,7 @@ use tracing::Instrument as _;
|
||||
use tracing::{debug, info, trace, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use buzz_auth::{generate_challenge, AuthContext};
|
||||
use buzz_auth::{generate_challenge, AuthContext, LimitType};
|
||||
use buzz_core::tenant::TenantContext;
|
||||
use nostr::Filter;
|
||||
|
||||
@@ -495,6 +495,10 @@ async fn handle_text_message(text: String, conn: Arc<ConnectionState>, state: Ar
|
||||
}
|
||||
};
|
||||
|
||||
if !enforce_ws_admission(&msg, &conn, &state).await {
|
||||
return;
|
||||
}
|
||||
|
||||
match msg {
|
||||
ClientMessage::Auth(event) => {
|
||||
// Auth is synchronous in the WS loop — no span context is lost.
|
||||
@@ -579,6 +583,86 @@ async fn handle_text_message(text: String, conn: Arc<ConnectionState>, state: Ar
|
||||
}
|
||||
}
|
||||
|
||||
async fn enforce_ws_admission(
|
||||
msg: &ClientMessage,
|
||||
conn: &ConnectionState,
|
||||
state: &AppState,
|
||||
) -> bool {
|
||||
let is_event = matches!(msg, ClientMessage::Event(_));
|
||||
if !is_event && !matches!(msg, ClientMessage::Req { .. } | ClientMessage::Count { .. }) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let (pubkey, is_agent) = {
|
||||
let auth = conn.auth_state.read().await;
|
||||
match &*auth {
|
||||
AuthState::Authenticated(ctx) => (ctx.pubkey, ctx.agent_owner_pubkey.is_some()),
|
||||
_ => return true,
|
||||
}
|
||||
};
|
||||
|
||||
let limits = &state.auth.config().rate_limits;
|
||||
let (ws_window_secs, ws_limit) =
|
||||
crate::admission::ws_admission_budget(limits.human_ws_events_per_sec);
|
||||
let ws_result = crate::admission::check_principal(
|
||||
state.admission_rate_limiter.as_ref(),
|
||||
&conn.tenant,
|
||||
&pubkey,
|
||||
LimitType::WsEvents,
|
||||
ws_window_secs,
|
||||
ws_limit,
|
||||
)
|
||||
.await;
|
||||
if !send_admission_result(conn, ws_result) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if is_event {
|
||||
let message_limit = if is_agent {
|
||||
limits.agent_standard_messages_per_min
|
||||
} else {
|
||||
limits.human_messages_per_min
|
||||
};
|
||||
let message_result = crate::admission::check_principal(
|
||||
state.admission_rate_limiter.as_ref(),
|
||||
&conn.tenant,
|
||||
&pubkey,
|
||||
LimitType::Messages,
|
||||
60,
|
||||
message_limit,
|
||||
)
|
||||
.await;
|
||||
if !send_admission_result(conn, message_result) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn send_admission_result(
|
||||
conn: &ConnectionState,
|
||||
result: Result<(), crate::admission::AdmissionError>,
|
||||
) -> bool {
|
||||
match result {
|
||||
Ok(()) => true,
|
||||
Err(crate::admission::AdmissionError::Exceeded { reset_in_secs }) => {
|
||||
metrics::counter!("buzz_admission_rejections_total", "transport" => "websocket", "reason" => "quota").increment(1);
|
||||
conn.send(RelayMessage::notice(&format!(
|
||||
"rate-limited: quota exceeded; retry in {reset_in_secs}s"
|
||||
)));
|
||||
false
|
||||
}
|
||||
Err(crate::admission::AdmissionError::Unavailable) => {
|
||||
metrics::counter!("buzz_admission_rejections_total", "transport" => "websocket", "reason" => "unavailable").increment(1);
|
||||
conn.send(RelayMessage::notice(
|
||||
"rate-limited: shared admission unavailable",
|
||||
));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn topic_for_subscription(channel_id: Option<Uuid>) -> EventTopic {
|
||||
match channel_id {
|
||||
Some(channel_id) => EventTopic::Channel(channel_id),
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
#![warn(missing_docs)]
|
||||
//! NIP-01 WebSocket relay for Buzz private team communication.
|
||||
|
||||
mod admission;
|
||||
|
||||
/// REST API route handlers.
|
||||
pub mod api;
|
||||
/// WebSocket audio relay for huddle voice channels.
|
||||
|
||||
@@ -23,6 +23,7 @@ use buzz_db::Db;
|
||||
use buzz_media::MediaStorage;
|
||||
use buzz_pubsub::cache_invalidation::CacheInvalidation;
|
||||
use buzz_pubsub::conn_control::ConnControl;
|
||||
use buzz_pubsub::rate_limiter::RedisRateLimiter;
|
||||
use buzz_pubsub::{PubSubManager, RedisNip98ReplayGuard};
|
||||
use buzz_search::SearchService;
|
||||
use buzz_workflow::WorkflowEngine;
|
||||
@@ -518,6 +519,8 @@ pub struct AppState {
|
||||
/// replace this with process-local caching; replay freshness must survive
|
||||
/// cross-pod routing.
|
||||
pub nip98_replay: Arc<dyn Nip98ReplayGuard>,
|
||||
/// Shared Redis-backed admission limits for ordinary HTTP and WebSocket work.
|
||||
pub admission_rate_limiter: Arc<RedisRateLimiter>,
|
||||
|
||||
/// Per-agent sliding-window rate limiter for observer frames (kind 24200).
|
||||
/// Key: (community_id, agent pubkey bytes). Value: (count, window_start).
|
||||
@@ -627,6 +630,7 @@ impl AppState {
|
||||
.expect("media storage was already constructed with this S3 config");
|
||||
let nip98_replay: Arc<dyn Nip98ReplayGuard> =
|
||||
Arc::new(RedisNip98ReplayGuard::new(redis_pool.clone()));
|
||||
let admission_rate_limiter = Arc::new(RedisRateLimiter::new(redis_pool.clone()));
|
||||
let state = Self {
|
||||
config: Arc::new(config),
|
||||
db,
|
||||
@@ -681,6 +685,7 @@ impl AppState {
|
||||
shutting_down: Arc::new(AtomicBool::new(false)),
|
||||
started_at: Instant::now(),
|
||||
nip98_replay,
|
||||
admission_rate_limiter,
|
||||
observer_rate_limiter: Arc::new(DashMap::new()),
|
||||
media_upload_rate_limiter: Arc::new(DashMap::new()),
|
||||
invite_claim_rate_limiter: Arc::new(
|
||||
|
||||
Reference in New Issue
Block a user