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 #[serde(skip_serializing_if = "Option::is_none")]
13 pub v6_http_ok: Option<bool>,
14 #[serde(skip_serializing_if = "Option::is_none")]
16 pub v4_connect_ms: Option<f64>,
17 #[serde(skip_serializing_if = "Option::is_none")]
19 pub v6_connect_ms: Option<f64>,
20 #[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 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
82async 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
101async 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 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 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 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 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 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 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}