Skip to main content

fips_core/node/
route_impl.rs

1use super::*;
2
3impl Node {
4    // === Routing ===
5
6    /// Check if a peer is a tree neighbor (parent or child in the spanning tree).
7    ///
8    /// Returns true if the peer is our current tree parent, or if the peer
9    /// has declared us as their parent (making them our child).
10    pub(crate) fn is_tree_peer(&self, peer_addr: &NodeAddr) -> bool {
11        // Peer is our parent
12        if !self.tree_state.is_root() && self.tree_state.my_declaration().parent_id() == peer_addr {
13            return true;
14        }
15        // Peer is our child (their declaration names us as parent)
16        if let Some(decl) = self.tree_state.peer_declaration(peer_addr)
17            && decl.parent_id() == self.node_addr()
18        {
19            return true;
20        }
21        false
22    }
23
24    /// Find next hop for a destination node address.
25    ///
26    /// Routing priority:
27    /// 1. Destination is self → `None` (local delivery)
28    /// 2. Destination is a healthy direct peer → that peer. A known fallback
29    ///    next-hop may beat a non-static direct path when it has a meaningful
30    ///    link-quality advantage; operator-configured static UDP peers stay
31    ///    pinned to direct while healthy and endpoint traffic is getting
32    ///    authenticated return traffic.
33    /// 3. Reply-learned routes in `reply_learned` mode. These are locally
34    ///    observed reverse paths, selected with weighted multipath plus
35    ///    periodic coordinate/tree exploration.
36    /// 4. Bloom filter candidates with cached dest coords → among peers whose
37    ///    bloom filter contains the destination, pick the one that minimizes
38    ///    tree distance to the destination, with
39    ///    `(link_cost, tree_distance_to_dest, node_addr)` tie-breaking.
40    ///    The self-distance check ensures only peers strictly closer to the
41    ///    destination than us are considered (prevents routing loops).
42    /// 5. Greedy tree routing fallback (requires cached dest coords)
43    /// 6. No route → `None`
44    ///
45    /// Both the bloom filter and tree routing paths require cached destination
46    /// coordinates (checked in `coord_cache`). Without coordinates, the node
47    /// cannot make loop-free forwarding decisions. The caller should signal
48    /// `CoordsRequired` back to the source when `None` is returned for a
49    /// non-local destination.
50    pub fn find_next_hop(&mut self, dest_node_addr: &NodeAddr) -> Option<&ActivePeer> {
51        // 1. Local delivery
52        if dest_node_addr == self.node_addr() {
53            return None;
54        }
55        let now_ms = Self::now_ms();
56        let direct_session_degraded =
57            self.session_direct_path_blocks_direct_payload(dest_node_addr, now_ms);
58        let direct_session_untrusted = !direct_session_degraded
59            && self.session_direct_path_exclusive_trust_expired(dest_node_addr, now_ms);
60        let stale_traversal_direct_route = self
61            .peers
62            .get(dest_node_addr)
63            .filter(|peer| !direct_session_degraded && !peer.is_healthy() && peer.can_send())
64            .and_then(|_| {
65                self.configured_peer(dest_node_addr)
66                    .and_then(|peer_config| {
67                        (peer_config.is_auto_connect()
68                            && self.active_peer_uses_traversal_path(dest_node_addr, peer_config))
69                        .then_some(*dest_node_addr)
70                    })
71            });
72
73        let healthy_direct_peer = self
74            .peers
75            .get(dest_node_addr)
76            .filter(|peer| peer.is_healthy() && peer.can_send())
77            .map(|_| *dest_node_addr);
78        let healthy_direct_route = self
79            .peers
80            .get(dest_node_addr)
81            .filter(|peer| peer.is_healthy() && !direct_session_degraded)
82            .map(|_| *dest_node_addr);
83        let direct_session_has_recent_data_return =
84            self.session_direct_path_has_recent_data_return(dest_node_addr, now_ms);
85        if let Some(direct_addr) = healthy_direct_route
86            && direct_session_has_recent_data_return
87        {
88            return self.peers.get(&direct_addr);
89        }
90        if let Some(direct_addr) = healthy_direct_route
91            && !direct_session_untrusted
92            && self
93                .peers
94                .get(&direct_addr)
95                .is_some_and(|peer| peer.link_cost() <= 1.0 + ROUTING_FALLBACK_MIN_COST_ADVANTAGE)
96        {
97            return self.peers.get(&direct_addr);
98        }
99        let direct_payload_eligible = healthy_direct_route.is_some();
100        let payload_candidate_can_send = |addr: &NodeAddr, peer: &ActivePeer| {
101            if addr == dest_node_addr {
102                direct_payload_eligible
103            } else {
104                peer.is_healthy()
105            }
106        };
107
108        // A healthy direct path is not automatically the best path. A
109        // hotspot/NAT hairpin can remain sendable with high RTT or mild loss;
110        // in that case a lower-cost mesh next-hop should carry traffic while
111        // direct probes continue in the background.
112        let fallback_beats_direct = |node: &Self, fallback_addr: NodeAddr| {
113            if direct_session_untrusted {
114                return healthy_direct_route != Some(fallback_addr)
115                    && node
116                        .peers
117                        .get(&fallback_addr)
118                        .is_some_and(|peer| peer.is_healthy());
119            }
120            node.route_candidate_beats_direct(healthy_direct_route, fallback_addr)
121        };
122
123        let sendable_learned_peers = if self.config.node.routing.mode == RoutingMode::ReplyLearned {
124            Some(
125                self.peers
126                    .iter()
127                    .filter(|(addr, peer)| payload_candidate_can_send(addr, peer))
128                    .map(|(addr, _)| *addr)
129                    .collect::<HashSet<_>>(),
130            )
131        } else {
132            None
133        };
134
135        let explore_fallback = sendable_learned_peers.as_ref().is_some_and(|sendable| {
136            self.learned_routes.should_explore_fallback(
137                dest_node_addr,
138                now_ms,
139                self.config.node.routing.learned_fallback_explore_interval,
140                |addr| sendable.contains(addr),
141            )
142        });
143        if explore_fallback && let Some(direct_addr) = healthy_direct_peer {
144            return self.peers.get(&direct_addr);
145        }
146
147        if let Some(next_hop_addr) = sendable_learned_peers.as_ref().and_then(|sendable| {
148            let session = self.sessions.get(dest_node_addr)?;
149            let next_hop_addr = session.last_outbound_next_hop()?;
150            if next_hop_addr == *dest_node_addr
151                || !session.is_established()
152                || !session.has_recent_outbound_activity(
153                    now_ms,
154                    self.session_direct_path_exclusive_trust_timeout_ms(),
155                )
156                || !sendable.contains(&next_hop_addr)
157                || !fallback_beats_direct(self, next_hop_addr)
158            {
159                return None;
160            }
161            Some(next_hop_addr)
162        }) {
163            self.learned_routes
164                .record_selected(dest_node_addr, &next_hop_addr, now_ms);
165            return self.peers.get(&next_hop_addr);
166        }
167
168        // 3. Optional reply-learned routing. These entries are not peer
169        // claims; they are local observations of which peer carried traffic
170        // or a verified lookup response back from the destination. Most
171        // packets use weighted multipath over learned routes, but periodic
172        // fallback exploration lets coord/bloom/tree routes discover better
173        // candidates.
174        if let Some(sendable) = &sendable_learned_peers
175            && !explore_fallback
176        {
177            let eligible = sendable
178                .iter()
179                .copied()
180                .filter(|addr| fallback_beats_direct(self, *addr))
181                .collect::<HashSet<_>>();
182            if !eligible.is_empty()
183                && let Some(next_hop_addr) =
184                    self.learned_routes
185                        .select_next_hop(dest_node_addr, now_ms, |addr| eligible.contains(addr))
186            {
187                return self.peers.get(&next_hop_addr);
188            }
189        }
190
191        // Look up cached destination coordinates (required by both bloom and tree paths).
192        let Some(dest_coords) = self
193            .coord_cache
194            .get_and_touch(dest_node_addr, now_ms)
195            .cloned()
196        else {
197            if (healthy_direct_route.is_none() || explore_fallback)
198                && let Some(sendable) = &sendable_learned_peers
199                && let Some(next_hop_addr) =
200                    self.learned_routes
201                        .select_next_hop(dest_node_addr, now_ms, |addr| sendable.contains(addr))
202            {
203                return self.peers.get(&next_hop_addr);
204            }
205            if let Some(direct_addr) = healthy_direct_route {
206                return self.peers.get(&direct_addr);
207            }
208            if let Some(direct_addr) = stale_traversal_direct_route {
209                return self.peers.get(&direct_addr);
210            }
211            return None;
212        };
213
214        // 4. Bloom filter candidates — requires dest_coords for loop-free selection.
215        //    If no candidate is strictly closer, fall through to tree routing.
216        let coordinate_route_addr = {
217            let candidates: Vec<&ActivePeer> = self
218                .peers
219                .iter()
220                .filter(|(addr, peer)| {
221                    payload_candidate_can_send(addr, peer) && peer.may_reach(dest_node_addr)
222                })
223                .map(|(_, peer)| peer)
224                .collect();
225            if !candidates.is_empty() {
226                self.select_best_candidate(&candidates, &dest_coords)
227                    .map(|peer| *peer.node_addr())
228            } else {
229                None
230            }
231        };
232        if let Some(next_hop_addr) = coordinate_route_addr
233            && fallback_beats_direct(self, next_hop_addr)
234        {
235            return self.peers.get(&next_hop_addr);
236        }
237
238        // 5. Greedy tree routing fallback
239        let tree_route_addr = self.select_tree_payload_candidate(
240            &dest_coords,
241            dest_node_addr,
242            direct_payload_eligible,
243        );
244        if let Some(next_hop_addr) = tree_route_addr
245            && fallback_beats_direct(self, next_hop_addr)
246        {
247            return self.peers.get(&next_hop_addr);
248        }
249
250        if explore_fallback
251            && let Some(peer) = sendable_learned_peers.as_ref().and_then(|sendable| {
252                self.learned_routes
253                    .select_next_hop(dest_node_addr, now_ms, |addr| sendable.contains(addr))
254                    .and_then(|next_hop_addr| self.peers.get(&next_hop_addr))
255            })
256        {
257            return Some(peer);
258        }
259
260        if let Some(direct_addr) = healthy_direct_route {
261            return self.peers.get(&direct_addr);
262        }
263
264        if let Some(sendable) = &sendable_learned_peers
265            && let Some(next_hop_addr) =
266                self.learned_routes
267                    .select_next_hop(dest_node_addr, now_ms, |addr| sendable.contains(addr))
268        {
269            return self.peers.get(&next_hop_addr);
270        }
271
272        if let Some(direct_addr) = stale_traversal_direct_route {
273            return self.peers.get(&direct_addr);
274        }
275
276        None
277    }
278
279    pub(in crate::node) fn find_transit_next_hop(
280        &mut self,
281        dest_node_addr: &NodeAddr,
282        previous_hop: &NodeAddr,
283    ) -> Option<NodeAddr> {
284        if dest_node_addr == self.node_addr() {
285            return None;
286        }
287
288        if dest_node_addr != previous_hop
289            && self
290                .peers
291                .get(dest_node_addr)
292                .is_some_and(|peer| peer.is_healthy())
293        {
294            return Some(*dest_node_addr);
295        }
296
297        let next_hop_addr = *self.find_next_hop(dest_node_addr)?.node_addr();
298        if &next_hop_addr == previous_hop {
299            self.record_route_failure(*dest_node_addr, next_hop_addr);
300            return None;
301        }
302        Some(next_hop_addr)
303    }
304
305    pub(super) fn route_candidate_beats_direct(
306        &self,
307        healthy_direct_route: Option<NodeAddr>,
308        candidate_addr: NodeAddr,
309    ) -> bool {
310        let Some(direct_addr) = healthy_direct_route else {
311            return true;
312        };
313        if candidate_addr == direct_addr {
314            return false;
315        }
316
317        let Some(direct) = self.peers.get(&direct_addr) else {
318            return true;
319        };
320        if self.active_peer_uses_configured_static_udp_path(&direct_addr) {
321            return false;
322        }
323        let Some(candidate) = self.peers.get(&candidate_addr) else {
324            return false;
325        };
326        if !candidate.is_healthy() {
327            return false;
328        }
329
330        let direct_cost = direct.link_cost();
331        let candidate_cost = candidate.link_cost();
332        candidate_cost + ROUTING_FALLBACK_MIN_COST_ADVANTAGE < direct_cost
333    }
334
335    pub(super) fn select_tree_payload_candidate(
336        &self,
337        dest_coords: &crate::tree::TreeCoordinate,
338        direct_dest: &NodeAddr,
339        direct_payload_eligible: bool,
340    ) -> Option<NodeAddr> {
341        if self.tree_state.my_coords().root_id() != dest_coords.root_id() {
342            return None;
343        }
344
345        let my_distance = self.tree_state.my_coords().distance_to(dest_coords);
346        let mut best: Option<(NodeAddr, usize)> = None;
347
348        for (peer_addr, peer) in &self.peers {
349            if peer_addr == direct_dest {
350                if !direct_payload_eligible {
351                    continue;
352                }
353            } else if !peer.is_healthy() {
354                continue;
355            }
356
357            let Some(peer_coords) = self.tree_state.peer_coords(peer_addr) else {
358                continue;
359            };
360            let distance = peer_coords.distance_to(dest_coords);
361            if distance >= my_distance {
362                continue;
363            }
364
365            let dominated = match &best {
366                None => true,
367                Some((best_id, best_dist)) => {
368                    distance < *best_dist || (distance == *best_dist && peer_addr < best_id)
369                }
370            };
371            if dominated {
372                best = Some((*peer_addr, distance));
373            }
374        }
375
376        best.map(|(peer_addr, _)| peer_addr)
377    }
378
379    pub(in crate::node) fn session_direct_path_is_degraded(
380        &mut self,
381        dest: &NodeAddr,
382        now_ms: u64,
383    ) -> bool {
384        self.session_direct_degradation.is_degraded(dest, now_ms)
385    }
386
387    pub(in crate::node) fn session_direct_path_degradation_active(
388        &self,
389        dest: &NodeAddr,
390        now_ms: u64,
391    ) -> bool {
392        self.session_direct_degradation.is_degraded_at(dest, now_ms)
393    }
394
395    pub(in crate::node) fn session_direct_path_blocks_direct_payload(
396        &mut self,
397        dest: &NodeAddr,
398        now_ms: u64,
399    ) -> bool {
400        self.session_direct_path_is_degraded(dest, now_ms)
401            || self.session_direct_discovered_endpoint_trust_expired(dest, now_ms)
402    }
403
404    pub(in crate::node) fn session_direct_path_exclusive_trust_timeout_ms(&self) -> u64 {
405        self.config
406            .node
407            .heartbeat_interval_secs
408            .saturating_mul(1000)
409            .saturating_add(1_500)
410            .max(SESSION_DIRECT_MIN_EXCLUSIVE_TRUST_MS)
411    }
412
413    pub(in crate::node) fn session_direct_path_exclusive_trust_expired(
414        &self,
415        dest: &NodeAddr,
416        now_ms: u64,
417    ) -> bool {
418        if !self
419            .peers
420            .get(dest)
421            .is_some_and(|peer| peer.is_healthy() && peer.can_send())
422        {
423            return false;
424        }
425        let Some(session) = self.sessions.get(dest) else {
426            return false;
427        };
428        if !session.is_established() {
429            return false;
430        }
431        session.has_recent_outbound_without_inbound(
432            now_ms,
433            self.session_direct_path_exclusive_trust_timeout_ms(),
434        )
435    }
436
437    pub(in crate::node) fn session_direct_path_has_recent_data_return(
438        &self,
439        dest: &NodeAddr,
440        now_ms: u64,
441    ) -> bool {
442        self.sessions
443            .get(dest)
444            .and_then(|session| session.last_authenticated_inbound_data_age_ms(now_ms))
445            .is_some_and(|age_ms| age_ms <= self.session_direct_path_exclusive_trust_timeout_ms())
446    }
447
448    fn session_direct_discovered_endpoint_trust_expired(
449        &self,
450        dest: &NodeAddr,
451        now_ms: u64,
452    ) -> bool {
453        self.session_direct_path_exclusive_trust_expired(dest, now_ms)
454            && self.configured_peer(dest).is_some_and(|peer_config| {
455                peer_config.is_auto_connect()
456                    && self.active_peer_uses_traversal_path(dest, peer_config)
457            })
458    }
459
460    pub(in crate::node) fn mark_session_direct_path_degraded(
461        &mut self,
462        dest: NodeAddr,
463        now_ms: u64,
464    ) -> bool {
465        let changed = self.session_direct_degradation.mark_degraded(
466            dest,
467            now_ms,
468            SESSION_DIRECT_DEGRADED_HOLD_MS,
469        );
470        #[cfg(unix)]
471        if changed && let Some(runtime) = &self.endpoint_bulk_send_runtime {
472            runtime.invalidate(&dest);
473        }
474        changed
475    }
476
477    pub(in crate::node) fn clear_session_direct_path_degraded(&mut self, dest: &NodeAddr) -> bool {
478        let changed = self.session_direct_degradation.clear(dest);
479        #[cfg(unix)]
480        if changed && let Some(runtime) = &self.endpoint_bulk_send_runtime {
481            runtime.invalidate(dest);
482        }
483        changed
484    }
485
486    pub(in crate::node) fn clear_session_direct_path_degraded_after_promotion(
487        &mut self,
488        dest: &NodeAddr,
489        now_ms: u64,
490    ) {
491        let keep_degraded = self.session_direct_path_blocks_direct_payload(dest, now_ms);
492        if !keep_degraded {
493            self.clear_session_direct_path_degraded(dest);
494        } else if self.promoted_path_matches_configured_static_peer(dest) {
495            debug!(
496                peer = %self.peer_display_name(dest),
497                "Clearing direct payload degradation after configured direct-path promotion"
498            );
499            self.clear_session_direct_path_degraded(dest);
500        } else {
501            debug!(
502                peer = %self.peer_display_name(dest),
503                "Keeping direct payload degraded after direct-path promotion"
504            );
505        }
506    }
507
508    fn promoted_path_matches_configured_static_peer(&self, peer_node_addr: &NodeAddr) -> bool {
509        self.config
510            .auto_connect_peers()
511            .filter(|peer_config| {
512                PeerIdentity::from_npub(&peer_config.npub)
513                    .ok()
514                    .is_some_and(|identity| identity.node_addr() == peer_node_addr)
515            })
516            .any(|peer_config| {
517                self.static_peer_addresses(peer_config)
518                    .iter()
519                    .any(|candidate| self.active_peer_matches_candidate(peer_node_addr, candidate))
520            })
521    }
522
523    pub(in crate::node) fn learn_reverse_route(
524        &mut self,
525        destination: NodeAddr,
526        next_hop: NodeAddr,
527    ) {
528        if self.config.node.routing.mode != RoutingMode::ReplyLearned
529            || destination == *self.node_addr()
530        {
531            return;
532        }
533        let now_ms = Self::now_ms();
534        self.learned_routes.learn(
535            destination,
536            next_hop,
537            now_ms,
538            self.config.node.routing.learned_ttl_secs,
539            self.config.node.routing.max_learned_routes_per_dest,
540        );
541    }
542
543    pub(in crate::node) fn record_route_failure(
544        &mut self,
545        destination: NodeAddr,
546        next_hop: NodeAddr,
547    ) {
548        if self.config.node.routing.mode != RoutingMode::ReplyLearned {
549            return;
550        }
551        self.learned_routes.record_failure(&destination, &next_hop);
552    }
553
554    pub(crate) fn learned_route_table_snapshot(&self, now_ms: u64) -> LearnedRouteTableSnapshot {
555        self.learned_routes.snapshot(now_ms)
556    }
557
558    pub(in crate::node) fn purge_learned_routes(&mut self, now_ms: u64) {
559        self.learned_routes.purge_expired(now_ms);
560    }
561
562    /// Select the best peer from a set of bloom filter candidates.
563    ///
564    /// Uses distance from each candidate's tree coordinates to the destination
565    /// as the primary metric (after link_cost). Only selects peers that are
566    /// strictly closer to the destination than we are (self-distance check
567    /// prevents routing loops).
568    ///
569    /// Ordering: `(link_cost, distance_to_dest, node_addr)`.
570    pub(super) fn select_best_candidate<'a>(
571        &'a self,
572        candidates: &[&'a ActivePeer],
573        dest_coords: &crate::tree::TreeCoordinate,
574    ) -> Option<&'a ActivePeer> {
575        let my_distance = self.tree_state.my_coords().distance_to(dest_coords);
576
577        let mut best: Option<(&ActivePeer, f64, usize)> = None;
578
579        for &candidate in candidates {
580            if !candidate.can_send() {
581                continue;
582            }
583
584            let cost = candidate.link_cost();
585
586            let dist = self
587                .tree_state
588                .peer_coords(candidate.node_addr())
589                .map(|pc| pc.distance_to(dest_coords))
590                .unwrap_or(usize::MAX);
591
592            // Self-distance check: only consider peers strictly closer
593            // to the destination than we are (prevents routing loops)
594            if dist >= my_distance {
595                continue;
596            }
597
598            let dominated = match &best {
599                None => true,
600                Some((_, best_cost, best_dist)) => {
601                    cost < *best_cost
602                        || (cost == *best_cost && dist < *best_dist)
603                        || (cost == *best_cost
604                            && dist == *best_dist
605                            && candidate.node_addr() < best.as_ref().unwrap().0.node_addr())
606                }
607            };
608
609            if dominated {
610                best = Some((candidate, cost, dist));
611            }
612        }
613
614        best.map(|(peer, _, _)| peer)
615    }
616
617    /// Check if a destination is in any peer's bloom filter.
618    pub fn destination_in_filters(&self, dest: &NodeAddr) -> Vec<&ActivePeer> {
619        self.peers.values().filter(|p| p.may_reach(dest)).collect()
620    }
621}