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