mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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
|
||||
|
||||
2
Justfile
2
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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user