1use serde::Serialize;
2
3use super::shared_cache::SharedCache;
4
5#[derive(Debug, Clone, Serialize)]
6pub struct VpnAdapter {
7 pub name: String,
8 pub adapter_type: String,
9 pub status: String,
10 pub ip_address: Option<String>,
11 pub vendor: Option<String>,
12 pub is_enterprise: bool,
13 pub interface_name: Option<String>,
14}
15
16pub async fn collect_with_cache(cache: &SharedCache) -> Option<Vec<VpnAdapter>> {
17 let mut vpns = Vec::new();
18
19 #[cfg(windows)]
20 {
21 if let Some(ref ic) = cache.ipconfig {
22 parse_vpn_from_ipconfig(&ic.raw, &mut vpns);
23 } else {
24 collect_windows_ipconfig(&mut vpns).await;
25 }
26 collect_windows_wmi(&mut vpns).await;
27 }
28
29 #[cfg(target_os = "macos")]
30 {
31 let _ = cache;
32 collect_macos_ifconfig(&mut vpns).await;
33 collect_macos_scutil(&mut vpns).await;
34 }
35
36 #[cfg(target_os = "linux")]
37 {
38 let _ = cache;
39 collect_linux_ip_link(&mut vpns).await;
40 collect_linux_nmcli(&mut vpns).await;
41 collect_linux_wireguard(&mut vpns).await;
42 }
43
44 vpns.dedup_by(|a, b| {
45 if let (Some(ref ai), Some(ref bi)) = (&a.interface_name, &b.interface_name) {
46 ai == bi
47 } else {
48 a.name == b.name
49 }
50 });
51
52 if vpns.is_empty() {
53 None
54 } else {
55 Some(vpns)
56 }
57}
58
59pub async fn collect() -> Option<Vec<VpnAdapter>> {
60 let mut vpns = Vec::new();
61
62 #[cfg(windows)]
63 {
64 collect_windows_ipconfig(&mut vpns).await;
65 collect_windows_wmi(&mut vpns).await;
66 }
67
68 #[cfg(target_os = "macos")]
69 {
70 collect_macos_ifconfig(&mut vpns).await;
71 collect_macos_scutil(&mut vpns).await;
72 }
73
74 #[cfg(target_os = "linux")]
75 {
76 collect_linux_ip_link(&mut vpns).await;
77 collect_linux_nmcli(&mut vpns).await;
78 collect_linux_wireguard(&mut vpns).await;
79 }
80
81 vpns.dedup_by(|a, b| {
83 if let (Some(ref ai), Some(ref bi)) = (&a.interface_name, &b.interface_name) {
84 ai == bi
85 } else {
86 a.name == b.name
87 }
88 });
89
90 if vpns.is_empty() {
91 None
92 } else {
93 Some(vpns)
94 }
95}
96
97#[cfg(windows)]
100fn parse_vpn_from_ipconfig(text: &str, vpns: &mut Vec<VpnAdapter>) {
101 let mut current_name = String::new();
102 let mut current_ip = None;
103
104 for line in text.lines() {
105 if !line.starts_with(' ') && !line.starts_with('\t') && line.contains("adapter") {
106 let name = line.trim().trim_end_matches(':');
107 let lower = name.to_lowercase();
108 if lower.contains("vpn")
109 || lower.contains("tap")
110 || lower.contains("tun")
111 || lower.contains("wireguard")
112 || lower.contains("wintun")
113 || lower.contains("fortinet")
114 || lower.contains("cisco")
115 || lower.contains("palo alto")
116 || lower.contains("global protect")
117 || lower.contains("nordlynx")
118 || lower.contains("expressvpn")
119 || lower.contains("mullvad")
120 || lower.contains("tailscale")
121 || lower.contains("zscaler")
122 || lower.contains("pulse")
123 {
124 if !current_name.is_empty() {
125 let vendor = detect_vendor(¤t_name);
126 let is_enterprise = is_enterprise_vendor(¤t_name, vendor.as_deref());
127 vpns.push(VpnAdapter {
128 name: current_name.clone(),
129 adapter_type: detect_vpn_type(¤t_name),
130 status: if current_ip.is_some() {
131 "Connected"
132 } else {
133 "Disconnected"
134 }
135 .to_string(),
136 ip_address: current_ip.take(),
137 vendor,
138 is_enterprise,
139 interface_name: None,
140 });
141 }
142 current_name = name.to_string();
143 current_ip = None;
144 } else {
145 current_name.clear();
146 }
147 } else if !current_name.is_empty() {
148 let trimmed = line.trim();
149 if trimmed.contains("IPv4 Address")
150 || (trimmed.contains("IP Address") && !trimmed.contains("Autoconfiguration"))
151 {
152 current_ip = trimmed
153 .split(':')
154 .nth(1)
155 .map(|s| s.trim().trim_end_matches("(Preferred)").trim().to_string());
156 }
157 }
158 }
159
160 if !current_name.is_empty() {
161 let vendor = detect_vendor(¤t_name);
162 let is_enterprise = is_enterprise_vendor(¤t_name, vendor.as_deref());
163 vpns.push(VpnAdapter {
164 name: current_name.clone(),
165 adapter_type: detect_vpn_type(¤t_name),
166 status: if current_ip.is_some() {
167 "Connected"
168 } else {
169 "Disconnected"
170 }
171 .to_string(),
172 ip_address: current_ip,
173 vendor,
174 is_enterprise,
175 interface_name: None,
176 });
177 }
178}
179
180#[cfg(windows)]
181async fn collect_windows_ipconfig(vpns: &mut Vec<VpnAdapter>) {
182 let mut cmd = tokio::process::Command::new("ipconfig");
183 cmd.args(["/all"]);
184 if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
185 let text = String::from_utf8_lossy(&output.stdout);
186 parse_vpn_from_ipconfig(&text, vpns);
187 }
188}
189
190#[cfg(windows)]
191async fn collect_windows_wmi(vpns: &mut Vec<VpnAdapter>) {
192 use std::collections::HashMap;
193 use wmi::{COMLibrary, WMIConnection};
194
195 let wmi_rows: Vec<(String, Option<String>, u16)> = tokio::task::spawn_blocking(|| {
197 let com = match COMLibrary::new() {
198 Ok(c) => c,
199 Err(_) => return Vec::new(),
200 };
201 let wmi = match WMIConnection::new(com) {
202 Ok(w) => w,
203 Err(_) => return Vec::new(),
204 };
205
206 let query = r#"SELECT Name, NetConnectionID, Description, NetConnectionStatus FROM Win32_NetworkAdapter WHERE Description LIKE '%TAP%' OR Description LIKE '%TUN%' OR Description LIKE '%Wintun%' OR Description LIKE '%WireGuard%' OR Description LIKE '%VPN%' OR Description LIKE '%NordLynx%' OR Description LIKE '%ExpressVPN%' OR Description LIKE '%Tailscale%'"#;
207 let results: Vec<HashMap<String, wmi::Variant>> = wmi.raw_query(query).unwrap_or_default();
208
209 results.into_iter().filter_map(|row| {
210 let description = match row.get("Description") {
211 Some(wmi::Variant::String(s)) => s.clone(),
212 _ => return None,
213 };
214 let net_id = match row.get("NetConnectionID") {
215 Some(wmi::Variant::String(s)) => Some(s.clone()),
216 _ => None,
217 };
218 let status_val = match row.get("NetConnectionStatus") {
219 Some(wmi::Variant::UI2(n)) => *n,
220 Some(wmi::Variant::I4(n)) => *n as u16,
221 _ => 0,
222 };
223 Some((description, net_id, status_val))
224 }).collect()
225 })
226 .await
227 .unwrap_or_default();
228
229 for (description, net_id, status_val) in wmi_rows {
230 let name_for_check = net_id.clone().unwrap_or_else(|| description.clone());
232 if vpns
233 .iter()
234 .any(|v| v.name == name_for_check || v.name == description)
235 {
236 continue;
237 }
238
239 let vendor = detect_vendor(&description);
240 let is_enterprise = is_enterprise_vendor(&description, vendor.as_deref());
241
242 vpns.push(VpnAdapter {
243 name: name_for_check,
244 adapter_type: detect_vpn_type(&description),
245 status: if status_val == 2 {
246 "Connected"
247 } else {
248 "Disconnected"
249 }
250 .to_string(),
251 ip_address: None,
252 vendor,
253 is_enterprise,
254 interface_name: net_id,
255 });
256 }
257}
258
259#[cfg(target_os = "macos")]
262async fn collect_macos_ifconfig(vpns: &mut Vec<VpnAdapter>) {
263 let cmd = tokio::process::Command::new("ifconfig");
264 if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
265 let text = String::from_utf8_lossy(&output.stdout);
266 let mut current_iface = String::new();
267 let mut current_ip = None;
268
269 for line in text.lines() {
270 if !line.starts_with('\t') && !line.starts_with(' ') {
271 if !current_iface.is_empty() && is_vpn_interface(¤t_iface) {
272 let vendor = detect_vendor(¤t_iface);
273 let is_enterprise = is_enterprise_vendor(¤t_iface, vendor.as_deref());
274 vpns.push(VpnAdapter {
275 name: current_iface.clone(),
276 adapter_type: detect_vpn_type(¤t_iface),
277 status: if current_ip.is_some() {
278 "Connected"
279 } else {
280 "Disconnected"
281 }
282 .to_string(),
283 ip_address: current_ip.take(),
284 vendor,
285 is_enterprise,
286 interface_name: Some(current_iface.clone()),
287 });
288 }
289 current_iface = line.split(':').next().unwrap_or("").to_string();
290 current_ip = None;
291 } else if line.contains("inet ") {
292 current_ip = line.split_whitespace().nth(1).map(|s| s.to_string());
293 }
294 }
295
296 if !current_iface.is_empty() && is_vpn_interface(¤t_iface) {
297 let vendor = detect_vendor(¤t_iface);
298 let is_enterprise = is_enterprise_vendor(¤t_iface, vendor.as_deref());
299 vpns.push(VpnAdapter {
300 name: current_iface.clone(),
301 adapter_type: detect_vpn_type(¤t_iface),
302 status: if current_ip.is_some() {
303 "Connected"
304 } else {
305 "Disconnected"
306 }
307 .to_string(),
308 ip_address: current_ip,
309 vendor,
310 is_enterprise,
311 interface_name: Some(current_iface),
312 });
313 }
314 }
315}
316
317#[cfg(target_os = "macos")]
318async fn collect_macos_scutil(vpns: &mut Vec<VpnAdapter>) {
319 let mut cmd = tokio::process::Command::new("scutil");
320 cmd.args(["--nc", "list"]);
321 if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
322 let text = String::from_utf8_lossy(&output.stdout);
323 for line in text.lines() {
324 let trimmed = line.trim();
327 let status = if trimmed.contains("(Connected)") {
328 "Connected"
329 } else if trimmed.contains("(Disconnected)") {
330 "Disconnected"
331 } else {
332 continue;
333 };
334
335 if let (Some(start), Some(end)) = (trimmed.find('"'), trimmed.rfind('"')) {
337 if start < end {
338 let name = &trimmed[start + 1..end];
339
340 if vpns.iter().any(|v| v.name == name) {
342 continue;
343 }
344
345 let vpn_type = if let Some(bracket_start) = trimmed.rfind('[') {
347 if let Some(bracket_end) = trimmed.rfind(']') {
348 trimmed[bracket_start + 1..bracket_end].to_string()
349 } else {
350 "VPN".to_string()
351 }
352 } else {
353 "VPN".to_string()
354 };
355
356 let vendor = detect_vendor(name);
357 let is_enterprise = is_enterprise_vendor(name, vendor.as_deref());
358
359 vpns.push(VpnAdapter {
360 name: name.to_string(),
361 adapter_type: vpn_type,
362 status: status.to_string(),
363 ip_address: None,
364 vendor,
365 is_enterprise,
366 interface_name: None,
367 });
368 }
369 }
370 }
371 }
372}
373
374#[cfg(target_os = "linux")]
377async fn collect_linux_ip_link(vpns: &mut Vec<VpnAdapter>) {
378 let mut cmd = tokio::process::Command::new("ip");
379 cmd.args(["link", "show"]);
380 if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
381 let text = String::from_utf8_lossy(&output.stdout);
382 for line in text.lines() {
383 let parts: Vec<&str> = line.split_whitespace().collect();
384 if parts.len() >= 2 {
385 let name = parts[1].trim_end_matches(':');
386 if is_vpn_interface(name) {
387 let is_up = line.contains("state UP");
388 let vendor = detect_vendor(name);
389 let is_enterprise = is_enterprise_vendor(name, vendor.as_deref());
390 vpns.push(VpnAdapter {
391 name: name.to_string(),
392 adapter_type: detect_vpn_type(name),
393 status: if is_up { "Connected" } else { "Disconnected" }.to_string(),
394 ip_address: None,
395 vendor,
396 is_enterprise,
397 interface_name: Some(name.to_string()),
398 });
399 }
400 }
401 }
402 }
403}
404
405#[cfg(target_os = "linux")]
406async fn collect_linux_nmcli(vpns: &mut Vec<VpnAdapter>) {
407 let mut cmd = tokio::process::Command::new("nmcli");
408 cmd.args([
409 "-t",
410 "-f",
411 "TYPE,NAME,DEVICE",
412 "connection",
413 "show",
414 "--active",
415 ]);
416 if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
417 let text = String::from_utf8_lossy(&output.stdout);
418 for line in text.lines() {
419 let parts: Vec<&str> = line.splitn(3, ':').collect();
420 if parts.len() >= 2 {
421 let conn_type = parts[0];
422 let conn_name = parts[1];
423 let device = if parts.len() >= 3 {
424 Some(parts[2])
425 } else {
426 None
427 };
428
429 if conn_type.contains("vpn") || conn_type.contains("wireguard") {
431 if vpns.iter().any(|v| v.name == conn_name) {
432 continue;
433 }
434 let vendor = detect_vendor(conn_name);
435 let is_enterprise = is_enterprise_vendor(conn_name, vendor.as_deref());
436 vpns.push(VpnAdapter {
437 name: conn_name.to_string(),
438 adapter_type: conn_type.to_string(),
439 status: "Connected".to_string(),
440 ip_address: None,
441 vendor,
442 is_enterprise,
443 interface_name: device.map(|d| d.to_string()),
444 });
445 }
446 }
447 }
448 }
449}
450
451#[cfg(target_os = "linux")]
452async fn collect_linux_wireguard(vpns: &mut Vec<VpnAdapter>) {
453 let mut cmd = tokio::process::Command::new("wg");
454 cmd.args(["show", "interfaces"]);
455 if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
456 if output.status.success() {
457 let text = String::from_utf8_lossy(&output.stdout);
458 for iface in text.split_whitespace() {
459 if vpns
460 .iter()
461 .any(|v| v.interface_name.as_deref() == Some(iface))
462 {
463 continue;
464 }
465 vpns.push(VpnAdapter {
466 name: iface.to_string(),
467 adapter_type: "WireGuard".to_string(),
468 status: "Connected".to_string(),
469 ip_address: None,
470 vendor: Some("WireGuard".to_string()),
471 is_enterprise: false,
472 interface_name: Some(iface.to_string()),
473 });
474 }
475 }
476 }
477}
478
479#[cfg(unix)]
482fn is_vpn_interface(name: &str) -> bool {
483 let lower = name.to_lowercase();
484 lower.starts_with("tun")
485 || lower.starts_with("tap")
486 || lower.starts_with("utun")
487 || lower.starts_with("wg")
488 || lower.starts_with("ppp")
489 || lower.contains("vpn")
490 || lower.contains("wireguard")
491 || lower.contains("wintun")
492}
493
494fn detect_vpn_type(name: &str) -> String {
495 let lower = name.to_lowercase();
496 if lower.contains("wireguard") || lower.starts_with("wg") || lower.contains("wintun") {
497 "WireGuard".to_string()
498 } else if lower.starts_with("tun") || lower.starts_with("utun") {
499 "TUN Tunnel".to_string()
500 } else if lower.starts_with("tap") {
501 "TAP Tunnel".to_string()
502 } else if lower.starts_with("ppp") {
503 "PPP".to_string()
504 } else if lower.contains("cisco") || lower.contains("anyconnect") {
505 "Cisco AnyConnect".to_string()
506 } else if lower.contains("fortinet") || lower.contains("forticlient") {
507 "FortiClient".to_string()
508 } else if lower.contains("global protect")
509 || lower.contains("globalprotect")
510 || lower.contains("palo alto")
511 {
512 "GlobalProtect".to_string()
513 } else if lower.contains("zscaler") {
514 "Zscaler".to_string()
515 } else if lower.contains("pulse") {
516 "Pulse Secure".to_string()
517 } else {
518 "VPN".to_string()
519 }
520}
521
522fn detect_vendor(name: &str) -> Option<String> {
523 let lower = name.to_lowercase();
524 if lower.contains("nord") || lower.contains("nordlynx") {
525 Some("NordVPN".to_string())
526 } else if lower.contains("expressvpn") {
527 Some("ExpressVPN".to_string())
528 } else if lower.contains("mullvad") {
529 Some("Mullvad".to_string())
530 } else if lower.contains("tailscale") {
531 Some("Tailscale".to_string())
532 } else if lower.contains("wireguard") || lower.starts_with("wg") {
533 Some("WireGuard".to_string())
534 } else if lower.contains("cisco") || lower.contains("anyconnect") {
535 Some("Cisco".to_string())
536 } else if lower.contains("globalprotect")
537 || lower.contains("global protect")
538 || lower.contains("palo alto")
539 {
540 Some("Palo Alto".to_string())
541 } else if lower.contains("fortinet") || lower.contains("forticlient") {
542 Some("Fortinet".to_string())
543 } else if lower.contains("zscaler") {
544 Some("Zscaler".to_string())
545 } else if lower.contains("pulse") {
546 Some("Pulse Secure".to_string())
547 } else {
548 None
549 }
550}
551
552fn is_enterprise_vendor(name: &str, vendor: Option<&str>) -> bool {
553 let lower = name.to_lowercase();
554 let vendor_lower = vendor.unwrap_or("").to_lowercase();
555
556 let enterprise_patterns = [
557 "cisco",
558 "anyconnect",
559 "globalprotect",
560 "palo alto",
561 "zscaler",
562 "forticlient",
563 "fortinet",
564 "pulse secure",
565 "juniper",
566 "f5 ",
567 "big-ip",
568 "checkpoint",
569 "corp",
570 "enterprise",
571 "mdm",
572 "company",
573 ];
574
575 enterprise_patterns
576 .iter()
577 .any(|p| lower.contains(p) || vendor_lower.contains(p))
578}