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 <jsibbison@squareup.com>
This commit is contained in:
Jarrod Sibbison
2026-10-14 08:30:20 +11:00
committed by GitHub
parent 43b88c3a76
commit 8599efb6a3
5 changed files with 97 additions and 16 deletions

View File

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

View File

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

View File

@@ -289,7 +289,7 @@ pub struct Config {
/// skipped — a typo must not silently disable an operator.
pub relay_operator_pubkeys: Vec<String>,
/// 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<String, url::Url>,
/// 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());

View File

@@ -636,7 +636,7 @@ mod tests {
);
}
async fn read_http_request(stream: &mut TcpStream) -> (String, Vec<u8>) {
async fn read_http_request(stream: &mut TcpStream) -> (String, Vec<u8>, 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]

View File

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