lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 4d03f12208b77e506bf4b655d5e0a4f8bdfbf264
parent f86dede27f1905cb7135085957b61ba1825c069b
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 06:18:46 +0000

transport-nostr: define relay configuration and URL policy

- validate and canonicalize relay URLs under explicit public, local, or trusted-network policy
- revalidate resolved addresses and convert relay identities through the generic Nostr target model
- bound relay counts, connection concurrency, and connection, request, and status deadlines
- verify package tests, strict Clippy, architecture, dependency, API, and source-maintenance gates

Diffstat:
MCargo.lock | 1+
Mcrates/transport_nostr/Cargo.toml | 1+
Mcrates/transport_nostr/src/client.rs | 153++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/transport_nostr/src/error.rs | 64+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/transport_nostr/src/relay.rs | 220+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
5 files changed, 426 insertions(+), 13 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -5128,6 +5128,7 @@ dependencies = [ "radroots_nostr", "radroots_protocol", "radroots_transport", + "url", ] [[package]] diff --git a/crates/transport_nostr/Cargo.toml b/crates/transport_nostr/Cargo.toml @@ -19,6 +19,7 @@ radroots_event_codec = { workspace = true, default-features = false } radroots_nostr = { workspace = true, default-features = false } radroots_protocol = { workspace = true, default-features = false } radroots_transport = { workspace = true, default-features = false } +url = { workspace = true } [lints] workspace = true diff --git a/crates/transport_nostr/src/client.rs b/crates/transport_nostr/src/client.rs @@ -1,17 +1,132 @@ //! Concrete Nostr transport composition. -/// Configuration for a concrete Nostr transport. -#[derive(Clone, Debug, Default, Eq, PartialEq)] -pub struct Config; +use crate::{Error, RelayUrl, RelayUrlPolicy}; +use std::collections::BTreeSet; + +/// Maximum relay targets accepted by one transport instance. +pub(crate) const MAX_RELAYS: usize = 64; +const MAX_TIMEOUT_MS: u64 = 120_000; +const MAX_CONNECTIONS: usize = 64; + +/// Validated configuration for a concrete Nostr transport. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Config { + relays: Vec<RelayUrl>, + relay_url_policy: RelayUrlPolicy, + connect_timeout_ms: u64, + request_timeout_ms: u64, + status_timeout_ms: u64, + max_connections: usize, +} + +impl Config { + /// Builds configuration from explicit relay and network policy inputs. + pub fn new<I, S>(relay_url_policy: RelayUrlPolicy, relays: I) -> Result<Self, Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut canonical = Vec::new(); + let mut seen = BTreeSet::new(); + for relay in relays { + let relay = RelayUrl::parse(relay, relay_url_policy)?; + if !seen.insert(relay.clone()) { + return Err(Error::DuplicateRelayUrl { + url: relay.to_string(), + }); + } + canonical.push(relay); + if canonical.len() > MAX_RELAYS { + return Err(Error::TooManyRelays { + max: MAX_RELAYS, + actual: canonical.len(), + }); + } + } + if canonical.is_empty() { + return Err(Error::EmptyRelaySet); + } + Ok(Self { + relays: canonical, + relay_url_policy, + connect_timeout_ms: 10_000, + request_timeout_ms: 30_000, + status_timeout_ms: 5_000, + max_connections: 8, + }) + } + + /// Sets explicit bounded connection, request, and status timeouts. + pub fn with_timeouts( + mut self, + connect_timeout_ms: u64, + request_timeout_ms: u64, + status_timeout_ms: u64, + ) -> Result<Self, Error> { + validate_timeout("connect", connect_timeout_ms)?; + validate_timeout("request", request_timeout_ms)?; + validate_timeout("status", status_timeout_ms)?; + self.connect_timeout_ms = connect_timeout_ms; + self.request_timeout_ms = request_timeout_ms; + self.status_timeout_ms = status_timeout_ms; + Ok(self) + } + + /// Sets the maximum simultaneous relay connections for one operation. + pub fn with_max_connections(mut self, value: usize) -> Result<Self, Error> { + if value == 0 || value > MAX_CONNECTIONS || value > self.relays.len() { + return Err(Error::InvalidConnectionLimit { value }); + } + self.max_connections = value; + Ok(self) + } + + /// Returns relays in caller-specified order. + pub fn relays(&self) -> &[RelayUrl] { + self.relays.as_slice() + } + + /// Returns the network policy that must also be applied after DNS resolution. + pub const fn relay_url_policy(&self) -> RelayUrlPolicy { + self.relay_url_policy + } + + /// Returns the connection establishment deadline in milliseconds. + pub const fn connect_timeout_ms(&self) -> u64 { + self.connect_timeout_ms + } + + /// Returns the bounded request deadline in milliseconds. + pub const fn request_timeout_ms(&self) -> u64 { + self.request_timeout_ms + } + + /// Returns the passive status observation deadline in milliseconds. + pub const fn status_timeout_ms(&self) -> u64 { + self.status_timeout_ms + } + + /// Returns the maximum simultaneous relay connections. + pub const fn max_connections(&self) -> usize { + self.max_connections + } +} + +fn validate_timeout(field: &'static str, value_ms: u64) -> Result<(), Error> { + if value_ms == 0 || value_ms > MAX_TIMEOUT_MS { + return Err(Error::InvalidTimeout { field, value_ms }); + } + Ok(()) +} /// Concrete Nostr implementation of the transport source and sink SPIs. -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug)] pub struct NostrTransport { config: Config, } impl NostrTransport { - /// Creates an inert transport from explicit configuration. + /// Creates an inert transport from validated explicit configuration. pub const fn new(config: Config) -> Self { Self { config } } @@ -21,3 +136,31 @@ impl NostrTransport { &self.config } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_rejects_empty_duplicate_and_excessive_relay_sets() { + assert!(Config::new(RelayUrlPolicy::Public, Vec::<String>::new()).is_err()); + assert!( + Config::new( + RelayUrlPolicy::Public, + ["wss://relay.example.com", "wss://RELAY.EXAMPLE.COM:443/"], + ) + .is_err() + ); + let relays = (0..=MAX_RELAYS).map(|index| format!("wss://r{index}.example.com")); + assert!(Config::new(RelayUrlPolicy::Public, relays).is_err()); + } + + #[test] + fn config_rejects_unbounded_limits() { + let config = + Config::new(RelayUrlPolicy::Public, ["wss://relay.example.com"]).expect("config"); + assert!(config.clone().with_timeouts(0, 1, 1).is_err()); + assert!(config.clone().with_timeouts(1, 120_001, 1).is_err()); + assert!(config.with_max_connections(2).is_err()); + } +} diff --git a/crates/transport_nostr/src/error.rs b/crates/transport_nostr/src/error.rs @@ -6,14 +6,72 @@ use core::fmt; #[derive(Clone, Debug, Eq, PartialEq)] #[non_exhaustive] pub enum Error { - /// The adapter has not yet been configured for an operation. - NotConfigured, + /// No relay was configured. + EmptyRelaySet, + /// The configured relay count exceeds the adapter bound. + TooManyRelays { max: usize, actual: usize }, + /// A canonical relay URL occurs more than once. + DuplicateRelayUrl { url: String }, + /// The URL is not a valid canonical Nostr relay target. + InvalidRelayUrl { url: String, reason: String }, + /// The URL scheme is not permitted by the selected policy. + RelaySchemeDenied { url: String }, + /// The URL destination is not permitted by the selected policy. + RelayDestinationDenied { url: String, reason: &'static str }, + /// DNS resolution produced no addresses. + EmptyResolution { url: String }, + /// A resolved address violates the selected policy. + ResolvedAddressDenied { url: String, address: String }, + /// A connection or request timeout is outside its governed bounds. + InvalidTimeout { field: &'static str, value_ms: u64 }, + /// The per-operation connection limit is outside its governed bounds. + InvalidConnectionLimit { value: usize }, + /// A transport-neutral target is not a Nostr target. + UnexpectedTransport { actual: String }, + /// The generic transport target rejected the relay URL. + Target(String), } impl fmt::Display for Error { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::NotConfigured => formatter.write_str("Nostr transport is not configured"), + Self::EmptyRelaySet => formatter.write_str("relay set must not be empty"), + Self::TooManyRelays { max, actual } => { + write!(formatter, "relay count {actual} exceeds maximum {max}") + } + Self::DuplicateRelayUrl { url } => write!(formatter, "duplicate relay URL `{url}`"), + Self::InvalidRelayUrl { url, reason } => { + write!(formatter, "invalid relay URL `{url}`: {reason}") + } + Self::RelaySchemeDenied { url } => { + write!(formatter, "relay URL scheme is denied by policy: `{url}`") + } + Self::RelayDestinationDenied { url, reason } => { + write!( + formatter, + "relay URL destination is denied: `{url}` ({reason})" + ) + } + Self::EmptyResolution { url } => { + write!(formatter, "relay URL resolved to no addresses: `{url}`") + } + Self::ResolvedAddressDenied { url, address } => write!( + formatter, + "relay URL `{url}` resolved to denied address `{address}`" + ), + Self::InvalidTimeout { field, value_ms } => { + write!(formatter, "invalid {field} timeout: {value_ms}ms") + } + Self::InvalidConnectionLimit { value } => { + write!(formatter, "invalid connection limit: {value}") + } + Self::UnexpectedTransport { actual } => { + write!( + formatter, + "expected Nostr transport target, received `{actual}`" + ) + } + Self::Target(reason) => write!(formatter, "transport target error: {reason}"), } } } diff --git a/crates/transport_nostr/src/relay.rs b/crates/transport_nostr/src/relay.rs @@ -1,22 +1,232 @@ //! Nostr relay identifiers and network policy. -/// Validated Nostr relay URL. +use crate::Error; +use core::fmt; +use radroots_transport::{Target, TransportId}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use url::Url; + +/// Validated canonical Nostr relay URL. #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct RelayUrl(String); impl RelayUrl { - /// Returns the validated URL representation. + /// Parses, canonicalizes, and applies an explicit destination policy. + pub fn parse(value: impl AsRef<str>, policy: RelayUrlPolicy) -> Result<Self, Error> { + let original = value.as_ref(); + let target = Target::nostr_relay(original).map_err(|error| Error::InvalidRelayUrl { + url: original.to_owned(), + reason: error.to_string(), + })?; + let canonical = target.uri().as_str(); + let parsed = Url::parse(canonical).map_err(|error| Error::InvalidRelayUrl { + url: original.to_owned(), + reason: error.to_string(), + })?; + let host = parsed.host_str().ok_or_else(|| Error::InvalidRelayUrl { + url: original.to_owned(), + reason: "host is required".to_owned(), + })?; + validate_scheme(canonical, parsed.scheme(), policy)?; + validate_host(canonical, host, policy)?; + Ok(Self(canonical.to_owned())) + } + + /// Converts a validated relay URL into the generic Nostr target model. + pub fn to_target(&self) -> Result<Target, Error> { + Target::nostr_relay(self.as_str()).map_err(|error| Error::Target(error.to_string())) + } + + /// Validates and converts a generic target under the selected policy. + pub fn from_target(target: &Target, policy: RelayUrlPolicy) -> Result<Self, Error> { + if *target.kind() != TransportId::NOSTR { + return Err(Error::UnexpectedTransport { + actual: target.kind().to_string(), + }); + } + Self::parse(target.uri().as_str(), policy) + } + + /// Revalidates every address returned by DNS before a connection is made. + pub fn validate_resolved_addresses( + &self, + policy: RelayUrlPolicy, + addresses: impl IntoIterator<Item = IpAddr>, + ) -> Result<(), Error> { + let mut resolved = false; + for address in addresses { + resolved = true; + if !policy.accepts_address(address) { + return Err(Error::ResolvedAddressDenied { + url: self.0.clone(), + address: address.to_string(), + }); + } + } + if !resolved { + return Err(Error::EmptyResolution { + url: self.0.clone(), + }); + } + Ok(()) + } + + /// Returns the canonical relay URL. pub fn as_str(&self) -> &str { self.0.as_str() } } -/// Network destinations accepted for a relay URL. +impl fmt::Display for RelayUrl { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// Destination class authorized for relay connections. #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[non_exhaustive] pub enum RelayUrlPolicy { - /// Public TLS relay endpoints only. + /// TLS-only public Internet endpoints; resolved addresses must be global. Public, - /// Exact loopback relay endpoints only. + /// Exact loopback endpoints; plaintext WebSocket is allowed. Local, + /// TLS-only endpoints on explicitly trusted private or public networks. + PrivateNetwork, +} + +impl RelayUrlPolicy { + fn accepts_address(self, address: IpAddr) -> bool { + match self { + Self::Public => public_address(address), + Self::Local => address.is_loopback(), + Self::PrivateNetwork => trusted_network_address(address), + } + } +} + +fn validate_scheme(url: &str, scheme: &str, policy: RelayUrlPolicy) -> Result<(), Error> { + if scheme == "wss" || scheme == "ws" && matches!(policy, RelayUrlPolicy::Local) { + return Ok(()); + } + Err(Error::RelaySchemeDenied { + url: url.to_owned(), + }) +} + +fn validate_host(url: &str, host: &str, policy: RelayUrlPolicy) -> Result<(), Error> { + let address = host.parse::<IpAddr>().ok(); + let accepted = match (policy, address) { + (RelayUrlPolicy::Public, Some(address)) => public_address(address), + (RelayUrlPolicy::Public, None) => public_hostname(host), + (RelayUrlPolicy::Local, Some(address)) => address.is_loopback(), + (RelayUrlPolicy::Local, None) => host.eq_ignore_ascii_case("localhost"), + (RelayUrlPolicy::PrivateNetwork, Some(address)) => trusted_network_address(address), + (RelayUrlPolicy::PrivateNetwork, None) => !host.eq_ignore_ascii_case("localhost"), + }; + if accepted { + Ok(()) + } else { + Err(Error::RelayDestinationDenied { + url: url.to_owned(), + reason: "destination class does not match relay policy", + }) + } +} + +fn public_hostname(host: &str) -> bool { + let host = host.to_ascii_lowercase(); + host.contains('.') + && host != "localhost" + && !host.ends_with(".localhost") + && !host.ends_with(".local") + && !host.ends_with(".home.arpa") +} + +fn public_address(address: IpAddr) -> bool { + match address { + IpAddr::V4(address) => public_ipv4(address), + IpAddr::V6(address) => public_ipv6(address), + } +} + +fn trusted_network_address(address: IpAddr) -> bool { + match address { + IpAddr::V4(address) => { + !address.is_unspecified() + && !address.is_loopback() + && !address.is_multicast() + && !address.is_broadcast() + } + IpAddr::V6(address) => { + !address.is_unspecified() && !address.is_loopback() && !address.is_multicast() + } + } +} + +fn public_ipv4(address: Ipv4Addr) -> bool { + let octets = address.octets(); + !(address.is_unspecified() + || octets[0] == 0 + || address.is_loopback() + || address.is_private() + || address.is_link_local() + || address.is_multicast() + || address.is_broadcast() + || address.is_documentation() + || octets[0] == 100 && (64..=127).contains(&octets[1]) + || octets[0] == 192 && octets[1] == 0 && octets[2] == 0 + || octets[0] == 192 && octets[1] == 88 && octets[2] == 99 + || octets[0] == 198 && matches!(octets[1], 18 | 19) + || octets[0] >= 240) +} + +fn public_ipv6(address: Ipv6Addr) -> bool { + if let Some(mapped) = address.to_ipv4_mapped() { + return public_ipv4(mapped); + } + let segments = address.segments(); + (segments[0] & 0xe000) == 0x2000 + && !address.is_multicast() + && (segments[0] & 0xfe00) != 0xfc00 + && (segments[0] & 0xffc0) != 0xfe80 + && !(segments[0] == 0x2001 && segments[1] <= 0x01ff) + && segments[0] != 0x2002 + && !(segments[0] == 0x3fff && (segments[1] & 0xf000) == 0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn policies_classify_literal_and_named_destinations() { + assert!(RelayUrl::parse("wss://relay.example.com", RelayUrlPolicy::Public).is_ok()); + assert!(RelayUrl::parse("wss://10.0.0.1", RelayUrlPolicy::Public).is_err()); + assert!(RelayUrl::parse("wss://10.0.0.1", RelayUrlPolicy::PrivateNetwork).is_ok()); + assert!(RelayUrl::parse("ws://127.0.0.1", RelayUrlPolicy::Local).is_ok()); + assert!(RelayUrl::parse("ws://relay.example.com", RelayUrlPolicy::Public).is_err()); + } + + #[test] + fn resolved_addresses_are_revalidated() { + let relay = RelayUrl::parse("wss://relay.example.com", RelayUrlPolicy::Public) + .expect("public relay"); + assert!( + relay + .validate_resolved_addresses( + RelayUrlPolicy::Public, + [IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34))], + ) + .is_ok() + ); + assert!( + relay + .validate_resolved_addresses( + RelayUrlPolicy::Public, + [IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))], + ) + .is_err() + ); + } }