[codex] Make relay frame limit configurable (#1225)

This commit is contained in:
Wes
2026-06-23 17:18:55 -07:00
committed by GitHub
parent 3e91ce274e
commit 38a9533439
6 changed files with 135 additions and 26 deletions

View File

@@ -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();

View File

@@ -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

View File

@@ -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"
);

View File

@@ -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);
}
}

View File

@@ -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()
}
}

View File

@@ -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())