1use std::{
2 collections::{HashMap, HashSet},
3 sync::{
4 Arc,
5 atomic::{AtomicBool, Ordering},
6 },
7 time::{Duration, Instant},
8};
9
10use dht::async_dht::AsyncDht;
11use ed25519_dalek::SigningKey;
12use futures_lite::StreamExt;
13use iroh::{Endpoint, EndpointId};
14use iroh_gossip::api::{GossipReceiver, GossipSender};
15use n0_future::time;
16use n0_watcher::Watchable;
17use sha2::Digest;
18use tokio::sync::Mutex;
19
20#[derive(Debug, Clone)]
21pub struct TopicDiscoveryConfig {
22 endpoint: Endpoint,
23
24 signing_key: SigningKey,
25 announce_interval: Duration,
27 discovery_interval: Duration,
29 first_connected_duration: Option<Duration>,
31 discovery_interval_first_connected: Duration,
33 discovery_interval_no_peers: Duration,
35 connection_timeout: Duration,
37 retry_interval: Duration,
39 max_peers_per_round: Option<usize>,
41 dht_retries: Option<usize>,
43}
44
45pub struct ConfigBuilder(TopicDiscoveryConfig);
46
47impl ConfigBuilder {
48 pub fn announce_interval(mut self, interval: Duration) -> Self {
49 self.0.announce_interval = interval;
50 self
51 }
52
53 pub fn discovery_interval(mut self, interval: Duration) -> Self {
54 self.0.discovery_interval = interval;
55 self
56 }
57
58 pub fn discovery_interval_no_peers(mut self, interval: Duration) -> Self {
59 self.0.discovery_interval_no_peers = interval;
60 self
61 }
62
63 pub fn discovery_interval_first_connected(mut self, interval: Duration) -> Self {
64 self.0.discovery_interval_first_connected = interval;
65 self
66 }
67
68 pub fn first_connected_duration(mut self, duration: Option<Duration>) -> Self {
69 self.0.first_connected_duration = duration;
70 self
71 }
72
73 pub fn connection_timeout(mut self, timeout: Duration) -> Self {
74 self.0.connection_timeout = timeout;
75 self
76 }
77
78 pub fn retry_interval(mut self, interval: Duration) -> Self {
79 self.0.retry_interval = interval;
80 self
81 }
82
83 pub fn max_peers_per_round(mut self, max: Option<usize>) -> Self {
84 self.0.max_peers_per_round = max;
85 self
86 }
87
88 pub fn dht_retries(mut self, retries: Option<usize>) -> Self {
89 self.0.dht_retries = retries;
90 self
91 }
92
93 pub fn build(&self) -> TopicDiscoveryConfig {
94 self.0.clone()
95 }
96}
97
98impl TopicDiscoveryConfig {
99 pub fn builder(endpoint: Endpoint) -> ConfigBuilder {
100 ConfigBuilder(Self {
101 signing_key: endpoint.secret_key().to_bytes().into(),
102 endpoint,
103 announce_interval: Duration::from_secs(300),
104 discovery_interval: Duration::from_secs(60),
105 first_connected_duration: Some(Duration::from_secs(60)),
106 discovery_interval_first_connected: Duration::from_secs(5),
107 discovery_interval_no_peers: Duration::from_secs(2),
108 connection_timeout: Duration::from_secs(5),
109 retry_interval: Duration::from_secs(300),
110 max_peers_per_round: Some(5),
111 dht_retries: None,
112 })
113 }
114
115 pub fn announce_interval(&self) -> Duration {
116 self.announce_interval
117 }
118
119 pub fn discovery_interval(&self) -> Duration {
120 self.discovery_interval
121 }
122
123 pub fn discovery_interval_no_peers(&self) -> Duration {
124 self.discovery_interval_no_peers
125 }
126
127 pub fn connection_timeout(&self) -> Duration {
128 self.connection_timeout
129 }
130
131 pub fn retry_interval(&self) -> Duration {
132 self.retry_interval
133 }
134
135 pub fn max_peers_per_round(&self) -> Option<usize> {
136 self.max_peers_per_round
137 }
138
139 pub fn dht_retries(&self) -> Option<usize> {
140 self.dht_retries
141 }
142}
143
144#[derive(Debug, Clone)]
145struct DiscoveryState {
146 once_connected_neighbors: Arc<Mutex<HashSet<EndpointId>>>,
148 stopped: Arc<AtomicBool>,
150 attempted: Arc<Mutex<HashMap<[u8; 32], Instant>>>,
152 retry_interval: Duration,
154 first_connected_timestamp: Watchable<Option<Instant>>,
156}
157
158impl DiscoveryState {
159 fn new(retry_interval: Duration) -> Arc<Self> {
160 Arc::new(Self {
161 once_connected_neighbors: Arc::new(Mutex::new(HashSet::new())),
162 stopped: Arc::new(AtomicBool::new(false)),
163 attempted: Arc::new(Mutex::new(HashMap::new())),
164 retry_interval,
165 first_connected_timestamp: Watchable::new(None),
166 })
167 }
168
169 fn stop(&self) {
170 self.stopped.store(true, Ordering::Relaxed);
171 }
172
173 fn is_stopped(&self) -> bool {
174 self.stopped.load(Ordering::Relaxed)
175 }
176
177 async fn has_connections(&self) -> bool {
178 let guard = self.once_connected_neighbors.lock().await;
179 !guard.is_empty()
180 }
181
182 async fn added_connection_count(&self) -> usize {
183 let guard = self.once_connected_neighbors.lock().await;
184 guard.len()
185 }
186
187 fn added_neighbors(&self) -> Arc<Mutex<HashSet<EndpointId>>> {
189 self.once_connected_neighbors.clone()
190 }
191
192 async fn should_attempt(&self, peer: [u8; 32]) -> bool {
195 let mut map = self.attempted.lock().await;
196 match map.get(&peer) {
197 None => {
198 map.insert(peer, Instant::now());
199 true
200 }
201 Some(last_attempt) => {
202 if last_attempt.elapsed() > self.retry_interval {
203 map.insert(peer, Instant::now());
204 true
205 } else {
206 false
207 }
208 }
209 }
210 }
211
212 async fn reset_attempt(&self, peer: [u8; 32]) {
213 let mut map = self.attempted.lock().await;
214 map.remove(&peer);
215 }
216
217 fn first_connected_timestamp_watcher(&self) -> Watchable<Option<Instant>> {
218 self.first_connected_timestamp.clone()
219 }
220
221 fn first_connected_phase(&self, config: &TopicDiscoveryConfig) -> bool {
222 if let Some(timestamp) = self.first_connected_timestamp_watcher().get()
223 && let Some(first_connected_duration) = config.first_connected_duration
224 && timestamp.elapsed() < first_connected_duration
225 {
226 true
227 } else {
228 false
229 }
230 }
231}
232
233#[derive(Debug)]
234pub struct TopicDiscoveryHandle {
235 state: Arc<DiscoveryState>,
236 _tasks: Vec<tokio::task::JoinHandle<()>>,
237}
238
239impl TopicDiscoveryHandle {
240 pub fn stop(&self) {
241 self.state.stop();
242 }
243
244 pub fn is_running(&self) -> bool {
245 !self.state.is_stopped()
246 }
247
248 pub async fn has_connections(&self) -> bool {
249 self.state.has_connections().await
250 }
251
252 pub async fn added_connection_count(&self) -> usize {
253 self.state.added_connection_count().await
254 }
255
256 pub async fn added_neighbors(&self) -> HashSet<EndpointId> {
259 let mtx = self.state.added_neighbors();
260 let guard = mtx.lock().await;
261 guard.clone()
262 }
263}
264
265impl Drop for TopicDiscoveryHandle {
266 fn drop(&mut self) {
267 self.stop();
268 }
269}
270
271pub trait TopicDiscoveryExt {
272 #[allow(async_fn_in_trait)]
273 async fn subscribe_with_discovery_joined(
274 &self,
275 topic_id: Vec<u8>,
276 bootstrap_nodes: Vec<EndpointId>,
277 config: TopicDiscoveryConfig,
278 ) -> anyhow::Result<(GossipSender, GossipReceiver, TopicDiscoveryHandle)>;
279
280 #[allow(async_fn_in_trait)]
281 async fn subscribe_with_discovery(
282 &self,
283 topic_id: Vec<u8>,
284 bootstrap_nodes: Vec<EndpointId>,
285 config: TopicDiscoveryConfig,
286 ) -> anyhow::Result<(GossipSender, GossipReceiver, TopicDiscoveryHandle)>;
287}
288
289impl TopicDiscoveryExt for iroh_gossip::net::Gossip {
290 async fn subscribe_with_discovery_joined(
291 &self,
292 topic_id: Vec<u8>,
293 bootstrap_nodes: Vec<EndpointId>,
294 config: TopicDiscoveryConfig,
295 ) -> anyhow::Result<(GossipSender, GossipReceiver, TopicDiscoveryHandle)> {
296 tracing::info!("subscribe_with_discovery_joined: starting subscription");
297 let (sender, mut receiver, handle) = self
298 .subscribe_with_discovery(topic_id, bootstrap_nodes, config)
299 .await?;
300 tracing::info!("subscribe_with_discovery_joined: waiting for receiver.joined()");
301 receiver.joined().await?;
302
303 while handle.added_connection_count().await < 1 {
304 tokio::time::sleep(Duration::from_millis(50)).await;
305 }
306 tracing::info!("subscribe_with_discovery_joined: joined successfully");
307 Ok((sender, receiver, handle))
308 }
309
310 async fn subscribe_with_discovery(
311 &self,
312 topic_id: Vec<u8>,
313 bootstrap_nodes: Vec<EndpointId>,
314 config: TopicDiscoveryConfig,
315 ) -> anyhow::Result<(GossipSender, GossipReceiver, TopicDiscoveryHandle)> {
316 tracing::info!("subscribe_with_discovery: computing topic hash");
317 let topic_bytes = topic_hash_32(&topic_id);
318 tracing::debug!(
319 "subscribe_with_discovery: topic_hash={}",
320 hex::encode(topic_bytes)
321 );
322
323 tracing::info!("subscribe_with_discovery: subscribing to gossip topic");
324 let (sender, receiver) = self
325 .subscribe(
326 iroh_gossip::proto::TopicId::from_bytes(topic_bytes),
327 bootstrap_nodes,
328 )
329 .await?
330 .split();
331
332 tracing::info!(
333 "subscribe_with_discovery: subscribed, spawning announce and discovery tasks"
334 );
335
336 let state = DiscoveryState::new(config.retry_interval);
337
338 tracing::info!("subscribe_with_discovery: initializing shared DHT");
339 let mut tries = 0;
340 let dht = loop {
341 if let Ok(dht) = init_dht().await {
342 break Arc::new(dht);
343 }
344 tracing::warn!("subscribe_with_discovery: DHT init failed, retrying in 2s");
345 tokio::time::sleep(Duration::from_secs(2)).await;
346 tries += 1;
347 if let Some(retries) = config.dht_retries()
348 && tries > retries
349 {
350 anyhow::bail!("DHT init failed after 5 attempts");
351 }
352 };
353
354 let tasks = vec![
355 spawn_announce_task(state.clone(), dht.clone(), topic_bytes, config.clone()),
356 spawn_discovery_task(state.clone(), dht, sender.clone(), topic_bytes, config),
357 ];
358
359 let handle = TopicDiscoveryHandle {
360 state,
361 _tasks: tasks,
362 };
363
364 Ok((sender, receiver, handle))
365 }
366}
367
368async fn init_dht() -> anyhow::Result<AsyncDht> {
369 tracing::info!("init_dht: building DHT with bootstrap nodes");
370 let dht = dht::Dht::builder()
371 .no_bootstrap()
372 .bootstrap(&["pkarr.rustonbsd.com:6881", "relay.pkarr.org:6881"])
373 .build()?
374 .as_async();
375
376 tracing::info!("init_dht: waiting for DHT bootstrap... ");
377 match tokio::time::timeout(Duration::from_secs(15), dht.bootstrapped()).await {
378 Ok(true) => {}
379 Ok(false) => {
380 tracing::error!("init_dht: DHT bootstrap failed");
381 anyhow::bail!("DHT bootstrap failed");
382 }
383 Err(_) => {
384 tracing::error!("init_dht: DHT bootstrap timed out");
385 anyhow::bail!("DHT bootstrap timed out");
386 }
387 }
388
389 Ok(dht)
390}
391
392fn spawn_connector(
393 state: Arc<DiscoveryState>,
394 gossip_sender: GossipSender,
395 peer: EndpointId,
396 timeout: Duration,
397 endpoint: Endpoint,
398) {
399 tokio::spawn(async move {
400 if state.is_stopped() {
401 return;
402 }
403
404 tracing::debug!("connector: joining peer {} via gossip", peer.fmt_short());
405
406 let _ = gossip_sender.join_peers(vec![peer]).await;
407
408 if state.is_stopped() {
409 return;
410 }
411
412 let wait_for_connection = async {
413 loop {
414 if let Some(remote_info) = endpoint.remote_info(peer).await
415 && remote_info.addrs().any(|addr| {
416 matches!(addr.usage(), iroh::endpoint::TransportAddrUsage::Active)
417 })
418 {
419 return true;
420 }
421 tokio::time::sleep(Duration::from_millis(250)).await;
422 }
423 };
424
425 match time::timeout(timeout, wait_for_connection).await {
426 Ok(true) => {
427 tracing::info!(
428 "connector: successfully connected to peer {}",
429 peer.fmt_short()
430 );
431 let mut guard = state.once_connected_neighbors.lock().await;
432 guard.insert(peer);
433 return;
434 }
435 Ok(false) => {
436 tracing::debug!(
437 "connector: stopped while waiting for connection to {}",
438 peer.fmt_short()
439 );
440 }
441 Err(_) => {
442 tracing::warn!(
443 "connector: timeout waiting for connection to {} after {:?}",
444 peer.fmt_short(),
445 timeout
446 );
447 }
448 }
449
450 state.reset_attempt(*peer.as_bytes()).await;
451 });
452}
453
454fn spawn_announce_task(
455 state: Arc<DiscoveryState>,
456 dht: Arc<AsyncDht>,
457 topic_hash_32: [u8; 32],
458 config: TopicDiscoveryConfig,
459) -> tokio::task::JoinHandle<()> {
460 tracing::info!("spawn_announce_task: starting announce task");
461 tokio::spawn(async move {
462 let mut backoff = Duration::from_secs(5);
463 let mut round = 0u64;
464
465 let id = match dht::Id::from_bytes(topic_hash_20(&topic_hash_32)) {
466 Ok(id) => id,
467 Err(e) => {
468 tracing::error!("announce_task: invalid topic hash: {e}");
469 return;
470 }
471 };
472
473 while !state.is_stopped() {
474 round += 1;
475 tracing::debug!("announce_task: round {round} starting");
476
477 tracing::debug!("announce_task: announcing to DHT");
478 match tokio::time::timeout(
479 Duration::from_secs(30),
480 dht.announce_signed_peer(id, &config.signing_key),
481 )
482 .await
483 {
484 Ok(Ok(_)) => {
485 tracing::info!("announce_task: DHT announce success");
486 backoff = Duration::from_secs(5);
487 tracing::debug!("announce_task: sleeping for {:?}", config.announce_interval);
488 tokio::time::sleep(config.announce_interval).await;
489 }
490 Ok(Err(e)) => {
491 tracing::warn!(
492 "announce_task: DHT announce failed: {e}, retrying in {backoff:?}"
493 );
494
495 tracing::debug!("announce_task: refreshing tokens via get_signed_peers");
499 let mut stream = dht.get_signed_peers(id).await;
500 let _ = stream.next().await;
501
502 tokio::time::sleep(backoff).await;
503 backoff = (backoff * 2).min(Duration::from_secs(60));
504 }
505 Err(_) => {
506 tracing::warn!(
507 "announce_task: DHT announce timed out, retrying in {backoff:?}"
508 );
509 tokio::time::sleep(backoff).await;
510 backoff = (backoff * 2).min(Duration::from_secs(60));
511 }
512 }
513 }
514 tracing::info!("announce_task: stopped");
515 })
516}
517
518fn spawn_discovery_task(
519 state: Arc<DiscoveryState>,
520 dht: Arc<AsyncDht>,
521 gossip_sender: GossipSender,
522 topic_hash_32: [u8; 32],
523 config: TopicDiscoveryConfig,
524) -> tokio::task::JoinHandle<()> {
525 let my_key = config.signing_key.verifying_key().to_bytes();
526
527 tracing::info!("spawn_discovery_task: starting discovery task");
528 tokio::spawn(async move {
529 let mut round = 0u64;
530
531 let mut no_peer_backoff = config.discovery_interval_no_peers;
532 let backoff_increment = config.discovery_interval_no_peers;
533
534 let id = match dht::Id::from_bytes(topic_hash_20(&topic_hash_32)) {
535 Ok(id) => id,
536 Err(e) => {
537 tracing::error!("discovery_task: invalid topic hash: {e}");
538 return;
539 }
540 };
541
542 while !state.is_stopped() {
543 round = round.saturating_add(1);
544 tracing::debug!(
545 "discovery_task: round {round} starting (connected: {}, backoff: {:?})",
546 state.added_connection_count().await,
547 no_peer_backoff
548 );
549
550 tracing::debug!("discovery_task: querying DHT for peers");
551 let peers = collect_peers_with_timeout(
552 &dht,
553 id,
554 Duration::from_secs(30),
555 config.announce_interval,
556 )
557 .await;
558 tracing::debug!("discovery_task: found {} peers from DHT", peers.len());
559
560 let mut spawned: usize = 0;
561 for key_bytes in peers
562 .iter()
563 .take(config.max_peers_per_round.unwrap_or(usize::MAX))
564 {
565 if *key_bytes == my_key {
566 continue;
567 }
568
569 if !state.should_attempt(*key_bytes).await {
570 continue;
571 }
572
573 let Some(peer) = ed25519_dalek::VerifyingKey::from_bytes(key_bytes)
574 .ok()
575 .map(iroh::PublicKey::from_verifying_key)
576 else {
577 continue;
578 };
579
580 spawn_connector(
581 state.clone(),
582 gossip_sender.clone(),
583 peer,
584 config.connection_timeout,
585 config.endpoint.clone(),
586 );
587 spawned = spawned.saturating_add(1);
588 }
589
590 if spawned > 0 {
591 tracing::info!("discovery_task: spawned {spawned} connector tasks");
592 }
593
594 let has_connection = state.has_connections().await;
595 let interval = if has_connection {
596 no_peer_backoff = config.discovery_interval_no_peers;
597 if state.first_connected_phase(&config) {
598 config.discovery_interval_first_connected
599 } else {
600 config.discovery_interval
601 }
602 } else {
603 let current = no_peer_backoff;
604 no_peer_backoff =
605 (no_peer_backoff + backoff_increment).min(config.discovery_interval);
606 current
607 };
608
609 tracing::debug!(
610 "discovery_task: sleeping for {:?} (has_connections: {}, next_backoff: {:?})",
611 interval,
612 has_connection,
613 no_peer_backoff
614 );
615 tokio::time::sleep(interval).await;
616 if !has_connection && state.has_connections().await {
617 let additional_interval = if state.first_connected_phase(&config) {
618 config.discovery_interval_first_connected
619 } else {
620 config.discovery_interval
621 }
622 .saturating_sub(interval);
623 no_peer_backoff = config.discovery_interval_no_peers;
624 tracing::debug!(
625 "discovery_task: conn established during sleep, additional sleep for {:?} (has_connections: {}, next_backoff: {:?})",
626 additional_interval,
627 state.has_connections().await,
628 no_peer_backoff
629 );
630 tokio::time::sleep(additional_interval).await;
631 }
632 }
633 tracing::info!("discovery_task: stopped");
634 })
635}
636
637async fn collect_peers_with_timeout(
638 dht: &AsyncDht,
639 id: dht::Id,
640 timeout: Duration,
641 announce_interval: Duration,
642) -> Vec<[u8; 32]> {
643 use futures_lite::StreamExt;
644
645 tracing::debug!("collect_peers_with_timeout: starting peer collection");
646 let mut stream = dht.get_signed_peers(id).await;
647 let deadline = tokio::time::Instant::now() + timeout;
648 let mut valid_items = Vec::new();
649 while let Ok(Some(items)) = tokio::time::timeout_at(deadline, stream.next()).await {
650 tracing::debug!(
651 "collect_peers_with_timeout: received batch of {} signed peers from DHT",
652 items.len()
653 );
654 for item in items {
655 let key_hex = hex::encode(&item.key()[..8]); tracing::debug!(
657 "collect_peers_with_timeout: peer key={key_hex}... timestamp={}",
658 item.timestamp()
659 );
660
661 let now = std::time::SystemTime::now()
662 .duration_since(std::time::UNIX_EPOCH)
663 .unwrap_or_default()
664 .as_micros();
665
666 let max_age = announce_interval.as_micros() + 10_000_000; let age = now.saturating_sub(item.timestamp() as u128);
668 if age > max_age {
669 tracing::debug!(
670 "collect_peers_with_timeout: skipping stale peer {key_hex}... (age: {}ms, max: {}ms)",
671 age / 1000,
672 max_age / 1000
673 );
674 continue;
675 }
676
677 if !valid_items.contains(&item) {
678 valid_items.push(item);
679 }
680 }
681 }
682
683 valid_items.sort_by_key(|item| item.timestamp());
684 valid_items.reverse();
685 valid_items.dedup_by_key(|item| item.key().to_vec());
686
687 tracing::debug!(
688 "collect_peers_with_timeout: finished with {} peers",
689 valid_items.len()
690 );
691 valid_items.iter().map(|item| *item.key()).collect()
692}
693
694fn topic_hash_32(topic_bytes: &Vec<u8>) -> [u8; 32] {
695 let mut hasher = sha2::Sha512::new();
696 hasher.update("/iroh/topic-discovery/v2");
697 hasher.update(topic_bytes);
698 hasher.finalize()[..32].try_into().expect("hashing failed")
699}
700
701fn topic_hash_20(topic_hash_32: &[u8; 32]) -> [u8; 20] {
702 let mut hasher = sha2::Sha512::new();
703 hasher.update(topic_hash_32);
704 hasher.finalize()[..20].try_into().expect("hashing failed")
705}