From 8599efb6a3cfbd3d4bc4534f6de766b4481c7fca Mon Sep 17 00:00:00 2001 From: Jarrod Sibbison <72240382+jsibbison-square@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:30:20 +1100 Subject: [PATCH] feat(relay): allow HTTP operator listener delivery URLs (#8287) ## Summary Operator listener configuration currently rejects HTTP URLs, preventing delivery to sibling services over internal HTTP connections. Accept HTTP and HTTPS delivery URLs while retaining the existing pubkey, duplicate-route, and URL restrictions. Add configuration coverage for internal hostnames and custom ports, verify signed delivery through the real HTTP client, and include the listener suite in both unit-test runners. Shared NIP-98 signing and push gateway code are unchanged. ### Related issue Related: #7934 refactors operator listener delivery tests. No matching open issue found. ### Testing - `cargo test -p buzz-relay --lib operator_listener`: 13 passed, including signed HTTP delivery and redirect rejection. - `cargo fmt --all -- --check`, shell syntax, Justfile parsing, and diff whitespace checks passed. - `just test`: passed. - `just ci`: stopped at mobile analysis with an existing undefined `buzzDevPushAppProfile` in `mobile/test/features/settings/notification_toggle_test.dart:81`. Workspace and native desktop formatting/Clippy, desktop frontend checks, and web checks passed. The mobile file is unchanged by this PR. Locally verified with a spiked listener client Signed-off-by: jsibbison-square --- .env.example | 2 + Justfile | 2 +- crates/buzz-relay/src/config.rs | 39 ++++++++----- crates/buzz-relay/src/operator_listener.rs | 67 +++++++++++++++++++++- scripts/run-tests.sh | 3 + 5 files changed, 97 insertions(+), 16 deletions(-) diff --git a/.env.example b/.env.example index 8e1fe15939..61f44af72d 100644 --- a/.env.example +++ b/.env.example @@ -150,6 +150,8 @@ BUZZ_PUSH_GATEWAY_DELIVERY_URL=https://push.buzz.xyz/v1/deliveries/apns # Deployment-global operator-listener routes. Each configured Nostr identity # may register target pubkeys and receives mention notifications at its URL. +# URLs may use HTTP or HTTPS. Use HTTP only for operator-controlled internal +# connections: NIP-98 signs requests. # Format: pubkey:deliveryUrl;pubkey:deliveryUrl # BUZZ_OPERATOR_LISTENER_TIMEOUT_MS=5000 # BUZZ_OPERATOR_LISTENERS=<64-char hex pubkey>:https://listener.example/mentions diff --git a/Justfile b/Justfile index d73a9cc547..312ec115b9 100644 --- a/Justfile +++ b/Justfile @@ -531,7 +531,7 @@ test-unit: # are infra-free; config parses env into a struct and never connects. # `^config::` is anchored so it does not also select nip_fi_config. cargo nextest run -p buzz-relay --lib --bin buzz-relay \ - -E 'test(/^api::admin::/) + test(/^push_runtime::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))' + -E 'test(/^api::admin::/) + test(/^push_runtime::/) + test(/^operator_listener::tests::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))' # Note on audio::join::tests scope: the full suite is infra-free (no DB, # no Redis). The infra-free audio/FI regression witnesses — bootstrap # ordering barrier, CommitConfirmed arm, pending-close invisibility, diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index d5c3cf60a2..45c8f5e35d 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -289,7 +289,7 @@ pub struct Config { /// skipped — a typo must not silently disable an operator. pub relay_operator_pubkeys: Vec, - /// Configured operator-listener identities and their HTTPS delivery URLs. + /// Configured operator-listener identities and their HTTP(S) delivery URLs. pub operator_listener_delivery_urls: HashMap, /// Allow NIP-OA owner attestation for relay membership. @@ -532,7 +532,7 @@ fn parse_operator_listener_delivery_urls( "BUZZ_OPERATOR_LISTENERS endpoint is not a valid URL: {e}" )) })?; - if url.scheme() != "https" + if !matches!(url.scheme(), "http" | "https") || url.host().is_none() || !url.username().is_empty() || url.password().is_some() @@ -540,7 +540,7 @@ fn parse_operator_listener_delivery_urls( || url.fragment().is_some() { return Err(ConfigError::InvalidValue( - "BUZZ_OPERATOR_LISTENERS endpoints must be HTTPS URLs without credentials, query, or fragment" + "BUZZ_OPERATOR_LISTENERS endpoints must be HTTP(S) URLs without credentials, query, or fragment" .to_string(), )); } @@ -2686,9 +2686,14 @@ mod tests { fn operator_listener_routes_reject_unsafe_or_duplicate_entries() { let key = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; for raw in [ - format!("{key}:http://listener.example/mentions"), + format!("{key}:ftp://listener.example/mentions"), + format!("{key}:http://user:pass@listener:8080/mentions"), + format!("{key}:http://listener:8080/mentions?token=secret"), + format!("{key}:http://listener:8080/mentions#fragment"), format!("{key}:https://user:pass@listener.example/mentions"), format!("{key}:https://listener.example/mentions?token=secret"), + format!("{key}:https://listener.example/mentions#fragment"), + format!("{key}:http://listener:8080/mentions;{key}:https://other.example"), format!("{key}:https://listener.example/mentions;{key}:https://other.example"), ] { assert!( @@ -2740,15 +2745,23 @@ mod tests { fn valid_and_absent_operator_listener_config_preserve_routes() { let _guards = env_guards(); let key = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - let raw = format!("{key}:https://listener.example/mentions"); - let config = config_with_operator_listeners(Some(&raw)).expect("valid listener config"); - assert_eq!( - config - .operator_listener_delivery_urls - .get(key) - .map(url::Url::as_str), - Some("https://listener.example/mentions") - ); + for endpoint in [ + "https://listener.example/mentions", + "http://operator-listener/mentions", + "http://operator-listener:8080/mentions", + "http://127.0.0.1:8080/mentions", + "http://[::1]:8080/mentions", + ] { + let raw = format!("{key}:{endpoint}"); + let config = config_with_operator_listeners(Some(&raw)).expect("valid listener config"); + assert_eq!( + config + .operator_listener_delivery_urls + .get(key) + .map(url::Url::as_str), + Some(endpoint) + ); + } let config = config_with_operator_listeners(None).expect("absent listener config"); assert!(config.operator_listener_delivery_urls.is_empty()); diff --git a/crates/buzz-relay/src/operator_listener.rs b/crates/buzz-relay/src/operator_listener.rs index a21292d2ae..8545497e38 100644 --- a/crates/buzz-relay/src/operator_listener.rs +++ b/crates/buzz-relay/src/operator_listener.rs @@ -636,7 +636,7 @@ mod tests { ); } - async fn read_http_request(stream: &mut TcpStream) -> (String, Vec) { + async fn read_http_request(stream: &mut TcpStream) -> (String, Vec, String) { let mut reader = BufReader::new(stream); let mut request_line = String::new(); reader @@ -645,6 +645,7 @@ mod tests { .expect("read request line"); let mut content_length = 0; + let mut authorization = String::new(); loop { let mut line = String::new(); reader.read_line(&mut line).await.expect("read header"); @@ -654,6 +655,8 @@ mod tests { if let Some((name, value)) = line.split_once(':') { if name.eq_ignore_ascii_case("content-length") { content_length = value.trim().parse().expect("valid content length"); + } else if name.eq_ignore_ascii_case("authorization") { + authorization = value.trim().to_owned(); } } } @@ -663,7 +666,67 @@ mod tests { .read_exact(&mut body) .await .expect("read request body"); - (request_line, body) + (request_line, body, authorization) + } + + #[tokio::test] + async fn successful_http_delivery_is_signed_and_completes_claim() { + use nostr::nips::nip98::{verify_auth_header, HttpMethod}; + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("HTTP listener"); + let endpoint = url::Url::parse(&format!( + "http://{}/mentions", + listener.local_addr().expect("listener address") + )) + .expect("HTTP endpoint"); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept delivery"); + let request = read_http_request(&mut stream).await; + stream + .write_all(b"HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n") + .await + .expect("write success response"); + request + }); + let (_, relay_keypair, delivery) = delivery_fixture(1); + let routes = HashMap::from([(hex::encode(&delivery.listener_pubkey), endpoint.clone())]); + let store = MockStore::default(); + let http = build_delivery_http_client(Duration::from_secs(2)).expect("delivery client"); + let transport = ReqwestDeliveryTransport { http: &http }; + + deliver_one( + &routes, + &relay_keypair, + &store, + &transport, + delivery.clone(), + ) + .await; + + let (request_line, body, authorization) = + tokio::time::timeout(Duration::from_secs(2), server) + .await + .expect("HTTP server completed") + .expect("HTTP server task"); + assert!(request_line.starts_with("POST /mentions HTTP/1.1")); + let signer = verify_auth_header( + &authorization, + &endpoint, + HttpMethod::POST, + nostr::Timestamp::now(), + Some(&body), + ) + .expect("valid signature binding the HTTP endpoint and body"); + assert_eq!(signer, relay_keypair.public_key()); + let payload: serde_json::Value = serde_json::from_slice(&body).expect("notification JSON"); + assert_eq!(payload["pubkey"], hex::encode(&delivery.target_pubkey)); + assert_eq!(payload["event_id"], hex::encode(&delivery.event_id)); + assert_eq!( + store.calls(), + [StoreCall::Complete(delivery.id, delivery.claim_id)] + ); } #[tokio::test] diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 9b8d613647..efdd511dba 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -178,6 +178,9 @@ run_unit_tests() { run_test_step "buzz-relay push runtime tests" \ cargo test -p buzz-relay --lib push_runtime:: -- --nocapture + run_test_step "buzz-relay operator listener tests" \ + cargo test -p buzz-relay --lib operator_listener::tests:: -- --nocapture + run_test_step "buzz-relay channel authorization tests" \ cargo test -p buzz-relay --lib handlers::channel_authz:: -- --nocapture