dig_ip/local.rs
1//! [`LocalStack`] — which address families THIS host can actually reach.
2//!
3//! This is the half of the intersection rule that no prior ecosystem copy captured: every existing
4//! happy-eyeballs implementation sorted candidates IPv6-first and raced them, but NONE removed a
5//! family the LOCAL host cannot reach. So an IPv4-only host still emitted IPv6 SYNs, and an
6//! IPv6-only peer from an IPv4-only host was attempted-then-timed-out instead of reported cleanly
7//! unreachable. [`LocalStack`] lets [`crate::dial_order`] filter by local capability.
8
9use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket};
10use std::sync::Mutex;
11use std::time::{Duration, Instant};
12
13use crate::family::Family;
14
15/// How long a [`LocalStack::cached`] detection is reused before it is re-probed. The host's stack
16/// rarely changes mid-process; a few minutes keeps the probe off the dial hot path while still
17/// picking up an interface change (e.g. a VPN coming up) within a bounded window.
18const CACHE_TTL: Duration = Duration::from_secs(300);
19
20/// The address families THIS host can originate connections on.
21///
22/// Detection uses the "connect a UDP socket to a documentation address" trick: connecting a UDP
23/// socket forces the OS to pick the local source address it would route from WITHOUT sending a
24/// packet, so a family with no route (no default route, no address) fails at `connect` and is
25/// recorded as absent. Construct it with [`LocalStack::detect`] in production, [`LocalStack::cached`]
26/// on the hot path, or [`LocalStack::from_flags`] deterministically in tests.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct LocalStack {
29 has_v6: bool,
30 has_v4: bool,
31}
32
33impl LocalStack {
34 /// Probe the host's real IPv6 + IPv4 capability (no packets sent).
35 pub fn detect() -> LocalStack {
36 LocalStack {
37 has_v6: probe_v6(),
38 has_v4: probe_v4(),
39 }
40 }
41
42 /// The process-wide cached detection, re-probed at most once per [`CACHE_TTL`].
43 ///
44 /// The dial hot path calls this on every connect; caching keeps the UDP-probe syscalls off it
45 /// while still refreshing within a bounded window if the host's stack changes.
46 pub fn cached() -> LocalStack {
47 static CACHE: Mutex<Option<(LocalStack, Instant)>> = Mutex::new(None);
48 let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
49 if let Some((stack, at)) = *guard {
50 if at.elapsed() < CACHE_TTL {
51 return stack;
52 }
53 }
54 let fresh = LocalStack::detect();
55 *guard = Some((fresh, Instant::now()));
56 fresh
57 }
58
59 /// A deterministic stack with the given capabilities — the test constructor for the intersection
60 /// matrix (no sockets, no host dependency).
61 pub const fn from_flags(has_v6: bool, has_v4: bool) -> LocalStack {
62 LocalStack { has_v6, has_v4 }
63 }
64
65 /// Whether this host can originate a connection on `family`.
66 pub fn has(&self, family: Family) -> bool {
67 match family {
68 Family::V6 => self.has_v6,
69 Family::V4 => self.has_v4,
70 }
71 }
72
73 /// The families this host has, in preference order (IPv6 before IPv4), present-only.
74 pub fn families(&self) -> Vec<Family> {
75 Family::PREFERENCE
76 .into_iter()
77 .filter(|f| self.has(*f))
78 .collect()
79 }
80}
81
82/// Probe whether the host has a routable IPv6 source address (connect a UDP socket to a
83/// documentation IPv6 address, `2001:db8::/32` — never actually contacted).
84fn probe_v6() -> bool {
85 let Ok(socket) = UdpSocket::bind((Ipv6Addr::UNSPECIFIED, 0)) else {
86 return false;
87 };
88 let probe = SocketAddr::new(IpAddr::V6("2001:db8::1".parse().unwrap()), 9);
89 socket.connect(probe).is_ok()
90}
91
92/// Probe whether the host has a routable IPv4 source address (connect a UDP socket to a
93/// documentation IPv4 address, TEST-NET-3 `203.0.113.0/24` — never actually contacted).
94fn probe_v4() -> bool {
95 let Ok(socket) = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)) else {
96 return false;
97 };
98 let probe = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1)), 9);
99 socket.connect(probe).is_ok()
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 #[test]
107 fn from_flags_reports_capability() {
108 let dual = LocalStack::from_flags(true, true);
109 assert!(dual.has(Family::V6));
110 assert!(dual.has(Family::V4));
111
112 let v4_only = LocalStack::from_flags(false, true);
113 assert!(!v4_only.has(Family::V6));
114 assert!(v4_only.has(Family::V4));
115 }
116
117 #[test]
118 fn families_are_preference_ordered_and_present_only() {
119 assert_eq!(
120 LocalStack::from_flags(true, true).families(),
121 vec![Family::V6, Family::V4]
122 );
123 assert_eq!(
124 LocalStack::from_flags(false, true).families(),
125 vec![Family::V4]
126 );
127 assert_eq!(
128 LocalStack::from_flags(true, false).families(),
129 vec![Family::V6]
130 );
131 assert!(LocalStack::from_flags(false, false).families().is_empty());
132 }
133
134 #[test]
135 fn detect_and_cached_run_without_panicking() {
136 // The result depends on the host, but detection must never panic and cached must agree with
137 // a same-instant detect (both reflect the same host).
138 let _ = LocalStack::detect();
139 let a = LocalStack::cached();
140 let b = LocalStack::cached();
141 assert_eq!(a, b, "cached detection is stable within the TTL");
142 }
143}