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}
12
13#[derive(Debug, Clone, Serialize)]
14pub struct Ipv6Address {
15    pub interface: String,
16    pub address: String,
17    pub scope: String,
18}
19
20#[derive(Debug, Clone, Serialize)]
21pub enum Ipv6Connectivity {
22    Full,
23    LinkLocal,
24    None,
25}
26
27pub async fn collect_with_cache(cache: &SharedCache) -> Option<Ipv6Info> {
28    let addresses = get_ipv6_addresses_cached(cache).await;
29    let has_global = addresses.iter().any(|a| a.scope == "global");
30    let has_link_local = addresses.iter().any(|a| a.scope == "link-local");
31
32    let connectivity = if has_global {
33        test_ipv6_connectivity().await
34    } else if has_link_local {
35        Ipv6Connectivity::LinkLocal
36    } else {
37        Ipv6Connectivity::None
38    };
39
40    let dual_stack = has_global;
41
42    Some(Ipv6Info {
43        available: !addresses.is_empty(),
44        addresses,
45        connectivity,
46        dual_stack,
47    })
48}
49
50async fn get_ipv6_addresses_cached(cache: &SharedCache) -> Vec<Ipv6Address> {
51    #[cfg(windows)]
52    {
53        // ipconfig /all is a superset of plain ipconfig — same IPv6 fields
54        if let Some(ref ic) = cache.ipconfig {
55            return parse_ipv6_from_ipconfig(&ic.raw);
56        }
57    }
58    let _ = cache;
59    get_ipv6_addresses().await
60}
61
62#[cfg(windows)]
63fn parse_ipv6_from_ipconfig(text: &str) -> Vec<Ipv6Address> {
64    let mut addrs = Vec::new();
65    let mut current_iface = String::new();
66
67    for line in text.lines() {
68        if !line.starts_with(' ') && !line.starts_with('\t') && line.contains("adapter") {
69            current_iface = line.trim().trim_end_matches(':').to_string();
70        }
71
72        let trimmed = line.trim();
73        if trimmed.contains("IPv6 Address")
74            || trimmed.contains("Link-local IPv6")
75            || trimmed.contains("Temporary IPv6")
76        {
77            if let Some(addr) = trimmed
78                .split(':')
79                .skip(1)
80                .collect::<Vec<&str>>()
81                .join(":")
82                .trim()
83                .strip_suffix("(Preferred)")
84            {
85                let scope = if trimmed.contains("Link-local") {
86                    "link-local"
87                } else {
88                    "global"
89                };
90                addrs.push(Ipv6Address {
91                    interface: current_iface.clone(),
92                    address: addr.trim().to_string(),
93                    scope: scope.to_string(),
94                });
95            } else {
96                let addr: String = trimmed.split(':').skip(1).collect::<Vec<&str>>().join(":");
97                let addr = addr.trim().trim_end_matches("(Preferred)").trim();
98                if !addr.is_empty() {
99                    let scope = if trimmed.contains("Link-local") {
100                        "link-local"
101                    } else {
102                        "global"
103                    };
104                    addrs.push(Ipv6Address {
105                        interface: current_iface.clone(),
106                        address: addr.to_string(),
107                        scope: scope.to_string(),
108                    });
109                }
110            }
111        }
112    }
113
114    addrs
115}
116
117pub async fn collect() -> Option<Ipv6Info> {
118    let addresses = get_ipv6_addresses().await;
119    let has_global = addresses.iter().any(|a| a.scope == "global");
120    let has_link_local = addresses.iter().any(|a| a.scope == "link-local");
121
122    // Test IPv6 connectivity
123    let connectivity = if has_global {
124        test_ipv6_connectivity().await
125    } else if has_link_local {
126        Ipv6Connectivity::LinkLocal
127    } else {
128        Ipv6Connectivity::None
129    };
130
131    let dual_stack = has_global;
132
133    Some(Ipv6Info {
134        available: !addresses.is_empty(),
135        addresses,
136        connectivity,
137        dual_stack,
138    })
139}
140
141async fn get_ipv6_addresses() -> Vec<Ipv6Address> {
142    let mut addrs = Vec::new();
143
144    #[cfg(windows)]
145    {
146        let cmd = tokio::process::Command::new("ipconfig");
147        if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
148            let text = String::from_utf8_lossy(&output.stdout);
149            let mut current_iface = String::new();
150
151            for line in text.lines() {
152                if !line.starts_with(' ') && !line.starts_with('\t') && line.contains("adapter") {
153                    current_iface = line.trim().trim_end_matches(':').to_string();
154                }
155
156                let trimmed = line.trim();
157                if trimmed.contains("IPv6 Address")
158                    || trimmed.contains("Link-local IPv6")
159                    || trimmed.contains("Temporary IPv6")
160                {
161                    if let Some(addr) = trimmed
162                        .split(':')
163                        .skip(1)
164                        .collect::<Vec<&str>>()
165                        .join(":")
166                        .trim()
167                        .strip_suffix("(Preferred)")
168                    {
169                        let scope = if trimmed.contains("Link-local") {
170                            "link-local"
171                        } else {
172                            "global"
173                        };
174                        addrs.push(Ipv6Address {
175                            interface: current_iface.clone(),
176                            address: addr.trim().to_string(),
177                            scope: scope.to_string(),
178                        });
179                    } else {
180                        let addr: String =
181                            trimmed.split(':').skip(1).collect::<Vec<&str>>().join(":");
182                        let addr = addr.trim().trim_end_matches("(Preferred)").trim();
183                        if !addr.is_empty() {
184                            let scope = if trimmed.contains("Link-local") {
185                                "link-local"
186                            } else {
187                                "global"
188                            };
189                            addrs.push(Ipv6Address {
190                                interface: current_iface.clone(),
191                                address: addr.to_string(),
192                                scope: scope.to_string(),
193                            });
194                        }
195                    }
196                }
197            }
198        }
199    }
200
201    #[cfg(unix)]
202    {
203        let mut cmd = tokio::process::Command::new("ip");
204        cmd.args(["-6", "addr", "show"]);
205        if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
206            let text = String::from_utf8_lossy(&output.stdout);
207            let mut current_iface = String::new();
208
209            for line in text.lines() {
210                let trimmed = line.trim();
211                if !line.starts_with(' ') {
212                    current_iface = trimmed.split(':').nth(1).unwrap_or("").trim().to_string();
213                } else if trimmed.starts_with("inet6") {
214                    let parts: Vec<&str> = trimmed.split_whitespace().collect();
215                    if parts.len() >= 4 {
216                        let addr = parts[1].split('/').next().unwrap_or(parts[1]);
217                        let scope = parts.get(3).unwrap_or(&"unknown").to_string();
218                        addrs.push(Ipv6Address {
219                            interface: current_iface.clone(),
220                            address: addr.to_string(),
221                            scope,
222                        });
223                    }
224                }
225            }
226        }
227
228        // Fallback for macOS if `ip` not available
229        if addrs.is_empty() {
230            let cmd = tokio::process::Command::new("ifconfig");
231            if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
232                let text = String::from_utf8_lossy(&output.stdout);
233                let mut current_iface = String::new();
234
235                for line in text.lines() {
236                    if !line.starts_with('\t') && !line.starts_with(' ') {
237                        current_iface = line.split(':').next().unwrap_or("").to_string();
238                    } else if line.contains("inet6") {
239                        let parts: Vec<&str> = line.split_whitespace().collect();
240                        if let Some(addr) = parts.get(1) {
241                            let scope = if addr.starts_with("fe80") {
242                                "link-local"
243                            } else if *addr == "::1" {
244                                "loopback"
245                            } else {
246                                "global"
247                            };
248                            addrs.push(Ipv6Address {
249                                interface: current_iface.clone(),
250                                address: addr.to_string(),
251                                scope: scope.to_string(),
252                            });
253                        }
254                    }
255                }
256            }
257        }
258    }
259
260    addrs
261}
262
263async fn test_ipv6_connectivity() -> Ipv6Connectivity {
264    // Try to connect to Google's IPv6 DNS
265    match tokio::time::timeout(
266        std::time::Duration::from_secs(5),
267        tokio::net::TcpStream::connect("[2001:4860:4860::8888]:443"),
268    )
269    .await
270    {
271        Ok(Ok(_)) => Ipv6Connectivity::Full,
272        _ => {
273            // Try Cloudflare IPv6
274            match tokio::time::timeout(
275                std::time::Duration::from_secs(3),
276                tokio::net::TcpStream::connect("[2606:4700:4700::1111]:443"),
277            )
278            .await
279            {
280                Ok(Ok(_)) => Ipv6Connectivity::Full,
281                _ => Ipv6Connectivity::None,
282            }
283        }
284    }
285}