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