diff --git a/CHANGELOG.md b/CHANGELOG.md index 51371f8..bbd4da9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 release, including the upstream NEG-OPEN handling fix and new local-relay resource hardening. The embedded relay now imposes 500 active REQs per connection; per-minute connection quotas of 60 event writes, 120 queries, - 30 authentication events, and 300 text messages; 20 filters per REQ; 500 + 30 authentication events, and 6,000 WebSocket messages (raised from + rust-nostr's 300/minute default after it disconnected legitimate bursty + clients in production); 20 filters per REQ; 500 results per filter; 250-byte subscription IDs; 1 MiB retained subscription state; 10 active negentropy sessions and 50,000 negentropy items per connection; 5 MiB WebSocket diff --git a/docs/explanation/defensive-measures.md b/docs/explanation/defensive-measures.md index 13ea9a0..3756232 100644 --- a/docs/explanation/defensive-measures.md +++ b/docs/explanation/defensive-measures.md @@ -32,8 +32,8 @@ These limits prevent individual connections from overwhelming the relay. The relay advertises the standard `max_subscriptions`, `max_limit`, `default_limit`, `max_message_length`, and `max_subid_length` NIP-11 fields. rust-nostr 0.45 also enforces fixed ngit-grasp-selected defaults of 120 queries, -30 authentication events, and 300 text messages per minute; 20 filters per -REQ; 1 MiB subscription state; 10 active negentropy sessions and 50,000 +30 authentication events, and 6,000 WebSocket messages per minute; 20 filters +per REQ; 1 MiB subscription state; 10 active negentropy sessions and 50,000 negentropy items per connection; a 5 MiB WebSocket message; and a 10-second handshake deadline. NIP-11 has no standard fields for most of those controls. diff --git a/docs/explanation/sync-scaling-constraints.md b/docs/explanation/sync-scaling-constraints.md index 6c91640..82058f0 100644 --- a/docs/explanation/sync-scaling-constraints.md +++ b/docs/explanation/sync-scaling-constraints.md @@ -170,8 +170,8 @@ though they advertise the larger accepted `max_limit`. #### Admission and rate limits (condensed) Native rate limiting varies wildly and is invisible to clients. Our own -embedded relay enforces per-connection per-minute quotas (120 queries, 300 -text messages, 60 event writes); nostream ships per-IP connection-attempt +embedded relay enforces per-connection per-minute quotas (120 queries, 6,000 +WebSocket messages, 60 event writes); nostream ships per-IP connection-attempt and kind-specific event quotas with EWMA decay; khatru and haven offer discrete leaky counters that drain over minutes; nostr-rs-relay, relayer, and rnostr have token-bucket limiters that are disabled by default; chorus diff --git a/docs/reference/relay-limits.md b/docs/reference/relay-limits.md index 4ef9b95..5ea68de 100644 --- a/docs/reference/relay-limits.md +++ b/docs/reference/relay-limits.md @@ -15,7 +15,7 @@ production admission policy. | Event writes per minute | 60 | Fixed | No standard field | | Queries per minute | 120 | Fixed | No standard field | | Authentication events per minute | 30 | Fixed | No standard field | -| Text messages per minute | 300 | Fixed | No standard field | +| WebSocket messages per minute | 6,000 | Fixed | No standard field | | WebSocket message size | 5 MiB | Fixed | `max_message_length` | | Handshake deadline | 10 seconds | Fixed | No standard field | | Subscription-ID length | 250 bytes | Fixed | `max_subid_length` | diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index b83981a..0714b71 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs @@ -34,6 +34,15 @@ use crate::nostr::SharedDatabase; use crate::purgatory::promotion_hooks::NostrPurgatoryPromotionHooks; use crate::sync::rejected_index::RejectedEventsIndex; +/// Connection-wide frame allowance layered above the operation-specific +/// write, query, and authentication quotas. +/// +/// rust-nostr's 300/minute default closed production browser clients during +/// ordinary bursty subscription churn. One hundred frames per second preserves a +/// bounded catch-all for malformed/non-operation traffic while leaving the +/// tighter operation quotas in charge of valid protocol work. +const CLIENT_MESSAGES_PER_MINUTE: u32 = 6_000; + /// NIP-34 Write Policy — admission and routing for GRASP-01 events /// /// Acts as the top-level admission gate and router. Each incoming event is: @@ -1014,7 +1023,7 @@ pub async fn create_relay( }) .queries_per_minute(120) .auth_events_per_minute(30) - .messages_per_minute(300) + .messages_per_minute(CLIENT_MESSAGES_PER_MINUTE) .max_websocket_message_size(5 * 1024 * 1024) .max_event_size(config.relay_max_event_size_bytes) .websocket_handshake_timeout(Duration::from_secs(10)) diff --git a/tests/relay_message_rate.rs b/tests/relay_message_rate.rs new file mode 100644 index 0000000..3694237 --- /dev/null +++ b/tests/relay_message_rate.rs @@ -0,0 +1,84 @@ +//! Connection-wide relay message-rate regression scenarios. +//! +//! rust-nostr 0.45 added a catch-all 300-frame-per-minute bucket on top of +//! its operation-specific quotas. Production browser clients crossed that +//! threshold during legitimate subscription churn and were disconnected. + +mod common; + +use std::time::Duration; + +use common::TestRelay; +use futures_util::{SinkExt, StreamExt}; +use tokio_tungstenite::tungstenite::Message; + +const CLIENT_MESSAGES_PER_MINUTE: usize = 6_000; + +async fn send_close_frames( + stream: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + count: usize, +) { + for index in 0..count { + stream + .send(Message::Text( + format!(r#"["CLOSE","burst-{index}"]"#).into(), + )) + .await + .expect("connection should accept frame"); + } +} + +#[tokio::test] +async fn legitimate_1201_frame_burst_keeps_connection_usable() { + let relay = TestRelay::start().await; + let (mut stream, _) = tokio_tungstenite::connect_async(relay.url()) + .await + .expect("connect to relay"); + + send_close_frames(&mut stream, 1_201).await; + stream + .send(Message::Text(r#"["REQ","proof",{"kinds":[1]}]"#.into())) + .await + .expect("send proof query"); + + let response = tokio::time::timeout(Duration::from_secs(5), async { + while let Some(message) = stream.next().await { + let text = message.expect("valid relay response").into_text().unwrap(); + if text.contains(r#"["EOSE","proof"]"#) { + return; + } + } + panic!("relay closed before answering proof query"); + }) + .await; + + assert!(response.is_ok(), "relay did not answer proof query in time"); + relay.stop().await; +} + +#[tokio::test] +async fn catch_all_limit_still_closes_excessive_frame_burst() { + let relay = TestRelay::start().await; + let (mut stream, _) = tokio_tungstenite::connect_async(relay.url()) + .await + .expect("connect to relay"); + + send_close_frames(&mut stream, CLIENT_MESSAGES_PER_MINUTE + 1).await; + + let closed = tokio::time::timeout(Duration::from_secs(5), async { + while let Some(message) = stream.next().await { + if message.is_err() { + return; + } + } + }) + .await; + + assert!( + closed.is_ok(), + "relay did not close excessive sender in time" + ); + relay.stop().await; +}