diff --git a/docs/tutorials/resolve-peers-via-nostr.md b/docs/tutorials/resolve-peers-via-nostr.md index 2764198..0a4bdb2 100644 --- a/docs/tutorials/resolve-peers-via-nostr.md +++ b/docs/tutorials/resolve-peers-via-nostr.md @@ -178,7 +178,7 @@ The resolution itself happens at debug-log level, so you will not see it in the default-level journal. The user-facing way to confirm everything worked is `fipsctl show peers` in the next step. (To watch the resolution in the journal, run the daemon -manually with `RUST_LOG=fips::discovery::nostr=debug`; not +manually with `RUST_LOG=fips::nostr=debug`; not necessary for this tutorial.) ## Step 5: Verify the resolved endpoint diff --git a/src/config/mod.rs b/src/config/mod.rs index 92f4450..0b3a2a8 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -34,7 +34,7 @@ use thiserror::Error; pub use gateway::{ConntrackConfig, GatewayConfig, GatewayDnsConfig, PortForward, Proto}; pub use node::{ BloomConfig, BuffersConfig, CacheConfig, ControlConfig, LimitsConfig, LookupConfig, MmpConfig, - NodeConfig, NostrDiscoveryConfig, NostrDiscoveryPolicy, RateLimitConfig, RekeyConfig, + NodeConfig, NostrRendezvousConfig, NostrRendezvousPolicy, RateLimitConfig, RekeyConfig, RendezvousConfig, RetryConfig, SessionConfig, SessionMmpConfig, TreeConfig, }; pub use peer::{ConnectPolicy, PeerAddress, PeerConfig}; @@ -1359,7 +1359,7 @@ peers: assert_eq!(config.node.rendezvous.nostr.signal_ttl_secs, 45); assert_eq!( config.node.rendezvous.nostr.policy, - NostrDiscoveryPolicy::ConfiguredOnly + NostrRendezvousPolicy::ConfiguredOnly ); assert_eq!(config.node.rendezvous.nostr.open_discovery_max_pending, 12); assert_eq!( diff --git a/src/config/node.rs b/src/config/node.rs index 5241dfa..d212301 100644 --- a/src/config/node.rs +++ b/src/config/node.rs @@ -263,13 +263,13 @@ impl LookupConfig { pub struct RendezvousConfig { /// Nostr-mediated overlay endpoint rendezvous (`node.rendezvous.nostr.*`). #[serde(default)] - pub nostr: NostrDiscoveryConfig, + pub nostr: NostrRendezvousConfig, /// mDNS / DNS-SD peer rendezvous on the local link (`node.rendezvous.lan.*`). /// Identity surface is a strict subset of what `nostr.advertise` already /// publishes publicly, so there's no marginal privacy cost; the latency /// win for same-LAN peers is large (sub-second pairing, no relay). #[serde(default)] - pub lan: crate::discovery::lan::LanDiscoveryConfig, + pub lan: crate::mdns::LanRendezvousConfig, } /// COMPAT (drop at the v2 cutover): a deprecated legacy `node.discovery:` block. @@ -290,8 +290,8 @@ pub(crate) struct DiscoveryConfigCompat { pub backoff_base_secs: Option, pub backoff_max_secs: Option, pub forward_min_interval_secs: Option, - pub nostr: Option, - pub lan: Option, + pub nostr: Option, + pub lan: Option, } /// Nostr advert discovery policy. @@ -303,7 +303,7 @@ pub(crate) struct DiscoveryConfigCompat { /// - `open`: also consider adverts for non-configured peers #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub enum NostrDiscoveryPolicy { +pub enum NostrRendezvousPolicy { Disabled, #[default] ConfiguredOnly, @@ -313,23 +313,23 @@ pub enum NostrDiscoveryPolicy { /// Nostr-mediated overlay endpoint discovery (`node.rendezvous.nostr.*`). #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct NostrDiscoveryConfig { +pub struct NostrRendezvousConfig { /// Enable Nostr-signaled traversal bootstrap. #[serde(default)] pub enabled: bool, /// Publish service advertisements so remote peers can bootstrap inbound. - #[serde(default = "NostrDiscoveryConfig::default_advertise")] + #[serde(default = "NostrRendezvousConfig::default_advertise")] pub advertise: bool, /// Relay URLs used for service advertisements. - #[serde(default = "NostrDiscoveryConfig::default_advert_relays")] + #[serde(default = "NostrRendezvousConfig::default_advert_relays")] pub advert_relays: Vec, /// Relay URLs used for encrypted signaling events. - #[serde(default = "NostrDiscoveryConfig::default_dm_relays")] + #[serde(default = "NostrRendezvousConfig::default_dm_relays")] pub dm_relays: Vec, /// STUN servers used for local reflexive address discovery. /// Outbound observation uses only this local list; peer-advertised STUN /// values are informational and are not treated as egress targets. - #[serde(default = "NostrDiscoveryConfig::default_stun_servers")] + #[serde(default = "NostrRendezvousConfig::default_stun_servers")] pub stun_servers: Vec, /// Whether to advertise local (RFC 1918 / ULA) interface addresses as /// host candidates in the traversal offer. @@ -343,85 +343,85 @@ pub struct NostrDiscoveryConfig { #[serde(default)] pub share_local_candidates: bool, /// Traversal application namespace and advert identifier suffix. - #[serde(default = "NostrDiscoveryConfig::default_app")] + #[serde(default = "NostrRendezvousConfig::default_app")] pub app: String, /// Signaling TTL in seconds. - #[serde(default = "NostrDiscoveryConfig::default_signal_ttl_secs")] + #[serde(default = "NostrRendezvousConfig::default_signal_ttl_secs")] pub signal_ttl_secs: u64, /// Policy for advert-derived endpoint discovery. #[serde(default)] - pub policy: NostrDiscoveryPolicy, + pub policy: NostrRendezvousPolicy, /// Max number of open-discovery peers queued for outbound retry/connection /// at once. Prevents unbounded queue growth from ambient advert traffic. - #[serde(default = "NostrDiscoveryConfig::default_open_discovery_max_pending")] + #[serde(default = "NostrRendezvousConfig::default_open_discovery_max_pending")] pub open_discovery_max_pending: usize, /// Max concurrent inbound traversal offers processed at once. /// Acts as a rate limit against offer spam from relays. - #[serde(default = "NostrDiscoveryConfig::default_max_concurrent_incoming_offers")] + #[serde(default = "NostrRendezvousConfig::default_max_concurrent_incoming_offers")] pub max_concurrent_incoming_offers: usize, /// Max cached overlay adverts retained from relay traffic. /// Bounds memory under ambient advert volume. - #[serde(default = "NostrDiscoveryConfig::default_advert_cache_max_entries")] + #[serde(default = "NostrRendezvousConfig::default_advert_cache_max_entries")] pub advert_cache_max_entries: usize, /// Max seen-session IDs retained for replay detection. /// Oldest entries are evicted when the cap is exceeded. - #[serde(default = "NostrDiscoveryConfig::default_seen_sessions_max_entries")] + #[serde(default = "NostrRendezvousConfig::default_seen_sessions_max_entries")] pub seen_sessions_max_entries: usize, /// Overall punch attempt timeout in seconds. - #[serde(default = "NostrDiscoveryConfig::default_attempt_timeout_secs")] + #[serde(default = "NostrRendezvousConfig::default_attempt_timeout_secs")] pub attempt_timeout_secs: u64, /// Replay tracking retention window in seconds. - #[serde(default = "NostrDiscoveryConfig::default_replay_window_secs")] + #[serde(default = "NostrRendezvousConfig::default_replay_window_secs")] pub replay_window_secs: u64, /// Delay before punch traffic starts. - #[serde(default = "NostrDiscoveryConfig::default_punch_start_delay_ms")] + #[serde(default = "NostrRendezvousConfig::default_punch_start_delay_ms")] pub punch_start_delay_ms: u64, /// Interval between punch packets. - #[serde(default = "NostrDiscoveryConfig::default_punch_interval_ms")] + #[serde(default = "NostrRendezvousConfig::default_punch_interval_ms")] pub punch_interval_ms: u64, /// How long to keep punching before failure. - #[serde(default = "NostrDiscoveryConfig::default_punch_duration_ms")] + #[serde(default = "NostrRendezvousConfig::default_punch_duration_ms")] pub punch_duration_ms: u64, /// Advert TTL in seconds. - #[serde(default = "NostrDiscoveryConfig::default_advert_ttl_secs")] + #[serde(default = "NostrRendezvousConfig::default_advert_ttl_secs")] pub advert_ttl_secs: u64, /// How often adverts are refreshed in seconds. - #[serde(default = "NostrDiscoveryConfig::default_advert_refresh_secs")] + #[serde(default = "NostrRendezvousConfig::default_advert_refresh_secs")] pub advert_refresh_secs: u64, /// Settle delay in seconds after Nostr discovery starts before the /// one-shot startup sweep of cached adverts runs. Allows the relay /// subscription backlog to populate the in-memory advert cache. /// Only used under `policy: open`. Default: 5. - #[serde(default = "NostrDiscoveryConfig::default_startup_sweep_delay_secs")] + #[serde(default = "NostrRendezvousConfig::default_startup_sweep_delay_secs")] pub startup_sweep_delay_secs: u64, /// Maximum age in seconds for cached adverts considered by the /// one-shot startup sweep. Adverts whose `created_at` is older than /// `now - startup_sweep_max_age_secs` are skipped. Only used under /// `policy: open`. Default: 3600 (1 hour). - #[serde(default = "NostrDiscoveryConfig::default_startup_sweep_max_age_secs")] + #[serde(default = "NostrRendezvousConfig::default_startup_sweep_max_age_secs")] pub startup_sweep_max_age_secs: u64, /// Number of consecutive NAT-traversal failures against a peer before /// an extended cooldown is applied to throttle further offer publishes. /// At this threshold the daemon also actively re-fetches the peer's /// advert from `advert_relays` to evict cache entries for peers that /// have gone away. Default: 5. - #[serde(default = "NostrDiscoveryConfig::default_failure_streak_threshold")] + #[serde(default = "NostrRendezvousConfig::default_failure_streak_threshold")] pub failure_streak_threshold: u32, /// Cooldown applied to a peer once `failure_streak_threshold` is hit. /// Suppresses both open-discovery sweep enqueues and per-attempt /// retry firings until elapsed. Default: 1800 (30 minutes). - #[serde(default = "NostrDiscoveryConfig::default_extended_cooldown_secs")] + #[serde(default = "NostrRendezvousConfig::default_extended_cooldown_secs")] pub extended_cooldown_secs: u64, /// Minimum interval between `NAT traversal failed` WARN log lines for /// the same peer. Subsequent failures inside the window log at DEBUG. /// Reduces log spam on public-test nodes with many cache-learned /// peers. Default: 300 (5 minutes). - #[serde(default = "NostrDiscoveryConfig::default_warn_log_interval_secs")] + #[serde(default = "NostrRendezvousConfig::default_warn_log_interval_secs")] pub warn_log_interval_secs: u64, /// Maximum entries retained in the per-npub failure-state map. /// Bounds memory under high cache turnover. Oldest entries (by last /// failure time) evicted when the cap is exceeded. Default: 4096. - #[serde(default = "NostrDiscoveryConfig::default_failure_state_max_entries")] + #[serde(default = "NostrRendezvousConfig::default_failure_state_max_entries")] pub failure_state_max_entries: usize, /// Cooldown applied after observing a fatal protocol mismatch on a /// Nostr-adopted bootstrap transport (e.g. `Unknown FMP version` @@ -429,11 +429,11 @@ pub struct NostrDiscoveryConfig { /// of `extended_cooldown_secs` and much longer because the mismatch /// is structural — re-traversing the peer is wasted effort until one /// side upgrades. Default: 86400 (24 hours). - #[serde(default = "NostrDiscoveryConfig::default_protocol_mismatch_cooldown_secs")] + #[serde(default = "NostrRendezvousConfig::default_protocol_mismatch_cooldown_secs")] pub protocol_mismatch_cooldown_secs: u64, } -impl Default for NostrDiscoveryConfig { +impl Default for NostrRendezvousConfig { fn default() -> Self { Self { enabled: false, @@ -444,7 +444,7 @@ impl Default for NostrDiscoveryConfig { share_local_candidates: false, app: Self::default_app(), signal_ttl_secs: Self::default_signal_ttl_secs(), - policy: NostrDiscoveryPolicy::default(), + policy: NostrRendezvousPolicy::default(), open_discovery_max_pending: Self::default_open_discovery_max_pending(), max_concurrent_incoming_offers: Self::default_max_concurrent_incoming_offers(), advert_cache_max_entries: Self::default_advert_cache_max_entries(), @@ -467,7 +467,7 @@ impl Default for NostrDiscoveryConfig { } } -impl NostrDiscoveryConfig { +impl NostrRendezvousConfig { fn default_advertise() -> bool { true } @@ -1241,27 +1241,27 @@ owd_window_size: 48 } #[test] - fn test_nostr_discovery_startup_sweep_defaults() { - let c = NostrDiscoveryConfig::default(); + fn test_nostr_rendezvous_startup_sweep_defaults() { + let c = NostrRendezvousConfig::default(); assert_eq!(c.startup_sweep_delay_secs, 5); assert_eq!(c.startup_sweep_max_age_secs, 3_600); } #[test] - fn test_nostr_discovery_startup_sweep_yaml_override() { + fn test_nostr_rendezvous_startup_sweep_yaml_override() { let yaml = "enabled: true\npolicy: open\nstartup_sweep_delay_secs: 10\nstartup_sweep_max_age_secs: 1800\n"; - let c: NostrDiscoveryConfig = serde_yaml::from_str(yaml).unwrap(); + let c: NostrRendezvousConfig = serde_yaml::from_str(yaml).unwrap(); assert!(c.enabled); - assert_eq!(c.policy, NostrDiscoveryPolicy::Open); + assert_eq!(c.policy, NostrRendezvousPolicy::Open); assert_eq!(c.startup_sweep_delay_secs, 10); assert_eq!(c.startup_sweep_max_age_secs, 1_800); } #[test] - fn test_nostr_discovery_startup_sweep_partial_yaml_uses_defaults() { + fn test_nostr_rendezvous_startup_sweep_partial_yaml_uses_defaults() { // Only override delay; max_age should fall back to default. let yaml = "enabled: true\nstartup_sweep_delay_secs: 30\n"; - let c: NostrDiscoveryConfig = serde_yaml::from_str(yaml).unwrap(); + let c: NostrRendezvousConfig = serde_yaml::from_str(yaml).unwrap(); assert_eq!(c.startup_sweep_delay_secs, 30); assert_eq!(c.startup_sweep_max_age_secs, 3_600); } diff --git a/src/control/queries.rs b/src/control/queries.rs index 31ca599..2bf9ac9 100644 --- a/src/control/queries.rs +++ b/src/control/queries.rs @@ -236,7 +236,7 @@ pub fn show_peers(node: &Node) -> Value { // Per-npub Nostr-traversal failure-state snapshot, indexed by npub // for O(1) per-peer lookup. Empty if Nostr discovery is disabled. let nostr_state: std::collections::HashMap = node - .nostr_discovery_handle() + .nostr_rendezvous_handle() .map(|d| { d.failure_state_snapshot() .into_iter() diff --git a/src/lib.rs b/src/lib.rs index e01bf70..54618a2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,12 +12,13 @@ extern crate alloc; pub mod cache; pub mod config; pub mod control; -pub mod discovery; #[cfg(target_os = "linux")] pub mod gateway; pub mod identity; +pub mod mdns; pub mod node; pub mod noise; +pub mod nostr; pub mod peer; pub mod perf_profile; pub(crate) mod proto; @@ -39,8 +40,8 @@ pub use identity::{ pub use config::{Config, ConfigError, IdentityConfig, NymConfig, TorConfig, UdpConfig}; pub use upper::config::{DnsConfig, TunConfig}; -// Re-export discovery types -pub use discovery::{BootstrapHandoffResult, EstablishedTraversal}; +// Re-export nostr rendezvous handoff types +pub use nostr::{BootstrapHandoffResult, EstablishedTraversal, is_punch_packet}; // Re-export tree types (relocated from tree:: to proto::stp) pub use proto::stp::{ diff --git a/src/discovery/lan/mod.rs b/src/mdns/mod.rs similarity index 92% rename from src/discovery/lan/mod.rs rename to src/mdns/mod.rs index e82b2a7..4da3ebb 100644 --- a/src/discovery/lan/mod.rs +++ b/src/mdns/mod.rs @@ -54,8 +54,12 @@ pub const TXT_KEY_SCOPE: &str = "scope"; /// `PROTOCOL_VERSION`). pub const TXT_KEY_VERSION: &str = "v"; +/// FIPS protocol version advertised in the mDNS TXT `v` key. Kept in sync +/// with the Nostr rendezvous `PROTOCOL_VERSION` (same value, `"1"`). +const TXT_PROTOCOL_VERSION: &str = "1"; + #[derive(Debug, Error)] -pub enum LanDiscoveryError { +pub enum LanRendezvousError { #[error("mDNS daemon init failed: {0}")] Daemon(String), #[error("mDNS register failed: {0}")] @@ -79,7 +83,7 @@ pub struct LanDiscoveredPeer { pub observed_at: Instant, } -/// Browser-side events surfaced by `LanDiscovery::drain_events`. +/// Browser-side events surfaced by `LanRendezvous::drain_events`. #[derive(Debug, Clone)] pub enum LanEvent { Discovered(LanDiscoveredPeer), @@ -87,17 +91,17 @@ pub enum LanEvent { /// Runtime configuration for the mDNS responder + browser. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct LanDiscoveryConfig { +pub struct LanRendezvousConfig { /// Master switch. Default: `false` — LAN discovery is opt-in. Operators /// who want sub-second same-LAN pairing enable it via /// `node.rendezvous.lan.enabled: true`. Default-off avoids reintroducing /// a per-LAN identity broadcast on nodes that have deliberately disabled /// other discovery channels, and avoids any multicast surprise on upgrade. - #[serde(default = "LanDiscoveryConfig::default_enabled")] + #[serde(default = "LanRendezvousConfig::default_enabled")] pub enabled: bool, /// Overridable service type, primarily so integration tests can run /// multiple isolated services on the same loopback interface. - #[serde(default = "LanDiscoveryConfig::default_service_type")] + #[serde(default = "LanRendezvousConfig::default_service_type")] pub service_type: String, /// Optional application/network scope carried in the LAN-only TXT /// record. Browsers that set a scope ignore adverts for other scopes. @@ -109,7 +113,7 @@ pub struct LanDiscoveryConfig { pub scope: Option, } -impl Default for LanDiscoveryConfig { +impl Default for LanRendezvousConfig { fn default() -> Self { Self { enabled: Self::default_enabled(), @@ -119,7 +123,7 @@ impl Default for LanDiscoveryConfig { } } -impl LanDiscoveryConfig { +impl LanRendezvousConfig { fn default_enabled() -> bool { false } @@ -129,7 +133,7 @@ impl LanDiscoveryConfig { } /// Running mDNS responder + browser bound to the node's UDP advert port. -pub struct LanDiscovery { +pub struct LanRendezvous { daemon: ServiceDaemon, own_npub: String, instance_fullname: String, @@ -137,7 +141,7 @@ pub struct LanDiscovery { event_pump: tokio::task::JoinHandle<()>, } -impl LanDiscovery { +impl LanRendezvous { /// Start the mDNS responder and browser. /// /// `advertised_port` is the UDP port the operational UDP transport @@ -148,16 +152,16 @@ impl LanDiscovery { identity: &Identity, scope: Option, advertised_port: u16, - config: LanDiscoveryConfig, - ) -> Result, LanDiscoveryError> { + config: LanRendezvousConfig, + ) -> Result, LanRendezvousError> { if !config.enabled { - return Err(LanDiscoveryError::Disabled); + return Err(LanRendezvousError::Disabled); } if advertised_port == 0 { - return Err(LanDiscoveryError::NoAdvertisedPort); + return Err(LanRendezvousError::NoAdvertisedPort); } - let daemon = ServiceDaemon::new().map_err(|e| LanDiscoveryError::Daemon(e.to_string()))?; + let daemon = ServiceDaemon::new().map_err(|e| LanRendezvousError::Daemon(e.to_string()))?; let npub = identity.npub(); // mDNS DNS labels are capped at 63 bytes. 16 bech32 chars of npub @@ -176,7 +180,7 @@ impl LanDiscovery { } props.insert( TXT_KEY_VERSION.to_string(), - super::nostr::PROTOCOL_VERSION.to_string(), + TXT_PROTOCOL_VERSION.to_string(), ); // host_ipv4 is set to "127.0.0.1" *and* enable_addr_auto() is @@ -193,18 +197,18 @@ impl LanDiscovery { advertised_port, Some(props), ) - .map_err(|e| LanDiscoveryError::Register(e.to_string()))? + .map_err(|e| LanRendezvousError::Register(e.to_string()))? .enable_addr_auto(); let instance_fullname = service_info.get_fullname().to_string(); daemon .register(service_info) - .map_err(|e| LanDiscoveryError::Register(e.to_string()))?; + .map_err(|e| LanRendezvousError::Register(e.to_string()))?; let browse_rx = daemon .browse(&config.service_type) - .map_err(|e| LanDiscoveryError::Browse(e.to_string()))?; + .map_err(|e| LanRendezvousError::Browse(e.to_string()))?; let (events_tx, events_rx) = tokio::sync::mpsc::unbounded_channel(); let own_npub = npub.clone(); diff --git a/src/discovery/lan/tests.rs b/src/mdns/tests.rs similarity index 91% rename from src/discovery/lan/tests.rs rename to src/mdns/tests.rs index 1e4867c..5983abe 100644 --- a/src/discovery/lan/tests.rs +++ b/src/mdns/tests.rs @@ -4,7 +4,7 @@ use std::time::Duration; use crate::Identity; use mdns_sd::ScopedIp; -use super::{LanDiscovery, LanDiscoveryConfig, LanEvent}; +use super::{LanEvent, LanRendezvous, LanRendezvousConfig}; /// Distinct service type per test run so concurrent cargo-test workers /// on the same machine don't cross-feed each other's adverts via the @@ -15,8 +15,8 @@ fn isolated_service_type(tag: &str) -> String { format!("_fipstest-{tag}-{rand:08x}._udp.local.") } -fn config_for(service_type: String) -> LanDiscoveryConfig { - LanDiscoveryConfig { +fn config_for(service_type: String) -> LanRendezvousConfig { + LanRendezvousConfig { enabled: true, service_type, scope: None, @@ -47,7 +47,7 @@ fn non_link_local_ipv6_advert_is_preserved() { } async fn wait_for_peer( - discovery: &LanDiscovery, + discovery: &LanRendezvous, expected_npub: &str, timeout: Duration, ) -> Option { @@ -64,7 +64,7 @@ async fn wait_for_peer( None } -/// Two LanDiscovery instances on isolated service types — `a` browses +/// Two LanRendezvous instances on isolated service types — `a` browses /// only its own type and never sees `b`, and vice versa. Sanity check /// that the scope-isolation defense works (we'd lose isolation if mdns- /// sd ever leaked across service types). @@ -76,7 +76,7 @@ async fn isolated_service_types_do_not_cross_feed() { let service_a = isolated_service_type("isolated-a"); let service_b = isolated_service_type("isolated-b"); - let lan_a = LanDiscovery::start( + let lan_a = LanRendezvous::start( &identity_a, Some("scope-x".to_string()), 61001, @@ -84,7 +84,7 @@ async fn isolated_service_types_do_not_cross_feed() { ) .await .expect("start a"); - let lan_b = LanDiscovery::start( + let lan_b = LanRendezvous::start( &identity_b, Some("scope-x".to_string()), 61002, @@ -118,7 +118,7 @@ async fn isolated_service_types_do_not_cross_feed() { assert!(!saw_a_from_b, "isolated service types must not cross-feed"); } -/// Two LanDiscovery instances on the same service type and the same +/// Two LanRendezvous instances on the same service type and the same /// scope: each should observe the other's advert within a few seconds. /// Exercises the responder + browser + TXT plumbing end-to-end. /// @@ -136,7 +136,7 @@ async fn matched_scope_peers_observe_each_other() { let service = isolated_service_type("matched"); - let lan_a = LanDiscovery::start( + let lan_a = LanRendezvous::start( &identity_a, Some("scope-shared".to_string()), 61101, @@ -144,7 +144,7 @@ async fn matched_scope_peers_observe_each_other() { ) .await .expect("start a"); - let lan_b = LanDiscovery::start( + let lan_b = LanRendezvous::start( &identity_b, Some("scope-shared".to_string()), 61102, @@ -181,7 +181,7 @@ async fn cross_scope_advert_is_filtered() { let service = isolated_service_type("cross-scope"); - let lan_a = LanDiscovery::start( + let lan_a = LanRendezvous::start( &identity_a, Some("scope-a".to_string()), 61201, @@ -189,7 +189,7 @@ async fn cross_scope_advert_is_filtered() { ) .await .expect("start a"); - let lan_b = LanDiscovery::start( + let lan_b = LanRendezvous::start( &identity_b, Some("scope-b".to_string()), 61202, diff --git a/src/node/handlers/rx_loop.rs b/src/node/handlers/rx_loop.rs index 1e4b017..57a6c86 100644 --- a/src/node/handlers/rx_loop.rs +++ b/src/node/handlers/rx_loop.rs @@ -259,13 +259,13 @@ impl Node { // is polled separately from `reload_peer_acl` because the // ACL's embedded alias reloader and this snapshot are // distinct resources; the `path_mtu_lookup` cache and the - // `nostr_discovery` subsystem are deliberately excluded + // `nostr_rendezvous` subsystem are deliberately excluded // from `Reloadable` since neither reloads from a backing // file (see `node::reloadable`). self.reload_host_map().await; self.poll_pending_connects().await; - self.poll_nostr_discovery().await; - self.poll_lan_discovery().await; + self.poll_nostr_rendezvous().await; + self.poll_lan_rendezvous().await; self.resend_pending_handshakes(now_ms).await; self.resend_pending_rekeys(now_ms).await; self.resend_pending_fmp_rekey_msg3(now_ms).await; @@ -322,12 +322,14 @@ impl Node { // though no msg1/msg2 exchange can ever succeed. Bump the // discovery-layer cooldown to the long protocol-mismatch // window and emit a single WARN per fresh observation. - if self.bootstrap_transports.contains(&packet.transport_id) + if self + .nostr_rendezvous + .is_bootstrap_transport(&packet.transport_id) && let Some(npub) = self - .bootstrap_transport_npubs - .get(&packet.transport_id) + .nostr_rendezvous + .bootstrap_transport_npub(&packet.transport_id) .cloned() - && let Some(handle) = self.nostr_discovery_handle() + && let Some(handle) = self.nostr_rendezvous_handle() { let now_ms = Self::now_ms(); let cooldown_secs = handle.protocol_mismatch_cooldown_secs(); diff --git a/src/node/lifecycle.rs b/src/node/lifecycle.rs index a592e14..2b7eaab 100644 --- a/src/node/lifecycle.rs +++ b/src/node/lifecycle.rs @@ -2,13 +2,10 @@ use super::{Node, NodeError, NodeState}; use crate::config::{ConnectPolicy, PeerAddress, PeerConfig}; -use crate::discovery::nostr::{ - ADVERT_IDENTIFIER, ADVERT_VERSION, BootstrapEvent, NostrDiscovery, OverlayAdvert, - OverlayEndpointAdvert, OverlayTransportKind, -}; -use crate::discovery::{BootstrapHandoffResult, EstablishedTraversal}; use crate::node::acl::PeerAclContext; use crate::node::wire::build_msg1; +use crate::nostr::{BootstrapEvent, NostrRendezvous}; +use crate::nostr::{BootstrapHandoffResult, EstablishedTraversal}; use crate::peer::PeerConnection; use crate::proto::fmp::{Disconnect, DisconnectReason}; use crate::transport::{Link, LinkDirection, LinkId, TransportAddr, TransportId, packet_channel}; @@ -266,7 +263,7 @@ impl Node { // would loop on the same dead address until expiry. Force a // re-fetch so the next retry tick picks up fresh endpoints. if matches!(e, crate::node::NodeError::NoTransportForType(_)) - && let Some(bootstrap) = self.nostr_discovery.clone() + && let Some(bootstrap) = self.nostr_rendezvous.engine_arc() { let npub = peer_config.npub.clone(); tokio::spawn(async move { @@ -360,7 +357,7 @@ impl Node { .filter(|(id, handle)| { handle.transport_type().name == "udp" && handle.is_operational() - && !self.bootstrap_transports.contains(id) + && !self.nostr_rendezvous.is_bootstrap_transport(id) }) .filter_map(|(id, handle)| { let local_addr = handle.local_addr()?; @@ -736,8 +733,8 @@ impl Node { } } - pub(super) async fn poll_nostr_discovery(&mut self) { - let Some(bootstrap) = self.nostr_discovery.clone() else { + pub(super) async fn poll_nostr_rendezvous(&mut self) { + let Some(bootstrap) = self.nostr_rendezvous.engine_arc() else { return; }; @@ -883,19 +880,19 @@ impl Node { tokio::spawn(async move { let outcome = bootstrap.refetch_advert_for_stale_check(&npub).await; match outcome { - crate::discovery::nostr::NostrRefetchOutcome::Evicted => info!( + crate::nostr::NostrRefetchOutcome::Evicted => info!( npub = %npub, "stale-advert sweep: peer evicted from advert cache" ), - crate::discovery::nostr::NostrRefetchOutcome::Refreshed => info!( + crate::nostr::NostrRefetchOutcome::Refreshed => info!( npub = %npub, "stale-advert sweep: peer republished, cache refreshed and streak reset" ), - crate::discovery::nostr::NostrRefetchOutcome::SameAdvert => debug!( + crate::nostr::NostrRefetchOutcome::SameAdvert => debug!( npub = %npub, "stale-advert sweep: advert unchanged, cooldown stands" ), - crate::discovery::nostr::NostrRefetchOutcome::Skipped => debug!( + crate::nostr::NostrRefetchOutcome::Skipped => debug!( npub = %npub, "stale-advert sweep: skipped (relay error or no advert_relays)" ), @@ -934,7 +931,7 @@ impl Node { /// changing the public Nostr discovery `app` tag. The older fallback /// extracts a scope from the Nostr app tag used by default scoped /// discovery. - pub(super) fn lan_discovery_scope(&self) -> Option { + pub(super) fn lan_rendezvous_scope(&self) -> Option { if let Some(scope) = self.config().node.rendezvous.lan.scope.as_deref() { let scope = scope.trim(); if !scope.is_empty() { @@ -961,8 +958,8 @@ impl Node { /// Drain mDNS-discovered peers and initiate Noise XX handshakes. /// The handshake itself is the authentication — a spoofed mDNS advert /// with someone else's npub fails the XX exchange and is dropped. - pub(super) async fn poll_lan_discovery(&mut self) { - let Some(runtime) = self.lan_discovery.clone() else { + pub(super) async fn poll_lan_rendezvous(&mut self) { + let Some(runtime) = self.lan_rendezvous.clone() else { return; }; let events = runtime.drain_events().await; @@ -970,7 +967,7 @@ impl Node { return; } for event in events { - let crate::discovery::lan::LanEvent::Discovered(peer) = event; + let crate::mdns::LanEvent::Discovered(peer) = event; let Some((transport_id, local_addr)) = self.find_udp_transport_for_remote_addr(peer.addr) else { @@ -1198,7 +1195,7 @@ impl Node { } if self.config().node.rendezvous.nostr.enabled { - match NostrDiscovery::start( + match NostrRendezvous::start( self.identity(), self.config().node.rendezvous.nostr.clone(), ) @@ -1208,8 +1205,8 @@ impl Node { if let Err(err) = self.refresh_overlay_advert(&runtime).await { warn!(error = %err, "Failed to publish initial Nostr overlay advert"); } - self.nostr_discovery = Some(runtime); - self.nostr_discovery_started_at_ms = Some(Self::now_ms()); + self.nostr_rendezvous.set_engine(runtime); + self.nostr_rendezvous.set_started_at_ms(Self::now_ms()); info!("Nostr overlay discovery enabled"); } Err(err) => { @@ -1234,14 +1231,14 @@ impl Node { .filter(|(id, h)| { h.transport_type().name == "udp" && h.is_operational() - && !self.bootstrap_transports.contains(id) + && !self.nostr_rendezvous.is_bootstrap_transport(id) }) .filter_map(|(id, h)| h.local_addr().map(|addr| (*id, addr.port()))) .min_by_key(|(id, _)| id.as_u32()) .map(|(_, port)| port) .unwrap_or(0); - let scope = self.lan_discovery_scope(); - match crate::discovery::lan::LanDiscovery::start( + let scope = self.lan_rendezvous_scope(); + match crate::mdns::LanRendezvous::start( self.identity(), scope, advertised_udp_port, @@ -1250,7 +1247,7 @@ impl Node { .await { Ok(runtime) => { - self.lan_discovery = Some(runtime); + self.lan_rendezvous = Some(runtime); info!("LAN mDNS discovery enabled"); } Err(err) => { @@ -1538,7 +1535,7 @@ impl Node { .await; // Stop Nostr overlay discovery background work and withdraw any advert. - if let Some(bootstrap) = self.nostr_discovery.take() + if let Some(bootstrap) = self.nostr_rendezvous.take_engine() && let Err(e) = bootstrap.shutdown().await { warn!(error = %e, "Failed to shutdown Nostr overlay discovery"); @@ -1547,7 +1544,7 @@ impl Node { // Tear down LAN mDNS responder + browser. Best-effort: the // OS will eventually time the advert out via its TTL even if // we don't get a clean unregister out before the daemon exits. - if let Some(lan) = self.lan_discovery.take() { + if let Some(lan) = self.lan_rendezvous.take() { lan.shutdown().await; } @@ -1680,89 +1677,6 @@ impl Node { .collect() } - async fn nostr_peer_fallback_addresses( - &self, - peer_config: &PeerConfig, - existing: &[PeerAddress], - ) -> Vec { - if !self.config().node.rendezvous.nostr.enabled - || !peer_config.via_nostr - || self.config().node.rendezvous.nostr.policy - == crate::config::NostrDiscoveryPolicy::Disabled - { - return Vec::new(); - } - - let Some(bootstrap) = self.nostr_discovery.clone() else { - return Vec::new(); - }; - let endpoints = match bootstrap.advert_endpoints_for_peer(&peer_config.npub).await { - Ok(endpoints) => endpoints, - Err(err) => { - debug!( - npub = %peer_config.npub, - error = %err, - "Failed to resolve Nostr advert endpoints for configured peer" - ); - return Vec::new(); - } - }; - - let mut fallback = Vec::new(); - let mut next_priority = existing - .iter() - .map(|addr| addr.priority) - .max() - .unwrap_or(100) - .saturating_add(1); - let seen_at_ms = Self::now_ms(); - for endpoint in endpoints { - let Some(candidate) = - Self::overlay_endpoint_to_peer_address(&endpoint, next_priority, seen_at_ms) - else { - continue; - }; - if existing - .iter() - .any(|addr| addr.transport == candidate.transport && addr.addr == candidate.addr) - || fallback.iter().any(|addr: &PeerAddress| { - addr.transport == candidate.transport && addr.addr == candidate.addr - }) - { - continue; - } - fallback.push(candidate); - next_priority = next_priority.saturating_add(1); - } - fallback - } - - fn overlay_endpoint_to_peer_address( - endpoint: &OverlayEndpointAdvert, - priority: u8, - seen_at_ms: u64, - ) -> Option { - let transport = match endpoint.transport { - OverlayTransportKind::Udp => "udp", - OverlayTransportKind::Tcp => "tcp", - OverlayTransportKind::Tor => "tor", - }; - Some( - PeerAddress::with_priority(transport, endpoint.addr.clone(), priority) - .with_seen_at_ms(seen_at_ms), - ) - } - - async fn request_nostr_bootstrap(&self, peer_config: &PeerConfig) -> bool { - let Some(bootstrap) = self.nostr_discovery.clone() else { - debug!(npub = %peer_config.npub, "No Nostr overlay runtime for udp:nat address"); - return false; - }; - bootstrap.request_connect(peer_config.clone()).await; - info!(npub = %peer_config.npub, "Started Nostr UDP NAT traversal attempt"); - true - } - async fn attempt_peer_address_list( &mut self, peer_config: &PeerConfig, @@ -1788,7 +1702,11 @@ impl Node { if !allow_bootstrap_nat { continue; } - if self.request_nostr_bootstrap(peer_config).await { + if self + .nostr_rendezvous + .request_nostr_bootstrap(peer_config) + .await + { attempted = attempted.saturating_add(1); } continue; @@ -1890,7 +1808,7 @@ impl Node { ))) } - async fn queue_open_discovery_retries(&mut self, bootstrap: &std::sync::Arc) { + async fn queue_open_discovery_retries(&mut self, bootstrap: &std::sync::Arc) { self.run_open_discovery_sweep(bootstrap, None, "per-tick") .await; } @@ -1907,13 +1825,13 @@ impl Node { /// startup sweeps are distinguishable in operator-facing logs. pub(in crate::node) async fn run_open_discovery_sweep( &mut self, - bootstrap: &std::sync::Arc, + bootstrap: &std::sync::Arc, max_age_secs: Option, caller: &'static str, ) { if !self.config().node.rendezvous.nostr.enabled || self.config().node.rendezvous.nostr.policy - != crate::config::NostrDiscoveryPolicy::Open + != crate::config::NostrRendezvousPolicy::Open { return; } @@ -2019,7 +1937,9 @@ impl Node { let seen_at_ms = Self::now_ms(); for endpoint in endpoints { let Some(candidate) = - Self::overlay_endpoint_to_peer_address(&endpoint, priority, seen_at_ms) + crate::nostr::RendezvousDriver::overlay_endpoint_to_peer_address( + &endpoint, priority, seen_at_ms, + ) else { continue; }; @@ -2105,20 +2025,20 @@ impl Node { /// `node.rendezvous.nostr.enabled` and `policy == open`. async fn maybe_run_startup_open_discovery_sweep( &mut self, - bootstrap: &std::sync::Arc, + bootstrap: &std::sync::Arc, ) { - if self.startup_open_discovery_sweep_done { + if self.nostr_rendezvous.startup_sweep_done() { return; } if !self.config().node.rendezvous.nostr.enabled || self.config().node.rendezvous.nostr.policy - != crate::config::NostrDiscoveryPolicy::Open + != crate::config::NostrRendezvousPolicy::Open { // Mark done so we don't keep re-checking on every tick. - self.startup_open_discovery_sweep_done = true; + self.nostr_rendezvous.set_startup_sweep_done(); return; } - let Some(started_at_ms) = self.nostr_discovery_started_at_ms else { + let Some(started_at_ms) = self.nostr_rendezvous.started_at_ms() else { return; }; let now_ms = Self::now_ms(); @@ -2141,7 +2061,7 @@ impl Node { .startup_sweep_max_age_secs; self.run_open_discovery_sweep(bootstrap, Some(max_age_secs), "startup") .await; - self.startup_open_discovery_sweep_done = true; + self.nostr_rendezvous.set_startup_sweep_done(); } fn available_outbound_slots(&self) -> usize { @@ -2254,163 +2174,66 @@ impl Node { ) } - async fn build_overlay_advert( - &self, - bootstrap: &std::sync::Arc, - ) -> Option { - if !self.config().node.rendezvous.nostr.enabled { - return None; - } - - let mut endpoints = Vec::new(); - let mut has_udp_nat = false; - + /// Capture the advertisable-endpoint inputs of every operational + /// transport into a snapshot the rendezvous driver can turn into an + /// `OverlayAdvert` without borrowing the transport table across the + /// STUN await. Iteration order matches `self.transports.values()`, and + /// only transports whose type matched a configured listener are + /// included, mirroring the original per-transport branch structure. + fn advert_transport_snapshot(&self) -> Vec { + use crate::nostr::AdvertTransportSnapshot; + let mut snapshot = Vec::new(); for handle in self.transports.values() { if !handle.is_operational() { continue; } - match handle.transport_type().name { "udp" => { let Some(cfg) = self.lookup_udp_config(handle.name()) else { continue; }; - if !cfg.advertise_on_nostr() { - continue; - } - if cfg.is_public() { - // Precedence: - // 1. operator-supplied `external_addr` (skips STUN) - // 2. non-wildcard `local_addr` (operator bound to - // a specific public IP directly) - // 3. STUN auto-discovery against ephemeral socket - // 4. loud warn + omit endpoint - if let Some(explicit) = cfg.external_advert_addr() { - endpoints.push(OverlayEndpointAdvert { - transport: OverlayTransportKind::Udp, - addr: explicit.to_string(), - }); - } else { - match handle.local_addr() { - Some(addr) if !addr.ip().is_unspecified() => { - endpoints.push(OverlayEndpointAdvert { - transport: OverlayTransportKind::Udp, - addr: addr.to_string(), - }); - } - Some(addr) => { - let key = handle.transport_id().as_u32(); - let port = addr.port(); - if let Some(public) = - bootstrap.learn_public_udp_addr(key, port).await - { - endpoints.push(OverlayEndpointAdvert { - transport: OverlayTransportKind::Udp, - addr: public.to_string(), - }); - } else { - warn!( - transport_id = key, - bind_addr = %addr, - "advert: udp public=true bound to wildcard but \ - STUN observation failed; advertising no UDP \ - endpoint. Either set transports.udp.external_addr, \ - bind to a specific public IP, or ensure \ - node.rendezvous.nostr.stun_servers is reachable" - ); - } - } - None => {} - } - } - } else { - endpoints.push(OverlayEndpointAdvert { - transport: OverlayTransportKind::Udp, - addr: "nat".to_string(), - }); - has_udp_nat = true; - } + snapshot.push(AdvertTransportSnapshot::Udp { + advertise: cfg.advertise_on_nostr(), + is_public: cfg.is_public(), + external_addr: cfg.external_advert_addr(), + local_addr: handle.local_addr(), + transport_key: handle.transport_id().as_u32(), + }); } "tcp" => { let Some(cfg) = self.lookup_tcp_config(handle.name()) else { continue; }; - if !cfg.advertise_on_nostr() { - continue; - } - // Precedence: - // 1. operator-supplied `external_addr` (only path that - // works on cloud-NAT setups where the public IP is - // not on a host interface). - // 2. non-wildcard `local_addr` (operator bound to a - // specific public IP directly). - // 3. loud warn + omit endpoint (no TCP STUN equivalent). - if let Some(explicit) = cfg.external_advert_addr() { - endpoints.push(OverlayEndpointAdvert { - transport: OverlayTransportKind::Tcp, - addr: explicit.to_string(), - }); - } else { - match handle.local_addr() { - Some(addr) if !addr.ip().is_unspecified() => { - endpoints.push(OverlayEndpointAdvert { - transport: OverlayTransportKind::Tcp, - addr: addr.to_string(), - }); - } - Some(addr) => { - warn!( - bind_addr = %addr, - "advert: tcp advertise_on_nostr=true bound to wildcard \ - and no transports.tcp.external_addr set; advertising no \ - TCP endpoint. Either set external_addr to the public \ - IP (recommended for cloud 1:1-NAT setups) or bind \ - explicitly to the public IP" - ); - } - None => {} - } - } + snapshot.push(AdvertTransportSnapshot::Tcp { + advertise: cfg.advertise_on_nostr(), + external_addr: cfg.external_advert_addr(), + local_addr: handle.local_addr(), + }); } "tor" => { let Some(cfg) = self.lookup_tor_config(handle.name()) else { continue; }; - if !cfg.advertise_on_nostr() { - continue; - } - if let Some(addr) = handle.onion_address() { - endpoints.push(OverlayEndpointAdvert { - transport: OverlayTransportKind::Tor, - addr: format!("{}:{}", addr, cfg.advertised_port()), - }); - } + snapshot.push(AdvertTransportSnapshot::Tor { + advertise: cfg.advertise_on_nostr(), + onion_addr: handle.onion_address().map(|s| s.to_string()), + advertised_port: cfg.advertised_port(), + }); } _ => {} } } - - if endpoints.is_empty() { - return None; - } - - Some(OverlayAdvert { - identifier: ADVERT_IDENTIFIER.to_string(), - version: ADVERT_VERSION, - endpoints, - signal_relays: has_udp_nat - .then(|| self.config().node.rendezvous.nostr.dm_relays.clone()), - stun_servers: has_udp_nat - .then(|| self.config().node.rendezvous.nostr.stun_servers.clone()), - }) + snapshot } async fn refresh_overlay_advert( &self, - bootstrap: &std::sync::Arc, - ) -> Result<(), crate::discovery::nostr::BootstrapError> { - let advert = self.build_overlay_advert(bootstrap).await; - bootstrap.update_local_advert(advert).await + bootstrap: &std::sync::Arc, + ) -> Result<(), crate::nostr::BootstrapError> { + let snapshot = self.advert_transport_snapshot(); + self.nostr_rendezvous + .refresh_overlay_advert(bootstrap, snapshot, &self.config().node.rendezvous.nostr) + .await } fn lookup_udp_config(&self, transport_name: Option<&str>) -> Option<&crate::config::UdpConfig> { @@ -2527,7 +2350,13 @@ impl Node { async fn peer_address_candidates(&self, peer_config: &PeerConfig) -> Vec { let static_addresses = self.static_peer_addresses(peer_config); let overlay_addresses = self - .nostr_peer_fallback_addresses(peer_config, &static_addresses) + .nostr_rendezvous + .nostr_peer_fallback_addresses( + peer_config, + &static_addresses, + &self.config().node.rendezvous.nostr, + Self::now_ms(), + ) .await; let mut candidates = Vec::with_capacity(overlay_addresses.len() + static_addresses.len()); @@ -2595,7 +2424,7 @@ impl Node { }; if peer .transport_id() - .map(|id| self.bootstrap_transports.contains(&id)) + .map(|id| self.nostr_rendezvous.is_bootstrap_transport(&id)) .unwrap_or(false) { return false; @@ -2791,17 +2620,16 @@ impl Node { transport_id, crate::transport::TransportHandle::Udp(transport), ); - self.bootstrap_transports.insert(transport_id); - self.bootstrap_transport_npubs - .insert(transport_id, traversal.peer_npub.clone()); + self.nostr_rendezvous + .insert_bootstrap_transport(transport_id, traversal.peer_npub.clone()); let remote_addr = TransportAddr::from_string(&traversal.remote_addr.to_string()); if let Err(err) = self .initiate_connection(transport_id, remote_addr.clone(), Some(peer_identity)) .await { - self.bootstrap_transports.remove(&transport_id); - self.bootstrap_transport_npubs.remove(&transport_id); + self.nostr_rendezvous + .remove_bootstrap_transport(&transport_id); if let Some(mut handle) = self.transports.remove(&transport_id) { let _ = handle.stop().await; } diff --git a/src/node/mod.rs b/src/node/mod.rs index 23019ef..5a88be6 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -79,7 +79,7 @@ use crate::upper::tun::{TunError, TunOutboundRx, TunState, TunTx}; use crate::utils::index::IndexAllocator; use crate::{Config, ConfigError, Identity, IdentityError, NodeAddr, PeerIdentity, TreeCoordinate}; use rand::Rng; -use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; +use std::collections::{BTreeSet, HashMap, VecDeque}; use std::fmt; use std::sync::Arc; use std::thread::JoinHandle; @@ -464,31 +464,16 @@ pub struct Node { /// are exhausted. retry_pending: HashMap, - /// Optional Nostr/STUN overlay discovery coordinator for `udp:nat` peers. - nostr_discovery: Option>, + /// Node-side driver state for the Nostr overlay peer-rendezvous + /// subsystem: the engine handle, its startup timestamp, the one-shot + /// startup-sweep latch, and the per-peer bootstrap-transport bookkeeping + /// adopted from NAT-traversal handoffs. + nostr_rendezvous: crate::nostr::RendezvousDriver, /// mDNS / DNS-SD responder + browser for local-link peer discovery. /// Identity is unverified at this layer — the Noise XX handshake /// initiated against an mDNS-observed endpoint is what proves the /// peer holds the matching private key. - lan_discovery: Option>, - /// Wall-clock ms when Nostr discovery successfully started, used to - /// schedule the one-shot startup advert sweep after a settle delay. - /// `None` until discovery comes up; remains `None` if discovery is - /// disabled or failed to start. - nostr_discovery_started_at_ms: Option, - /// Whether the one-shot startup advert sweep has run. Set to true - /// after the first sweep fires (under `policy: open`); thereafter - /// only the per-tick `queue_open_discovery_retries` continues. - startup_open_discovery_sweep_done: bool, - /// Per-peer UDP transports adopted from NAT traversal handoff. - bootstrap_transports: HashSet, - /// Originating peer npub (bech32) for each adopted bootstrap - /// transport, captured at `adopt_established_traversal` time. - /// Populated alongside `bootstrap_transports`; cleared in - /// `cleanup_bootstrap_transport_if_unused`. Used by the rx loop to - /// route fatal-protocol-mismatch observations back to the - /// Nostr-discovery `failure_state` for long cooldown application. - bootstrap_transport_npubs: HashMap, + lan_rendezvous: Option>, // === Periodic Parent Re-evaluation === /// Timestamp of last periodic parent re-evaluation (for pacing). @@ -707,12 +692,8 @@ impl Node { ), pending_connects: Vec::new(), retry_pending: HashMap::new(), - nostr_discovery: None, - nostr_discovery_started_at_ms: None, - lan_discovery: None, - startup_open_discovery_sweep_done: false, - bootstrap_transports: HashSet::new(), - bootstrap_transport_npubs: HashMap::new(), + nostr_rendezvous: crate::nostr::RendezvousDriver::default(), + lan_rendezvous: None, last_parent_reeval: None, last_congestion_log: None, estimated_mesh_size: None, @@ -871,12 +852,8 @@ impl Node { lookup: Lookup::new(LookupBackoff::new(), LookupForwardRateLimiter::new()), pending_connects: Vec::new(), retry_pending: HashMap::new(), - nostr_discovery: None, - nostr_discovery_started_at_ms: None, - lan_discovery: None, - startup_open_discovery_sweep_done: false, - bootstrap_transports: HashSet::new(), - bootstrap_transport_npubs: HashMap::new(), + nostr_rendezvous: crate::nostr::RendezvousDriver::default(), + lan_rendezvous: None, last_parent_reeval: None, last_congestion_log: None, estimated_mesh_size: None, @@ -1874,7 +1851,7 @@ impl Node { // Per-npub Nostr-traversal failure-state, indexed by npub for O(1) // per-peer lookup (empty when Nostr discovery is disabled). let nostr_state: std::collections::HashMap = self - .nostr_discovery_handle() + .nostr_rendezvous_handle() .map(|d| { d.failure_state_snapshot() .into_iter() @@ -2331,7 +2308,7 @@ impl Node { } pub(crate) fn cleanup_bootstrap_transport_if_unused(&mut self, transport_id: TransportId) { - if !self.bootstrap_transports.contains(&transport_id) { + if !self.nostr_rendezvous.is_bootstrap_transport(&transport_id) { return; } @@ -2361,8 +2338,8 @@ impl Node { "bootstrap transport has no remaining references; dropping" ); - self.bootstrap_transports.remove(&transport_id); - self.bootstrap_transport_npubs.remove(&transport_id); + self.nostr_rendezvous + .remove_bootstrap_transport(&transport_id); self.transport_drops.remove(&transport_id); self.transports.remove(&transport_id); } @@ -2437,8 +2414,8 @@ impl Node { /// Reference to the Nostr discovery handle if discovery is enabled. /// Used by control queries (`show_peers` per-peer Nostr-traversal /// state) to read failure-state without taking shared ownership. - pub fn nostr_discovery_handle(&self) -> Option<&crate::discovery::nostr::NostrDiscovery> { - self.nostr_discovery.as_deref() + pub fn nostr_rendezvous_handle(&self) -> Option<&crate::nostr::NostrRendezvous> { + self.nostr_rendezvous.engine() } /// Iterate over all peer node IDs. diff --git a/src/node/reloadable.rs b/src/node/reloadable.rs index 6013110..1bd90cb 100644 --- a/src/node/reloadable.rs +++ b/src/node/reloadable.rs @@ -41,7 +41,7 @@ //! file. There is nothing to poll. (Its read side could adopt the same //! lock-free `ArcSwap` shape in the future, but that is an optimization, not //! a reload.) -//! - `nostr_discovery` is an async spawned subsystem, not a snapshot of disk +//! - `nostr_rendezvous` is an async spawned subsystem, not a snapshot of disk //! state. //! //! Both [`HostMapReloadable`] and the peer ACL reloader currently stat diff --git a/src/node/retry.rs b/src/node/retry.rs index cfafe2d..ba422aa 100644 --- a/src/node/retry.rs +++ b/src/node/retry.rs @@ -282,7 +282,7 @@ impl Node { // evicts if the relay has nothing, otherwise leaves it. Cheap // (one Filter fetch with 2s timeout) and bounded by the retry // backoff cadence. - if let Some(bootstrap) = self.nostr_discovery.clone() { + if let Some(bootstrap) = self.nostr_rendezvous.engine_arc() { let _ = bootstrap .refetch_advert_for_stale_check(&peer_config.npub) .await; @@ -318,7 +318,7 @@ impl Node { // entry expires. Force a re-fetch so the next retry tick // picks up fresh endpoints. if matches!(e, NodeError::NoTransportForType(_)) - && let Some(bootstrap) = self.nostr_discovery.clone() + && let Some(bootstrap) = self.nostr_rendezvous.engine_arc() { let npub = peer_config.npub.clone(); tokio::spawn(async move { diff --git a/src/node/tests/discovery.rs b/src/node/tests/discovery.rs index 1da1b28..769432e 100644 --- a/src/node/tests/discovery.rs +++ b/src/node/tests/discovery.rs @@ -1043,20 +1043,20 @@ async fn test_response_path_mtu_four_node_chain() { /// Pin the iterate-filter-queue contract of `run_open_discovery_sweep`. /// /// Builds a `Node` with `nostr.policy = Open` and an empty peer list, -/// then injects three cached adverts into a test `NostrDiscovery` and +/// then injects three cached adverts into a test `NostrRendezvous` and /// asserts the sweep: /// - queues a retry for an eligible (unknown, not-self) advert, /// - skips the advert whose author is our own node identity, and /// - skips the advert whose author is an already-connected peer. /// -/// Uses `NostrDiscovery::new_for_test()` and `insert_advert_for_test()` +/// Uses `NostrRendezvous::new_for_test()` and `insert_advert_for_test()` /// (both `#[cfg(test)]`-gated test escape hatches in /// `src/discovery/nostr/runtime.rs`) to populate the cache without /// requiring live relay subscriptions. #[tokio::test] async fn test_open_discovery_sweep_queues_eligible_skips_filtered() { - use crate::config::NostrDiscoveryPolicy; - use crate::discovery::nostr::{NostrDiscovery, OverlayEndpointAdvert, OverlayTransportKind}; + use crate::config::NostrRendezvousPolicy; + use crate::nostr::{NostrRendezvous, OverlayEndpointAdvert, OverlayTransportKind}; use crate::peer::ActivePeer; use crate::transport::LinkId; use std::sync::Arc; @@ -1064,7 +1064,7 @@ async fn test_open_discovery_sweep_queues_eligible_skips_filtered() { // Build node with open-discovery enabled. let mut config = crate::Config::new(); config.node.rendezvous.nostr.enabled = true; - config.node.rendezvous.nostr.policy = NostrDiscoveryPolicy::Open; + config.node.rendezvous.nostr.policy = NostrRendezvousPolicy::Open; let mut node = crate::Node::new(config).unwrap(); // Identity of an already-connected peer; insert into node.peers @@ -1087,8 +1087,8 @@ async fn test_open_discovery_sweep_queues_eligible_skips_filtered() { let self_npub = crate::encode_npub(&node.identity().pubkey()); let self_node_addr = *node.identity().node_addr(); - // Build a NostrDiscovery test instance and inject the three adverts. - let bootstrap = Arc::new(NostrDiscovery::new_for_test()); + // Build a NostrRendezvous test instance and inject the three adverts. + let bootstrap = Arc::new(NostrRendezvous::new_for_test()); let endpoint = OverlayEndpointAdvert { transport: OverlayTransportKind::Udp, addr: "203.0.113.7:2121".to_string(), @@ -1099,7 +1099,7 @@ async fn test_open_discovery_sweep_queues_eligible_skips_filtered() { .unwrap_or(0); for npub in [&eligible_npub, &connected_npub, &self_npub] { let advert = - NostrDiscovery::cached_advert_for_test(npub.clone(), endpoint.clone(), now_secs); + NostrRendezvous::cached_advert_for_test(npub.clone(), endpoint.clone(), now_secs); bootstrap.insert_advert_for_test(npub.clone(), advert).await; } diff --git a/src/node/tests/unit.rs b/src/node/tests/unit.rs index 9da1061..205b338 100644 --- a/src/node/tests/unit.rs +++ b/src/node/tests/unit.rs @@ -1,5 +1,5 @@ use super::*; -use crate::discovery::nostr::{BootstrapEvent, NostrDiscovery}; +use crate::nostr::{BootstrapEvent, NostrRendezvous}; use crate::peer::PromotionResult; use crate::transport::udp::UdpTransport; use crate::transport::{TransportHandle, packet_channel}; @@ -171,7 +171,7 @@ async fn test_node_start_does_not_wait_for_nostr_relay_startup() { config.node.control.enabled = false; config.node.rendezvous.nostr.enabled = true; config.node.rendezvous.nostr.advertise = true; - config.node.rendezvous.nostr.policy = crate::config::NostrDiscoveryPolicy::Open; + config.node.rendezvous.nostr.policy = crate::config::NostrRendezvousPolicy::Open; config.node.rendezvous.nostr.advert_relays = vec!["wss://127.0.0.1:9".to_string()]; config.node.rendezvous.nostr.dm_relays = vec!["wss://127.0.0.1:9".to_string()]; config.transports.udp = crate::config::TransportInstances::Single(crate::config::UdpConfig { @@ -189,7 +189,7 @@ async fn test_node_start_does_not_wait_for_nostr_relay_startup() { .unwrap(); assert!(node.is_running()); - assert!(node.nostr_discovery_handle().is_some()); + assert!(node.nostr_rendezvous_handle().is_some()); node.stop().await.unwrap(); } @@ -1128,14 +1128,14 @@ async fn test_nostr_traversal_failure_skips_connected_peer() { node.promote_connection(link_id, peer_identity, 2000) .unwrap(); - let bootstrap = Arc::new(NostrDiscovery::new_for_test()); + let bootstrap = Arc::new(NostrRendezvous::new_for_test()); bootstrap.push_event_for_test(BootstrapEvent::Failed { peer_config: crate::config::PeerConfig::new(peer_identity.npub(), "udp", "127.0.0.1:9"), reason: "stale traversal failure".to_string(), }); - node.nostr_discovery = Some(bootstrap.clone()); + node.nostr_rendezvous.set_engine(bootstrap.clone()); - node.poll_nostr_discovery().await; + node.poll_nostr_rendezvous().await; assert!( bootstrap.failure_state_snapshot().is_empty(), @@ -1149,7 +1149,7 @@ async fn test_nostr_traversal_failure_skips_connected_peer() { #[tokio::test] async fn test_nostr_traversal_established_skips_connected_peer() { - use crate::discovery::EstablishedTraversal; + use crate::nostr::EstablishedTraversal; use std::net::UdpSocket; let mut node = make_node(); @@ -1162,7 +1162,7 @@ async fn test_nostr_traversal_established_skips_connected_peer() { let link_count = node.link_count(); let connection_count = node.connection_count(); - let bootstrap = Arc::new(NostrDiscovery::new_for_test()); + let bootstrap = Arc::new(NostrRendezvous::new_for_test()); let socket = UdpSocket::bind("127.0.0.1:0").expect("bind local UDP socket"); let remote_addr = "127.0.0.1:9999".parse().expect("parse remote addr"); bootstrap.push_event_for_test(BootstrapEvent::Established { @@ -1173,9 +1173,9 @@ async fn test_nostr_traversal_established_skips_connected_peer() { socket, ), }); - node.nostr_discovery = Some(bootstrap.clone()); + node.nostr_rendezvous.set_engine(bootstrap.clone()); - node.poll_nostr_discovery().await; + node.poll_nostr_rendezvous().await; assert_eq!( node.link_count(), @@ -1711,14 +1711,14 @@ async fn process_pending_retries_gated_at_capacity() { } #[tokio::test] -async fn poll_nostr_discovery_established_gated_at_capacity() { - use crate::discovery::EstablishedTraversal; +async fn poll_nostr_rendezvous_established_gated_at_capacity() { + use crate::nostr::EstablishedTraversal; use std::net::UdpSocket; let mut node = make_node_with_max_peers(2); inject_dummy_peers(&mut node, 2); - let bootstrap = Arc::new(NostrDiscovery::new_for_test()); + let bootstrap = Arc::new(NostrRendezvous::new_for_test()); let socket = UdpSocket::bind("127.0.0.1:0").expect("bind local UDP socket"); let remote_addr = "127.0.0.1:9999".parse().expect("parse remote addr"); let peer_identity = Identity::generate(); @@ -1730,13 +1730,13 @@ async fn poll_nostr_discovery_established_gated_at_capacity() { socket, ), }); - node.nostr_discovery = Some(bootstrap.clone()); + node.nostr_rendezvous.set_engine(bootstrap.clone()); let before_peers = node.peer_count(); let before_links = node.link_count(); let before_connections = node.connection_count(); - node.poll_nostr_discovery().await; + node.poll_nostr_rendezvous().await; assert_eq!( node.peer_count(), @@ -1756,11 +1756,11 @@ async fn poll_nostr_discovery_established_gated_at_capacity() { } #[test] -fn nostr_discovery_outbound_admission_atomic_roundtrip() { +fn nostr_rendezvous_outbound_admission_atomic_roundtrip() { // Verifies the runtime-side plumbing for the two NAT-traversal gate // points: the setter mutates the atomic and the (super-visible) // reader observes the value the Node-side wiring would publish. - let bootstrap = NostrDiscovery::new_for_test(); + let bootstrap = NostrRendezvous::new_for_test(); assert!( bootstrap.outbound_admission_allowed(), "default must allow (start unsaturated)" diff --git a/src/nostr/driver.rs b/src/nostr/driver.rs new file mode 100644 index 0000000..e595181 --- /dev/null +++ b/src/nostr/driver.rs @@ -0,0 +1,399 @@ +//! Node-side driver state for the Nostr overlay peer-rendezvous subsystem. +//! +//! [`RendezvousDriver`] consolidates the rendezvous-subsystem state that +//! previously lived as loose fields on the `Node` struct: the engine handle, +//! its startup timestamp, the one-shot startup-sweep latch, and the +//! per-peer bootstrap-transport bookkeeping adopted from NAT-traversal +//! handoffs. Keeping it in the `nostr` module gives the subsystem a single +//! home while leaving the transport/connection-table mutations that consume +//! this state on `Node`. + +use std::collections::{HashMap, HashSet}; +use std::net::SocketAddr; +use std::sync::Arc; + +use tracing::{debug, info, warn}; + +use crate::config::{NostrRendezvousConfig, NostrRendezvousPolicy, PeerAddress, PeerConfig}; +use crate::transport::TransportId; + +use super::{ + ADVERT_IDENTIFIER, ADVERT_VERSION, BootstrapError, NostrRendezvous, OverlayAdvert, + OverlayEndpointAdvert, OverlayTransportKind, +}; + +/// Snapshot of a single operational transport's advertisable endpoint +/// inputs, captured on `Node` at advert-build time so the driver can +/// assemble the overlay advert without borrowing the transport table. +/// Only transports whose type matched a configured listener are included; +/// the `advertise` gate is carried verbatim so the driver reproduces the +/// original per-transport branch logic exactly. +pub enum AdvertTransportSnapshot { + Udp { + advertise: bool, + is_public: bool, + external_addr: Option, + local_addr: Option, + transport_key: u32, + }, + Tcp { + advertise: bool, + external_addr: Option, + local_addr: Option, + }, + Tor { + advertise: bool, + onion_addr: Option, + advertised_port: u16, + }, +} + +/// Node-side rendezvous-subsystem state and bootstrap-transport bookkeeping. +#[derive(Default)] +pub struct RendezvousDriver { + /// Optional Nostr/STUN overlay discovery coordinator for `udp:nat` peers. + engine: Option>, + /// Wall-clock ms when Nostr discovery successfully started, used to + /// schedule the one-shot startup advert sweep after a settle delay. + /// `None` until discovery comes up; remains `None` if discovery is + /// disabled or failed to start. + started_at_ms: Option, + /// Whether the one-shot startup advert sweep has run. Set to true + /// after the first sweep fires (under `policy: open`); thereafter + /// only the per-tick `queue_open_discovery_retries` continues. + startup_sweep_done: bool, + /// Per-peer UDP transports adopted from NAT traversal handoff. + bootstrap_transports: HashSet, + /// Originating peer npub (bech32) for each adopted bootstrap + /// transport, captured at `adopt_established_traversal` time. + /// Populated alongside `bootstrap_transports`; cleared in + /// `cleanup_bootstrap_transport_if_unused`. Used by the rx loop to + /// route fatal-protocol-mismatch observations back to the + /// Nostr-discovery `failure_state` for long cooldown application. + bootstrap_transport_npubs: HashMap, +} + +impl RendezvousDriver { + /// Borrow the engine handle if discovery is running. + pub fn engine(&self) -> Option<&NostrRendezvous> { + self.engine.as_deref() + } + + /// Clone the engine `Arc` handle if discovery is running. + pub fn engine_arc(&self) -> Option> { + self.engine.clone() + } + + /// Install the engine handle once discovery starts. + pub fn set_engine(&mut self, engine: Arc) { + self.engine = Some(engine); + } + + /// Take the engine handle for shutdown, clearing it. + pub fn take_engine(&mut self) -> Option> { + self.engine.take() + } + + /// Record the wall-clock ms at which discovery successfully started. + pub fn set_started_at_ms(&mut self, now_ms: u64) { + self.started_at_ms = Some(now_ms); + } + + /// Wall-clock ms when discovery started, if it has. + pub fn started_at_ms(&self) -> Option { + self.started_at_ms + } + + /// Whether the one-shot startup sweep has already run. + pub fn startup_sweep_done(&self) -> bool { + self.startup_sweep_done + } + + /// Latch the one-shot startup sweep as done. + pub fn set_startup_sweep_done(&mut self) { + self.startup_sweep_done = true; + } + + /// Whether `transport_id` is an adopted bootstrap transport. + pub fn is_bootstrap_transport(&self, transport_id: &TransportId) -> bool { + self.bootstrap_transports.contains(transport_id) + } + + /// Originating peer npub for an adopted bootstrap transport, if any. + pub fn bootstrap_transport_npub(&self, transport_id: &TransportId) -> Option<&String> { + self.bootstrap_transport_npubs.get(transport_id) + } + + /// Register an adopted bootstrap transport and its originating npub. + pub fn insert_bootstrap_transport(&mut self, transport_id: TransportId, npub: String) { + self.bootstrap_transports.insert(transport_id); + self.bootstrap_transport_npubs.insert(transport_id, npub); + } + + /// Drop an adopted bootstrap transport from both bookkeeping maps. + pub fn remove_bootstrap_transport(&mut self, transport_id: &TransportId) { + self.bootstrap_transports.remove(transport_id); + self.bootstrap_transport_npubs.remove(transport_id); + } + + /// Convert an advertised overlay endpoint into a `PeerAddress` candidate. + /// Pure mapping; `seen_at_ms` is supplied by the caller. + pub fn overlay_endpoint_to_peer_address( + endpoint: &OverlayEndpointAdvert, + priority: u8, + seen_at_ms: u64, + ) -> Option { + let transport = match endpoint.transport { + OverlayTransportKind::Udp => "udp", + OverlayTransportKind::Tcp => "tcp", + OverlayTransportKind::Tor => "tor", + }; + Some( + PeerAddress::with_priority(transport, endpoint.addr.clone(), priority) + .with_seen_at_ms(seen_at_ms), + ) + } + + /// Kick off a Nostr-mediated UDP NAT-traversal attempt for `peer_config`. + /// Returns whether an attempt was started (false if discovery is down). + pub async fn request_nostr_bootstrap(&self, peer_config: &PeerConfig) -> bool { + let Some(bootstrap) = self.engine_arc() else { + debug!(npub = %peer_config.npub, "No Nostr overlay runtime for udp:nat address"); + return false; + }; + bootstrap.request_connect(peer_config.clone()).await; + info!(npub = %peer_config.npub, "Started Nostr UDP NAT traversal attempt"); + true + } + + /// Resolve additional overlay `PeerAddress` candidates for a `via_nostr` + /// configured peer by fetching its published advert endpoints. `existing` + /// is the already-known static address list (used for priority and dedup); + /// `now_ms` stamps the returned candidates' `seen_at`. + pub async fn nostr_peer_fallback_addresses( + &self, + peer_config: &PeerConfig, + existing: &[PeerAddress], + nostr_cfg: &NostrRendezvousConfig, + now_ms: u64, + ) -> Vec { + if !nostr_cfg.enabled + || !peer_config.via_nostr + || nostr_cfg.policy == NostrRendezvousPolicy::Disabled + { + return Vec::new(); + } + + let Some(bootstrap) = self.engine_arc() else { + return Vec::new(); + }; + let endpoints = match bootstrap.advert_endpoints_for_peer(&peer_config.npub).await { + Ok(endpoints) => endpoints, + Err(err) => { + debug!( + npub = %peer_config.npub, + error = %err, + "Failed to resolve Nostr advert endpoints for configured peer" + ); + return Vec::new(); + } + }; + + let mut fallback = Vec::new(); + let mut next_priority = existing + .iter() + .map(|addr| addr.priority) + .max() + .unwrap_or(100) + .saturating_add(1); + let seen_at_ms = now_ms; + for endpoint in endpoints { + let Some(candidate) = + Self::overlay_endpoint_to_peer_address(&endpoint, next_priority, seen_at_ms) + else { + continue; + }; + if existing + .iter() + .any(|addr| addr.transport == candidate.transport && addr.addr == candidate.addr) + || fallback.iter().any(|addr: &PeerAddress| { + addr.transport == candidate.transport && addr.addr == candidate.addr + }) + { + continue; + } + fallback.push(candidate); + next_priority = next_priority.saturating_add(1); + } + fallback + } + + /// Publish (or withdraw) the local overlay advert built from `snapshot`. + /// `bootstrap` is passed explicitly because the startup path refreshes the + /// advert before the engine handle is installed on the driver. + pub async fn refresh_overlay_advert( + &self, + bootstrap: &Arc, + snapshot: Vec, + nostr_cfg: &NostrRendezvousConfig, + ) -> Result<(), BootstrapError> { + let advert = self + .build_overlay_advert(bootstrap, snapshot, nostr_cfg) + .await; + bootstrap.update_local_advert(advert).await + } + + /// Assemble the local `OverlayAdvert` from the per-transport `snapshot`. + /// The STUN `learn_public_udp_addr` await for wildcard-bound public UDP + /// sockets is reached through the `bootstrap` handle. + async fn build_overlay_advert( + &self, + bootstrap: &Arc, + snapshot: Vec, + nostr_cfg: &NostrRendezvousConfig, + ) -> Option { + if !nostr_cfg.enabled { + return None; + } + + let mut endpoints = Vec::new(); + let mut has_udp_nat = false; + + for entry in snapshot { + match entry { + AdvertTransportSnapshot::Udp { + advertise, + is_public, + external_addr, + local_addr, + transport_key, + } => { + if !advertise { + continue; + } + if is_public { + // Precedence: + // 1. operator-supplied `external_addr` (skips STUN) + // 2. non-wildcard `local_addr` (operator bound to + // a specific public IP directly) + // 3. STUN auto-discovery against ephemeral socket + // 4. loud warn + omit endpoint + if let Some(explicit) = external_addr { + endpoints.push(OverlayEndpointAdvert { + transport: OverlayTransportKind::Udp, + addr: explicit.to_string(), + }); + } else { + match local_addr { + Some(addr) if !addr.ip().is_unspecified() => { + endpoints.push(OverlayEndpointAdvert { + transport: OverlayTransportKind::Udp, + addr: addr.to_string(), + }); + } + Some(addr) => { + let key = transport_key; + let port = addr.port(); + if let Some(public) = + bootstrap.learn_public_udp_addr(key, port).await + { + endpoints.push(OverlayEndpointAdvert { + transport: OverlayTransportKind::Udp, + addr: public.to_string(), + }); + } else { + warn!( + transport_id = key, + bind_addr = %addr, + "advert: udp public=true bound to wildcard but \ + STUN observation failed; advertising no UDP \ + endpoint. Either set transports.udp.external_addr, \ + bind to a specific public IP, or ensure \ + node.rendezvous.nostr.stun_servers is reachable" + ); + } + } + None => {} + } + } + } else { + endpoints.push(OverlayEndpointAdvert { + transport: OverlayTransportKind::Udp, + addr: "nat".to_string(), + }); + has_udp_nat = true; + } + } + AdvertTransportSnapshot::Tcp { + advertise, + external_addr, + local_addr, + } => { + if !advertise { + continue; + } + // Precedence: + // 1. operator-supplied `external_addr` (only path that + // works on cloud-NAT setups where the public IP is + // not on a host interface). + // 2. non-wildcard `local_addr` (operator bound to a + // specific public IP directly). + // 3. loud warn + omit endpoint (no TCP STUN equivalent). + if let Some(explicit) = external_addr { + endpoints.push(OverlayEndpointAdvert { + transport: OverlayTransportKind::Tcp, + addr: explicit.to_string(), + }); + } else { + match local_addr { + Some(addr) if !addr.ip().is_unspecified() => { + endpoints.push(OverlayEndpointAdvert { + transport: OverlayTransportKind::Tcp, + addr: addr.to_string(), + }); + } + Some(addr) => { + warn!( + bind_addr = %addr, + "advert: tcp advertise_on_nostr=true bound to wildcard \ + and no transports.tcp.external_addr set; advertising no \ + TCP endpoint. Either set external_addr to the public \ + IP (recommended for cloud 1:1-NAT setups) or bind \ + explicitly to the public IP" + ); + } + None => {} + } + } + } + AdvertTransportSnapshot::Tor { + advertise, + onion_addr, + advertised_port, + } => { + if !advertise { + continue; + } + if let Some(addr) = onion_addr { + endpoints.push(OverlayEndpointAdvert { + transport: OverlayTransportKind::Tor, + addr: format!("{}:{}", addr, advertised_port), + }); + } + } + } + } + + if endpoints.is_empty() { + return None; + } + + Some(OverlayAdvert { + identifier: ADVERT_IDENTIFIER.to_string(), + version: ADVERT_VERSION, + endpoints, + signal_relays: has_udp_nat.then(|| nostr_cfg.dm_relays.clone()), + stun_servers: has_udp_nat.then(|| nostr_cfg.stun_servers.clone()), + }) + } +} diff --git a/src/discovery/nostr/failure_state.rs b/src/nostr/failure_state.rs similarity index 100% rename from src/discovery/nostr/failure_state.rs rename to src/nostr/failure_state.rs diff --git a/src/discovery.rs b/src/nostr/handoff.rs similarity index 94% rename from src/discovery.rs rename to src/nostr/handoff.rs index 3612fb5..2ee4028 100644 --- a/src/discovery.rs +++ b/src/nostr/handoff.rs @@ -6,9 +6,6 @@ //! it hands the live socket and selected remote endpoint to FIPS so the //! existing Noise/FMP transport path can take over. -pub mod lan; -pub mod nostr; - use crate::config::UdpConfig; use crate::{NodeAddr, TransportId}; use std::net::{SocketAddr, UdpSocket}; @@ -16,9 +13,9 @@ use std::net::{SocketAddr, UdpSocket}; /// Punch-probe magic ("NPTC", network byte order). First byte `0x4E` /// collides with FMP's prefix-version high-nibble check, so the UDP /// transport silently filters packets carrying this magic to keep -/// post-adoption handshake logs clean. Defined at the top-level -/// `discovery` module so the UDP filter and the nostr submodule's -/// punch sender share the same constant. +/// post-adoption handshake logs clean. Defined in the nostr rendezvous +/// home so the UDP filter and the nostr submodule's punch sender share +/// the same constant. pub const PUNCH_MAGIC: u32 = 0x4E505443; /// Punch-probe-ack magic ("NPTA", network byte order). Same filter as diff --git a/src/discovery/nostr/mod.rs b/src/nostr/mod.rs similarity index 72% rename from src/discovery/nostr/mod.rs rename to src/nostr/mod.rs index 881fb2e..24ec3cf 100644 --- a/src/discovery/nostr/mod.rs +++ b/src/nostr/mod.rs @@ -1,4 +1,6 @@ +mod driver; mod failure_state; +mod handoff; mod runtime; mod signal; mod stun; @@ -8,7 +10,9 @@ mod types; #[cfg(test)] mod tests; -pub use runtime::NostrDiscovery; +pub use driver::{AdvertTransportSnapshot, RendezvousDriver}; +pub use handoff::{BootstrapHandoffResult, EstablishedTraversal, is_punch_packet}; +pub use runtime::NostrRendezvous; pub use types::{ ADVERT_IDENTIFIER, ADVERT_KIND, ADVERT_VERSION, BootstrapError, BootstrapEvent, CachedOverlayAdvert, NostrFailureDecision, NostrPeerFailureView, NostrRefetchOutcome, diff --git a/src/discovery/nostr/runtime.rs b/src/nostr/runtime.rs similarity index 99% rename from src/discovery/nostr/runtime.rs rename to src/nostr/runtime.rs index f1bb485..e2e1a16 100644 --- a/src/discovery/nostr/runtime.rs +++ b/src/nostr/runtime.rs @@ -17,6 +17,7 @@ use tokio::task::JoinHandle; use tracing::{debug, info, trace, warn}; use super::failure_state::FailureState; +use super::handoff::EstablishedTraversal; use super::signal::{ FreshnessOutcome, SignalEnvelope, build_signal_event, create_traversal_answer, create_traversal_offer, estimate_clock_skew, unwrap_signal_event, validate_offer_freshness, @@ -30,8 +31,7 @@ use super::types::{ OverlayAdvert, OverlayEndpointAdvert, PROTOCOL_VERSION, PunchHint, SIGNAL_KIND, TraversalAnswer, TraversalOffer, }; -use crate::config::{NostrDiscoveryConfig, PeerConfig}; -use crate::discovery::EstablishedTraversal; +use crate::config::{NostrRendezvousConfig, PeerConfig}; use crate::{NodeAddr, PeerIdentity}; const ADVERT_CACHE_STALE_GRACE_MULTIPLIER: u64 = 2; @@ -157,7 +157,7 @@ fn endpoint_advert_is_publicly_usable(endpoint: &OverlayEndpointAdvert) -> bool } /// Cached STUN-derived public address for an advert-eligible UDP transport -/// bound to a wildcard. Lives on `NostrDiscovery` so the freshness window +/// bound to a wildcard. Lives on `NostrRendezvous` so the freshness window /// survives advert refresh cycles. struct CachedPublicUdpAddr { /// Most recent STUN observation. `None` means the last attempt failed @@ -177,12 +177,12 @@ const PUBLIC_UDP_ADDR_FAILURE_TTL: Duration = Duration::from_secs(60); const RELAY_STARTUP_OP_TIMEOUT: Duration = Duration::from_secs(5); const ADVERT_PUBLISH_TIMEOUT: Duration = Duration::from_secs(10); -pub struct NostrDiscovery { +pub struct NostrRendezvous { client: Client, keys: nostr::Keys, pubkey: PublicKey, npub: String, - config: NostrDiscoveryConfig, + config: NostrRendezvousConfig, advert_cache: RwLock>, local_advert: RwLock>, current_advert_event_id: RwLock>, @@ -212,10 +212,10 @@ pub struct NostrDiscovery { outbound_admission: AtomicBool, } -impl NostrDiscovery { +impl NostrRendezvous { pub async fn start( identity: &crate::Identity, - config: NostrDiscoveryConfig, + config: NostrRendezvousConfig, ) -> Result, BootstrapError> { if !config.enabled { return Err(BootstrapError::Disabled); @@ -1733,8 +1733,8 @@ impl NostrDiscovery { } #[cfg(test)] -impl NostrDiscovery { - /// Build a minimal `NostrDiscovery` for unit tests. No relay client is +impl NostrRendezvous { + /// Build a minimal `NostrRendezvous` for unit tests. No relay client is /// connected and no background tasks are spawned; only the in-memory /// `advert_cache` and `npub` are usable. Intended for cache-injection /// tests of consumers (e.g. `Node::run_open_discovery_sweep`). @@ -1746,7 +1746,7 @@ impl NostrDiscovery { .signer(keys.clone()) .opts(ClientOptions::new().autoconnect(false)) .build(); - let config = NostrDiscoveryConfig::default(); + let config = NostrRendezvousConfig::default(); let offer_slots = Arc::new(Semaphore::new(config.max_concurrent_incoming_offers)); let (event_tx, event_rx) = mpsc::unbounded_channel(); let failure_state = FailureState::new( diff --git a/src/discovery/nostr/signal.rs b/src/nostr/signal.rs similarity index 100% rename from src/discovery/nostr/signal.rs rename to src/nostr/signal.rs diff --git a/src/discovery/nostr/stun.rs b/src/nostr/stun.rs similarity index 100% rename from src/discovery/nostr/stun.rs rename to src/nostr/stun.rs diff --git a/src/discovery/nostr/tests.rs b/src/nostr/tests.rs similarity index 96% rename from src/discovery/nostr/tests.rs rename to src/nostr/tests.rs index 3a5b7d6..63450b7 100644 --- a/src/discovery/nostr/tests.rs +++ b/src/nostr/tests.rs @@ -1,6 +1,6 @@ use nostr::prelude::{EventBuilder, Kind, Tag, Timestamp}; -use super::runtime::{NostrDiscovery, suppress_responder_for_own_initiator}; +use super::runtime::{NostrRendezvous, suppress_responder_for_own_initiator}; use super::signal::{ FreshnessOutcome, build_signal_event, create_traversal_answer, create_traversal_offer, estimate_clock_skew, validate_offer_freshness, validate_traversal_answer_for_offer, @@ -104,7 +104,7 @@ fn rejects_invalid_overlay_adverts() { signal_relays: None, stun_servers: None, }; - assert!(NostrDiscovery::validate_overlay_advert(missing_nat_metadata).is_err()); + assert!(NostrRendezvous::validate_overlay_advert(missing_nat_metadata).is_err()); let wrong_identifier = OverlayAdvert { identifier: "not-fips-overlay".to_string(), @@ -116,7 +116,7 @@ fn rejects_invalid_overlay_adverts() { signal_relays: None, stun_servers: None, }; - assert!(NostrDiscovery::validate_overlay_advert(wrong_identifier).is_err()); + assert!(NostrRendezvous::validate_overlay_advert(wrong_identifier).is_err()); } #[test] @@ -142,7 +142,7 @@ fn validate_overlay_advert_filters_unroutable_direct_endpoints() { stun_servers: None, }; - let validated = NostrDiscovery::validate_overlay_advert(advert).unwrap(); + let validated = NostrRendezvous::validate_overlay_advert(advert).unwrap(); assert_eq!(validated.endpoints.len(), 1); assert_eq!(validated.endpoints[0].addr, "8.8.8.8:443"); } @@ -166,7 +166,7 @@ fn validate_overlay_advert_rejects_only_unroutable_direct_endpoints() { stun_servers: None, }; - let err = NostrDiscovery::validate_overlay_advert(advert).unwrap_err(); + let err = NostrRendezvous::validate_overlay_advert(advert).unwrap_err(); assert!(err.to_string().contains("missing publicly routable")); } @@ -175,7 +175,7 @@ fn advert_freshness_rejects_expired_events() { let now_secs = Timestamp::now().as_secs(); let event = signed_overlay_advert_event(now_secs, Some(now_secs.saturating_sub(1))); let valid_until = - NostrDiscovery::compute_advert_valid_until_ms(&event, 600_000, now_secs * 1000); + NostrRendezvous::compute_advert_valid_until_ms(&event, 600_000, now_secs * 1000); assert!(valid_until.is_none()); } @@ -185,7 +185,7 @@ fn advert_freshness_rejects_stale_created_at_without_expiration() { let stale_created = now_secs.saturating_sub(10_000); let event = signed_overlay_advert_event(stale_created, None); let valid_until = - NostrDiscovery::compute_advert_valid_until_ms(&event, 600_000, now_secs * 1000); + NostrRendezvous::compute_advert_valid_until_ms(&event, 600_000, now_secs * 1000); assert!(valid_until.is_none()); } @@ -194,7 +194,7 @@ fn advert_freshness_uses_earliest_expiration_bound() { let now_secs = Timestamp::now().as_secs(); let event = signed_overlay_advert_event(now_secs.saturating_sub(10), Some(now_secs + 30)); let valid_until = - NostrDiscovery::compute_advert_valid_until_ms(&event, 3_600_000, now_secs * 1000) + NostrRendezvous::compute_advert_valid_until_ms(&event, 3_600_000, now_secs * 1000) .expect("event should be fresh"); assert_eq!(valid_until, (now_secs + 30) * 1000); } diff --git a/src/discovery/nostr/traversal.rs b/src/nostr/traversal.rs similarity index 100% rename from src/discovery/nostr/traversal.rs rename to src/nostr/traversal.rs diff --git a/src/discovery/nostr/types.rs b/src/nostr/types.rs similarity index 95% rename from src/discovery/nostr/types.rs rename to src/nostr/types.rs index b13f7c0..1bd4207 100644 --- a/src/discovery/nostr/types.rs +++ b/src/nostr/types.rs @@ -1,5 +1,5 @@ +use super::handoff::EstablishedTraversal; use crate::config::PeerConfig; -use crate::discovery::EstablishedTraversal; use serde::{Deserialize, Serialize}; pub const ADVERT_KIND: u16 = 37195; @@ -13,9 +13,9 @@ pub const ADVERT_KIND: u16 = 37195; pub const ADVERT_IDENTIFIER: &str = "fips-overlay-v1-next"; pub const ADVERT_VERSION: u32 = 1; pub const SIGNAL_KIND: u16 = 21059; -// Defined at the top-level `discovery` module; re-exported here so the +// Defined in the nostr `handoff` submodule; re-exported here so the // existing punch sender / receiver imports remain unchanged. -pub use crate::discovery::{PUNCH_ACK_MAGIC, PUNCH_MAGIC}; +pub use super::handoff::{PUNCH_ACK_MAGIC, PUNCH_MAGIC}; pub const PROTOCOL_VERSION: &str = "1"; #[derive(Debug, thiserror::Error)] @@ -196,7 +196,7 @@ pub struct PunchPacket { pub session_hash: [u8; 16], } -/// Outcome of `NostrDiscovery::record_traversal_failure`. +/// Outcome of `NostrRendezvous::record_traversal_failure`. #[derive(Debug, Clone, Copy)] pub struct NostrFailureDecision { pub consecutive_failures: u32, @@ -219,7 +219,7 @@ pub struct NostrPeerFailureView { pub last_observed_skew_ms: Option, } -/// Outcome of `NostrDiscovery::refetch_advert_for_stale_check` (B6). +/// Outcome of `NostrRendezvous::refetch_advert_for_stale_check` (B6). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NostrRefetchOutcome { Evicted, diff --git a/src/transport/udp/mod.rs b/src/transport/udp/mod.rs index 3728fb9..780af5a 100644 --- a/src/transport/udp/mod.rs +++ b/src/transport/udp/mod.rs @@ -16,7 +16,7 @@ pub(crate) mod socket; mod stats; use super::resolve_socket_addr; use crate::config::UdpConfig; -use crate::discovery::is_punch_packet; +use crate::nostr::is_punch_packet; use socket::{AsyncUdpSocket, UdpRawSocket}; use stats::UdpStats; use std::collections::HashMap; @@ -960,7 +960,7 @@ mod tests { #[test] fn test_is_punch_packet_helper() { - use crate::discovery::is_punch_packet; + use crate::nostr::is_punch_packet; // PUNCH_MAGIC ("NPTC", be) assert!(is_punch_packet(&[0x4E, 0x50, 0x54, 0x43, 0xAA, 0xBB])); // PUNCH_ACK_MAGIC ("NPTA", be) diff --git a/testing/mesh-lab/compose-trace-nat.yml b/testing/mesh-lab/compose-trace-nat.yml index 63680d1..9c60921 100644 --- a/testing/mesh-lab/compose-trace-nat.yml +++ b/testing/mesh-lab/compose-trace-nat.yml @@ -2,7 +2,7 @@ # relevant to NAT-traversal handshake-completion flake evidence # collection (ISSUE-2026-0027): # -# - fips::discovery::nostr — overlay advert publish/consume +# - fips::nostr — overlay advert publish/consume # (where the cross-init race begins) # - fips::transport::udp — UDP socket bind/send/recv # (where the punch packets flow) @@ -33,7 +33,7 @@ # is set and the suite is nat-lan. x-trace-rust-log: &trace-rust-log - RUST_LOG: "info,fips::discovery::nostr=trace,fips::transport::udp=trace,fips::node::lifecycle=trace,fips::node::handlers::handshake=trace,fips::node::handlers::forwarding=trace" + RUST_LOG: "info,fips::nostr=trace,fips::transport::udp=trace,fips::node::lifecycle=trace,fips::node::handlers::handshake=trace,fips::node::handlers::forwarding=trace" services: lan-a: diff --git a/testing/nat/docker-compose.yml b/testing/nat/docker-compose.yml index 72fe7ee..2f974e3 100644 --- a/testing/nat/docker-compose.yml +++ b/testing/nat/docker-compose.yml @@ -27,7 +27,7 @@ x-fips-common: &fips-common - net.ipv6.conf.all.disable_ipv6=0 restart: "no" environment: - - RUST_LOG=info,fips::discovery::nostr=debug,fips::node::lifecycle=debug + - RUST_LOG=info,fips::nostr=debug,fips::node::lifecycle=debug services: relay: @@ -115,7 +115,7 @@ services: entrypoint: - /usr/local/bin/nat-node-entrypoint.sh environment: - - RUST_LOG=info,fips::discovery::nostr=debug,fips::node::lifecycle=debug + - RUST_LOG=info,fips::nostr=debug,fips::node::lifecycle=debug - DATA_IF=eth0 - ROUTE_SUBNET=172.31.254.0/24 - ROUTE_VIA=172.31.1.254 @@ -141,7 +141,7 @@ services: entrypoint: - /usr/local/bin/nat-node-entrypoint.sh environment: - - RUST_LOG=info,fips::discovery::nostr=debug,fips::node::lifecycle=debug + - RUST_LOG=info,fips::nostr=debug,fips::node::lifecycle=debug - DATA_IF=eth0 - ROUTE_SUBNET=172.31.254.0/24 - ROUTE_VIA=172.31.2.254 @@ -167,7 +167,7 @@ services: entrypoint: - /usr/local/bin/nat-node-entrypoint.sh environment: - - RUST_LOG=info,fips::discovery::nostr=debug,fips::node::lifecycle=debug + - RUST_LOG=info,fips::nostr=debug,fips::node::lifecycle=debug - DATA_IF=eth0 - ROUTE_SUBNET=172.31.254.0/24 - ROUTE_VIA=172.31.1.254 @@ -193,7 +193,7 @@ services: entrypoint: - /usr/local/bin/nat-node-entrypoint.sh environment: - - RUST_LOG=info,fips::discovery::nostr=debug,fips::node::lifecycle=debug + - RUST_LOG=info,fips::nostr=debug,fips::node::lifecycle=debug - DATA_IF=eth0 - ROUTE_SUBNET=172.31.254.0/24 - ROUTE_VIA=172.31.2.254