Skip to main content

nd_300/diagnostics/
ipv6.rs

1use serde::Serialize;
2
3use super::shared_cache::SharedCache;
4
5#[derive(Debug, Clone, Serialize)]
6pub struct Ipv6Info {
7    pub available: bool,
8    pub addresses: Vec<Ipv6Address>,
9    pub connectivity: Ipv6Connectivity,
10    pub dual_stack: bool,
11    /// Real HTTPS-over-v6 fetch succeeded (additive, v3.4.0+).
12    #[serde(skip_serializing_if = "Option::is_none")]
13    pub v6_http_ok: Option<bool>,
14    /// Timed TCP connect to a v4 anycast endpoint (ms).
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub v4_connect_ms: Option<f64>,
17    /// Timed TCP connect to a v6 anycast endpoint (ms).
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub v6_connect_ms: Option<f64>,
20    /// v6 − v4 connect time: the happy-eyeballs penalty users feel when v6
21    /// is configured but slow.
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub v6_penalty_ms: Option<f64>,
24}
25
26#[derive(Debug, Clone, Serialize)]
27pub struct Ipv6Address {
28    pub interface: String,
29    pub address: String,
30    pub scope: String,
31}
32
33#[derive(Debug, Clone, Serialize)]
34pub enum Ipv6Connectivity {
35    Full,
36    LinkLocal,
37    None,
38}
39
40pub async fn collect_with_cache(cache: &SharedCache) -> Option<Ipv6Info> {
41    let addresses = get_ipv6_addresses_cached(cache).await;
42    let has_global = addresses.iter().any(|a| a.scope == "global");
43    let has_link_local = addresses.iter().any(|a| a.scope == "link-local");
44
45    let connectivity = if has_global {
46        test_ipv6_connectivity().await
47    } else if has_link_local {
48        Ipv6Connectivity::LinkLocal
49    } else {
50        Ipv6Connectivity::None
51    };
52
53    // Deep probes (v3.4.0+): only meaningful when a global v6 address exists.
54    let (v6_http_ok, v4_connect_ms, v6_connect_ms, v6_penalty_ms) = if has_global {
55        let v6_http = fetch_over_v6().await;
56        let v4_ms = timed_connect("1.1.1.1:443").await;
57        let v6_ms = timed_connect("[2606:4700:4700::1111]:443").await;
58        let penalty = match (v4_ms, v6_ms) {
59            (Some(v4), Some(v6)) => Some(v6 - v4),
60            _ => None,
61        };
62        (v6_http, v4_ms, v6_ms, penalty)
63    } else {
64        (None, None, None, None)
65    };
66    let dual_stack = matches!(connectivity, Ipv6Connectivity::Full)
67        && v4_connect_ms.is_some()
68        && v6_connect_ms.is_some();
69
70    Some(Ipv6Info {
71        available: !addresses.is_empty(),
72        addresses,
73        connectivity,
74        dual_stack,
75        v6_http_ok,
76        v4_connect_ms,
77        v6_connect_ms,
78        v6_penalty_ms,
79    })
80}
81
82/// HTTPS fetch whose DNS result is pinned to Cloudflare IPv6 while the URL
83/// retains a hostname. This preserves SNI and certificate hostname
84/// validation; requesting a raw IPv6 literal can falsely fail TLS even on a
85/// healthy IPv6 path.
86async fn fetch_over_v6() -> Option<bool> {
87    let endpoint = "[2606:4700:4700::1111]:443".parse().ok()?;
88    let client = reqwest::Client::builder()
89        .resolve("one.one.one.one", endpoint)
90        .timeout(std::time::Duration::from_secs(5))
91        .build()
92        .ok()?;
93    let response = client
94        .get("https://one.one.one.one/cdn-cgi/trace")
95        .send()
96        .await
97        .ok()?;
98    Some(response.status().is_success())
99}
100
101/// Timed TCP connect — used for the v4-vs-v6 happy-eyeballs comparison.
102async fn timed_connect(addr: &str) -> Option<f64> {
103    let start = std::time::Instant::now();
104    match tokio::time::timeout(
105        std::time::Duration::from_secs(5),
106        tokio::net::TcpStream::connect(addr),
107    )
108    .await
109    {
110        Ok(Ok(_)) => Some(start.elapsed().as_secs_f64() * 1000.0),
111        _ => None,
112    }
113}
114
115async fn get_ipv6_addresses_cached(cache: &SharedCache) -> Vec<Ipv6Address> {
116    #[cfg(windows)]
117    {
118        // ipconfig /all is a superset of plain ipconfig — same IPv6 fields
119        if let Some(ref ic) = cache.ipconfig {
120            return parse_ipv6_from_ipconfig(&ic.raw);
121        }
122    }
123    let _ = cache;
124    get_ipv6_addresses().await
125}
126
127#[cfg(windows)]
128fn parse_ipv6_from_ipconfig(text: &str) -> Vec<Ipv6Address> {
129    let mut addrs = Vec::new();
130    let mut current_iface = String::new();
131
132    for line in text.lines() {
133        if !line.starts_with(' ') && !line.starts_with('\t') && line.contains("adapter") {
134            current_iface = line.trim().trim_end_matches(':').to_string();
135        }
136
137        let trimmed = line.trim();
138        if trimmed.contains("IPv6 Address")
139            || trimmed.contains("Link-local IPv6")
140            || trimmed.contains("Temporary IPv6")
141        {
142            if let Some(addr) = trimmed
143                .split(':')
144                .skip(1)
145                .collect::<Vec<&str>>()
146                .join(":")
147                .trim()
148                .strip_suffix("(Preferred)")
149            {
150                let scope = classify_ipv6_scope(addr.trim());
151                addrs.push(Ipv6Address {
152                    interface: current_iface.clone(),
153                    address: addr.trim().to_string(),
154                    scope,
155                });
156            } else {
157                let addr: String = trimmed.split(':').skip(1).collect::<Vec<&str>>().join(":");
158                let addr = addr.trim().trim_end_matches("(Preferred)").trim();
159                if !addr.is_empty() {
160                    let scope = classify_ipv6_scope(addr);
161                    addrs.push(Ipv6Address {
162                        interface: current_iface.clone(),
163                        address: addr.to_string(),
164                        scope,
165                    });
166                }
167            }
168        }
169    }
170
171    addrs
172}
173
174pub async fn collect() -> Option<Ipv6Info> {
175    let addresses = get_ipv6_addresses().await;
176    let has_global = addresses.iter().any(|a| a.scope == "global");
177    let has_link_local = addresses.iter().any(|a| a.scope == "link-local");
178
179    // Test IPv6 connectivity
180    let connectivity = if has_global {
181        test_ipv6_connectivity().await
182    } else if has_link_local {
183        Ipv6Connectivity::LinkLocal
184    } else {
185        Ipv6Connectivity::None
186    };
187
188    let dual_stack = matches!(connectivity, Ipv6Connectivity::Full)
189        && timed_connect("1.1.1.1:443").await.is_some();
190
191    Some(Ipv6Info {
192        available: !addresses.is_empty(),
193        addresses,
194        connectivity,
195        dual_stack,
196        // The cache-less path skips the deep probes (used outside tech mode).
197        v6_http_ok: None,
198        v4_connect_ms: None,
199        v6_connect_ms: None,
200        v6_penalty_ms: None,
201    })
202}
203
204async fn get_ipv6_addresses() -> Vec<Ipv6Address> {
205    let mut addrs = Vec::new();
206
207    #[cfg(windows)]
208    {
209        let cmd = tokio::process::Command::new("ipconfig");
210        if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
211            let text = String::from_utf8_lossy(&output.stdout);
212            let mut current_iface = String::new();
213
214            for line in text.lines() {
215                if !line.starts_with(' ') && !line.starts_with('\t') && line.contains("adapter") {
216                    current_iface = line.trim().trim_end_matches(':').to_string();
217                }
218
219                let trimmed = line.trim();
220                if trimmed.contains("IPv6 Address")
221                    || trimmed.contains("Link-local IPv6")
222                    || trimmed.contains("Temporary IPv6")
223                {
224                    if let Some(addr) = trimmed
225                        .split(':')
226                        .skip(1)
227                        .collect::<Vec<&str>>()
228                        .join(":")
229                        .trim()
230                        .strip_suffix("(Preferred)")
231                    {
232                        let scope = classify_ipv6_scope(addr.trim());
233                        addrs.push(Ipv6Address {
234                            interface: current_iface.clone(),
235                            address: addr.trim().to_string(),
236                            scope,
237                        });
238                    } else {
239                        let addr: String =
240                            trimmed.split(':').skip(1).collect::<Vec<&str>>().join(":");
241                        let addr = addr.trim().trim_end_matches("(Preferred)").trim();
242                        if !addr.is_empty() {
243                            let scope = classify_ipv6_scope(addr);
244                            addrs.push(Ipv6Address {
245                                interface: current_iface.clone(),
246                                address: addr.to_string(),
247                                scope,
248                            });
249                        }
250                    }
251                }
252            }
253        }
254    }
255
256    #[cfg(unix)]
257    {
258        let mut cmd = tokio::process::Command::new("ip");
259        cmd.args(["-6", "addr", "show"]);
260        if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
261            let text = String::from_utf8_lossy(&output.stdout);
262            let mut current_iface = String::new();
263
264            for line in text.lines() {
265                let trimmed = line.trim();
266                if !line.starts_with(' ') {
267                    current_iface = trimmed.split(':').nth(1).unwrap_or("").trim().to_string();
268                } else if trimmed.starts_with("inet6") {
269                    let parts: Vec<&str> = trimmed.split_whitespace().collect();
270                    if parts.len() >= 4 {
271                        let addr = parts[1].split('/').next().unwrap_or(parts[1]);
272                        let scope = classify_ipv6_scope(addr);
273                        addrs.push(Ipv6Address {
274                            interface: current_iface.clone(),
275                            address: addr.to_string(),
276                            scope,
277                        });
278                    }
279                }
280            }
281        }
282
283        // Fallback for macOS if `ip` not available
284        if addrs.is_empty() {
285            let cmd = tokio::process::Command::new("ifconfig");
286            if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
287                let text = String::from_utf8_lossy(&output.stdout);
288                let mut current_iface = String::new();
289
290                for line in text.lines() {
291                    if !line.starts_with('\t') && !line.starts_with(' ') {
292                        current_iface = line.split(':').next().unwrap_or("").to_string();
293                    } else if line.contains("inet6") {
294                        let parts: Vec<&str> = line.split_whitespace().collect();
295                        if let Some(addr) = parts.get(1) {
296                            let scope = classify_ipv6_scope(addr);
297                            addrs.push(Ipv6Address {
298                                interface: current_iface.clone(),
299                                address: addr.to_string(),
300                                scope,
301                            });
302                        }
303                    }
304                }
305            }
306        }
307    }
308
309    addrs
310}
311
312fn classify_ipv6_scope(address: &str) -> String {
313    let address = address
314        .split('%')
315        .next()
316        .unwrap_or(address)
317        .split('/')
318        .next()
319        .unwrap_or(address);
320    let Ok(address) = address.parse::<std::net::Ipv6Addr>() else {
321        return "unknown".to_string();
322    };
323    if address.is_loopback() {
324        "loopback"
325    } else if address.is_unicast_link_local() {
326        "link-local"
327    } else if address.is_unique_local() {
328        "unique-local"
329    } else if address.is_multicast() {
330        "multicast"
331    } else if address.is_unspecified() {
332        "unspecified"
333    } else {
334        "global"
335    }
336    .to_string()
337}
338
339async fn test_ipv6_connectivity() -> Ipv6Connectivity {
340    // Try to connect to Google's IPv6 DNS
341    match tokio::time::timeout(
342        std::time::Duration::from_secs(5),
343        tokio::net::TcpStream::connect("[2001:4860:4860::8888]:443"),
344    )
345    .await
346    {
347        Ok(Ok(_)) => Ipv6Connectivity::Full,
348        _ => {
349            // Try Cloudflare IPv6
350            match tokio::time::timeout(
351                std::time::Duration::from_secs(3),
352                tokio::net::TcpStream::connect("[2606:4700:4700::1111]:443"),
353            )
354            .await
355            {
356                Ok(Ok(_)) => Ipv6Connectivity::Full,
357                _ => Ipv6Connectivity::None,
358            }
359        }
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    #[test]
368    fn ipv6_unique_local_is_not_global() {
369        assert_eq!(classify_ipv6_scope("fd48:7b1c:8406::1"), "unique-local");
370        assert_eq!(classify_ipv6_scope("fe80::1%en0"), "link-local");
371        assert_eq!(classify_ipv6_scope("::1"), "loopback");
372        assert_eq!(classify_ipv6_scope("2606:4700:4700::1111"), "global");
373    }
374}