Skip to main content

urma_node/
peer_discovery.rs

1//! Bitcoin-style peer discovery. Seeds bootstrap a sparse book; afterwards
2//! connected peers exchange verified addresses. A dial proves only that a
3//! Noise identity on this chain answered at an address: never record
4//! availability or honesty. Each Service round performs at most three
5//! network operations and checks cancellation between them.
6use crate::{
7    Error,
8    engine::Engine,
9    model::{BookEntry, DiscoveryStatus},
10    peer::PeerNetwork,
11    peer_address::Dialed,
12    peer_protocol::GossipPeer,
13    peerbook::PeerBook,
14    recorded::{RecordedError, read, unix_seconds as now},
15};
16use libp2p::{Multiaddr, PeerId, multiaddr::Protocol};
17use rand::seq::SliceRandom;
18use std::{
19    mem,
20    path::PathBuf,
21    sync::{
22        Arc, Mutex, MutexGuard,
23        atomic::{AtomicBool, AtomicU64, Ordering},
24    },
25    time::{Duration, Instant},
26};
27
28pub struct Discovery {
29    network: Arc<PeerNetwork>,
30    book: Arc<Mutex<PeerBook>>,
31    path: PathBuf,
32    round: AtomicU64,
33    seed_error: Mutex<Option<RecordedError>>,
34    gossip_error: Mutex<Option<RecordedError>>,
35    sync_backoff: Mutex<Vec<String>>,
36    own_addresses: Mutex<Vec<Multiaddr>>,
37    relayed: Mutex<Vec<Relayed>>,
38}
39
40/// A circuit dial that reached its peer only through a relay. It is neither
41/// reached nor failed until DCUtR gives the peer a direct connection.
42struct Relayed {
43    address: Multiaddr,
44    peer: PeerId,
45    since: Instant,
46}
47
48/// What a waiting circuit dial needs this round.
49#[derive(Debug, PartialEq, Eq)]
50enum Settle {
51    /// A direct connection exists: run the chain probe over it.
52    Probe,
53    /// Still relayed within the circuit lifetime: DCUtR may yet succeed.
54    Wait,
55    /// The relayed contact ended, or outlived the circuit, without a direct
56    /// connection: hole punching failed, so the address backs off.
57    Fail,
58}
59
60fn settle(direct: bool, connected: bool, waited: Duration) -> Settle {
61    match (direct, connected && waited < Discovery::CIRCUIT_WAIT) {
62        (true, _) => Settle::Probe,
63        (false, true) => Settle::Wait,
64        (false, false) => Settle::Fail,
65    }
66}
67
68/// Network operations left in this round; stop ends the round early.
69struct Budget<'a> {
70    left: usize,
71    stop: &'a AtomicBool,
72}
73
74impl Budget<'_> {
75    fn take(&mut self) -> bool {
76        if self.left == 0 || self.stop.load(Ordering::Acquire) {
77            return false;
78        }
79        self.left -= 1;
80        true
81    }
82}
83
84/// An address that can only ever be this node: it names our identity, or is
85/// an IP literal (no name that might also resolve elsewhere).
86fn names_self(address: &Multiaddr, local: &Protocol) -> bool {
87    let literal = matches!(
88        address.iter().next(),
89        Some(Protocol::Ip4(_) | Protocol::Ip6(_))
90    );
91    literal || address.iter().any(|part| part == *local)
92}
93
94/// A seed already connected (relayed connections included) is never
95/// re-dialed: the dial would only re-run the chain probe over a live link.
96fn names_connected(address: &Multiaddr, connected: &[PeerId]) -> bool {
97    matches!(address.iter().last(), Some(Protocol::P2p(peer)) if connected.contains(&peer))
98}
99
100fn poisoned() -> Error {
101    Error::Invalid("peer discovery state poisoned".into())
102}
103
104impl Discovery {
105    /// Connected peers the round tries to reach.
106    pub const TARGET: usize = 8;
107    pub const DIALS_PER_ROUND: usize = 2;
108    pub const OPERATIONS_PER_ROUND: usize = 3;
109    /// Rounds between address requests to one random connected peer.
110    pub const GOSSIP_EVERY: u64 = 30;
111    pub const GOSSIP_LIMIT: u32 = 32;
112    /// Rounds between seed passes while below target but connected.
113    pub const SEED_EVERY: u64 = 600;
114    /// At startup seeds are dialed while fewer entries than this were ever reached.
115    pub const BOOTSTRAP_REACHED: u64 = 4;
116    /// Wall-clock bound of one dial or address request.
117    pub const OPERATION_TIME: Duration = Duration::from_secs(10);
118    /// How long a relayed-only contact may wait for DCUtR: one circuit.
119    pub const CIRCUIT_WAIT: Duration = Duration::from_secs(120);
120    /// Circuit dials waiting for a direct connection at once.
121    pub const RELAYED_MAX: usize = 16;
122
123    /// `path` is where the book is saved after a round changes it.
124    pub fn new(network: Arc<PeerNetwork>, book: Arc<Mutex<PeerBook>>, path: PathBuf) -> Self {
125        Self {
126            network,
127            book,
128            path,
129            round: AtomicU64::new(0),
130            seed_error: Mutex::new(None),
131            gossip_error: Mutex::new(None),
132            sync_backoff: Mutex::new(Vec::new()),
133            own_addresses: Mutex::new(Vec::new()),
134            relayed: Mutex::new(Vec::new()),
135        }
136    }
137
138    /// Peers inventory sync is holding off (evicted, idle or blamed): neither
139    /// seed passes nor candidate dials reconnect them until they leave this set.
140    pub fn hold_off(&self, peers: Vec<String>) -> Result<(), Error> {
141        *self.sync_backoff.lock().map_err(|_| poisoned())? = peers;
142        Ok(())
143    }
144
145    fn lock(&self) -> Result<MutexGuard<'_, PeerBook>, Error> {
146        self.book.lock().map_err(|_| poisoned())
147    }
148
149    /// One bounded round: merge peers learned through identify, refresh the
150    /// served gossip snapshot, seeds when due, up to two dials while below
151    /// target, every thirtieth round one address request.
152    pub fn step(&self, engine: &Engine, stop: &AtomicBool) -> Result<(), Error> {
153        let round = self.round.fetch_add(1, Ordering::Relaxed);
154        let omitting = engine.omitting_peers()?;
155        let mut changed = self.merge_identified(&omitting)?;
156        self.publish(&omitting)?;
157        let mut budget = Budget {
158            left: Self::OPERATIONS_PER_ROUND,
159            stop,
160        };
161        let mut avoid = omitting.clone();
162        avoid.extend(
163            self.sync_backoff
164                .lock()
165                .map_err(|_| poisoned())?
166                .iter()
167                .cloned(),
168        );
169        if self.seeds_due(round)? {
170            changed |= self.dial_seeds(&avoid, &mut budget, round == 0)?;
171        }
172        changed |= self.settle_relayed(&mut budget)?;
173        changed |= self.dial_candidates(&avoid, &mut budget)?;
174        if round % Self::GOSSIP_EVERY == Self::GOSSIP_EVERY - 1 {
175            changed |= self.ask_for_peers(&omitting, &mut budget)?;
176        }
177        if changed {
178            self.lock()?.save(&self.path)?;
179        }
180        Ok(())
181    }
182
183    /// Peers that identified themselves to us, with public literal addresses,
184    /// enter the book as gossip under the same caps; a local operation.
185    fn merge_identified(&self, omitting: &[String]) -> Result<bool, Error> {
186        let at = now()?;
187        let peers: Vec<GossipPeer> = self
188            .network
189            .drain_identified()
190            .into_iter()
191            .map(|(peer_id, addrs)| GossipPeer {
192                peer_id,
193                addrs,
194                last_seen: at,
195            })
196            .collect();
197        if peers.is_empty() {
198            return Ok(false);
199        }
200        let local = self.network.peer_id();
201        Ok(self.lock()?.merge_identified(&peers, local, omitting) > 0)
202    }
203
204    /// Verified book entries; this node itself is learned through identify.
205    fn publish(&self, omitting: &[String]) -> Result<(), Error> {
206        let limit = usize::try_from(Self::GOSSIP_LIMIT)
207            .map_err(|error| Error::Invalid(format!("gossip limit: {error}")))?;
208        let at = now()?;
209        let mut peers = Vec::new();
210        for mut peer in self.lock()?.gossip(limit, omitting) {
211            peer.last_seen = peer.last_seen.min(at);
212            peers.push(peer);
213        }
214        self.network.set_gossip(peers);
215        Ok(())
216    }
217
218    /// Isolated nodes retry seeds every round, limited by each seed's backoff.
219    fn seeds_due(&self, round: u64) -> Result<bool, Error> {
220        let connected = self.direct_peers().len();
221        if connected >= Self::TARGET {
222            return Ok(false);
223        }
224        if connected == 0 {
225            return Ok(true);
226        }
227        let reached_ever = self.lock()?.stats().reached_ever;
228        Ok((round == 0 && reached_ever < Self::BOOTSTRAP_REACHED)
229            || (round > 0 && round % Self::SEED_EVERY == 0))
230    }
231
232    /// Dial one address outside the book lock and record the outcome. An
233    /// address that reached this node itself (a seed resolving to us) is
234    /// remembered and never dialed again by this process; the book keeps it,
235    /// since the same name is a real peer for every other node.
236    fn dial(&self, address: &Multiaddr, stop: &AtomicBool) -> Result<Result<PeerId, Error>, Error> {
237        let deadline = Instant::now() + Self::OPERATION_TIME;
238        let local = Protocol::P2p(self.network.peer_id());
239        let outcome = match address.iter().any(|part| part == local) {
240            true => Err(Error::LocalPeer),
241            false => match self.network.dns().admits(address) {
242                // Without a resolver the address is unreachable here, not bad.
243                Err(unresolvable) => return Ok(Err(unresolvable)),
244                Ok(()) => match self.network.dial_any(address.clone(), stop, deadline) {
245                    Ok(Dialed::Direct(peer)) => Ok(peer),
246                    // Neither reached nor failed: Busy leaves the book as is.
247                    Ok(Dialed::Relayed(peer)) => {
248                        self.await_direct(address.clone(), peer)?;
249                        Err(Error::Busy(format!(
250                            "{peer} reachable only through a relay; awaiting hole punching"
251                        )))
252                    }
253                    Err(failure) => Err(failure),
254                },
255            },
256        };
257        if stop.load(Ordering::Acquire) {
258            return Ok(outcome);
259        }
260        let at = now()?;
261        let mut book = self.lock()?;
262        match &outcome {
263            Ok(peer) => book.succeeded(address, *peer, self.network.peer_id(), at),
264            Err(Error::LocalPeer) if names_self(address, &local) => {
265                self.own()?.push(address.clone())
266            }
267            // A name may resolve to other hosts too: skip only this dial.
268            Err(Error::LocalPeer) => (),
269            // Our own capacity or a busy peer says nothing about the address.
270            Err(Error::DialDenied(_) | Error::Busy(_) | Error::PeerBusy) => (),
271            Err(_failure) => book.failed(address, at),
272        }
273        Ok(outcome)
274    }
275
276    fn own(&self) -> Result<MutexGuard<'_, Vec<Multiaddr>>, Error> {
277        self.own_addresses.lock().map_err(|_| poisoned())
278    }
279
280    /// The startup pass ignores backoff persisted by an earlier process.
281    fn dial_seeds(
282        &self,
283        omitting: &[String],
284        budget: &mut Budget,
285        startup: bool,
286    ) -> Result<bool, Error> {
287        let backoff_at = if startup { None } else { Some(now()?) };
288        let mut seeds = self.lock()?.seeds(omitting, backoff_at);
289        let own = self.own()?.clone();
290        let connected = self.network.connected_peer_ids();
291        seeds.retain(|seed| !own.contains(seed) && !names_connected(seed, &connected));
292        let mut dialed = false;
293        let mut error = None;
294        let mut reached = false;
295        for seed in seeds {
296            if !budget.take() {
297                break;
298            }
299            match self.dial(&seed, budget.stop)? {
300                Ok(_peer) => reached = true,
301                Err(Error::LocalPeer) => continue,
302                Err(failure) => error = Some(format!("{seed}: {failure}")),
303            }
304            dialed = true;
305        }
306        if dialed {
307            let recorded = if reached {
308                None
309            } else {
310                error.map(RecordedError::now)
311            };
312            *self.seed_error.lock().map_err(|_| poisoned())? = recorded;
313        }
314        Ok(dialed)
315    }
316
317    fn dial_candidates(&self, omitting: &[String], budget: &mut Budget) -> Result<bool, Error> {
318        if self.direct_peers().len() >= Self::TARGET {
319            return Ok(false);
320        }
321        // Relayed-only peers are excluded too: their circuit is already open.
322        let connected = self.network.connected_peer_ids();
323        let mut candidates =
324            self.lock()?
325                .candidates(now()?, &connected, omitting, Self::DIALS_PER_ROUND);
326        let own = self.own()?.clone();
327        candidates.retain(|candidate| !own.contains(candidate));
328        let mut dialed = false;
329        for candidate in candidates {
330            if !budget.take() {
331                break;
332            }
333            dialed = true;
334            // Either outcome is already recorded in the book (success or backoff).
335            match self.dial(&candidate, budget.stop)? {
336                Ok(_reached) => (),
337                Err(_backed_off) => (),
338            }
339        }
340        Ok(dialed)
341    }
342
343    /// Ask one random connected, non-omitting peer for addresses and merge
344    /// them under the book's caps. Lock order: the book lock is released
345    /// before `gossip_error` is taken; the two are never held together.
346    fn ask_for_peers(&self, omitting: &[String], budget: &mut Budget) -> Result<bool, Error> {
347        let connected: Vec<PeerId> = self
348            .network
349            .connected_peer_ids()
350            .into_iter()
351            .filter(|peer| self.network.can_gossip(*peer))
352            .filter(|peer| !omitting.contains(&peer.to_string()))
353            .collect();
354        let Some(peer) = connected.choose(&mut rand::thread_rng()).copied() else {
355            return Ok(false);
356        };
357        if !budget.take() {
358            return Ok(false);
359        }
360        let deadline = Instant::now() + Self::OPERATION_TIME;
361        let outcome = self
362            .network
363            .peers_of(peer, Self::GOSSIP_LIMIT, budget.stop, deadline);
364        let (changed, recorded) = match outcome {
365            Ok(peers) => {
366                let local = self.network.peer_id();
367                (self.lock()?.merge(&peers, local, omitting) > 0, None)
368            }
369            Err(failure) => (
370                false,
371                Some(RecordedError::now(format!("{peer}: {failure}"))),
372            ),
373        };
374        *self.gossip_error.lock().map_err(|_| poisoned())? = recorded;
375        Ok(changed)
376    }
377
378    /// Peers holding a direct data connection. Only these count toward the
379    /// target and carry records/5 (gossip, chain probe); a relayed-only or
380    /// auxiliary-only peer doesn't.
381    fn direct_peers(&self) -> Vec<PeerId> {
382        let mut peers = self.network.connected_peer_ids();
383        peers.retain(|peer| self.network.has_direct(*peer));
384        peers
385    }
386
387    fn relayed(&self) -> Result<MutexGuard<'_, Vec<Relayed>>, Error> {
388        self.relayed.lock().map_err(|_| poisoned())
389    }
390
391    /// Remember a circuit dial until DCUtR settles it; a newer contact
392    /// replaces the peer's older one. The oldest of a full list makes room
393    /// and counts as a failed dial; the book lock is taken only after the
394    /// waiting list is released.
395    fn await_direct(&self, address: Multiaddr, peer: PeerId) -> Result<(), Error> {
396        let evicted = {
397            let mut relayed = self.relayed()?;
398            relayed.retain(|waiting| waiting.peer != peer);
399            let evicted = match relayed.len() == Self::RELAYED_MAX {
400                true => Some(relayed.remove(0)),
401                false => None,
402            };
403            relayed.push(Relayed {
404                address,
405                peer,
406                since: Instant::now(),
407            });
408            evicted
409        };
410        let Some(entry) = evicted else {
411            return Ok(());
412        };
413        self.lock()?.failed(&entry.address, now()?);
414        Ok(())
415    }
416
417    /// Settle circuit dials (contract ยง3.6): a peer that gained a direct
418    /// connection gets the chain probe over it and counts as reached; one
419    /// whose relayed contact ended or outlived the circuit backs off. The
420    /// waiting list and the book lock are never held together.
421    fn settle_relayed(&self, budget: &mut Budget) -> Result<bool, Error> {
422        let waiting = mem::take(&mut *self.relayed()?);
423        let connected = self.network.connected_peer_ids();
424        let mut keep = Vec::new();
425        let mut changed = false;
426        for entry in waiting {
427            // A punched link released unproven before its probe is demoted,
428            // not gone: the probe still rides it and proves the chain.
429            let direct = self.network.any_direct(entry.peer);
430            let verdict = settle(
431                direct,
432                connected.contains(&entry.peer),
433                entry.since.elapsed(),
434            );
435            let settled = match verdict {
436                Settle::Wait => false,
437                Settle::Probe => budget.take() && self.probe_relayed(&entry, budget.stop)?,
438                Settle::Fail => {
439                    self.lock()?.failed(&entry.address, now()?);
440                    true
441                }
442            };
443            match settled {
444                true => changed = true,
445                false => keep.push(entry),
446            }
447        }
448        self.relayed()?.extend(keep);
449        Ok(changed)
450    }
451
452    /// The chain probe over a punched connection; false while the peer is
453    /// busy or the request found no data connection yet (Transport), so the
454    /// contact waits, at most until the circuit expires.
455    fn probe_relayed(&self, entry: &Relayed, stop: &AtomicBool) -> Result<bool, Error> {
456        let deadline = Instant::now() + Self::OPERATION_TIME;
457        let outcome = self.network.probe(entry.peer, stop, deadline);
458        let at = now()?;
459        let local = self.network.peer_id();
460        match outcome {
461            Ok(peer) => self.lock()?.succeeded(&entry.address, peer, local, at),
462            Err(Error::Busy(_) | Error::PeerBusy | Error::Transport(_)) => return Ok(false),
463            Err(_failure) => self.lock()?.failed(&entry.address, at),
464        }
465        Ok(true)
466    }
467
468    pub fn status(&self) -> Result<DiscoveryStatus, Error> {
469        let stats = self.lock()?.stats();
470        let (last_seed_round_error, last_seed_round_error_at) =
471            RecordedError::split(read(&self.seed_error, "seed round")?);
472        let (last_gossip_error, last_gossip_error_at) =
473            RecordedError::split(read(&self.gossip_error, "gossip")?);
474        let mut status = DiscoveryStatus::default();
475        status.seeds = stats.seeds;
476        status.book = stats.book;
477        status.reached = stats.reached;
478        status.reached_ever = stats.reached_ever;
479        status.gossip = stats.gossip;
480        status.last_seed_round_error = last_seed_round_error;
481        status.last_seed_round_error_at = last_seed_round_error_at;
482        status.last_gossip_error = last_gossip_error;
483        status.last_gossip_error_at = last_gossip_error_at;
484        Ok(status)
485    }
486
487    /// Peers the inventory scheduler caught misbehaving back off in the book
488    /// too, so discovery stops dialing them; the book is saved if it changed.
489    pub fn penalize(&self, peers: &[PeerId]) -> Result<(), Error> {
490        if peers.is_empty() {
491            return Ok(());
492        }
493        let at = now()?;
494        let mut book = self.lock()?;
495        let mut changed = false;
496        for peer in peers {
497            changed |= book.failed_peer(*peer, at);
498        }
499        if changed {
500            book.save(&self.path)?;
501        }
502        Ok(())
503    }
504
505    pub fn book(&self) -> Result<Vec<BookEntry>, Error> {
506        Ok(self.lock()?.listing())
507    }
508}
509
510#[cfg(test)]
511mod tests {
512    use super::{Discovery, Settle, names_connected, settle};
513    use crate::{
514        Error,
515        error::DialLimit,
516        peer_gossip::{Shared, probe_closes},
517        peer_limits::Class,
518    };
519    use libp2p::{Multiaddr, PeerId, identity::Keypair, multiaddr::Protocol};
520    use std::time::Duration;
521
522    fn peer() -> PeerId {
523        Keypair::generate_ed25519().public().to_peer_id()
524    }
525
526    #[test]
527    fn only_a_peer_on_another_chain_loses_its_connections_to_a_failed_probe() {
528        assert!(probe_closes(&Error::Invalid(
529            "peer response network mismatch".into()
530        )));
531        let kept = [
532            Error::Busy("peer dial deadline".into()),
533            Error::PeerBusy,
534            Error::Transport("Timeout".into()),
535            Error::Transport("Failed to dial the requested peer".into()),
536            Error::DialDenied(DialLimit::Duplicate),
537        ];
538        for error in kept {
539            assert!(!probe_closes(&error), "{error}");
540        }
541    }
542
543    #[test]
544    fn a_connected_seed_is_not_dialed_again() {
545        let (seed, other) = (peer(), peer());
546        let named: Multiaddr = format!("/ip4/11.0.0.1/tcp/28332/p2p/{seed}")
547            .parse()
548            .unwrap();
549        assert!(names_connected(&named, &[other, seed]));
550        assert!(!names_connected(&named, &[other]));
551        let unnamed: Multiaddr = "/dnsaddr/seed.example".parse().unwrap();
552        assert!(
553            !names_connected(&unnamed, &[seed]),
554            "an unidentified seed is dialed"
555        );
556        let circuit = named
557            .clone()
558            .with(Protocol::P2pCircuit)
559            .with(Protocol::P2p(other));
560        assert!(names_connected(&circuit, &[other]));
561    }
562
563    #[test]
564    fn a_punched_peer_is_probed_whatever_its_relay_does() {
565        let late = Discovery::CIRCUIT_WAIT + Duration::from_secs(1);
566        assert_eq!(settle(true, true, Duration::ZERO), Settle::Probe);
567        assert_eq!(settle(true, false, late), Settle::Probe);
568    }
569
570    #[test]
571    fn a_punched_link_demoted_before_its_probe_is_still_probed() {
572        let shared = Shared::new(peer());
573        let remote = peer();
574        shared.opened(remote, false, Class::Relayed);
575        shared.opened(remote, true, Class::Data);
576        shared.reclassed(remote, true, (Class::Data, Class::Demoted));
577        assert!(!shared.has_direct(remote));
578        let late = Discovery::CIRCUIT_WAIT + Duration::from_secs(1);
579        assert_eq!(
580            settle(shared.any_direct(remote), shared.is_connected(remote), late),
581            Settle::Probe
582        );
583    }
584
585    #[test]
586    fn a_relayed_contact_waits_one_circuit_then_fails() {
587        let early = Discovery::CIRCUIT_WAIT - Duration::from_secs(1);
588        assert_eq!(settle(false, true, early), Settle::Wait);
589        assert_eq!(settle(false, true, Discovery::CIRCUIT_WAIT), Settle::Fail);
590        assert_eq!(settle(false, false, Duration::ZERO), Settle::Fail);
591    }
592}