Skip to main content

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}