Skip to main content

sui_node/
address_prober.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The adddress prober periodically checks addresses of trusted peers for connectability, reported
5//! via Prometheus metrics.
6//!
7//! - P2P: `anemo::Network::probe_address`, which verifies reachability + the peer's `PeerId` without
8//!   joining the peer set or disturbing any existing connection.
9//! - Consensus: a throwaway tonic+rustls `connect()` replicating the real consensus client TLS
10//!   (expected network key, `consensus_epoch_{epoch}` server name, our network key as the client
11//!   cert). Only a current committee member can complete this handshake.
12
13use std::collections::{HashMap, HashSet};
14use std::sync::Arc;
15use std::time::{Duration, SystemTime, UNIX_EPOCH};
16
17use anemo::{Network, PeerId};
18use consensus_config::{
19    Authority as ConsensusAuthority, Committee as ConsensusCommittee,
20    NetworkKeyPair as ConsensusNetworkKeyPair, NetworkPublicKey as ConsensusNetworkPublicKey,
21};
22use fastcrypto::encoding::{Encoding, Hex};
23use futures::future::{BoxFuture, FutureExt, join_all};
24use futures::stream::{FuturesUnordered, StreamExt};
25use mysten_metrics::spawn_monitored_task;
26use mysten_network::Multiaddr;
27use prometheus::{
28    IntCounterVec, IntGaugeVec, Registry, register_int_counter_vec_with_registry,
29    register_int_gauge_vec_with_registry,
30};
31use serde::Serialize;
32use sui_config::AddressProberConfig;
33use sui_core::consensus_manager::ConsensusManager;
34use sui_network::discovery::{Sender as DiscoverySender, TrustedPeerP2pAddresses};
35use sui_network::endpoint_manager::AddressSource;
36use tokio::sync::{Semaphore, mpsc, oneshot};
37use tokio::time::Instant;
38use tracing::{debug, info};
39
40const MAILBOX_CAPACITY: usize = 128; // updates are rare (once per epoch)
41
42/// Which transport an address belongs to. The `&'static str` is the Prometheus `endpoint_type`
43/// label.
44#[derive(Clone, Copy, PartialEq, Eq)]
45enum EndpointType {
46    P2p,
47    Consensus,
48}
49
50impl EndpointType {
51    fn as_str(self) -> &'static str {
52        match self {
53            EndpointType::P2p => "p2p",
54            EndpointType::Consensus => "consensus",
55        }
56    }
57}
58
59/// Outcome of probing a single address, unified across the P2P and consensus paths. The string form
60/// is the Prometheus `result` label on the attempts counter.
61#[derive(Clone, Copy, PartialEq, Eq)]
62enum ProbeResult {
63    Reachable,
64    Unreachable,
65    WrongIdentity,
66    BadAddress,
67    Timeout,
68    // Note: Update ProbeResult::ALL if adding new variants.
69}
70
71impl ProbeResult {
72    /// All variants, so the `result`-labelled attempts series can be enumerated — e.g. to drop a
73    /// churned-out peer's series, or in test helpers.
74    const ALL: [ProbeResult; 5] = [
75        ProbeResult::Reachable,
76        ProbeResult::Unreachable,
77        ProbeResult::WrongIdentity,
78        ProbeResult::BadAddress,
79        ProbeResult::Timeout,
80    ];
81
82    fn is_reachable(self) -> bool {
83        matches!(self, ProbeResult::Reachable)
84    }
85
86    fn as_str(self) -> &'static str {
87        match self {
88            ProbeResult::Reachable => "reachable",
89            ProbeResult::Unreachable => "unreachable",
90            ProbeResult::WrongIdentity => "wrong_identity",
91            ProbeResult::BadAddress => "bad_address",
92            ProbeResult::Timeout => "timeout",
93        }
94    }
95}
96
97impl From<anemo::ProbeOutcome> for ProbeResult {
98    fn from(outcome: anemo::ProbeOutcome) -> Self {
99        match outcome {
100            anemo::ProbeOutcome::Reachable => ProbeResult::Reachable,
101            anemo::ProbeOutcome::Unreachable => ProbeResult::Unreachable,
102            anemo::ProbeOutcome::WrongIdentity => ProbeResult::WrongIdentity,
103            anemo::ProbeOutcome::BadAddress => ProbeResult::BadAddress,
104            anemo::ProbeOutcome::Timeout => ProbeResult::Timeout,
105        }
106    }
107}
108
109/// One concrete address to probe, tagged with how to probe it.
110#[derive(Clone)]
111enum AddressTarget {
112    P2p {
113        peer_id: PeerId,
114        address: anemo::types::Address,
115    },
116    Consensus {
117        target_key: ConsensusNetworkPublicKey,
118        address: Multiaddr,
119    },
120}
121
122impl AddressTarget {
123    fn display(&self) -> String {
124        match self {
125            AddressTarget::P2p { address, .. } => address.to_string(),
126            AddressTarget::Consensus { address, .. } => address.to_string(),
127        }
128    }
129}
130
131/// Operator-facing identity of a peer that is a current committee validator, resolved from the
132/// consensus committee `Authority`. `None` for trusted non-validator peers (seeds / configured
133/// fullnodes) that aren't in the committee.
134#[derive(Clone)]
135struct AuthorityInfo {
136    /// On-chain authority name (protocol public key), hex-encoded — matches the Sui-side
137    /// `AuthorityName`.
138    authority_name: String,
139    /// The validator's advertised hostname from the committee.
140    hostname: String,
141}
142
143/// All addresses advertised for one `(peer, endpoint_type, source)` triple.
144struct ProbeGroup {
145    peer_label: String,
146    endpoint_type: EndpointType,
147    source: AddressSource,
148    /// Validator identity (name + hostname), if this peer is in the current committee.
149    authority: Option<AuthorityInfo>,
150    addresses: Vec<AddressTarget>,
151}
152
153/// Identifies a `(peer, endpoint_type, source)` triple in the prober's per-group state.
154type GroupKey = (String, &'static str, &'static str);
155
156/// The most recent probe result for a single concrete address within a group. Retained only for the
157/// admin report (the metrics deliberately omit the address to bound cardinality).
158struct AddressOutcome {
159    address: String,
160    result: ProbeResult,
161}
162
163/// A trusted peer's `(peer, endpoint_type, source)` triple tracked across probe cycles.
164struct Group {
165    peer_label: String,
166    endpoint_type: EndpointType,
167    source: AddressSource,
168    /// Validator identity (name + hostname), if this peer is in the current committee.
169    authority: Option<AuthorityInfo>,
170    /// Addresses to probe, refreshed from discovery/committee each scan.
171    targets: Vec<AddressTarget>,
172    /// True while this group's probe is in flight, so a scan doesn't re-spawn it.
173    probing: bool,
174    last_probed: Option<Instant>,
175    consecutive_failures: u32,
176    /// Smoothed connectability (mirrors the `discovery_probe_connectable` gauge): `true` until
177    /// `failure_threshold` consecutive failures flip it to `false`.
178    connectable: bool,
179    last_success_unix_secs: Option<i64>,
180    outcomes: Vec<AddressOutcome>,
181}
182
183impl Group {
184    fn new(candidate: ProbeGroup) -> Self {
185        Self {
186            peer_label: candidate.peer_label,
187            endpoint_type: candidate.endpoint_type,
188            source: candidate.source,
189            authority: candidate.authority,
190            targets: candidate.addresses,
191            probing: false,
192            last_probed: None,
193            consecutive_failures: 0,
194            connectable: false,
195            last_success_unix_secs: None,
196            outcomes: Vec::new(),
197        }
198    }
199
200    /// When this group is next due to be probed: a never-probed group is due now, otherwise its last
201    /// probe plus the good/failed interval selected by its recent history.
202    fn next_due(
203        &self,
204        good_interval: Duration,
205        failed_interval: Duration,
206        now: Instant,
207    ) -> Instant {
208        match self.last_probed {
209            None => now,
210            Some(last_probed) => {
211                let interval = if self.consecutive_failures == 0 {
212                    good_interval
213                } else {
214                    failed_interval
215                };
216                last_probed + interval
217            }
218        }
219    }
220}
221
222/// Epoch-scoped inputs to the prober.
223pub struct ProberEpochContext {
224    pub epoch: u64,
225    pub consensus_committee: ConsensusCommittee,
226    pub consensus_manager: Arc<ConsensusManager>,
227}
228
229enum ProberMessage {
230    /// This node is a current validator for `epoch`; probe its committee's consensus endpoints + the
231    /// trusted peers' P2P endpoints.
232    UpdateEpoch {
233        epoch: u64,
234        consensus_committee: ConsensusCommittee,
235        consensus_manager: Arc<ConsensusManager>,
236    },
237    /// This node is no longer a current validator; the prober idles until the next `UpdateEpoch`.
238    LeaveCommittee,
239    /// Admin snapshot request: reply with the prober's latest results (see [`Handle::probe_report`]).
240    GetReport { reply: oneshot::Sender<ProbeReport> },
241}
242
243/// A point-in-time snapshot of the prober's latest results, served by the admin endpoint.
244#[derive(Clone, Debug, Serialize)]
245pub struct ProbeReport {
246    pub epoch: Option<u64>,
247    pub groups: Vec<ProbeGroupReport>,
248}
249
250/// Latest probe outcome for one `(peer, endpoint_type, source)` group.
251#[derive(Clone, Debug, Serialize)]
252pub struct ProbeGroupReport {
253    /// `peer_id` hex (P2P) or consensus network public key hex (consensus).
254    pub peer: String,
255    /// On-chain authority name (protocol public key) hex, if this peer is a current committee
256    /// validator; `None` for trusted non-validator peers.
257    pub authority_name: Option<String>,
258    /// The validator's committee hostname, if this peer is a current committee validator.
259    pub hostname: Option<String>,
260    pub endpoint_type: String,
261    pub address_source: String,
262    /// Smoothed connectability (matches the `discovery_probe_connectable` gauge).
263    pub connectable: bool,
264    pub consecutive_failures: u32,
265    /// How long ago the group was last probed, in seconds.
266    pub seconds_since_last_probe: u64,
267    /// Unix timestamp (seconds) of the last successful probe, if ever reachable.
268    pub last_success_unix_seconds: Option<i64>,
269    pub addresses: Vec<ProbeAddressReport>,
270}
271
272/// Latest probe result for a single concrete address.
273#[derive(Clone, Debug, Serialize)]
274pub struct ProbeAddressReport {
275    pub address: String,
276    /// One of `reachable`, `unreachable`, `wrong_identity`, `bad_address`, `timeout`.
277    pub result: String,
278}
279
280pub struct AddressProberMetrics {
281    connectable: IntGaugeVec,
282    last_success_timestamp_seconds: IntGaugeVec,
283    attempts_total: IntCounterVec,
284}
285
286impl AddressProberMetrics {
287    pub fn new(registry: &Registry) -> Arc<Self> {
288        Arc::new(Self {
289            connectable: register_int_gauge_vec_with_registry!(
290                "discovery_probe_connectable",
291                "1 if a trusted peer's advertised address for this endpoint/source is connectable, \
292                 0 after N consecutive failed probe cycles",
293                &["peer_id", "endpoint_type", "address_source"],
294                registry
295            )
296            .unwrap(),
297            last_success_timestamp_seconds: register_int_gauge_vec_with_registry!(
298                "discovery_probe_last_success_timestamp_seconds",
299                "Unix timestamp (seconds) of the last successful probe for this peer/endpoint/source",
300                &["peer_id", "endpoint_type", "address_source"],
301                registry
302            )
303            .unwrap(),
304            attempts_total: register_int_counter_vec_with_registry!(
305                "discovery_probe_attempts_total",
306                "Total address probe attempts by peer/endpoint/source and result",
307                &["peer_id", "endpoint_type", "address_source", "result"],
308                registry
309            )
310            .unwrap(),
311        })
312    }
313}
314
315#[cfg(any(test, msim))]
316impl AddressProberMetrics {
317    /// Current value of the smoothed connectability gauge for a triple (creates the series at 0 if
318    /// it has never been written, so distinguish via [`Self::attempts_value_for_testing`]).
319    pub fn connectable_for_testing(
320        &self,
321        peer_label: &str,
322        endpoint_type: &str,
323        source: &str,
324    ) -> i64 {
325        self.connectable
326            .with_label_values(&[peer_label, endpoint_type, source])
327            .get()
328    }
329
330    /// Number of probe attempts recorded for a triple with the given result.
331    pub fn attempts_value_for_testing(
332        &self,
333        peer_label: &str,
334        endpoint_type: &str,
335        source: &str,
336        result: &str,
337    ) -> u64 {
338        self.attempts_total
339            .with_label_values(&[peer_label, endpoint_type, source, result])
340            .get()
341    }
342
343    /// Total probe attempts recorded for a triple across all results.
344    pub fn total_attempts_for_testing(
345        &self,
346        peer_label: &str,
347        endpoint_type: &str,
348        source: &str,
349    ) -> u64 {
350        ProbeResult::ALL
351            .into_iter()
352            .map(|result| {
353                self.attempts_value_for_testing(peer_label, endpoint_type, source, result.as_str())
354            })
355            .sum()
356    }
357}
358
359/// Handle to the address prober. Dropping all clones closes the mailbox and the event loop
360/// shuts down. Holds the metrics so tests can read them.
361#[derive(Clone)]
362pub struct Handle {
363    sender: mpsc::Sender<ProberMessage>,
364    // Retained only so tests can read the prober's metrics.
365    #[cfg(any(test, msim))]
366    metrics: Arc<AddressProberMetrics>,
367}
368
369impl Handle {
370    /// Activates the prober for an epoch. Call if this node is a current validator.
371    pub fn update_epoch(
372        &self,
373        epoch: u64,
374        consensus_committee: ConsensusCommittee,
375        consensus_manager: Arc<ConsensusManager>,
376    ) {
377        self.sender
378            .try_send(ProberMessage::UpdateEpoch {
379                epoch,
380                consensus_committee,
381                consensus_manager,
382            })
383            .expect("address prober mailbox should not overflow or be closed");
384    }
385
386    /// Deactivates the prober.
387    pub fn leave_committee(&self) {
388        self.sender
389            .try_send(ProberMessage::LeaveCommittee)
390            .expect("address prober mailbox should not overflow or be closed");
391    }
392
393    /// Snapshot the prober's latest results (full addresses + per-address outcomes).
394    /// Returns `None` if the event loop has shut down or dropped the reply.
395    pub async fn probe_report(&self) -> Option<ProbeReport> {
396        let (reply, response) = oneshot::channel();
397        if self
398            .sender
399            .send(ProberMessage::GetReport { reply })
400            .await
401            .is_err()
402        {
403            return None;
404        }
405        response.await.ok()
406    }
407
408    #[cfg(any(test, msim))]
409    pub fn metrics_for_testing(&self) -> Arc<AddressProberMetrics> {
410        self.metrics.clone()
411    }
412}
413
414pub struct Builder {
415    config: AddressProberConfig,
416    metrics: Option<Arc<AddressProberMetrics>>,
417}
418
419impl Default for Builder {
420    fn default() -> Self {
421        Self::new()
422    }
423}
424
425impl Builder {
426    pub fn new() -> Self {
427        Self {
428            config: AddressProberConfig::default(),
429            metrics: None,
430        }
431    }
432
433    pub fn config(mut self, config: AddressProberConfig) -> Self {
434        self.config = config;
435        self
436    }
437
438    pub fn with_metrics(mut self, registry: &Registry) -> Self {
439        self.metrics = Some(AddressProberMetrics::new(registry));
440        self
441    }
442
443    pub fn build(self) -> UnstartedAddressProber {
444        let metrics = self
445            .metrics
446            .unwrap_or_else(|| AddressProberMetrics::new(&Registry::new()));
447        let (sender, mailbox) = mpsc::channel(MAILBOX_CAPACITY);
448        let handle = Handle {
449            sender,
450            #[cfg(any(test, msim))]
451            metrics: metrics.clone(),
452        };
453        UnstartedAddressProber {
454            config: self.config,
455            metrics,
456            handle,
457            mailbox,
458        }
459    }
460}
461
462/// A built-but-not-started prober: holds the runtime-independent state until [`start`] injects the
463/// network/discovery/keypair and spawns the event loop.
464///
465/// [`start`]: UnstartedAddressProber::start
466pub struct UnstartedAddressProber {
467    config: AddressProberConfig,
468    metrics: Arc<AddressProberMetrics>,
469    handle: Handle,
470    mailbox: mpsc::Receiver<ProberMessage>,
471}
472
473impl UnstartedAddressProber {
474    /// Spawns the prober loop.
475    pub fn start(
476        self,
477        network: Network,
478        discovery: DiscoverySender,
479        own_consensus_keypair: ConsensusNetworkKeyPair,
480    ) -> Handle {
481        let event_loop = AddressProberEventLoop::new(
482            self.config,
483            self.metrics,
484            self.mailbox,
485            network,
486            discovery,
487            own_consensus_keypair,
488        );
489        spawn_monitored_task!(event_loop.start());
490        self.handle
491    }
492}
493
494struct AddressProberEventLoop {
495    // Resolved config knobs (`Copy`); the per-probe futures capture these directly.
496    good_interval: Duration,
497    failed_interval: Duration,
498    failure_threshold: u32,
499    consensus_probe_timeout: Duration,
500    // Node-lifetime inputs.
501    network: Network,
502    discovery: DiscoverySender,
503    /// This node's consensus network keypair, used as the client cert for consensus probes.
504    own_consensus_keypair: ConsensusNetworkKeyPair,
505    own_peer_id: PeerId,
506    own_consensus_key: ConsensusNetworkPublicKey,
507    metrics: Arc<AddressProberMetrics>,
508    mailbox: mpsc::Receiver<ProberMessage>,
509    inflight_probe_limit: Arc<Semaphore>,
510    /// In-flight probes; each yields `(group key, per-address outcomes)` when it completes.
511    tasks: FuturesUnordered<BoxFuture<'static, (GroupKey, Vec<AddressOutcome>)>>,
512    groups: HashMap<GroupKey, Group>,
513    /// Current epoch's probe inputs, or `None` when this node is not a current validator.
514    epoch_state: Option<Arc<ProberEpochContext>>,
515}
516
517impl AddressProberEventLoop {
518    fn new(
519        config: AddressProberConfig,
520        metrics: Arc<AddressProberMetrics>,
521        mailbox: mpsc::Receiver<ProberMessage>,
522        network: Network,
523        discovery: DiscoverySender,
524        own_consensus_keypair: ConsensusNetworkKeyPair,
525    ) -> Self {
526        let own_peer_id = network.peer_id();
527        let own_consensus_key = own_consensus_keypair.public();
528        Self {
529            good_interval: config.good_interval(),
530            failed_interval: config.failed_interval(),
531            failure_threshold: config.failure_threshold(),
532            consensus_probe_timeout: config.consensus_probe_timeout(),
533            network,
534            discovery,
535            own_consensus_keypair,
536            own_peer_id,
537            own_consensus_key,
538            metrics,
539            mailbox,
540            inflight_probe_limit: Arc::new(Semaphore::new(config.concurrency())),
541            tasks: FuturesUnordered::new(),
542            groups: HashMap::new(),
543            epoch_state: None,
544        }
545    }
546
547    async fn start(mut self) {
548        info!(
549            good_interval_secs = self.good_interval.as_secs(),
550            failed_interval_secs = self.failed_interval.as_secs(),
551            "starting discovery address prober",
552        );
553
554        // A single resettable timer fires when the next group is due (see `next_deadline`).
555        let mut timer = Box::pin(tokio::time::sleep_until(Instant::now()));
556        loop {
557            tokio::select! {
558                _ = &mut timer => self.scan().await,
559                Some((key, outcomes)) = self.tasks.next() => self.handle_probe_result(key, outcomes),
560                maybe_message = self.mailbox.recv() => match maybe_message {
561                    // Once all `Handle`s have been dropped this yields `None`, so we shut down.
562                    Some(message) => self.handle_message(message),
563                    None => break,
564                },
565            }
566            timer.as_mut().reset(self.next_deadline());
567        }
568
569        info!("discovery address prober ended");
570    }
571
572    fn handle_message(&mut self, message: ProberMessage) {
573        match message {
574            ProberMessage::UpdateEpoch {
575                epoch,
576                consensus_committee,
577                consensus_manager,
578            } => {
579                self.epoch_state = Some(Arc::new(ProberEpochContext {
580                    epoch,
581                    consensus_committee,
582                    consensus_manager,
583                }));
584            }
585            ProberMessage::LeaveCommittee => {
586                self.epoch_state = None;
587                // We no longer probe anyone; drop all tracked groups and their metric series so a
588                // demoted node doesn't keep exporting stale per-peer metrics.
589                for key in self.groups.keys().cloned().collect::<Vec<_>>() {
590                    self.remove_group_metrics(&key);
591                }
592                self.groups.clear();
593            }
594            ProberMessage::GetReport { reply } => {
595                let _ = reply.send(self.build_report());
596            }
597        }
598    }
599
600    /// When to next scan for due groups: the earliest per-group due time, capped by `failed_interval`
601    /// so newly-advertised peers/addresses are still discovered promptly even when everything known
602    /// is healthy. Groups with an in-flight probe are excluded — they reschedule when they complete.
603    fn next_deadline(&self) -> Instant {
604        let now = Instant::now();
605        let cap = now + self.failed_interval;
606        if self.epoch_state.is_none() {
607            return cap;
608        }
609        self.groups
610            .values()
611            .filter(|group| !group.probing)
612            .map(|group| group.next_due(self.good_interval, self.failed_interval, now))
613            .min()
614            .map_or(cap, |deadline| deadline.min(cap))
615            .max(now)
616    }
617
618    /// Rebuild the current candidate set from discovery + the committee, merge it into `self.groups`,
619    /// then spawn a probe for every group that is now due and not already being probed. The discovery
620    /// snapshot fetch is the only await; the probes run off-loop.
621    async fn scan(&mut self) {
622        let Some(context) = self.epoch_state.clone() else {
623            return;
624        };
625        let epoch = context.epoch;
626        let trusted_p2p_addresses = self.discovery.trusted_peer_p2p_addresses().await;
627        let candidates = build_groups(
628            trusted_p2p_addresses,
629            &context.consensus_manager,
630            &context.consensus_committee,
631            &self.own_peer_id,
632            &self.own_consensus_key,
633        );
634        self.refresh_groups(candidates);
635
636        let now = Instant::now();
637        let due: Vec<GroupKey> = self
638            .groups
639            .iter()
640            .filter(|(_, group)| {
641                !group.probing
642                    && group.next_due(self.good_interval, self.failed_interval, now) <= now
643            })
644            .map(|(key, _)| key.clone())
645            .collect();
646        for key in due {
647            self.spawn_probe(epoch, &key);
648        }
649    }
650
651    /// Merge a freshly-built candidate set into `self.groups`: refresh addresses/identity for known
652    /// groups, start tracking new ones, and drop groups no longer advertised — unless a probe is
653    /// still in flight for them, in which case they survive until that probe is recorded.
654    fn refresh_groups(&mut self, candidates: Vec<ProbeGroup>) {
655        let current: HashSet<GroupKey> = candidates.iter().map(group_key).collect();
656        // Drop groups no longer advertised (unless a probe is still in flight for them) and clear
657        // their metric series, so a peer removed from the trusted set stops being exported.
658        let removed: Vec<GroupKey> = self
659            .groups
660            .iter()
661            .filter(|(key, group)| !current.contains(*key) && !group.probing)
662            .map(|(key, _)| key.clone())
663            .collect();
664        for key in removed {
665            self.remove_group_metrics(&key);
666            self.groups.remove(&key);
667        }
668        for candidate in candidates {
669            let key = group_key(&candidate);
670            match self.groups.get_mut(&key) {
671                Some(group) => {
672                    group.targets = candidate.addresses;
673                    group.authority = candidate.authority;
674                }
675                None => {
676                    self.groups.insert(key, Group::new(candidate));
677                }
678            }
679        }
680    }
681
682    /// Drop the Prometheus series for a group that is no longer tracked, so churned-out peers don't
683    /// linger in the exported metrics. Removing a nonexistent series is a no-op (ignored error).
684    fn remove_group_metrics(&self, key: &GroupKey) {
685        let (peer, endpoint_type, source) = (key.0.as_str(), key.1, key.2);
686        let labels = [peer, endpoint_type, source];
687        let _ = self.metrics.connectable.remove_label_values(&labels);
688        let _ = self
689            .metrics
690            .last_success_timestamp_seconds
691            .remove_label_values(&labels);
692        // `attempts_total` also carries the `result` label, so one series exists per outcome.
693        for result in ProbeResult::ALL {
694            let _ = self.metrics.attempts_total.remove_label_values(&[
695                peer,
696                endpoint_type,
697                source,
698                result.as_str(),
699            ]);
700        }
701    }
702
703    /// Mark a group in-flight and spawn its probe onto `self.tasks`.
704    fn spawn_probe(&mut self, epoch: u64, key: &GroupKey) {
705        let targets = {
706            let group = self
707                .groups
708                .get_mut(key)
709                .expect("a due group is present in the map");
710            group.probing = true;
711            group.targets.clone()
712        };
713        let key = key.clone();
714        let network = self.network.clone();
715        let own_consensus_keypair = self.own_consensus_keypair.clone();
716        let consensus_probe_timeout = self.consensus_probe_timeout;
717        let inflight_probe_limit = self.inflight_probe_limit.clone();
718        self.tasks.push(
719            async move {
720                let outcomes = join_all(targets.into_iter().map(|target| {
721                    let network = network.clone();
722                    let own_consensus_keypair = own_consensus_keypair.clone();
723                    let inflight_probe_limit = inflight_probe_limit.clone();
724                    async move {
725                        let _permit = inflight_probe_limit
726                            .acquire_owned()
727                            .await
728                            .expect("prober semaphore is never closed");
729                        let address = target.display();
730                        let result = probe_one(
731                            &target,
732                            &network,
733                            &own_consensus_keypair,
734                            epoch,
735                            consensus_probe_timeout,
736                        )
737                        .await;
738                        AddressOutcome { address, result }
739                    }
740                }))
741                .await;
742                (key, outcomes)
743            }
744            .boxed(),
745        );
746    }
747
748    fn handle_probe_result(&mut self, key: GroupKey, outcomes: Vec<AddressOutcome>) {
749        // The group may have been dropped from the map if it churned out while probing; if so, the
750        // result is stale — discard it.
751        let Some(group) = self.groups.get_mut(&key) else {
752            return;
753        };
754        group.probing = false;
755
756        let now = Instant::now();
757        let connectable = outcomes.iter().any(|outcome| outcome.result.is_reachable());
758        let peer_label = group.peer_label.clone();
759        let endpoint_type = group.endpoint_type.as_str();
760        let source = address_source_str(group.source);
761        let labels = [peer_label.as_str(), endpoint_type, source];
762
763        for outcome in &outcomes {
764            self.metrics
765                .attempts_total
766                .with_label_values(&[
767                    peer_label.as_str(),
768                    endpoint_type,
769                    source,
770                    outcome.result.as_str(),
771                ])
772                .inc();
773            debug!(
774                peer = %peer_label,
775                endpoint_type,
776                source,
777                address = %outcome.address,
778                result = outcome.result.as_str(),
779                "probed address"
780            );
781        }
782
783        // Update the smoothed connectability gauge: set on any reachable probe, cleared only after
784        // `failure_threshold` consecutive failures so transient blips don't flap the gauge.
785        if connectable {
786            self.metrics.connectable.with_label_values(&labels).set(1);
787            let timestamp = now_unix_seconds();
788            self.metrics
789                .last_success_timestamp_seconds
790                .with_label_values(&labels)
791                .set(timestamp);
792            group.connectable = true;
793            group.last_success_unix_secs = Some(timestamp);
794            group.consecutive_failures = 0;
795        } else {
796            group.consecutive_failures += 1;
797            if group.consecutive_failures >= self.failure_threshold {
798                self.metrics.connectable.with_label_values(&labels).set(0);
799                group.connectable = false;
800            }
801        }
802        group.last_probed = Some(now);
803        group.outcomes = outcomes;
804    }
805
806    /// Snapshot the latest probe results for manual inspection.
807    fn build_report(&self) -> ProbeReport {
808        let now = Instant::now();
809        let mut groups: Vec<ProbeGroupReport> = self
810            .groups
811            .values()
812            .filter_map(|group| {
813                let last_probed = group.last_probed?;
814                Some(ProbeGroupReport {
815                    peer: group.peer_label.clone(),
816                    authority_name: group
817                        .authority
818                        .as_ref()
819                        .map(|authority| authority.authority_name.clone()),
820                    hostname: group
821                        .authority
822                        .as_ref()
823                        .map(|authority| authority.hostname.clone()),
824                    endpoint_type: group.endpoint_type.as_str().to_string(),
825                    address_source: address_source_str(group.source).to_string(),
826                    connectable: group.connectable,
827                    consecutive_failures: group.consecutive_failures,
828                    seconds_since_last_probe: now.duration_since(last_probed).as_secs(),
829                    last_success_unix_seconds: group.last_success_unix_secs,
830                    addresses: group
831                        .outcomes
832                        .iter()
833                        .map(|outcome| ProbeAddressReport {
834                            address: outcome.address.clone(),
835                            result: outcome.result.as_str().to_string(),
836                        })
837                        .collect(),
838                })
839            })
840            .collect();
841
842        // Surface problems first: most-failed groups on top, then a stable label ordering.
843        groups.sort_by(|a, b| {
844            b.consecutive_failures
845                .cmp(&a.consecutive_failures)
846                .then_with(|| a.endpoint_type.cmp(&b.endpoint_type))
847                .then_with(|| a.peer.cmp(&b.peer))
848                .then_with(|| a.address_source.cmp(&b.address_source))
849        });
850
851        ProbeReport {
852            epoch: self.epoch_state.as_ref().map(|context| context.epoch),
853            groups,
854        }
855    }
856}
857
858/// Build the set of probe groups for this cycle: discovery's per-source P2P addresses, the
859/// consensus override addresses, and the consensus on-chain (`Chain`) baseline from the committee.
860fn build_groups(
861    trusted_p2p_addresses: TrustedPeerP2pAddresses,
862    consensus_manager: &ConsensusManager,
863    committee: &ConsensusCommittee,
864    own_peer_id: &PeerId,
865    own_consensus_key: &ConsensusNetworkPublicKey,
866) -> Vec<ProbeGroup> {
867    let mut groups = Vec::new();
868
869    // Index committee validators by their 32-byte network key so each group can be tagged with the
870    // validator's name + hostname. The anemo `PeerId` is these same bytes (both are the validator's
871    // `narwhal_network_pubkey`), so this resolves P2P peers as well as consensus ones; trusted
872    // non-validator peers (seeds / fullnodes) simply don't match and stay unnamed.
873    let authority_by_network_key: HashMap<[u8; 32], &ConsensusAuthority> = committee
874        .authorities()
875        .map(|(_, authority)| (authority.network_key.to_bytes(), authority))
876        .collect();
877
878    // (a) P2P: every source for every trusted peer.
879    for (peer_id, sources) in trusted_p2p_addresses {
880        if &peer_id == own_peer_id {
881            continue;
882        }
883        let authority = authority_by_network_key
884            .get(&peer_id.0)
885            .copied()
886            .map(authority_info);
887        for (source, addresses) in sources {
888            if addresses.is_empty() {
889                continue;
890            }
891            groups.push(ProbeGroup {
892                peer_label: peer_id.to_string(),
893                endpoint_type: EndpointType::P2p,
894                source,
895                authority: authority.clone(),
896                addresses: addresses
897                    .into_iter()
898                    .map(|address| AddressTarget::P2p { peer_id, address })
899                    .collect(),
900            });
901        }
902    }
903
904    // (b) Consensus overrides (Discovery/Admin), per source.
905    for (network_key, sources) in consensus_manager.address_overrides_snapshot() {
906        if &network_key == own_consensus_key {
907            continue;
908        }
909        let authority = authority_by_network_key
910            .get(&network_key.to_bytes())
911            .copied()
912            .map(authority_info);
913        for (source, addresses) in sources {
914            if addresses.is_empty() {
915                continue;
916            }
917            groups.push(ProbeGroup {
918                peer_label: consensus_peer_label(&network_key),
919                endpoint_type: EndpointType::Consensus,
920                source,
921                authority: authority.clone(),
922                addresses: addresses
923                    .into_iter()
924                    .map(|address| AddressTarget::Consensus {
925                        target_key: network_key.clone(),
926                        address,
927                    })
928                    .collect(),
929            });
930        }
931    }
932
933    // (c) Consensus on-chain baseline (the committee address), labeled `Chain`.
934    for (_index, authority) in committee.authorities() {
935        if &authority.network_key == own_consensus_key {
936            continue;
937        }
938        groups.push(ProbeGroup {
939            peer_label: consensus_peer_label(&authority.network_key),
940            endpoint_type: EndpointType::Consensus,
941            source: AddressSource::Chain,
942            authority: Some(authority_info(authority)),
943            addresses: vec![AddressTarget::Consensus {
944                target_key: authority.network_key.clone(),
945                address: authority.address.clone(),
946            }],
947        });
948    }
949
950    groups
951}
952
953/// Operator-facing identity (name + hostname) for a committee validator.
954fn authority_info(authority: &ConsensusAuthority) -> AuthorityInfo {
955    AuthorityInfo {
956        authority_name: Hex::encode(authority.authority_name.to_bytes()),
957        hostname: authority.hostname.clone(),
958    }
959}
960
961async fn probe_one(
962    target: &AddressTarget,
963    network: &Network,
964    own_consensus_keypair: &ConsensusNetworkKeyPair,
965    epoch: u64,
966    consensus_probe_timeout: Duration,
967) -> ProbeResult {
968    match target {
969        AddressTarget::P2p { peer_id, address } => network
970            .probe_address(address.clone(), *peer_id)
971            .await
972            .into(),
973        AddressTarget::Consensus {
974            target_key,
975            address,
976        } => {
977            probe_consensus_address(
978                own_consensus_keypair,
979                target_key,
980                epoch,
981                address,
982                consensus_probe_timeout,
983            )
984            .await
985        }
986    }
987}
988
989/// Replicates the real consensus client's mutual-TLS setup (`tonic_network::get_channel`) in a
990/// throwaway endpoint — expected peer network key, `consensus_epoch_{epoch}` server name, and our
991/// own network key as the client cert — then eagerly connects with a bounded timeout and drops the
992/// connection. Does not use the shared channel pool (that caches one channel per peer and would
993/// defeat per-source probing).
994async fn probe_consensus_address(
995    own_consensus_keypair: &ConsensusNetworkKeyPair,
996    target_key: &ConsensusNetworkPublicKey,
997    epoch: u64,
998    address: &Multiaddr,
999    timeout: Duration,
1000) -> ProbeResult {
1001    let Some(host_port) = consensus_host_port(address) else {
1002        return ProbeResult::BadAddress;
1003    };
1004    let uri = format!("https://{host_port}");
1005
1006    // Matches `consensus/core/src/network/tonic_tls.rs::certificate_server_name`.
1007    let server_name = format!("consensus_epoch_{epoch}");
1008    let client_tls_config = sui_tls::create_rustls_client_config(
1009        target_key.clone().into_inner(),
1010        server_name,
1011        Some(own_consensus_keypair.clone().private_key().into_inner()),
1012    );
1013
1014    let endpoint = match tonic_rustls::Channel::from_shared(uri) {
1015        Ok(endpoint) => endpoint.connect_timeout(timeout),
1016        Err(_) => return ProbeResult::BadAddress,
1017    };
1018    let endpoint = match endpoint.tls_config(client_tls_config) {
1019        Ok(endpoint) => endpoint,
1020        Err(_) => return ProbeResult::BadAddress,
1021    };
1022
1023    match tokio::time::timeout(timeout, endpoint.connect()).await {
1024        Ok(Ok(_channel)) => ProbeResult::Reachable,
1025        // A failed connect covers both unreachable endpoints and TLS failures (e.g. wrong key or
1026        // wrong epoch); tonic does not let us cleanly distinguish them here.
1027        Ok(Err(_)) => ProbeResult::Unreachable,
1028        Err(_) => ProbeResult::Timeout,
1029    }
1030}
1031
1032/// host:port for the tonic URI, bracketing IPv6 literals. Mirrors
1033/// `consensus/core/src/network/mod.rs::to_host_port_str` for `/ip{4,6}|dns/.../udp/{port}`.
1034fn consensus_host_port(addr: &Multiaddr) -> Option<String> {
1035    let host = addr.hostname()?;
1036    let port = addr.port()?;
1037    if host.contains(':') {
1038        Some(format!("[{host}]:{port}"))
1039    } else {
1040        Some(format!("{host}:{port}"))
1041    }
1042}
1043
1044/// Key identifying a group in the per-group scheduling/smoothing state.
1045fn group_key(group: &ProbeGroup) -> GroupKey {
1046    (
1047        group.peer_label.clone(),
1048        group.endpoint_type.as_str(),
1049        address_source_str(group.source),
1050    )
1051}
1052
1053/// Metric `peer_id` label for a consensus endpoint: the hex-encoded network public key. (Consensus
1054/// peers are keyed by network key, not by anemo `PeerId`.)
1055fn consensus_peer_label(key: &ConsensusNetworkPublicKey) -> String {
1056    Hex::encode(key.to_bytes())
1057}
1058
1059/// Test helper: compute the consensus `peer_id` metric label from raw network public key bytes,
1060/// matching [`consensus_peer_label`].
1061#[cfg(any(test, msim))]
1062pub fn consensus_peer_label_for_testing(network_key_bytes: [u8; 32]) -> String {
1063    Hex::encode(network_key_bytes)
1064}
1065
1066fn address_source_str(source: AddressSource) -> &'static str {
1067    match source {
1068        AddressSource::Admin => "admin",
1069        AddressSource::Config => "config",
1070        AddressSource::Discovery => "discovery",
1071        AddressSource::Seed => "seed",
1072        AddressSource::Chain => "chain",
1073    }
1074}
1075
1076fn now_unix_seconds() -> i64 {
1077    SystemTime::now()
1078        .duration_since(UNIX_EPOCH)
1079        .map(|d| d.as_secs() as i64)
1080        .unwrap_or(0)
1081}