mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
[codex] Make relay frame limit configurable (#1225)
This commit is contained in:
@@ -5,6 +5,12 @@ use std::net::SocketAddr;
|
||||
use thiserror::Error;
|
||||
use tracing::warn;
|
||||
|
||||
/// Default maximum inbound WebSocket frame size in bytes.
|
||||
///
|
||||
/// Must comfortably exceed accepted event content sizes after Nostr JSON and
|
||||
/// NIP-44 encryption overhead.
|
||||
pub const DEFAULT_MAX_FRAME_BYTES: usize = 512 * 1024;
|
||||
|
||||
/// Errors that can occur while loading relay configuration.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ConfigError {
|
||||
@@ -37,6 +43,8 @@ pub struct Config {
|
||||
pub max_concurrent_handlers: usize,
|
||||
/// Per-connection outbound message buffer size (number of messages).
|
||||
pub send_buffer_size: usize,
|
||||
/// Maximum inbound WebSocket frame size in bytes.
|
||||
pub max_frame_bytes: usize,
|
||||
/// Authentication provider configuration.
|
||||
pub auth: buzz_auth::AuthConfig,
|
||||
/// Whether REST API requests must present a valid token. Independent of
|
||||
@@ -159,6 +167,12 @@ impl Config {
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(1_000);
|
||||
|
||||
let max_frame_bytes = std::env::var("BUZZ_MAX_FRAME_BYTES")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<usize>().ok())
|
||||
.filter(|&v| v > 0)
|
||||
.unwrap_or(DEFAULT_MAX_FRAME_BYTES);
|
||||
|
||||
let require_auth_token = std::env::var("BUZZ_REQUIRE_AUTH_TOKEN")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
@@ -360,6 +374,7 @@ impl Config {
|
||||
max_connections,
|
||||
max_concurrent_handlers,
|
||||
send_buffer_size,
|
||||
max_frame_bytes,
|
||||
auth,
|
||||
require_auth_token,
|
||||
cors_origins,
|
||||
@@ -401,6 +416,7 @@ mod tests {
|
||||
assert!(!config.redis_url.is_empty());
|
||||
assert!(config.max_connections > 0);
|
||||
assert!(config.send_buffer_size > 0);
|
||||
assert_eq!(config.max_frame_bytes, DEFAULT_MAX_FRAME_BYTES);
|
||||
assert!(
|
||||
!config.pubkey_allowlist_enabled,
|
||||
"pubkey_allowlist_enabled should default to false"
|
||||
@@ -428,6 +444,15 @@ mod tests {
|
||||
assert!(matches!(result, Err(ConfigError::InvalidBindAddr(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_frame_bytes_can_be_configured() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
std::env::set_var("BUZZ_MAX_FRAME_BYTES", "262144");
|
||||
let config = Config::from_env().expect("config");
|
||||
std::env::remove_var("BUZZ_MAX_FRAME_BYTES");
|
||||
assert_eq!(config.max_frame_bytes, 262_144);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_domain_auto_derived_from_relay_url() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
|
||||
@@ -283,9 +283,6 @@ async fn heartbeat_loop(
|
||||
}
|
||||
}
|
||||
|
||||
/// NIP-11 advertised max_message_length. Frames exceeding this are rejected.
|
||||
pub const MAX_FRAME_BYTES: usize = 65536;
|
||||
|
||||
async fn recv_loop(
|
||||
mut ws_recv: futures_util::stream::SplitStream<WebSocket>,
|
||||
conn: Arc<ConnectionState>,
|
||||
@@ -298,16 +295,28 @@ async fn recv_loop(
|
||||
msg = ws_recv.next() => {
|
||||
match msg {
|
||||
Some(Ok(WsMessage::Text(text))) => {
|
||||
if text.len() > MAX_FRAME_BYTES {
|
||||
warn!(conn_id = %conn.conn_id, bytes = text.len(), "frame too large — disconnecting");
|
||||
let max_frame_bytes = state.config.max_frame_bytes;
|
||||
if text.len() > max_frame_bytes {
|
||||
warn!(
|
||||
conn_id = %conn.conn_id,
|
||||
bytes = text.len(),
|
||||
max_frame_bytes,
|
||||
"frame too large — disconnecting"
|
||||
);
|
||||
break;
|
||||
}
|
||||
trace!(len = text.len(), "frame received");
|
||||
handle_text_message(text.to_string(), Arc::clone(&conn), Arc::clone(&state)).await;
|
||||
}
|
||||
Some(Ok(WsMessage::Binary(bytes))) => {
|
||||
if bytes.len() > MAX_FRAME_BYTES {
|
||||
warn!(conn_id = %conn.conn_id, bytes = bytes.len(), "binary frame too large — disconnecting");
|
||||
let max_frame_bytes = state.config.max_frame_bytes;
|
||||
if bytes.len() > max_frame_bytes {
|
||||
warn!(
|
||||
conn_id = %conn.conn_id,
|
||||
bytes = bytes.len(),
|
||||
max_frame_bytes,
|
||||
"binary frame too large — disconnecting"
|
||||
);
|
||||
break;
|
||||
}
|
||||
// Binary frames: attempt UTF-8 decode and treat as text. Some clients
|
||||
|
||||
@@ -44,6 +44,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
relay_url = %config.relay_url,
|
||||
health_port = config.health_port,
|
||||
metrics_port = config.metrics_port,
|
||||
max_frame_bytes = config.max_frame_bytes,
|
||||
"Config loaded"
|
||||
);
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::connection::MAX_FRAME_BYTES;
|
||||
#[cfg(test)]
|
||||
use crate::config::DEFAULT_MAX_FRAME_BYTES;
|
||||
|
||||
/// NIPs unconditionally supported by this relay, advertised in the NIP-11
|
||||
/// document. Kept as a module-level constant so tests can verify it without
|
||||
@@ -82,14 +83,14 @@ pub struct RelayLimitation {
|
||||
/// unconditionally reject connections that are not in
|
||||
/// `AuthState::Authenticated`. This is independent of the REST API token
|
||||
/// toggle (`config.require_auth_token`).
|
||||
fn relay_limitation() -> RelayLimitation {
|
||||
fn relay_limitation(max_message_length: usize) -> RelayLimitation {
|
||||
let max_not_before_delta: u64 = std::env::var("SPROUT_MAX_NOT_BEFORE_DELTA")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(31_536_000); // 1 year default
|
||||
|
||||
RelayLimitation {
|
||||
max_message_length: Some(MAX_FRAME_BYTES as u64),
|
||||
max_message_length: Some(max_message_length as u64),
|
||||
max_subscriptions: Some(1024),
|
||||
max_filters: Some(10),
|
||||
max_limit: Some(10_000),
|
||||
@@ -118,7 +119,11 @@ impl RelayInfo {
|
||||
/// gates on NIP-43 events — i.e. has a stable key AND enforces
|
||||
/// membership. NIP-43 events are verified against `self`, so it is a
|
||||
/// programmer error to advertise NIP-43 without a `relay_self`.
|
||||
pub fn build(relay_self: Option<&str>, advertise_nip43: bool) -> Self {
|
||||
pub fn build(
|
||||
relay_self: Option<&str>,
|
||||
advertise_nip43: bool,
|
||||
max_message_length: usize,
|
||||
) -> Self {
|
||||
debug_assert!(
|
||||
!advertise_nip43 || relay_self.is_some(),
|
||||
"advertise_nip43=true requires relay_self=Some — NIP-43 events are verified against `self`"
|
||||
@@ -138,7 +143,7 @@ impl RelayInfo {
|
||||
supported_extensions: Some(vec!["nip-er".to_string()]),
|
||||
software: "https://github.com/block/buzz".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
limitation: Some(relay_limitation()),
|
||||
limitation: Some(relay_limitation(max_message_length)),
|
||||
relay_self: relay_self.map(|s| s.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -149,7 +154,11 @@ pub async fn relay_info_handler(
|
||||
axum::extract::State(state): axum::extract::State<std::sync::Arc<crate::state::AppState>>,
|
||||
) -> axum::response::Json<RelayInfo> {
|
||||
let (relay_self, advertise_nip43) = nip11_facts(&state);
|
||||
axum::response::Json(RelayInfo::build(relay_self.as_deref(), advertise_nip43))
|
||||
axum::response::Json(RelayInfo::build(
|
||||
relay_self.as_deref(),
|
||||
advertise_nip43,
|
||||
state.config.max_frame_bytes,
|
||||
))
|
||||
}
|
||||
|
||||
/// Derives the two NIP-11 facts that depend on runtime config:
|
||||
@@ -199,7 +208,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn build_advertises_buzz_repository_url() {
|
||||
let info = RelayInfo::build(None, false);
|
||||
let info = RelayInfo::build(None, false, DEFAULT_MAX_FRAME_BYTES);
|
||||
assert_eq!(info.software, "https://github.com/block/buzz");
|
||||
}
|
||||
|
||||
@@ -208,7 +217,14 @@ mod tests {
|
||||
// REQ, EVENT, and COUNT all unconditionally require
|
||||
// `AuthState::Authenticated` (see `crates/buzz-relay/src/handlers/`),
|
||||
// so the NIP-11 doc must advertise it.
|
||||
assert!(relay_limitation().auth_required);
|
||||
assert!(relay_limitation(DEFAULT_MAX_FRAME_BYTES).auth_required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_message_length_uses_configured_frame_limit() {
|
||||
let info = RelayInfo::build(None, false, 262_144);
|
||||
let limitation = info.limitation.expect("limitation");
|
||||
assert_eq!(limitation.max_message_length, Some(262_144));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -237,7 +253,7 @@ mod tests {
|
||||
/// Open relay, ephemeral key — both `self` and NIP-43 are absent.
|
||||
#[test]
|
||||
fn build_open_relay_ephemeral_key_omits_self_and_nip43() {
|
||||
let info = RelayInfo::build(None, false);
|
||||
let info = RelayInfo::build(None, false, DEFAULT_MAX_FRAME_BYTES);
|
||||
assert!(info.relay_self.is_none());
|
||||
assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
|
||||
}
|
||||
@@ -250,7 +266,7 @@ mod tests {
|
||||
#[test]
|
||||
fn build_open_relay_stable_key_advertises_self_but_not_nip43() {
|
||||
let pk = "0000000000000000000000000000000000000000000000000000000000000001";
|
||||
let info = RelayInfo::build(Some(pk), false);
|
||||
let info = RelayInfo::build(Some(pk), false, DEFAULT_MAX_FRAME_BYTES);
|
||||
assert_eq!(info.relay_self.as_deref(), Some(pk));
|
||||
assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
|
||||
}
|
||||
@@ -259,7 +275,7 @@ mod tests {
|
||||
#[test]
|
||||
fn build_membership_relay_advertises_self_and_nip43() {
|
||||
let pk = "0000000000000000000000000000000000000000000000000000000000000001";
|
||||
let info = RelayInfo::build(Some(pk), true);
|
||||
let info = RelayInfo::build(Some(pk), true, DEFAULT_MAX_FRAME_BYTES);
|
||||
assert_eq!(info.relay_self.as_deref(), Some(pk));
|
||||
assert!(info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
|
||||
}
|
||||
@@ -270,6 +286,6 @@ mod tests {
|
||||
#[test]
|
||||
#[should_panic(expected = "advertise_nip43=true requires relay_self=Some")]
|
||||
fn build_nip43_without_self_panics_in_debug() {
|
||||
let _ = RelayInfo::build(None, true);
|
||||
let _ = RelayInfo::build(None, true, DEFAULT_MAX_FRAME_BYTES);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +156,11 @@ async fn nip11_or_ws_handler(
|
||||
let (relay_self, advertise_nip43) = nip11_facts(&state);
|
||||
|
||||
if accept.contains("application/nostr+json") {
|
||||
let info = RelayInfo::build(relay_self.as_deref(), advertise_nip43);
|
||||
let info = RelayInfo::build(
|
||||
relay_self.as_deref(),
|
||||
advertise_nip43,
|
||||
state.config.max_frame_bytes,
|
||||
);
|
||||
return Json(info).into_response();
|
||||
}
|
||||
|
||||
@@ -175,7 +179,11 @@ async fn nip11_or_ws_handler(
|
||||
}
|
||||
}
|
||||
// Not a WS request and not asking for nostr+json — serve NIP-11 as fallback.
|
||||
let info = RelayInfo::build(relay_self.as_deref(), advertise_nip43);
|
||||
let info = RelayInfo::build(
|
||||
relay_self.as_deref(),
|
||||
advertise_nip43,
|
||||
state.config.max_frame_bytes,
|
||||
);
|
||||
Json(info).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ fn relay_http_url() -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Create a real channel via a signed kind:9007 event submitted to POST /api/events.
|
||||
/// Create a real channel via a signed kind:9007 event submitted to POST /events.
|
||||
async fn create_test_channel(keys: &Keys) -> String {
|
||||
let client = reqwest::Client::new();
|
||||
let pubkey_hex = keys.public_key().to_hex();
|
||||
@@ -57,7 +57,7 @@ async fn create_test_channel(keys: &Keys) -> String {
|
||||
.unwrap();
|
||||
|
||||
let resp = client
|
||||
.post(format!("{}/api/events", relay_http_url()))
|
||||
.post(format!("{}/events", relay_http_url()))
|
||||
.header("X-Pubkey", &pubkey_hex)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(serde_json::to_string(&event).unwrap())
|
||||
@@ -151,6 +151,56 @@ async fn test_send_event_and_receive_via_subscription() {
|
||||
client_b.disconnect().await.expect("disconnect B");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_large_event_frame_below_configured_limit_is_accepted() {
|
||||
let url = relay_url();
|
||||
let kind: u16 = 9;
|
||||
|
||||
let keys = Keys::generate();
|
||||
let channel = create_test_channel(&keys).await;
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
|
||||
let h_tag = Tag::parse(["h", channel.as_str()]).expect("h tag");
|
||||
let content = "x".repeat(70_000);
|
||||
let event = EventBuilder::new(Kind::Custom(kind), content)
|
||||
.tags([h_tag])
|
||||
.sign_with_keys(&keys)
|
||||
.expect("sign large event");
|
||||
|
||||
let frame = serde_json::to_string(&serde_json::json!(["EVENT", &event])).expect("frame JSON");
|
||||
assert!(
|
||||
frame.len() > 65_536,
|
||||
"test frame must exceed the old 64 KiB cap; got {} bytes",
|
||||
frame.len()
|
||||
);
|
||||
assert!(
|
||||
frame.len() < 512 * 1024,
|
||||
"test frame should fit under the new default cap; got {} bytes",
|
||||
frame.len()
|
||||
);
|
||||
|
||||
let ok = client.send_event(event).await.expect("send large event");
|
||||
assert!(ok.accepted, "large event rejected: {}", ok.message);
|
||||
|
||||
let ok_after = client
|
||||
.send_text_message(
|
||||
&keys,
|
||||
&channel,
|
||||
"socket still usable after large frame",
|
||||
kind,
|
||||
)
|
||||
.await
|
||||
.expect("send follow-up event");
|
||||
assert!(
|
||||
ok_after.accepted,
|
||||
"follow-up event rejected: {}",
|
||||
ok_after.message
|
||||
);
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_subscription_filters_by_kind() {
|
||||
@@ -1347,7 +1397,7 @@ async fn test_membership_notification_emitted_on_add() {
|
||||
.sign_with_keys(&owner_keys)
|
||||
.unwrap();
|
||||
let resp = http_client
|
||||
.post(format!("{}/api/events", relay_http_url()))
|
||||
.post(format!("{}/events", relay_http_url()))
|
||||
.header("X-Pubkey", &owner_keys.public_key().to_hex())
|
||||
.header("Content-Type", "application/json")
|
||||
.body(serde_json::to_string(&add_event).unwrap())
|
||||
@@ -1619,7 +1669,7 @@ async fn test_membership_notification_emitted_on_remove() {
|
||||
.sign_with_keys(&owner_keys)
|
||||
.unwrap();
|
||||
let resp = http_client
|
||||
.post(format!("{}/api/events", relay_http_url()))
|
||||
.post(format!("{}/events", relay_http_url()))
|
||||
.header("X-Pubkey", &owner_pubkey_hex)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(serde_json::to_string(&add_event).unwrap())
|
||||
@@ -1658,7 +1708,7 @@ async fn test_membership_notification_emitted_on_remove() {
|
||||
.sign_with_keys(&owner_keys)
|
||||
.unwrap();
|
||||
let resp = http_client
|
||||
.post(format!("{}/api/events", relay_http_url()))
|
||||
.post(format!("{}/events", relay_http_url()))
|
||||
.header("X-Pubkey", &owner_pubkey_hex)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(serde_json::to_string(&remove_event).unwrap())
|
||||
|
||||
Reference in New Issue
Block a user