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:
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()
+ );
+ }
}