Skip to main content

fips_core/node/
accessors_impl.rs

1use super::*;
2
3impl Node {
4    // === Identity Accessors ===
5
6    /// Get this node's identity.
7    pub fn identity(&self) -> &Identity {
8        &self.identity
9    }
10
11    /// Get this node's NodeAddr.
12    pub fn node_addr(&self) -> &NodeAddr {
13        self.identity.node_addr()
14    }
15
16    /// Get this node's npub.
17    pub fn npub(&self) -> String {
18        self.identity.npub()
19    }
20
21    /// Return a human-readable display name for a NodeAddr.
22    ///
23    /// Lookup order:
24    /// 1. Host map hostname (from peer aliases + /etc/fips/hosts)
25    /// 2. Configured peer alias or short npub (from startup map)
26    /// 3. Active peer's short npub (e.g., inbound peer not in config)
27    /// 4. Session endpoint's short npub (end-to-end, may not be direct peer)
28    /// 5. Truncated NodeAddr hex (unknown address)
29    pub(crate) fn peer_display_name(&self, addr: &NodeAddr) -> String {
30        if let Some(hostname) = self.host_map.lookup_hostname(addr) {
31            return hostname.to_string();
32        }
33        if let Some(name) = self.peer_aliases.get(addr) {
34            return name.clone();
35        }
36        if let Some(peer) = self.peers.get(addr) {
37            return peer.identity().short_npub();
38        }
39        if let Some(entry) = self.sessions.get(addr) {
40            let (xonly, _) = entry.remote_pubkey().x_only_public_key();
41            return PeerIdentity::from_pubkey(xonly).short_npub();
42        }
43        addr.short_hex()
44    }
45
46    /// Tear down a receiver-index entry **and** keep the shard-owned
47    /// decrypt-worker state coherent: removes the same `cache_key`
48    /// from the registered-sessions tracking set and tells the
49    /// assigned shard worker to drop its `OwnedSessionState` entry.
50    ///
51    /// Use this instead of a bare session-index removal at every
52    /// session-lifecycle teardown site (rekey cross-connection swap, peer
53    /// disconnect, dispatch session-rotation) so the peer index, connected UDP
54    /// state, and decrypt-worker state remain coherent. The
55    /// follow-up `RegisterSession` for the NEW key (if any) will then
56    /// install the fresh state on the same shard.
57    pub(in crate::node) fn deregister_session_index(&mut self, cache_key: (TransportId, u32)) {
58        // Remove the index and ask the peer registry for the remaining-owner
59        // state in one step. Rekey drain depends on seeing the NEW index that
60        // was already installed for the same peer.
61        let removed_index = self.peers.remove_session_index_with_owner_state(&cache_key);
62        let session_key = DecryptSessionKey::from(cache_key);
63        if self
64            .sessions
65            .unregister_worker_session_if_registered(&session_key)
66            && let Some(workers) = self.decrypt_workers.as_ref()
67        {
68            workers.unregister_session(session_key);
69        }
70        // Tear down the per-peer connected UDP socket *only* if no
71        // other receiver-index entry still resolves to this peer.
72        // Rekey drain calls into this helper with the OLD session
73        // index while the NEW index is already installed and points
74        // at the same peer — there the connect()-ed 5-tuple is
75        // still valid for the new session and we must not close it.
76        // Peer-teardown sites (CrossConnection swap, stale-index
77        // fall-through in encrypted.rs, disconnect handler) call
78        // here when this is the peer's last index, so the connected
79        // socket goes away with the peer.
80        if let Some(removed_index) = removed_index
81            && !removed_index.owner_has_remaining_index
82        {
83            self.clear_connected_udp_for_peer(&removed_index.owner);
84        }
85    }
86
87    /// Ensure the current FMP receive index resolves to this peer.
88    ///
89    /// Rekey msg1/msg2 handlers pre-register the pending index before
90    /// cutover, but losing that registration in a debug build used to
91    /// panic in the cutover path. Repairing the map here is safe: the
92    /// peer has already promoted the pending session, and the decrypt
93    /// worker registration immediately after cutover depends on the
94    /// same `(transport_id, our_index)` key.
95    pub(in crate::node) fn ensure_current_session_index_registered(
96        &mut self,
97        node_addr: &NodeAddr,
98        context: &'static str,
99    ) -> bool {
100        match self
101            .peers
102            .ensure_current_session_index_registered(node_addr)
103        {
104            CurrentSessionIndexRegistration::MissingActivePeer => false,
105            CurrentSessionIndexRegistration::MissingTransportId => {
106                warn!(
107                    peer = %self.peer_display_name(node_addr),
108                    context,
109                    "Cannot register current session index without transport id"
110                );
111                false
112            }
113            CurrentSessionIndexRegistration::MissingLocalIndex => {
114                warn!(
115                    peer = %self.peer_display_name(node_addr),
116                    context,
117                    "Cannot register current session index without local index"
118                );
119                false
120            }
121            CurrentSessionIndexRegistration::AlreadyRegistered(_) => true,
122            CurrentSessionIndexRegistration::Repaired(registered) => {
123                if let Some(existing) = registered.previous_owner {
124                    warn!(
125                        peer = %self.peer_display_name(node_addr),
126                        previous_owner = %self.peer_display_name(&existing),
127                        transport_id = %registered.session_index.key.0,
128                        our_index = %registered.session_index.index,
129                        context,
130                        "Repairing current session index with stale owner"
131                    );
132                } else {
133                    warn!(
134                        peer = %self.peer_display_name(node_addr),
135                        transport_id = %registered.session_index.key.0,
136                        our_index = %registered.session_index.index,
137                        context,
138                        "Repairing missing current session index"
139                    );
140                }
141                true
142            }
143        }
144    }
145
146    pub(in crate::node) fn log_active_peer_insert_result(
147        &self,
148        node_addr: &NodeAddr,
149        inserted: &InsertedActivePeer,
150        context: &'static str,
151    ) {
152        if let Some(previous_peer) = inserted.previous_peer.as_ref() {
153            debug!(
154                peer = %self.peer_display_name(node_addr),
155                previous_link_id = %previous_peer.link_id(),
156                context,
157                "Replaced active peer storage during lifecycle insert"
158            );
159        }
160
161        match inserted.current_session_index {
162            Some(registered) => {
163                if let Some(previous_owner) = registered.previous_owner {
164                    debug!(
165                        peer = %self.peer_display_name(node_addr),
166                        previous_owner = %self.peer_display_name(&previous_owner),
167                        transport_id = %registered.session_index.key.0,
168                        our_index = %registered.session_index.index,
169                        context,
170                        "Replaced current session-index owner during lifecycle insert"
171                    );
172                }
173            }
174            None => {
175                warn!(
176                    peer = %self.peer_display_name(node_addr),
177                    context,
178                    "Inserted active peer without a current session index"
179                );
180            }
181        }
182    }
183
184    pub(in crate::node) fn log_active_peer_session_replacement_result(
185        &self,
186        node_addr: &NodeAddr,
187        replacement: &ReplacedActivePeerCurrentSession,
188        context: &'static str,
189    ) {
190        if replacement.replay_suppressed_count > 0 {
191            debug!(
192                peer = %self.peer_display_name(node_addr),
193                count = replacement.replay_suppressed_count,
194                context,
195                "Suppressed replay detections during link transition"
196            );
197        }
198
199        if let Some(previous_owner) = replacement.new_session_index.previous_owner {
200            debug!(
201                peer = %self.peer_display_name(node_addr),
202                previous_owner = %self.peer_display_name(&previous_owner),
203                transport_id = %replacement.new_session_index.session_index.key.0,
204                our_index = %replacement.new_session_index.session_index.index,
205                context,
206                "Replaced current session-index owner during session replacement"
207            );
208        }
209    }
210
211    pub(in crate::node) fn log_registered_peer_session_index_result(
212        &self,
213        node_addr: &NodeAddr,
214        registered: &RegisteredPeerSessionIndex,
215        context: &'static str,
216    ) {
217        if let Some(previous_owner) = registered.previous_owner {
218            debug!(
219                peer = %self.peer_display_name(node_addr),
220                previous_owner = %self.peer_display_name(&previous_owner),
221                transport_id = %registered.session_index.key.0,
222                our_index = %registered.session_index.index,
223                index_kind = ?registered.session_index.kind,
224                context,
225                "Replaced session-index owner during lifecycle registration"
226            );
227        }
228    }
229
230    // === Configuration ===
231
232    /// Get the configuration.
233    pub fn config(&self) -> &Config {
234        &self.config
235    }
236
237    /// Calculate the effective IPv6 MTU that can be sent over FIPS.
238    ///
239    /// Delegates to `upper::icmp::effective_ipv6_mtu()` with this node's
240    /// transport MTU. Returns the maximum IPv6 packet size (including
241    /// IPv6 header) that can be transmitted through the FIPS mesh.
242    pub fn effective_ipv6_mtu(&self) -> u16 {
243        crate::upper::icmp::effective_ipv6_mtu(self.transport_mtu())
244    }
245
246    /// Get the transport MTU governing the global TUN-boundary MSS clamp.
247    ///
248    /// Returns the **minimum** MTU across all operational transports, or
249    /// 1280 (IPv6 minimum) as fallback. Used for initial TUN configuration
250    /// where a specific egress transport isn't yet known: the resulting
251    /// `effective_ipv6_mtu` (transport_mtu - 77) and `max_mss`
252    /// (effective_mtu - 60) form a conservative ceiling that fits ANY
253    /// configured-transport's egress, eliminating PMTU-D black holes that
254    /// would otherwise occur when a flow's actual egress is smaller than
255    /// the clamp ceiling assumed at TUN init.
256    ///
257    /// Returning the smallest (rather than the first-iterated, which used
258    /// to vary across HashMap iteration order + async-startup race) makes
259    /// the clamp deterministic across daemon restarts.
260    ///
261    /// See `ISSUE-2026-0011` for the empirical investigation.
262    pub fn transport_mtu(&self) -> u16 {
263        let min_operational = self
264            .transports
265            .values()
266            .filter(|h| h.is_operational())
267            .map(|h| h.mtu())
268            .min();
269        if let Some(mtu) = min_operational {
270            return mtu;
271        }
272        // Fallback to config: try UDP first, then Ethernet
273        if let Some((_, cfg)) = self.config.transports.udp.iter().next() {
274            return cfg.mtu();
275        }
276        1280
277    }
278
279    // === State ===
280
281    /// Get the node state.
282    pub fn state(&self) -> NodeState {
283        self.state
284    }
285
286    /// Get the node uptime.
287    pub fn uptime(&self) -> std::time::Duration {
288        self.started_at.elapsed()
289    }
290
291    /// Check if node is operational.
292    pub fn is_running(&self) -> bool {
293        self.state.is_operational()
294    }
295
296    /// Check if this is a leaf-only node.
297    pub fn is_leaf_only(&self) -> bool {
298        self.is_leaf_only
299    }
300
301    // === Tree State ===
302
303    /// Get the tree state.
304    pub fn tree_state(&self) -> &TreeState {
305        &self.tree_state
306    }
307
308    /// Get mutable tree state.
309    pub fn tree_state_mut(&mut self) -> &mut TreeState {
310        &mut self.tree_state
311    }
312
313    // === Bloom State ===
314
315    /// Get the Bloom filter state.
316    pub fn bloom_state(&self) -> &BloomState {
317        &self.bloom_state
318    }
319
320    /// Get mutable Bloom filter state.
321    pub fn bloom_state_mut(&mut self) -> &mut BloomState {
322        &mut self.bloom_state
323    }
324
325    // === Mesh Size Estimate ===
326
327    /// Get the cached estimated mesh size.
328    pub fn estimated_mesh_size(&self) -> Option<u64> {
329        self.estimated_mesh_size
330    }
331
332    /// Compute and cache the estimated mesh size from bloom filters.
333    ///
334    /// Uses the spanning tree partition: parent's filter covers nodes reachable
335    /// upward, children's filters cover subtrees downward. The OR-union of
336    /// those filters plus self approximates total network size without
337    /// double-counting overlapping filters.
338    pub(crate) fn compute_mesh_size(&mut self) {
339        let my_addr = *self.tree_state.my_node_addr();
340        let parent_id = *self.tree_state.my_declaration().parent_id();
341        let is_root = self.tree_state.is_root();
342
343        let max_fpr = self.config.node.bloom.max_inbound_fpr;
344        let mut child_count: u32 = 0;
345        let mut union: Option<BloomFilter> = None;
346
347        let add_to_union = |union: &mut Option<BloomFilter>, filter: &BloomFilter| match union {
348            None => *union = Some(filter.clone()),
349            Some(existing) => {
350                // Size-class mismatch is skipped rather than fatal.
351                let _ = existing.merge(filter);
352            }
353        };
354
355        // Parent's filter: nodes reachable upward through the tree.
356        if !is_root
357            && let Some(parent) = self.peers.get(&parent_id)
358            && let Some(filter) = parent.inbound_filter()
359        {
360            add_to_union(&mut union, filter);
361        }
362
363        // Children's filters: each child's subtree is ideally disjoint; OR is
364        // idempotent when filters overlap.
365        for (peer_addr, peer) in &self.peers {
366            if peer_addr == &parent_id {
367                continue;
368            }
369            if let Some(decl) = self.tree_state.peer_declaration(peer_addr)
370                && *decl.parent_id() == my_addr
371            {
372                child_count += 1;
373                if let Some(filter) = peer.inbound_filter() {
374                    add_to_union(&mut union, filter);
375                }
376            }
377        }
378
379        let Some(mut union) = union else {
380            self.estimated_mesh_size = None;
381            return;
382        };
383        union.insert(&my_addr);
384
385        // If the union is saturated or above the FPR cap, refuse to estimate
386        // rather than publish a biased aggregate.
387        let Some(union_estimate) = union.estimated_count(max_fpr) else {
388            self.estimated_mesh_size = None;
389            return;
390        };
391
392        let size = union_estimate.round() as u64;
393        self.estimated_mesh_size = Some(size);
394
395        // Periodic logging (reuse MMP default interval: 30s)
396        let now = std::time::Instant::now();
397        let should_log = match self.last_mesh_size_log {
398            None => true,
399            Some(last) => {
400                now.duration_since(last)
401                    >= std::time::Duration::from_secs(self.config.node.mmp.log_interval_secs)
402            }
403        };
404        if should_log {
405            tracing::debug!(
406                estimated_mesh_size = size,
407                peers = self.peers.len(),
408                children = child_count,
409                "Mesh size estimate"
410            );
411            self.last_mesh_size_log = Some(now);
412        }
413    }
414
415    // === Coord Cache ===
416
417    /// Get the coordinate cache.
418    pub fn coord_cache(&self) -> &CoordCache {
419        &self.coord_cache
420    }
421
422    /// Get mutable coordinate cache.
423    pub fn coord_cache_mut(&mut self) -> &mut CoordCache {
424        &mut self.coord_cache
425    }
426
427    // === Node Statistics ===
428
429    /// Get the node statistics.
430    pub fn stats(&self) -> &stats::NodeStats {
431        &self.stats
432    }
433
434    /// Get mutable node statistics.
435    pub(crate) fn stats_mut(&mut self) -> &mut stats::NodeStats {
436        &mut self.stats
437    }
438
439    /// Get the stats history collector.
440    pub fn stats_history(&self) -> &stats_history::StatsHistory {
441        &self.stats_history
442    }
443
444    /// Sample the current node state into the stats history ring.
445    /// Called once per tick from the RX loop.
446    pub(crate) fn record_stats_history(&mut self) {
447        let fwd = &self.stats.forwarding;
448        let peers_with_mmp: Vec<f64> = self
449            .peers
450            .values()
451            .filter_map(|p| p.mmp().map(|m| m.metrics.loss_rate()))
452            .collect();
453        let loss_rate = if peers_with_mmp.is_empty() {
454            0.0
455        } else {
456            peers_with_mmp.iter().sum::<f64>() / peers_with_mmp.len() as f64
457        };
458
459        let snap = stats_history::Snapshot {
460            mesh_size: self.estimated_mesh_size,
461            tree_depth: self.tree_state.my_coords().depth() as u32,
462            peer_count: self.peers.len() as u64,
463            parent_switches_total: self.stats.tree.parent_switches,
464            bytes_in_total: fwd.received_bytes,
465            bytes_out_total: fwd.forwarded_bytes + fwd.originated_bytes,
466            packets_in_total: fwd.received_packets,
467            packets_out_total: fwd.forwarded_packets + fwd.originated_packets,
468            loss_rate,
469            active_sessions: self.sessions.len() as u64,
470        };
471
472        let now = std::time::Instant::now();
473        let peer_snaps: Vec<stats_history::PeerSnapshot> = self
474            .peers
475            .values()
476            .map(|p| {
477                let stats = p.link_stats();
478                let (srtt_ms, loss_rate, ecn_ce) = match p.mmp() {
479                    Some(m) => (
480                        m.metrics.srtt_ms(),
481                        Some(m.metrics.loss_rate()),
482                        m.receiver.ecn_ce_count() as u64,
483                    ),
484                    None => (None, None, 0),
485                };
486                stats_history::PeerSnapshot {
487                    node_addr: *p.node_addr(),
488                    last_seen: now,
489                    srtt_ms,
490                    loss_rate,
491                    bytes_in_total: stats.bytes_recv,
492                    bytes_out_total: stats.bytes_sent,
493                    packets_in_total: stats.packets_recv,
494                    packets_out_total: stats.packets_sent,
495                    ecn_ce_total: ecn_ce,
496                }
497            })
498            .collect();
499
500        self.stats_history.tick(now, &snap, &peer_snaps);
501    }
502
503    // === TUN Interface ===
504
505    /// Get the TUN state.
506    pub fn tun_state(&self) -> TunState {
507        self.tun_state
508    }
509
510    /// Get the TUN interface name, if active.
511    pub fn tun_name(&self) -> Option<&str> {
512        self.tun_name.as_deref()
513    }
514
515    // === Resource Limits ===
516
517    /// Set the maximum number of connections (handshake phase).
518    pub fn set_max_connections(&mut self, max: usize) {
519        self.max_connections = max;
520    }
521
522    /// Set the maximum number of peers (authenticated).
523    pub fn set_max_peers(&mut self, max: usize) {
524        self.max_peers = max;
525    }
526
527    /// Returns false when starting more outbound work would exceed a resource
528    /// cap. A cap of `0` means uncapped.
529    pub(crate) fn outbound_admission_check(&self) -> bool {
530        let connection_used = self
531            .peers
532            .connection_len()
533            .saturating_add(self.pending_connects.len());
534        let peer_allowed = self.max_peers == 0 || self.peers.len() < self.max_peers;
535        let connection_allowed =
536            self.max_connections == 0 || connection_used < self.max_connections;
537        let link_allowed = self.max_links == 0 || self.links.len() < self.max_links;
538        peer_allowed && connection_allowed && link_allowed
539    }
540
541    /// Admission for public/open-discovery outbound work. This includes the
542    /// general connection/link caps and, when open Nostr discovery is enabled,
543    /// the configured non-peer budget.
544    pub(crate) fn open_discovery_outbound_admission_check(&self) -> bool {
545        if !self.outbound_admission_check() {
546            return false;
547        }
548
549        let nostr = &self.config.node.discovery.nostr;
550        if !nostr.enabled || nostr.policy != NostrDiscoveryPolicy::Open {
551            return true;
552        }
553
554        let configured_npubs = self
555            .config
556            .peers()
557            .iter()
558            .map(|peer| peer.npub.clone())
559            .collect::<HashSet<_>>();
560        self.open_discovery_enqueue_budget(&configured_npubs) > 0
561    }
562
563    /// Like `outbound_admission_check`, but for racing a better path to a
564    /// peer that is already authenticated. This may temporarily add a
565    /// connection/link, but it does not consume a new peer slot.
566    pub(crate) fn outbound_direct_refresh_admission_check(&self) -> bool {
567        let connection_used = self
568            .peers
569            .connection_len()
570            .saturating_add(self.pending_connects.len());
571        let connection_allowed =
572            self.max_connections == 0 || connection_used < self.max_connections;
573        let link_allowed = self.max_links == 0 || self.links.len() < self.max_links;
574        connection_allowed && link_allowed
575    }
576
577    /// Set the maximum number of links.
578    pub fn set_max_links(&mut self, max: usize) {
579        self.max_links = max;
580    }
581
582    // === Counts ===
583
584    /// Number of pending connections (handshake in progress).
585    pub fn connection_count(&self) -> usize {
586        self.peers.connection_len()
587    }
588
589    /// Number of authenticated peers.
590    pub fn peer_count(&self) -> usize {
591        self.peers.len()
592    }
593
594    /// Number of active links.
595    pub fn link_count(&self) -> usize {
596        self.links.len()
597    }
598
599    /// Number of active transports.
600    pub fn transport_count(&self) -> usize {
601        self.transports.len()
602    }
603
604    // === Transport Management ===
605
606    /// Allocate a new transport ID.
607    pub fn allocate_transport_id(&mut self) -> TransportId {
608        let id = TransportId::new(self.next_transport_id);
609        self.next_transport_id += 1;
610        id
611    }
612
613    /// Get a transport by ID.
614    pub fn get_transport(&self, id: &TransportId) -> Option<&TransportHandle> {
615        self.transports.get(id)
616    }
617
618    /// Get mutable transport by ID.
619    pub fn get_transport_mut(&mut self, id: &TransportId) -> Option<&mut TransportHandle> {
620        self.transports.get_mut(id)
621    }
622
623    /// Iterate over transport IDs.
624    pub fn transport_ids(&self) -> impl Iterator<Item = &TransportId> {
625        self.transports.keys()
626    }
627
628    /// Get the packet receiver for the event loop.
629    pub fn packet_rx(&mut self) -> Option<&mut PacketRx> {
630        self.packet_rx.as_mut()
631    }
632
633    // === Link Management ===
634
635    /// Allocate a new link ID.
636    pub fn allocate_link_id(&mut self) -> LinkId {
637        let id = LinkId::new(self.next_link_id);
638        self.next_link_id += 1;
639        id
640    }
641
642    /// Add a link.
643    pub fn add_link(&mut self, link: Link) -> Result<(), NodeError> {
644        if self.max_links > 0 && self.links.len() >= self.max_links {
645            return Err(NodeError::MaxLinksExceeded {
646                max: self.max_links,
647            });
648        }
649        let link_id = link.link_id();
650
651        self.links.insert(link_id, link);
652        Ok(())
653    }
654
655    /// Get a link by ID.
656    pub fn get_link(&self, link_id: &LinkId) -> Option<&Link> {
657        self.links.get(link_id)
658    }
659
660    /// Get a mutable link by ID.
661    pub fn get_link_mut(&mut self, link_id: &LinkId) -> Option<&mut Link> {
662        self.links.get_mut(link_id)
663    }
664
665    /// Find link ID by transport address.
666    pub fn find_link_by_addr(
667        &self,
668        transport_id: TransportId,
669        addr: &TransportAddr,
670    ) -> Option<LinkId> {
671        self.links.lookup_addr(transport_id, addr)
672    }
673
674    /// Remove a link.
675    ///
676    /// Only removes the reverse address dispatch entry if it still points to this
677    /// link. In cross-connection scenarios, a newer link may have replaced the
678    /// entry for the same address.
679    pub fn remove_link(&mut self, link_id: &LinkId) -> Option<Link> {
680        self.links.remove(link_id)
681    }
682
683    pub(crate) fn cleanup_bootstrap_transport_if_unused(&mut self, transport_id: TransportId) {
684        if !self.bootstrap_transports.contains(&transport_id) {
685            return;
686        }
687
688        let transport_in_use = self
689            .links
690            .values()
691            .any(|link| link.transport_id() == transport_id)
692            || self
693                .peers
694                .connection_values()
695                .any(|conn| conn.transport_id() == Some(transport_id))
696            || self
697                .peers
698                .values()
699                .any(|peer| peer.transport_id() == Some(transport_id))
700            || self
701                .pending_connects
702                .iter()
703                .any(|pending| pending.transport_id == transport_id);
704
705        if transport_in_use {
706            return;
707        }
708
709        tracing::debug!(
710            transport_id = %transport_id,
711            "bootstrap transport has no remaining references; dropping"
712        );
713
714        self.bootstrap_transports.remove(&transport_id);
715        self.transport_drops.remove(&transport_id);
716        self.transports.remove(&transport_id);
717    }
718
719    /// Iterate over all links.
720    pub fn links(&self) -> impl Iterator<Item = &Link> {
721        self.links.values()
722    }
723
724    // === Connection Management (Handshake Phase) ===
725
726    /// Add a pending connection.
727    pub fn add_connection(&mut self, connection: PeerConnection) -> Result<(), NodeError> {
728        let link_id = connection.link_id();
729
730        if self.peers.contains_connection(&link_id) {
731            return Err(NodeError::ConnectionAlreadyExists(link_id));
732        }
733
734        if self.max_connections > 0 && self.peers.connection_len() >= self.max_connections {
735            return Err(NodeError::MaxConnectionsExceeded {
736                max: self.max_connections,
737            });
738        }
739
740        self.peers.insert_connection(link_id, connection);
741        Ok(())
742    }
743
744    /// Get a connection by LinkId.
745    pub fn get_connection(&self, link_id: &LinkId) -> Option<&PeerConnection> {
746        self.peers.get_connection(link_id)
747    }
748
749    /// Get a mutable connection by LinkId.
750    pub fn get_connection_mut(&mut self, link_id: &LinkId) -> Option<&mut PeerConnection> {
751        self.peers.get_connection_mut(link_id)
752    }
753
754    /// Remove a connection.
755    pub fn remove_connection(&mut self, link_id: &LinkId) -> Option<PeerConnection> {
756        self.peers.remove_connection(link_id)
757    }
758
759    /// Iterate over all connections.
760    pub fn connections(&self) -> impl Iterator<Item = &PeerConnection> {
761        self.peers.connection_values()
762    }
763
764    // === Peer Management (Active Phase) ===
765
766    /// Get a peer by NodeAddr.
767    pub fn get_peer(&self, node_addr: &NodeAddr) -> Option<&ActivePeer> {
768        self.peers.get(node_addr)
769    }
770
771    /// Get a mutable peer by NodeAddr.
772    pub fn get_peer_mut(&mut self, node_addr: &NodeAddr) -> Option<&mut ActivePeer> {
773        self.peers.get_mut(node_addr)
774    }
775
776    /// Remove a peer.
777    pub fn remove_peer(&mut self, node_addr: &NodeAddr) -> Option<ActivePeer> {
778        self.peers.remove(node_addr)
779    }
780
781    /// Iterate over all peers.
782    pub fn peers(&self) -> impl Iterator<Item = &ActivePeer> {
783        self.peers.values()
784    }
785
786    /// Reference to the Nostr discovery handle if discovery is enabled.
787    /// Used by control queries (`show_peers` per-peer Nostr-traversal
788    /// state) to read failure-state without taking shared ownership.
789    pub fn nostr_discovery_handle(&self) -> Option<&crate::discovery::nostr::NostrDiscovery> {
790        self.nostr_discovery.as_deref()
791    }
792
793    /// Iterate over all peer node IDs.
794    pub fn peer_ids(&self) -> impl Iterator<Item = &NodeAddr> {
795        self.peers.keys()
796    }
797
798    /// Iterate over peers that can send traffic.
799    pub fn sendable_peers(&self) -> impl Iterator<Item = &ActivePeer> {
800        self.peers.values().filter(|p| p.can_send())
801    }
802
803    /// Number of peers that can send traffic.
804    pub fn sendable_peer_count(&self) -> usize {
805        self.peers.values().filter(|p| p.can_send()).count()
806    }
807
808    pub(crate) fn set_discovery_fallback_transit_allowed(
809        &mut self,
810        peer_addr: NodeAddr,
811        allowed: bool,
812    ) {
813        self.discovery_fallback_transit
814            .set_allowed(peer_addr, allowed);
815    }
816
817    pub(crate) fn configured_discovery_fallback_transit(
818        &self,
819        peer_addr: &NodeAddr,
820    ) -> Option<bool> {
821        self.configured_peer(peer_addr)
822            .map(|peer| peer.discovery_fallback_transit)
823    }
824
825    pub(crate) fn configured_peer(&self, peer_addr: &NodeAddr) -> Option<&PeerConfig> {
826        self.config.peers().iter().find(|peer| {
827            PeerIdentity::from_npub(&peer.npub)
828                .ok()
829                .is_some_and(|identity| identity.node_addr() == peer_addr)
830        })
831    }
832
833    pub(in crate::node) fn active_peer_uses_configured_static_udp_path(
834        &self,
835        peer_addr: &NodeAddr,
836    ) -> bool {
837        let Some(peer_config) = self.configured_peer(peer_addr) else {
838            return false;
839        };
840
841        peer_config.addresses.iter().any(|candidate| {
842            candidate.seen_at_ms.is_none()
843                && candidate.transport.eq_ignore_ascii_case("udp")
844                && self.active_peer_matches_candidate(peer_addr, candidate)
845        })
846    }
847
848    pub(crate) fn discovery_fallback_transit_for_promotion(&self, peer_addr: &NodeAddr) -> bool {
849        if let Some(retry_state) = self.retry_pending.get(peer_addr) {
850            return retry_state.peer_config.discovery_fallback_transit;
851        }
852
853        if let Some(allowed) = self.configured_discovery_fallback_transit(peer_addr) {
854            return allowed;
855        }
856
857        self.config.node.discovery.nostr.policy != crate::config::NostrDiscoveryPolicy::Open
858    }
859}