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
61        let healthy_direct_route = self
62            .peers
63            .get(dest_node_addr)
64            .filter(|peer| peer.is_healthy() && !direct_session_degraded)
65            .map(|_| *dest_node_addr);
66        if let Some(direct_addr) = healthy_direct_route
67            && !direct_session_untrusted
68            && self
69                .peers
70                .get(&direct_addr)
71                .is_some_and(|peer| peer.link_cost() <= 1.0 + ROUTING_FALLBACK_MIN_COST_ADVANTAGE)
72        {
73            return self.peers.get(&direct_addr);
74        }
75        let direct_payload_eligible = healthy_direct_route.is_some();
76        let payload_candidate_can_send = |addr: &NodeAddr, peer: &ActivePeer| {
77            if addr == dest_node_addr {
78                direct_payload_eligible
79            } else {
80                peer.is_healthy()
81            }
82        };
83
84        // A healthy direct path is not automatically the best path. A
85        // hotspot/NAT hairpin can remain sendable with high RTT or mild loss;
86        // in that case a lower-cost mesh next-hop should carry traffic while
87        // direct probes continue in the background.
88        let fallback_beats_direct = |node: &Self, fallback_addr: NodeAddr| {
89            if direct_session_untrusted {
90                return healthy_direct_route != Some(fallback_addr)
91                    && node
92                        .peers
93                        .get(&fallback_addr)
94                        .is_some_and(|peer| peer.is_healthy());
95            }
96            node.route_candidate_beats_direct(healthy_direct_route, fallback_addr)
97        };
98
99        let sendable_learned_peers = if self.config.node.routing.mode == RoutingMode::ReplyLearned {
100            Some(
101                self.peers
102                    .iter()
103                    .filter(|(addr, peer)| payload_candidate_can_send(addr, peer))
104                    .map(|(addr, _)| *addr)
105                    .collect::<HashSet<_>>(),
106            )
107        } else {
108            None
109        };
110
111        // 3. Optional reply-learned routing. These entries are not peer
112        // claims; they are local observations of which peer carried traffic
113        // or a verified lookup response back from the destination. Most
114        // packets use weighted multipath over learned routes, but periodic
115        // fallback exploration lets coord/bloom/tree routes discover better
116        // candidates.
117        let explore_fallback = sendable_learned_peers.as_ref().is_some_and(|sendable| {
118            self.learned_routes.should_explore_fallback(
119                dest_node_addr,
120                now_ms,
121                self.config.node.routing.learned_fallback_explore_interval,
122                |addr| sendable.contains(addr),
123            )
124        });
125        if let Some(sendable) = &sendable_learned_peers
126            && !explore_fallback
127        {
128            let eligible = sendable
129                .iter()
130                .copied()
131                .filter(|addr| fallback_beats_direct(self, *addr))
132                .collect::<HashSet<_>>();
133            if !eligible.is_empty()
134                && let Some(next_hop_addr) =
135                    self.learned_routes
136                        .select_next_hop(dest_node_addr, now_ms, |addr| eligible.contains(addr))
137            {
138                return self.peers.get(&next_hop_addr);
139            }
140        }
141
142        // Look up cached destination coordinates (required by both bloom and tree paths).
143        let Some(dest_coords) = self
144            .coord_cache
145            .get_and_touch(dest_node_addr, now_ms)
146            .cloned()
147        else {
148            if (healthy_direct_route.is_none() || explore_fallback)
149                && let Some(sendable) = &sendable_learned_peers
150                && let Some(next_hop_addr) =
151                    self.learned_routes
152                        .select_next_hop(dest_node_addr, now_ms, |addr| sendable.contains(addr))
153            {
154                return self.peers.get(&next_hop_addr);
155            }
156            if let Some(direct_addr) = healthy_direct_route
157                && !direct_session_untrusted
158            {
159                return self.peers.get(&direct_addr);
160            }
161            return None;
162        };
163
164        // 4. Bloom filter candidates — requires dest_coords for loop-free selection.
165        //    If no candidate is strictly closer, fall through to tree routing.
166        let coordinate_route_addr = {
167            let candidates: Vec<&ActivePeer> = self
168                .peers
169                .iter()
170                .filter(|(addr, peer)| {
171                    payload_candidate_can_send(addr, peer) && peer.may_reach(dest_node_addr)
172                })
173                .map(|(_, peer)| peer)
174                .collect();
175            if !candidates.is_empty() {
176                self.select_best_candidate(&candidates, &dest_coords)
177                    .map(|peer| *peer.node_addr())
178            } else {
179                None
180            }
181        };
182        if let Some(next_hop_addr) = coordinate_route_addr
183            && fallback_beats_direct(self, next_hop_addr)
184        {
185            return self.peers.get(&next_hop_addr);
186        }
187
188        // 5. Greedy tree routing fallback
189        let tree_route_addr = self.select_tree_payload_candidate(
190            &dest_coords,
191            dest_node_addr,
192            direct_payload_eligible,
193        );
194        if let Some(next_hop_addr) = tree_route_addr
195            && fallback_beats_direct(self, next_hop_addr)
196        {
197            return self.peers.get(&next_hop_addr);
198        }
199
200        if explore_fallback {
201            return sendable_learned_peers.as_ref().and_then(|sendable| {
202                self.learned_routes
203                    .select_next_hop(dest_node_addr, now_ms, |addr| sendable.contains(addr))
204                    .and_then(|next_hop_addr| self.peers.get(&next_hop_addr))
205            });
206        }
207
208        if let Some(direct_addr) = healthy_direct_route
209            && !direct_session_untrusted
210        {
211            return self.peers.get(&direct_addr);
212        }
213
214        if let Some(sendable) = &sendable_learned_peers
215            && let Some(next_hop_addr) =
216                self.learned_routes
217                    .select_next_hop(dest_node_addr, now_ms, |addr| sendable.contains(addr))
218        {
219            return self.peers.get(&next_hop_addr);
220        }
221
222        None
223    }
224
225    pub(in crate::node) fn find_transit_next_hop(
226        &mut self,
227        dest_node_addr: &NodeAddr,
228        previous_hop: &NodeAddr,
229    ) -> Option<NodeAddr> {
230        if dest_node_addr == self.node_addr() {
231            return None;
232        }
233
234        if dest_node_addr != previous_hop
235            && self
236                .peers
237                .get(dest_node_addr)
238                .is_some_and(|peer| peer.is_healthy())
239        {
240            return Some(*dest_node_addr);
241        }
242
243        let next_hop_addr = *self.find_next_hop(dest_node_addr)?.node_addr();
244        if &next_hop_addr == previous_hop {
245            self.record_route_failure(*dest_node_addr, next_hop_addr);
246            return None;
247        }
248        Some(next_hop_addr)
249    }
250
251    pub(super) fn route_candidate_beats_direct(
252        &self,
253        healthy_direct_route: Option<NodeAddr>,
254        candidate_addr: NodeAddr,
255    ) -> bool {
256        let Some(direct_addr) = healthy_direct_route else {
257            return true;
258        };
259        if candidate_addr == direct_addr {
260            return false;
261        }
262
263        let Some(direct) = self.peers.get(&direct_addr) else {
264            return true;
265        };
266        if self.active_peer_uses_configured_static_udp_path(&direct_addr) {
267            return false;
268        }
269        let Some(candidate) = self.peers.get(&candidate_addr) else {
270            return false;
271        };
272        if !candidate.is_healthy() {
273            return false;
274        }
275
276        let direct_cost = direct.link_cost();
277        let candidate_cost = candidate.link_cost();
278        candidate_cost + ROUTING_FALLBACK_MIN_COST_ADVANTAGE < direct_cost
279    }
280
281    pub(super) fn select_tree_payload_candidate(
282        &self,
283        dest_coords: &crate::tree::TreeCoordinate,
284        direct_dest: &NodeAddr,
285        direct_payload_eligible: bool,
286    ) -> Option<NodeAddr> {
287        if self.tree_state.my_coords().root_id() != dest_coords.root_id() {
288            return None;
289        }
290
291        let my_distance = self.tree_state.my_coords().distance_to(dest_coords);
292        let mut best: Option<(NodeAddr, usize)> = None;
293
294        for (peer_addr, peer) in &self.peers {
295            if peer_addr == direct_dest {
296                if !direct_payload_eligible {
297                    continue;
298                }
299            } else if !peer.is_healthy() {
300                continue;
301            }
302
303            let Some(peer_coords) = self.tree_state.peer_coords(peer_addr) else {
304                continue;
305            };
306            let distance = peer_coords.distance_to(dest_coords);
307            if distance >= my_distance {
308                continue;
309            }
310
311            let dominated = match &best {
312                None => true,
313                Some((best_id, best_dist)) => {
314                    distance < *best_dist || (distance == *best_dist && peer_addr < best_id)
315                }
316            };
317            if dominated {
318                best = Some((*peer_addr, distance));
319            }
320        }
321
322        best.map(|(peer_addr, _)| peer_addr)
323    }
324
325    pub(in crate::node) fn session_direct_path_is_degraded(
326        &mut self,
327        dest: &NodeAddr,
328        now_ms: u64,
329    ) -> bool {
330        self.session_direct_degradation.is_degraded(dest, now_ms)
331    }
332
333    pub(in crate::node) fn session_direct_path_blocks_direct_payload(
334        &mut self,
335        dest: &NodeAddr,
336        now_ms: u64,
337    ) -> bool {
338        self.session_direct_path_is_degraded(dest, now_ms)
339            && !self.active_peer_uses_configured_static_udp_path(dest)
340    }
341
342    pub(in crate::node) fn session_direct_path_exclusive_trust_timeout_ms(&self) -> u64 {
343        self.config
344            .node
345            .heartbeat_interval_secs
346            .saturating_mul(1000)
347            .saturating_add(1_500)
348            .max(SESSION_DIRECT_MIN_EXCLUSIVE_TRUST_MS)
349    }
350
351    pub(in crate::node) fn session_direct_path_exclusive_trust_expired(
352        &self,
353        dest: &NodeAddr,
354        now_ms: u64,
355    ) -> bool {
356        if !self
357            .peers
358            .get(dest)
359            .is_some_and(|peer| peer.is_healthy() && peer.can_send())
360        {
361            return false;
362        }
363        let Some(session) = self.sessions.get(dest) else {
364            return false;
365        };
366        if !session.is_established() {
367            return false;
368        }
369        session.has_recent_outbound_without_inbound(
370            now_ms,
371            self.session_direct_path_exclusive_trust_timeout_ms(),
372        )
373    }
374
375    pub(in crate::node) fn mark_session_direct_path_degraded(
376        &mut self,
377        dest: NodeAddr,
378        now_ms: u64,
379    ) -> bool {
380        self.session_direct_degradation
381            .mark_degraded(dest, now_ms, SESSION_DIRECT_DEGRADED_HOLD_MS)
382    }
383
384    pub(in crate::node) fn clear_session_direct_path_degraded(&mut self, dest: &NodeAddr) -> bool {
385        self.session_direct_degradation.clear(dest)
386    }
387
388    pub(in crate::node) fn clear_session_direct_path_degraded_after_promotion(
389        &mut self,
390        dest: &NodeAddr,
391        now_ms: u64,
392    ) {
393        let keep_degraded = self.session_direct_path_blocks_direct_payload(dest, now_ms);
394        if keep_degraded {
395            debug!(
396                peer = %self.peer_display_name(dest),
397                "Keeping direct payload degraded after direct-path promotion"
398            );
399        } else {
400            self.clear_session_direct_path_degraded(dest);
401        }
402    }
403
404    pub(in crate::node) fn learn_reverse_route(
405        &mut self,
406        destination: NodeAddr,
407        next_hop: NodeAddr,
408    ) {
409        if self.config.node.routing.mode != RoutingMode::ReplyLearned
410            || destination == *self.node_addr()
411        {
412            return;
413        }
414        let now_ms = Self::now_ms();
415        self.learned_routes.learn(
416            destination,
417            next_hop,
418            now_ms,
419            self.config.node.routing.learned_ttl_secs,
420            self.config.node.routing.max_learned_routes_per_dest,
421        );
422    }
423
424    pub(in crate::node) fn record_route_failure(
425        &mut self,
426        destination: NodeAddr,
427        next_hop: NodeAddr,
428    ) {
429        if self.config.node.routing.mode != RoutingMode::ReplyLearned {
430            return;
431        }
432        self.learned_routes.record_failure(&destination, &next_hop);
433    }
434
435    pub(crate) fn learned_route_table_snapshot(&self, now_ms: u64) -> LearnedRouteTableSnapshot {
436        self.learned_routes.snapshot(now_ms)
437    }
438
439    pub(in crate::node) fn purge_learned_routes(&mut self, now_ms: u64) {
440        self.learned_routes.purge_expired(now_ms);
441    }
442
443    /// Select the best peer from a set of bloom filter candidates.
444    ///
445    /// Uses distance from each candidate's tree coordinates to the destination
446    /// as the primary metric (after link_cost). Only selects peers that are
447    /// strictly closer to the destination than we are (self-distance check
448    /// prevents routing loops).
449    ///
450    /// Ordering: `(link_cost, distance_to_dest, node_addr)`.
451    pub(super) fn select_best_candidate<'a>(
452        &'a self,
453        candidates: &[&'a ActivePeer],
454        dest_coords: &crate::tree::TreeCoordinate,
455    ) -> Option<&'a ActivePeer> {
456        let my_distance = self.tree_state.my_coords().distance_to(dest_coords);
457
458        let mut best: Option<(&ActivePeer, f64, usize)> = None;
459
460        for &candidate in candidates {
461            if !candidate.can_send() {
462                continue;
463            }
464
465            let cost = candidate.link_cost();
466
467            let dist = self
468                .tree_state
469                .peer_coords(candidate.node_addr())
470                .map(|pc| pc.distance_to(dest_coords))
471                .unwrap_or(usize::MAX);
472
473            // Self-distance check: only consider peers strictly closer
474            // to the destination than we are (prevents routing loops)
475            if dist >= my_distance {
476                continue;
477            }
478
479            let dominated = match &best {
480                None => true,
481                Some((_, best_cost, best_dist)) => {
482                    cost < *best_cost
483                        || (cost == *best_cost && dist < *best_dist)
484                        || (cost == *best_cost
485                            && dist == *best_dist
486                            && candidate.node_addr() < best.as_ref().unwrap().0.node_addr())
487                }
488            };
489
490            if dominated {
491                best = Some((candidate, cost, dist));
492            }
493        }
494
495        best.map(|(peer, _, _)| peer)
496    }
497
498    /// Check if a destination is in any peer's bloom filter.
499    pub fn destination_in_filters(&self, dest: &NodeAddr) -> Vec<&ActivePeer> {
500        self.peers.values().filter(|p| p.may_reach(dest)).collect()
501    }
502}