radrootsd

JSON-RPC bridge for Radroots event publishing
git clone https://radroots.dev/git/radrootsd.git
Log | Files | Refs | README | LICENSE

commit 941d2b87bea7c6f583e0643dda4e3d50b6386e69
parent 1ed09f90a4fdf16acb411b93c6eb3d54f8830138
Author: triesap <tyson@radroots.org>
Date:   Tue, 23 Jun 2026 22:05:09 +0000

publish-proxy: prove HTTP DNS rejection

- expose the test relay resolver hook to server tests
- add raw HTTP public relay-policy rejection coverage
- resolve the public relay to a forbidden localhost address
- assert rejected unattempted relay evidence and no publish

Diffstat:
Msrc/core/publish_proxy/mod.rs | 6+++---
Msrc/transport/jsonrpc/server.rs | 106++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
2 files changed, 97 insertions(+), 15 deletions(-)

diff --git a/src/core/publish_proxy/mod.rs b/src/core/publish_proxy/mod.rs @@ -109,7 +109,7 @@ impl PublishProxy { } #[cfg(test)] - fn with_relay_resolver(mut self, resolver: Arc<dyn PublishRelayResolver>) -> Self { + pub(crate) fn with_relay_resolver(mut self, resolver: Arc<dyn PublishRelayResolver>) -> Self { self.resolver = resolver; self } @@ -617,10 +617,10 @@ impl PublishRelayResolution { } } -type PublishRelayResolveFuture<'a> = +pub(crate) type PublishRelayResolveFuture<'a> = Pin<Box<dyn Future<Output = Result<Vec<IpAddr>, std::io::Error>> + Send + 'a>>; -trait PublishRelayResolver: Send + Sync { +pub(crate) trait PublishRelayResolver: Send + Sync { fn resolve<'a>(&'a self, url: &'a RadrootsRelayUrl) -> PublishRelayResolveFuture<'a>; } diff --git a/src/transport/jsonrpc/server.rs b/src/transport/jsonrpc/server.rs @@ -81,7 +81,8 @@ mod tests { }; use crate::core::Radrootsd; use crate::core::publish_proxy::{ - PublishJobVisibility, PublishPrincipalInit, generate_bearer_token, hash_bearer_token, + PublishJobVisibility, PublishPrincipalInit, PublishRelayResolveFuture, + PublishRelayResolver, generate_bearer_token, hash_bearer_token, }; use crate::transport::jsonrpc::methods; use crate::transport::jsonrpc::{MethodRegistry, RpcContext}; @@ -94,11 +95,12 @@ mod tests { use radroots_publish_proxy_protocol::PublishRelayPolicy; use radroots_relay_transport::RadrootsMockRelayPublishAdapter; use serde_json::Value; - use std::net::{SocketAddr, TcpListener}; + use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}; use std::sync::Arc; use tokio::io::{AsyncReadExt, AsyncWriteExt}; const RELAY_PRIMARY: &str = "ws://localhost:7777"; + const RELAY_PUBLIC: &str = "wss://relay.example.com"; fn unused_addr() -> SocketAddr { let listener = TcpListener::bind("127.0.0.1:0").expect("bind local addr"); @@ -136,7 +138,10 @@ mod tests { String::from_utf8(bytes).expect("response utf8") } - fn publish_server_state() -> ( + fn publish_server_state_with_config( + publish_proxy_config: PublishProxyConfig, + resolver: Option<Arc<dyn PublishRelayResolver>>, + ) -> ( Radrootsd, String, RadrootsIdentity, @@ -145,11 +150,6 @@ mod tests { let identity = RadrootsIdentity::generate(); let metadata: RadrootsNostrMetadata = serde_json::from_str(r#"{"name":"radrootsd-test"}"#).expect("metadata"); - let publish_proxy_config = PublishProxyConfig { - daemon_default_publish_relays: vec![RELAY_PRIMARY.to_owned()], - relay_url_policy: PublishProxyRelayUrlPolicy::Localhost, - ..PublishProxyConfig::default() - }; let mut state = Radrootsd::new( identity.clone(), metadata, @@ -158,10 +158,11 @@ mod tests { ) .expect("state"); let adapter = RadrootsMockRelayPublishAdapter::new(); - state.publish_proxy = state - .publish_proxy - .clone() - .with_publisher(Arc::new(adapter.clone())); + let mut publish_proxy = state.publish_proxy.clone(); + if let Some(resolver) = resolver { + publish_proxy = publish_proxy.with_relay_resolver(resolver); + } + state.publish_proxy = publish_proxy.with_publisher(Arc::new(adapter.clone())); let token = generate_bearer_token(); state .publish_proxy @@ -180,6 +181,43 @@ mod tests { (state, token, identity, adapter) } + fn publish_server_state() -> ( + Radrootsd, + String, + RadrootsIdentity, + RadrootsMockRelayPublishAdapter, + ) { + publish_server_state_with_config( + PublishProxyConfig { + daemon_default_publish_relays: vec![RELAY_PRIMARY.to_owned()], + relay_url_policy: PublishProxyRelayUrlPolicy::Localhost, + ..PublishProxyConfig::default() + }, + None, + ) + } + + struct StaticPublishRelayResolver { + addresses: Vec<IpAddr>, + } + + impl StaticPublishRelayResolver { + fn forbidden_localhost() -> Self { + Self { + addresses: vec![IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))], + } + } + } + + impl PublishRelayResolver for StaticPublishRelayResolver { + fn resolve<'a>( + &'a self, + _url: &'a radroots_relay_transport::RadrootsRelayUrl, + ) -> PublishRelayResolveFuture<'a> { + Box::pin(async move { Ok(self.addresses.clone()) }) + } + } + async fn start_publish_server( state: Radrootsd, rpc_cfg: RpcConfig, @@ -263,6 +301,50 @@ mod tests { } #[tokio::test] + async fn raw_http_publish_event_rejects_public_relay_forbidden_dns_destination() { + let (state, token, identity, adapter) = publish_server_state_with_config( + PublishProxyConfig { + daemon_default_publish_relays: vec![RELAY_PUBLIC.to_owned()], + relay_url_policy: PublishProxyRelayUrlPolicy::Public, + ..PublishProxyConfig::default() + }, + Some(Arc::new(StaticPublishRelayResolver::forbidden_localhost())), + ); + let event_json = signed_event_json(&identity); + let (addr, handle) = start_publish_server(state, RpcConfig::default()).await; + let publish = format!( + r#"{{ + "jsonrpc":"2.0", + "method":"publish.event", + "params":{{ + "event":{}, + "relays":[], + "relay_policy":"daemon_default_only", + "delivery_policy":{{"mode":"any"}}, + "idempotency_key":"raw-http-public-dns-reject" + }}, + "id":1 + }}"#, + event_json + ); + let publish_response = post_json(addr, publish.as_str(), Some(token.as_str())).await; + handle.stop().expect("stop server"); + + let publish_value = json_response_body(publish_response.as_str()); + let job = &publish_value["result"]["job"]; + assert_eq!(publish_value["result"]["deduplicated"], false); + assert_eq!(job["status"], "rejected"); + assert_eq!(job["last_error"], "no_publish_relays"); + let relays = job["relays"].as_array().expect("relay outcomes"); + assert_eq!(relays.len(), 1); + assert_eq!(relays[0]["relay_url"], RELAY_PUBLIC); + assert_eq!(relays[0]["source"], "daemon_default"); + assert_eq!(relays[0]["outcome_kind"], "relay_url_rejected"); + assert_eq!(relays[0]["attempted"], false); + assert!(adapter.captured_raw_events().is_empty()); + } + + #[tokio::test] async fn publish_notifications_do_not_create_jobs() { let (state, token, identity, _adapter) = publish_server_state(); let store = state.publish_proxy.store.clone();