Skip to main content

dig_ip/
dial.rs

1//! [`dial_order`] — THE local∩peer family-intersection rule, the reason this crate exists.
2//!
3//! Every prior ecosystem copy sorted candidates IPv6-first and raced them, but none removed a family
4//! the LOCAL host cannot reach. [`dial_order`] computes the INTERSECTION of the families the local
5//! host has and the families the peer offers, then emits the peer's addresses of those families in
6//! IPv6-first preference order — so an IPv4-only host prefers not to emit an IPv6 SYN.
7//!
8//! ## Detection confidence — fail OPEN, never strand a reachable peer
9//!
10//! [`LocalStack`] detection is affirmative-only: a probe that SUCCEEDS proves the host has a routable
11//! source address for that family, but a probe that FAILS proves only that there is no *default* route
12//! to the public documentation address — NOT that the family is unreachable. On overlay / split-tunnel
13//! / subnet-routed networks (Tailscale/WireGuard `100.64/10`·`10.x`, isolated LANs, containers) and in
14//! the window before the route is up at boot, the probe returns `ENETUNREACH` even though peers on a
15//! specific route ARE reachable. Treating that false negative as "family absent" made the intersection
16//! fail CLOSED and refuse to dial a peer that was actually reachable (the regression this crate fixes).
17//!
18//! So the intersection is an OPTIMIZATION applied ONLY when local detection is affirmative for at least
19//! one of the peer's families. When the intersection is empty but the peer HAS candidates, dialing
20//! fails OPEN: it attempts ALL of the peer's candidates (IPv6-first) rather than stranding a peer the
21//! (unreliable) negative detection cannot honestly rule out. [`NoCommonFamily`] is reserved for the one
22//! case with genuinely nothing to dial — a peer with no candidates at all.
23
24use std::net::SocketAddr;
25
26use tracing::warn;
27
28use crate::candidate::PeerCandidates;
29use crate::family::Family;
30use crate::local::LocalStack;
31
32/// The peer offers no candidate address at all, so there is nothing to dial.
33///
34/// This is a clean, immediate, non-hanging outcome: the caller reports the peer unreachable rather
35/// than launching attempts that can only time out. It is NOT returned merely because local stack
36/// detection failed to find a common family — negative detection is unreliable (see the module docs),
37/// so an empty local∩peer intersection over a peer that HAS candidates fails OPEN instead.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct NoCommonFamily {
40    /// The families the local host can originate on.
41    pub local: Vec<Family>,
42    /// The families the peer offers candidates on.
43    pub peer: Vec<Family>,
44}
45
46impl std::fmt::Display for NoCommonFamily {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        write!(
49            f,
50            "no common address family: local has {:?}, peer offers {:?}",
51            self.local, self.peer
52        )
53    }
54}
55
56impl std::error::Error for NoCommonFamily {}
57
58/// The addresses to dial, in IPv6-first preference order.
59///
60/// When local detection is affirmative for at least one of the peer's families, the result is the
61/// local∩peer INTERSECTION and therefore:
62///
63/// - contains no family the local host affirmatively lacks (the anti-mis-dial optimization, G1);
64/// - contains no family the peer lacks (only the peer's own candidates are used, G2);
65/// - lists all viable IPv6 addresses before any IPv4 address (IPv6-first, CLAUDE.md §5.2, G3).
66///
67/// When that intersection is empty but the peer HAS candidates, the result FAILS OPEN: it is ALL of
68/// the peer's candidates, IPv6-first — because negative local detection is unreliable and must not
69/// strand a reachable peer (see the module docs). This path emits a `warn!` so the connectivity gap
70/// is observable. [`Err(NoCommonFamily)`] is returned ONLY when the peer offers no candidate at all.
71pub fn dial_order(
72    local: &LocalStack,
73    peer: &PeerCandidates,
74) -> Result<Vec<SocketAddr>, NoCommonFamily> {
75    match plan(local, peer) {
76        DialPlan::Intersection(order) => Ok(order),
77        DialPlan::FailOpen(order) => {
78            warn!(
79                local = ?local.families(),
80                peer = ?peer.families(),
81                candidates = order.len(),
82                "local∩peer address-family intersection is empty; failing OPEN to all peer \
83                 candidates (IPv6-first) — local-stack detection may be a false negative on an \
84                 overlay/split-tunnel/pre-route network"
85            );
86            Ok(order)
87        }
88        DialPlan::NoCandidates => Err(NoCommonFamily {
89            local: local.families(),
90            peer: peer.families().into_iter().collect(),
91        }),
92    }
93}
94
95/// The dial selection outcome, split out from [`dial_order`] as a side-effect-free test seam so the
96/// confident-intersection, fail-open, and nothing-to-dial paths can each be asserted directly.
97#[derive(Debug, PartialEq, Eq)]
98enum DialPlan {
99    /// Local detection is affirmative for ≥1 of the peer's families: the filtered local∩peer
100    /// intersection, IPv6-first (G1/G2/G3 hold).
101    Intersection(Vec<SocketAddr>),
102    /// The intersection is empty but the peer has candidates: fail OPEN to ALL peer candidates,
103    /// IPv6-first, because negative local detection is unreliable and must not strand the peer.
104    FailOpen(Vec<SocketAddr>),
105    /// The peer offers no candidate at all — genuinely nothing to dial.
106    NoCandidates,
107}
108
109/// Decide how to dial `peer` from `local` (see [`DialPlan`]). Pure: no logging, no I/O.
110fn plan(local: &LocalStack, peer: &PeerCandidates) -> DialPlan {
111    let intersection = candidates_in_preference_order(peer, |family| local.has(family));
112    if !intersection.is_empty() {
113        return DialPlan::Intersection(intersection);
114    }
115    if peer.is_empty() {
116        return DialPlan::NoCandidates;
117    }
118    // Empty intersection over a peer that HAS candidates: the negative detection cannot be trusted,
119    // so attempt every candidate the peer offers rather than stranding it.
120    DialPlan::FailOpen(candidates_in_preference_order(peer, |_| true))
121}
122
123/// The peer's candidate addresses whose family passes `accept`, emitted IPv6-first (all V6 before any
124/// V4) with each family's discovery order preserved.
125fn candidates_in_preference_order(
126    peer: &PeerCandidates,
127    accept: impl Fn(Family) -> bool,
128) -> Vec<SocketAddr> {
129    let mut ordered = Vec::new();
130    for family in Family::PREFERENCE {
131        if accept(family) {
132            ordered.extend(peer.of_family(family));
133        }
134    }
135    ordered
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::candidate::CandidateSource;
142
143    fn sa(s: &str) -> SocketAddr {
144        s.parse().unwrap()
145    }
146
147    /// A peer reachable on the given families (one canned address each).
148    fn peer_with(v6: bool, v4: bool) -> PeerCandidates {
149        let mut p = PeerCandidates::new();
150        if v6 {
151            p.add(sa("[2001:db8::1]:443"), CandidateSource::Dht);
152        }
153        if v4 {
154            p.add(sa("203.0.113.1:443"), CandidateSource::DnsA);
155        }
156        p
157    }
158
159    // (a) Disjoint AFFIRMATIVE detection is no longer stranded: a v4-only-detected local dialing a
160    // v6-only peer FAILS OPEN to the peer's v6 candidate — the negative v6 detection may be a false
161    // negative (overlay/pre-route), and the peer HAS a candidate, so it must be attempted.
162    #[test]
163    fn empty_intersection_over_a_reachable_peer_fails_open() {
164        let local = LocalStack::from_flags(false, true);
165        let peer = peer_with(true, false);
166        assert_eq!(
167            plan(&local, &peer),
168            DialPlan::FailOpen(vec![sa("[2001:db8::1]:443")])
169        );
170        // The public API still yields the peer's candidate (no NoCommonFamily strand).
171        assert_eq!(
172            dial_order(&local, &peer).unwrap(),
173            vec![sa("[2001:db8::1]:443")]
174        );
175    }
176
177    // No default route at all (both probes false → empty families) is treated as dual-stack: attempt
178    // every candidate the peer offers, IPv6-first.
179    #[test]
180    fn no_default_route_local_dials_all_peer_candidates_ipv6_first() {
181        let local = LocalStack::from_flags(false, false);
182        let peer = peer_with(true, true);
183        assert_eq!(
184            plan(&local, &peer),
185            DialPlan::FailOpen(vec![sa("[2001:db8::1]:443"), sa("203.0.113.1:443")])
186        );
187        assert_eq!(
188            dial_order(&local, &peer).unwrap(),
189            vec![sa("[2001:db8::1]:443"), sa("203.0.113.1:443")]
190        );
191    }
192
193    // The overlay case: a host with a public IPv6 route but only an overlay IPv4 route (v4 probe is a
194    // false negative) reaching a v4-only peer must still dial the peer's v4 candidate.
195    #[test]
196    fn v6_affirmative_local_still_dials_a_v4_only_peer() {
197        let local = LocalStack::from_flags(true, false);
198        let peer = peer_with(false, true);
199        assert_eq!(
200            plan(&local, &peer),
201            DialPlan::FailOpen(vec![sa("203.0.113.1:443")])
202        );
203        assert_eq!(
204            dial_order(&local, &peer).unwrap(),
205            vec![sa("203.0.113.1:443")]
206        );
207    }
208
209    // A confident common family keeps the intersection as an optimization (not a fail-open).
210    #[test]
211    fn affirmative_common_family_uses_the_intersection() {
212        let local = LocalStack::from_flags(false, true);
213        let peer = peer_with(true, true);
214        assert_eq!(
215            plan(&local, &peer),
216            DialPlan::Intersection(vec![sa("203.0.113.1:443")])
217        );
218    }
219
220    // (b) dual-stack both → IPv6 leads the order.
221    #[test]
222    fn dual_stack_prefers_ipv6() {
223        let local = LocalStack::from_flags(true, true);
224        let peer = peer_with(true, true);
225        let order = dial_order(&local, &peer).unwrap();
226        assert_eq!(order, vec![sa("[2001:db8::1]:443"), sa("203.0.113.1:443")]);
227    }
228
229    // (d) NEVER dial a family the PEER lacks: v4-only peer, dual-stack local → only v4.
230    #[test]
231    fn never_dials_a_family_the_peer_lacks() {
232        let local = LocalStack::from_flags(true, true);
233        let peer = peer_with(false, true);
234        let order = dial_order(&local, &peer).unwrap();
235        assert_eq!(order, vec![sa("203.0.113.1:443")]);
236        assert!(order.iter().all(|a| Family::of(a) == Family::V4));
237    }
238
239    // (e) NEVER dial a family the LOCAL host lacks: v4-only local, dual-stack peer → only v4.
240    #[test]
241    fn never_dials_a_family_the_local_host_lacks() {
242        let local = LocalStack::from_flags(false, true);
243        let peer = peer_with(true, true);
244        let order = dial_order(&local, &peer).unwrap();
245        assert_eq!(order, vec![sa("203.0.113.1:443")]);
246        assert!(order.iter().all(|a| Family::of(a) == Family::V4));
247    }
248
249    #[test]
250    fn v6_only_local_and_dual_peer_yields_only_v6() {
251        let local = LocalStack::from_flags(true, false);
252        let peer = peer_with(true, true);
253        let order = dial_order(&local, &peer).unwrap();
254        assert_eq!(order, vec![sa("[2001:db8::1]:443")]);
255    }
256
257    #[test]
258    fn empty_peer_is_no_common_family() {
259        let local = LocalStack::from_flags(true, true);
260        let err = dial_order(&local, &PeerCandidates::new()).unwrap_err();
261        assert!(err.peer.is_empty());
262    }
263
264    #[test]
265    fn multiple_addresses_per_family_keep_discovery_order() {
266        let local = LocalStack::from_flags(true, true);
267        let mut peer = PeerCandidates::new();
268        peer.add(sa("[2001:db8::2]:443"), CandidateSource::ListenAddr);
269        peer.add(sa("198.51.100.7:443"), CandidateSource::Pex);
270        peer.add(sa("[2001:db8::1]:443"), CandidateSource::Dht);
271        let order = dial_order(&local, &peer).unwrap();
272        assert_eq!(
273            order,
274            vec![
275                sa("[2001:db8::2]:443"),
276                sa("[2001:db8::1]:443"),
277                sa("198.51.100.7:443"),
278            ]
279        );
280    }
281}