use std::collections::{HashMap, HashSet};
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use nostr::nips::nip17;
use nostr::nips::nip19::ToBech32;
use nostr::prelude::{
Alphabet, Event, EventBuilder, EventId, Filter, Kind, PublicKey, RelayUrl, SingleLetterTag,
Tag, TagKind, Timestamp,
};
use nostr_sdk::{Client, ClientOptions, prelude::RelayPoolNotification};
use serde::Serialize;
use tokio::sync::{Mutex, Notify, RwLock, Semaphore, broadcast, mpsc, oneshot};
use tokio::task::JoinHandle;
use tracing::{debug, info, trace, warn};
use super::failure_state::{FailureDecision, FailureState, NostrPeerKey};
use super::signal::{
FreshnessOutcome, SignalEnvelope, build_signal_event, create_traversal_answer,
create_traversal_offer, estimate_clock_skew, unwrap_signal_event, validate_offer_freshness,
validate_traversal_answer_for_offer,
};
use super::stun::{ADVERT_STUN_TIMEOUT, TRAVERSAL_STUN_TIMEOUT, observe_traversal_addresses};
use super::traversal::{nonce, now_ms, planned_remote_endpoints, run_punch_attempt};
use super::types::{
ADVERT_IDENTIFIER, ADVERT_KIND, ADVERT_VERSION, BootstrapError, BootstrapEvent,
CachedOverlayAdvert, MeshTraversalSignal, NostrFailureDecision, NostrPeerFailureView,
NostrRefetchOutcome, NostrRelayStatus, OverlayAdvert, OverlayEndpointAdvert,
OverlayTransportKind, PROTOCOL_VERSION, PunchHint, SIGNAL_KIND, TraversalAnswer,
TraversalOffer, advert_d_tag,
};
use crate::config::{NostrDiscoveryConfig, PeerConfig};
use crate::discovery::EstablishedTraversal;
use crate::{NodeAddr, PeerIdentity};
mod advert;
mod events;
mod notifications;
mod signals;
mod tasks;
mod traversal;
mod verified_event;
#[cfg(test)]
mod test_support;
#[cfg(test)]
mod tests;
pub(in crate::discovery::nostr) use verified_event::VerifiedEvent;
const ADVERT_CACHE_STALE_GRACE_MULTIPLIER: u64 = 2;
fn bind_traversal_udp_socket() -> std::io::Result<std::net::UdpSocket> {
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
use socket2::{Domain, Protocol, Socket, Type};
let socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?;
let _ = socket.set_reuse_address(true);
let _ = socket.set_reuse_port(true);
socket.bind(&SocketAddr::from(([0, 0, 0, 0], 0)).into())?;
let socket: std::net::UdpSocket = socket.into();
socket.set_nonblocking(true)?;
Ok(socket)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let socket = std::net::UdpSocket::bind(("0.0.0.0", 0))?;
socket.set_nonblocking(true)?;
Ok(socket)
}
}
fn short_npub(npub: &str) -> String {
npub.strip_prefix("npub1")
.filter(|s| s.len() >= 8)
.map(|s| format!("npub1{}..{}", &s[..4], &s[s.len() - 4..]))
.unwrap_or_else(|| npub.to_string())
}
fn short_id(id: &str) -> String {
if id.len() > 8 {
id[..8].to_string()
} else {
id.to_string()
}
}
#[derive(Clone, Copy)]
pub(super) enum TraversalSignalPath {
Mesh,
Nostr,
}
impl TraversalSignalPath {
fn cache_key(self, session_id: &str) -> String {
let prefix = match self {
Self::Mesh => "mesh",
Self::Nostr => "nostr",
};
format!("{prefix}:{session_id}")
}
}
pub(super) fn suppress_responder_for_own_initiator(
our_addr: &NodeAddr,
peer_addr: &NodeAddr,
have_active_initiator: bool,
) -> bool {
have_active_initiator && our_addr < peer_addr
}
fn endpoint_summary(endpoints: &[OverlayEndpointAdvert]) -> String {
endpoints
.iter()
.map(|e| format!("{:?}:{}", e.transport, e.addr).to_lowercase())
.collect::<Vec<_>>()
.join(",")
}
fn is_unroutable_direct_advert_ip(ip: std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(v4) => {
v4.is_private()
|| v4.is_loopback()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.is_multicast()
|| v4.is_broadcast()
|| v4.is_documentation()
|| (v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 64)
}
std::net::IpAddr::V6(v6) => {
v6.is_loopback()
|| v6.is_unspecified()
|| v6.is_unique_local()
|| v6.is_multicast()
|| (v6.segments()[0] & 0xffc0) == 0xfe80
}
}
}
fn endpoint_advert_is_publicly_usable(endpoint: &OverlayEndpointAdvert) -> bool {
let addr = endpoint.addr.trim();
if addr.is_empty() {
return false;
}
if endpoint.transport == super::types::OverlayTransportKind::Udp
&& addr.eq_ignore_ascii_case("nat")
{
return true;
}
if addr.eq_ignore_ascii_case("nat") {
return false;
}
match endpoint.transport {
super::types::OverlayTransportKind::Udp | super::types::OverlayTransportKind::Tcp => {
let Ok(socket_addr) = addr.parse::<SocketAddr>() else {
let Some((host, port)) = addr.rsplit_once(':') else {
return false;
};
let host = host.trim().trim_start_matches('[').trim_end_matches(']');
if host.is_empty() || port.trim().parse::<u16>().ok().is_none_or(|p| p == 0) {
return false;
}
if host.eq_ignore_ascii_case("localhost") {
return false;
}
return host
.parse::<std::net::IpAddr>()
.ok()
.is_none_or(|ip| !is_unroutable_direct_advert_ip(ip));
};
socket_addr.port() != 0 && !is_unroutable_direct_advert_ip(socket_addr.ip())
}
super::types::OverlayTransportKind::Tor => true,
super::types::OverlayTransportKind::WebRtc => is_compressed_pubkey_hex(addr),
}
}
fn is_compressed_pubkey_hex(addr: &str) -> bool {
addr.len() == 66
&& (addr.starts_with("02") || addr.starts_with("03"))
&& addr.as_bytes().iter().all(u8::is_ascii_hexdigit)
}
struct CachedPublicUdpAddr {
addr: Option<SocketAddr>,
fetched_at: Instant,
}
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);
const ADVERT_PUBLISH_RETRY_INITIAL: Duration = Duration::from_secs(2);
const ADVERT_PUBLISH_RETRY_MAX: Duration = Duration::from_secs(30);
fn next_advert_publish_retry_delay(current: Duration) -> Duration {
current.saturating_mul(2).min(ADVERT_PUBLISH_RETRY_MAX)
}
fn signal_answer_timeout(config: &NostrDiscoveryConfig) -> Duration {
Duration::from_secs(
config
.signal_ttl_secs
.min(config.attempt_timeout_secs)
.max(1),
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct NostrRelayConfig {
advert_relays: Vec<String>,
dm_relays: Vec<String>,
}
impl From<&NostrDiscoveryConfig> for NostrRelayConfig {
fn from(config: &NostrDiscoveryConfig) -> Self {
Self {
advert_relays: config.advert_relays.clone(),
dm_relays: config.dm_relays.clone(),
}
}
}
impl NostrRelayConfig {
fn union(&self) -> HashSet<String> {
self.advert_relays
.iter()
.chain(self.dm_relays.iter())
.cloned()
.collect()
}
}
pub struct NostrDiscovery {
client: Client,
keys: nostr::Keys,
pubkey: PublicKey,
npub: String,
config: NostrDiscoveryConfig,
relay_config: RwLock<NostrRelayConfig>,
advert_cache: RwLock<HashMap<NostrPeerKey, CachedOverlayAdvert>>,
local_advert: RwLock<Option<OverlayAdvert>>,
current_advert_event_id: RwLock<Option<EventId>>,
pending_answers: Mutex<HashMap<String, oneshot::Sender<SignalEnvelope<TraversalAnswer>>>>,
active_initiators: Mutex<HashSet<NostrPeerKey>>,
active_refetches: Mutex<HashSet<NostrPeerKey>>,
seen_sessions: Mutex<HashMap<String, u64>>,
offer_slots: Arc<Semaphore>,
event_tx: mpsc::Sender<BootstrapEvent>,
event_rx: Mutex<mpsc::Receiver<BootstrapEvent>>,
mesh_signal_tx: mpsc::Sender<MeshTraversalSignal>,
mesh_signal_rx: Mutex<mpsc::Receiver<MeshTraversalSignal>>,
connect_task: Mutex<Option<JoinHandle<()>>>,
relay_startup_task: Mutex<Option<JoinHandle<()>>>,
publish_task: Mutex<Option<JoinHandle<()>>>,
publish_notify: Notify,
notify_task: Mutex<Option<JoinHandle<()>>>,
advertise_task: Mutex<Option<JoinHandle<()>>>,
failure_state: FailureState,
public_udp_addr_cache: RwLock<HashMap<u32, CachedPublicUdpAddr>>,
outbound_admission: AtomicBool,
direct_refresh_admission: AtomicBool,
}
impl NostrDiscovery {
fn empty_failure_decision() -> NostrFailureDecision {
NostrFailureDecision {
consecutive_failures: 0,
should_warn: false,
cooldown_until_ms: None,
crossed_threshold: false,
}
}
fn failure_decision_from(decision: FailureDecision) -> NostrFailureDecision {
NostrFailureDecision {
consecutive_failures: decision.consecutive_failures,
should_warn: decision.should_warn,
cooldown_until_ms: decision.cooldown_until_ms,
crossed_threshold: decision.crossed_threshold,
}
}
pub async fn start(
identity: &crate::Identity,
config: NostrDiscoveryConfig,
) -> Result<Arc<Self>, BootstrapError> {
if !config.enabled {
return Err(BootstrapError::Disabled);
}
let keys = nostr::Keys::parse(&hex::encode(identity.keypair().secret_bytes()))
.map_err(|e| BootstrapError::Nostr(e.to_string()))?;
let client = Client::builder()
.signer(keys.clone())
.opts(ClientOptions::new().autoconnect(false))
.build();
let mut relay_union = HashSet::new();
relay_union.extend(config.advert_relays.iter().cloned());
relay_union.extend(config.dm_relays.iter().cloned());
for relay in relay_union {
client
.add_relay(&relay)
.await
.map_err(|e| BootstrapError::Nostr(e.to_string()))?;
}
let pubkey = keys.public_key();
let npub = crate::encode_npub(&identity.pubkey());
let (event_tx, event_rx) = mpsc::channel(event_channel_capacity(&config));
let (mesh_signal_tx, mesh_signal_rx) = mpsc::channel(event_channel_capacity(&config));
let offer_slots = Arc::new(Semaphore::new(config.max_concurrent_incoming_offers));
let failure_state = FailureState::new(
config.failure_streak_threshold,
config.extended_cooldown_secs,
config.warn_log_interval_secs,
config.failure_state_max_entries,
);
let runtime = Arc::new(Self {
client,
keys,
pubkey,
npub,
relay_config: RwLock::new(NostrRelayConfig::from(&config)),
config,
advert_cache: RwLock::new(HashMap::new()),
local_advert: RwLock::new(None),
current_advert_event_id: RwLock::new(None),
pending_answers: Mutex::new(HashMap::new()),
active_initiators: Mutex::new(HashSet::new()),
active_refetches: Mutex::new(HashSet::new()),
seen_sessions: Mutex::new(HashMap::new()),
offer_slots,
event_tx,
event_rx: Mutex::new(event_rx),
mesh_signal_tx,
mesh_signal_rx: Mutex::new(mesh_signal_rx),
connect_task: Mutex::new(None),
relay_startup_task: Mutex::new(None),
publish_task: Mutex::new(None),
publish_notify: Notify::new(),
notify_task: Mutex::new(None),
advertise_task: Mutex::new(None),
failure_state,
public_udp_addr_cache: RwLock::new(HashMap::new()),
outbound_admission: AtomicBool::new(true),
direct_refresh_admission: AtomicBool::new(true),
});
let notifications = runtime.client.notifications();
*runtime.publish_task.lock().await = Some(runtime.clone().spawn_publish_loop());
*runtime.connect_task.lock().await = Some(runtime.clone().spawn_connect_loop());
*runtime.relay_startup_task.lock().await = Some(runtime.clone().spawn_relay_startup_loop());
*runtime.advertise_task.lock().await = Some(runtime.clone().spawn_advertise_loop());
*runtime.notify_task.lock().await = Some(runtime.clone().spawn_notify_loop(notifications));
Ok(runtime)
}
pub fn set_outbound_admission(&self, allow: bool) {
self.outbound_admission.store(allow, Ordering::Relaxed);
}
pub(crate) fn outbound_admission_allowed(&self) -> bool {
self.outbound_admission.load(Ordering::Relaxed)
}
pub fn set_direct_refresh_admission(&self, allow: bool) {
self.direct_refresh_admission
.store(allow, Ordering::Relaxed);
}
pub(crate) fn direct_refresh_admission_allowed(&self) -> bool {
self.direct_refresh_admission.load(Ordering::Relaxed)
}
fn self_peer_key(&self) -> NostrPeerKey {
NostrPeerKey::from_public_key_ref(&self.pubkey)
}
fn traversal_initiator_admission_allowed(&self, mesh_signaling_allowed: bool) -> bool {
if mesh_signaling_allowed {
self.direct_refresh_admission_allowed()
} else {
self.outbound_admission_allowed()
}
}
pub async fn relay_statuses(&self) -> Vec<NostrRelayStatus> {
let relay_config = self.relay_config.read().await.clone();
let mut statuses = relay_config
.advert_relays
.iter()
.chain(relay_config.dm_relays.iter())
.map(|url| {
(
url.clone(),
NostrRelayStatus {
url: url.clone(),
status: "unknown".to_string(),
},
)
})
.collect::<HashMap<_, _>>();
for (relay_url, relay) in self.client.relays().await {
let url = relay_url.to_string();
statuses.insert(
url.clone(),
NostrRelayStatus {
url,
status: relay.status().to_string().to_ascii_lowercase(),
},
);
}
let mut statuses = statuses.into_values().collect::<Vec<_>>();
statuses.sort_by(|lhs, rhs| lhs.url.cmp(&rhs.url));
statuses
}
pub async fn update_relays(
&self,
advert_relays: Vec<String>,
dm_relays: Vec<String>,
) -> Result<(), BootstrapError> {
let next = NostrRelayConfig {
advert_relays,
dm_relays,
};
let previous = self.relay_config.read().await.clone();
if previous == next {
return Ok(());
}
let previous_union = previous.union();
let next_union = next.union();
for relay in next_union.difference(&previous_union) {
self.client
.add_relay(relay)
.await
.map_err(|e| BootstrapError::Nostr(e.to_string()))?;
}
for relay in previous_union.difference(&next_union) {
self.client
.force_remove_relay(relay)
.await
.map_err(|e| BootstrapError::Nostr(e.to_string()))?;
}
{
let mut relay_config = self.relay_config.write().await;
*relay_config = next;
}
for relay in next_union {
if let Err(error) = self.client.connect_relay(relay.clone()).await {
warn!(relay = %relay, error = %error, "failed to connect updated Nostr relay");
}
}
if let Err(error) = self.subscribe().await {
warn!(error = %error, "failed to subscribe updated Nostr relays");
}
if let Err(error) = self.publish_inbox_relays().await {
warn!(error = %error, "failed to publish updated Nostr inbox relay list");
}
self.request_publish_advert();
Ok(())
}
pub fn record_traversal_failure(&self, npub: &str, now_ms: u64) -> NostrFailureDecision {
let Ok(peer) = NostrPeerKey::parse(npub) else {
return Self::empty_failure_decision();
};
Self::failure_decision_from(self.failure_state.record_failure(peer, now_ms))
}
pub(crate) fn record_traversal_failure_for_peer(
&self,
peer: PeerIdentity,
now_ms: u64,
) -> NostrFailureDecision {
Self::failure_decision_from(
self.failure_state
.record_failure(NostrPeerKey::from_peer_identity(peer), now_ms),
)
}
pub fn record_unstable_path(&self, npub: &str, now_ms: u64) -> NostrFailureDecision {
let Ok(peer) = NostrPeerKey::parse(npub) else {
return Self::empty_failure_decision();
};
Self::failure_decision_from(self.failure_state.record_unstable_path(peer, now_ms))
}
pub(crate) fn record_unstable_path_for_peer(
&self,
peer: PeerIdentity,
now_ms: u64,
) -> NostrFailureDecision {
Self::failure_decision_from(
self.failure_state
.record_unstable_path(NostrPeerKey::from_peer_identity(peer), now_ms),
)
}
pub fn record_traversal_success(&self, npub: &str, now_ms: u64) {
if let Ok(peer) = NostrPeerKey::parse(npub) {
self.failure_state.record_success(peer, now_ms);
}
}
pub fn cooldown_until(&self, npub: &str, now_ms: u64) -> Option<u64> {
let Ok(peer) = NostrPeerKey::parse(npub) else {
return None;
};
self.failure_state.cooldown_until(peer, now_ms)
}
pub(crate) fn cooldown_until_peer(&self, peer: PeerIdentity, now_ms: u64) -> Option<u64> {
self.failure_state
.cooldown_until(NostrPeerKey::from_peer_identity(peer), now_ms)
}
pub fn record_protocol_mismatch(&self, npub: &str, now_ms: u64) -> bool {
let Ok(peer) = NostrPeerKey::parse(npub) else {
return false;
};
let cooldown_ms = self
.config
.protocol_mismatch_cooldown_secs
.saturating_mul(1000);
self.failure_state
.record_protocol_mismatch(peer, now_ms, cooldown_ms)
}
pub fn protocol_mismatch_cooldown_secs(&self) -> u64 {
self.config.protocol_mismatch_cooldown_secs
}
pub fn failure_state_snapshot(&self) -> Vec<NostrPeerFailureView> {
self.failure_state
.snapshot()
.into_iter()
.map(|(peer, rec)| NostrPeerFailureView {
npub: peer.npub(),
consecutive_failures: rec.consecutive_failures,
cooldown_until_ms: rec.cooldown_until_ms,
last_observed_skew_ms: rec.last_observed_skew_ms,
})
.collect()
}
}
fn event_channel_capacity(config: &NostrDiscoveryConfig) -> usize {
let work_limit = config
.open_discovery_max_pending
.max(config.max_concurrent_incoming_offers)
.max(1);
work_limit.saturating_mul(4).clamp(64, 4096)
}