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}