1use owo_colors::OwoColorize;
7use sysinfo::Networks;
8
9pub fn detect_active_interface_and_local_ip() -> (Option<String>, Option<String>) {
11 let local_ip = std::net::UdpSocket::bind("0.0.0.0:0")
12 .ok()
13 .and_then(|socket| {
14 socket.connect("8.8.8.8:53").ok()?;
15 socket.local_addr().ok().map(|addr| addr.ip().to_string())
16 });
17
18 let active_interface = {
19 #[cfg(target_os = "linux")]
20 {
21 let native_iface = std::fs::read_to_string("/proc/net/route")
22 .ok()
23 .and_then(|content| parse_proc_net_route(&content));
24
25 native_iface.or_else(|| {
26 std::process::Command::new("ip")
27 .args(["route", "show", "default"])
28 .output()
29 .ok()
30 .and_then(|o| String::from_utf8(o.stdout).ok())
31 .and_then(|s| {
32 s.split_whitespace()
33 .position(|w| w == "dev")
34 .and_then(|i| s.split_whitespace().nth(i + 1))
35 .map(|s| s.to_string())
36 })
37 })
38 }
39 #[cfg(target_os = "macos")]
40 {
41 std::process::Command::new("route")
42 .args(["-n", "get", "default"])
43 .output()
44 .ok()
45 .and_then(|o| String::from_utf8(o.stdout).ok())
46 .and_then(|s| {
47 s.lines()
48 .find(|l| l.contains("interface:"))
49 .and_then(|l| l.split_whitespace().last())
50 .map(|s| s.to_string())
51 })
52 }
53 #[cfg(target_os = "windows")]
54 {
55 local_ip
62 .as_deref()
63 .and_then(|ip| ip.parse::<std::net::IpAddr>().ok())
64 .and_then(|target| {
65 let networks = Networks::new_with_refreshed_list();
66 match_active_interface(
67 networks.iter().map(|(name, data)| {
68 (
69 name.to_string(),
70 data.ip_networks().iter().map(|n| n.addr).collect(),
71 )
72 }),
73 target,
74 )
75 })
76 }
77 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
78 {
79 None
80 }
81 };
82
83 (local_ip, active_interface)
84}
85
86#[cfg(any(target_os = "windows", test))]
93fn match_active_interface(
94 ifaces: impl Iterator<Item = (String, Vec<std::net::IpAddr>)>,
95 local_ip: std::net::IpAddr,
96) -> Option<String> {
97 ifaces
98 .into_iter()
99 .find(|(_, ips)| ips.contains(&local_ip))
100 .map(|(name, _)| name)
101}
102
103pub fn detect_public_ip() -> Option<String> {
105 std::process::Command::new("curl")
106 .args(["-s", "--max-time", "2", "https://api.ipify.org"])
107 .output()
108 .ok()
109 .and_then(|o| String::from_utf8(o.stdout).ok())
110 .map(|s| s.trim().to_string())
111 .filter(|s| !s.is_empty())
112}
113
114pub fn detect_networks(active_interface: Option<&str>, local_ip: Option<&str>) -> Vec<String> {
116 Networks::new_with_refreshed_list()
117 .iter()
118 .map(|(name, data)| {
119 let rx = format_bytes(data.total_received());
120 let tx = format_bytes(data.total_transmitted());
121 let is_up = data.operational_state() == sysinfo::InterfaceOperationalState::Up
122 || data.total_received() > 0
123 || data.total_transmitted() > 0;
124 let status = if is_up {
125 "Up".green().to_string()
126 } else {
127 "Down".red().to_string()
128 };
129
130 let mut ipv4_addresses = Vec::new();
131 let mut ipv6_addresses = Vec::new();
132
133 if is_up {
134 for ip_net in data.ip_networks() {
135 let ip = ip_net.addr;
136 let name_lower = name.to_lowercase();
137 let is_loopback_iface =
138 name_lower.starts_with("lo") || name_lower.contains("loopback");
139 if ip.is_loopback() && !is_loopback_iface {
140 continue;
141 }
142 match ip {
143 std::net::IpAddr::V4(v4) => {
144 ipv4_addresses.push(v4.to_string());
145 }
146 std::net::IpAddr::V6(v6) => {
147 if !v6.is_unicast_link_local() {
148 ipv6_addresses.push(v6.to_string());
149 }
150 }
151 }
152 }
153
154 if ipv4_addresses.is_empty() && ipv6_addresses.is_empty() {
156 if let (Some(active), Some(ip)) = (active_interface, local_ip) {
157 if name == active {
158 ipv4_addresses.push(ip.to_string());
159 }
160 }
161 }
162 }
163
164 let ip_str = if !ipv4_addresses.is_empty() || !ipv6_addresses.is_empty() {
165 let mut combined = Vec::new();
166 if !ipv4_addresses.is_empty() {
167 combined.push(ipv4_addresses.join(", "));
168 }
169 if !ipv6_addresses.is_empty() {
170 combined.push(ipv6_addresses.join(", "));
171 }
172 format!(" ({})", combined.join(", "))
173 } else {
174 String::new()
175 };
176
177 format!("{}{} [{}] RX: {} TX: {}", name, ip_str, status, rx, tx)
178 })
179 .collect()
180}
181
182pub fn format_bytes(bytes: u64) -> String {
184 const KB: u64 = 1024;
185 const MB: u64 = KB * 1024;
186 const GB: u64 = MB * 1024;
187
188 if bytes >= GB {
189 format!("{:.1} GB", bytes as f64 / GB as f64)
190 } else if bytes >= MB {
191 format!("{:.1} MB", bytes as f64 / MB as f64)
192 } else if bytes >= KB {
193 format!("{:.1} KB", bytes as f64 / KB as f64)
194 } else {
195 format!("{} B", bytes)
196 }
197}
198
199pub fn lookup_pci_vendor(vendor_id: &str) -> Option<String> {
203 let vendor_id = vendor_id.trim_start_matches("0x").to_lowercase();
204 let paths = ["/usr/share/hwdata/pci.ids", "/usr/share/misc/pci.ids"];
205 for path in &paths {
206 if let Ok(content) = std::fs::read_to_string(path) {
207 for line in content.lines() {
208 if line.starts_with('#') || line.is_empty() {
209 continue;
210 }
211 if !line.starts_with('\t') {
212 let parts: Vec<&str> = line.split_whitespace().collect();
213 if parts.len() >= 2 && parts[0].to_lowercase() == vendor_id {
214 let name = line.strip_prefix(parts[0]).unwrap().trim();
215 return Some(name.to_string());
216 }
217 }
218 }
219 }
220 }
221 None
222}
223
224pub fn detect_wifi() -> Option<String> {
226 #[cfg(target_os = "linux")]
227 {
228 let mut wifi_interface = None;
229 if let Ok(entries) = std::fs::read_dir("/sys/class/net") {
230 for entry in entries.filter_map(|e| e.ok()) {
231 let path = entry.path();
232 if path.join("wireless").exists() || path.join("phy80211").exists() {
233 wifi_interface = Some(entry.file_name().to_string_lossy().to_string());
234 break;
235 }
236 }
237 }
238
239 if let Some(ref iface) = wifi_interface {
240 if let Ok(output) = std::process::Command::new("iw")
241 .args(["dev", iface, "link"])
242 .output()
243 {
244 if let Ok(stdout) = String::from_utf8(output.stdout) {
245 let (ssid, links) = parse_iw_link_output(&stdout);
246 if let Some(s) = ssid {
247 let card_model = get_wifi_card_model(iface);
248 let prefix = if let Some(m) = card_model {
249 format!("{} [{}] - ", m, iface)
250 } else {
251 format!("[{}] - ", iface)
252 };
253
254 if !links.is_empty() {
255 let mut link_strs = Vec::new();
256 for link in links {
257 let freq_str = link.freq.map(|f| {
258 let ghz_mhz = if f >= 1000.0 {
259 format!("{:.1} GHz", f / 1000.0)
260 } else {
261 format!("{} MHz", f)
262 };
263 if let Some(ch) = freq_to_channel(f) {
264 format!("{} ch{}", ghz_mhz, ch)
265 } else {
266 ghz_mhz
267 }
268 });
269
270 let mut rx_tx = Vec::new();
271 if let Some(rx) = link.rx_rate {
272 if rx != "0"
273 && !rx.starts_with("0 ")
274 && rx != "0 Mbps"
275 && rx != "0 MBit/s"
276 {
277 rx_tx.push(format!("↓{}", clean_rate(&rx)));
278 }
279 }
280 if let Some(tx) = link.tx_rate {
281 if tx != "0"
282 && !tx.starts_with("0 ")
283 && tx != "0 Mbps"
284 && tx != "0 MBit/s"
285 {
286 rx_tx.push(format!("↑{}", clean_rate(&tx)));
287 }
288 }
289
290 match (freq_str, rx_tx.is_empty()) {
291 (Some(f), false) => {
292 link_strs.push(format!("{} [{}]", f, rx_tx.join(" ")))
293 }
294 (Some(f), true) => link_strs.push(f),
295 (None, false) => link_strs.push(rx_tx.join(" ")),
296 _ => {}
297 }
298 }
299 if !link_strs.is_empty() {
300 return Some(format!(
301 "{}{}{} ({})",
302 prefix,
303 s,
304 "",
305 link_strs.join(", ")
306 ));
307 } else {
308 return Some(format!("{}{}", prefix, s));
309 }
310 }
311 return Some(format!("{}{}", prefix, s));
312 }
313 }
314 }
315 }
316
317 if let Ok(output) = std::process::Command::new("nmcli")
319 .args([
320 "-t",
321 "-f",
322 "active,ssid,rate",
323 "device",
324 "wifi",
325 "list",
326 "--rescan",
327 "no",
328 ])
329 .output()
330 {
331 if let Ok(stdout) = String::from_utf8(output.stdout) {
332 for line in stdout.lines() {
333 let line = line.trim();
334 if let Some(rest) = line.strip_prefix("yes:") {
335 if let Some(colon_idx) = rest.rfind(':') {
336 let ssid = &rest[..colon_idx];
337 let rate = rest[colon_idx + 1..].trim();
338 if !ssid.is_empty() {
339 if !rate.is_empty()
340 && rate != "0"
341 && !rate.starts_with("0 ")
342 && rate != "0 Mbit/s"
343 && rate != "0 Mbps"
344 {
345 return Some(format!("{} ({})", ssid, clean_rate(rate)));
346 } else {
347 return Some(ssid.to_string());
348 }
349 }
350 } else if !rest.is_empty() {
351 return Some(rest.to_string());
352 }
353 }
354 }
355 }
356 }
357
358 if let Ok(output) = std::process::Command::new("iwgetid").arg("-r").output() {
360 if let Ok(stdout) = String::from_utf8(output.stdout) {
361 let ssid = stdout.trim();
362 if !ssid.is_empty() {
363 return Some(ssid.to_string());
364 }
365 }
366 }
367 None
368 }
369
370 #[cfg(target_os = "macos")]
371 {
372 crate::macos_ffi::get_wifi_info().map(|(ssid, rate)| match rate {
373 Some(r) if r > 0 => format!("{} (↑{} Mbps)", ssid, r),
374 _ => ssid,
375 })
376 }
377
378 #[cfg(target_os = "windows")]
379 {
380 if let Ok(output) = std::process::Command::new("netsh")
381 .args(["wlan", "show", "interfaces"])
382 .output()
383 {
384 if let Ok(stdout) = String::from_utf8(output.stdout) {
385 return parse_netsh_output(&stdout);
386 }
387 }
388 None
389 }
390
391 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
392 {
393 None
394 }
395}
396
397#[cfg(any(target_os = "linux", test))]
398pub fn parse_proc_net_route(content: &str) -> Option<String> {
399 for line in content.lines().skip(1) {
400 let parts: Vec<&str> = line.split_whitespace().collect();
401 if parts.len() >= 8 {
402 let dest = parts[1];
403 let mask = parts[7];
404 if dest == "00000000" && mask == "00000000" {
405 return Some(parts[0].to_string());
406 }
407 }
408 }
409 None
410}
411
412#[allow(
413 clippy::manual_is_multiple_of,
414 clippy::manual_range_contains,
415 dead_code
416)]
417fn freq_to_channel(freq_mhz: f64) -> Option<u32> {
418 let freq = freq_mhz.round() as u32;
419 if freq >= 2412 && freq <= 2472 {
420 Some((freq - 2407) / 5)
421 } else if freq == 2484 {
422 Some(14)
423 } else if freq >= 5160 && freq <= 5885 {
424 if (freq - 5000) % 5 == 0 {
425 Some((freq - 5000) / 5)
426 } else {
427 None
428 }
429 } else if freq >= 5955 && freq <= 7115 {
430 if (freq - 5950) % 5 == 0 {
431 Some((freq - 5950) / 5)
432 } else {
433 None
434 }
435 } else {
436 None
437 }
438}
439
440#[allow(dead_code)]
441fn get_wifi_card_model(iface: &str) -> Option<String> {
442 let vendor = std::fs::read_to_string(format!("/sys/class/net/{}/device/vendor", iface)).ok()?;
443 let device = std::fs::read_to_string(format!("/sys/class/net/{}/device/device", iface)).ok()?;
444 let vendor_clean = vendor.trim().trim_start_matches("0x").to_lowercase();
445 let device_clean = device.trim().trim_start_matches("0x").to_lowercase();
446
447 let vendor_name = lookup_pci_vendor(&vendor_clean);
448 let model_name = crate::gpu::lookup_pci_device(&vendor_clean, &device_clean);
449
450 match (vendor_name, model_name) {
451 (Some(v), Some(m)) => {
452 let v_clean = v.replace(", Inc.", "").replace(" Corporation", "");
453 if m.to_lowercase().contains(&v_clean.to_lowercase())
454 || m.to_lowercase().contains(
455 &v_clean
456 .split_whitespace()
457 .next()
458 .unwrap_or("")
459 .to_lowercase(),
460 )
461 {
462 Some(m)
463 } else {
464 Some(format!("{} {}", v_clean, m))
465 }
466 }
467 (None, Some(m)) => Some(m),
468 _ => None,
469 }
470}
471
472#[allow(dead_code)]
473fn clean_rate(rate: &str) -> String {
474 rate.replace("MBit/s", "Mbps")
475 .replace("GBit/s", "Gbps")
476 .replace("Bit/s", "bps")
477}
478
479#[derive(Debug, Clone)]
480pub struct WifiLink {
481 pub freq: Option<f64>,
482 pub rx_rate: Option<String>,
483 pub tx_rate: Option<String>,
484}
485
486#[allow(dead_code)]
487pub fn parse_iw_link_output(stdout: &str) -> (Option<String>, Vec<WifiLink>) {
488 let mut ssid = None;
489 let mut links = Vec::new();
490 let mut current_link = None;
491
492 for line in stdout.lines() {
493 let trimmed = line.trim();
494 if trimmed.starts_with("Connected to") || trimmed.starts_with("link") {
495 if let Some(link) = current_link.take() {
496 links.push(link);
497 }
498 current_link = Some(WifiLink {
499 freq: None,
500 rx_rate: None,
501 tx_rate: None,
502 });
503 } else if trimmed.starts_with("SSID:") {
504 ssid = Some(trimmed.strip_prefix("SSID:").unwrap().trim().to_string());
505 } else if trimmed.starts_with("freq:") {
506 if let Some(ref mut link) = current_link {
507 let freq_str = trimmed.strip_prefix("freq:").unwrap().trim();
508 link.freq = freq_str.parse::<f64>().ok();
509 }
510 } else if trimmed.starts_with("rx bitrate:") {
511 if let Some(ref mut link) = current_link {
512 let rx_str = trimmed.strip_prefix("rx bitrate:").unwrap().trim();
513 let rate = rx_str
514 .split_whitespace()
515 .take(2)
516 .collect::<Vec<&str>>()
517 .join(" ");
518 link.rx_rate = Some(rate);
519 }
520 } else if trimmed.starts_with("tx bitrate:") {
521 if let Some(ref mut link) = current_link {
522 let tx_str = trimmed.strip_prefix("tx bitrate:").unwrap().trim();
523 let rate = tx_str
524 .split_whitespace()
525 .take(2)
526 .collect::<Vec<&str>>()
527 .join(" ");
528 link.tx_rate = Some(rate);
529 }
530 }
531 }
532 if let Some(link) = current_link {
533 links.push(link);
534 }
535 (ssid, links)
536}
537
538#[allow(dead_code)]
539pub fn parse_netsh_output(stdout: &str) -> Option<String> {
540 let mut ssid = None;
541 let mut rx = None;
542 let mut tx = None;
543 let mut band = None;
544 for line in stdout.lines() {
545 let trimmed = line.trim();
546 if trimmed.starts_with("SSID") {
547 if let Some(idx) = trimmed.find(':') {
548 let val = trimmed[idx + 1..].trim().to_string();
549 if !val.is_empty() {
550 ssid = Some(val);
551 }
552 }
553 } else if trimmed.starts_with("Receive rate (Mbps)") {
554 if let Some(idx) = trimmed.find(':') {
555 let val = trimmed[idx + 1..].trim().to_string();
556 if !val.is_empty() {
557 rx = Some(val);
558 }
559 }
560 } else if trimmed.starts_with("Transmit rate (Mbps)") {
561 if let Some(idx) = trimmed.find(':') {
562 let val = trimmed[idx + 1..].trim().to_string();
563 if !val.is_empty() {
564 tx = Some(val);
565 }
566 }
567 } else if trimmed.starts_with("Band") {
568 if let Some(idx) = trimmed.find(':') {
569 let val = trimmed[idx + 1..].trim().to_string();
570 if !val.is_empty() {
571 band = Some(val);
572 }
573 }
574 }
575 }
576 if let Some(s) = ssid {
577 let mut rate_strs = Vec::new();
578 if let Some(rx_val) = rx {
579 if rx_val != "0" {
580 rate_strs.push(format!("↓{} Mbps", rx_val));
581 }
582 }
583 if let Some(tx_val) = tx {
584 if tx_val != "0" {
585 rate_strs.push(format!("↑{} Mbps", tx_val));
586 }
587 }
588 let info = match (band, rate_strs.is_empty()) {
589 (Some(b), false) => format!("{} [{}]", b, rate_strs.join(" ")),
590 (Some(b), true) => b,
591 (None, false) => rate_strs.join(" "),
592 _ => String::new(),
593 };
594 if !info.is_empty() {
595 Some(format!("{} ({})", s, info))
596 } else {
597 Some(s)
598 }
599 } else {
600 None
601 }
602}
603
604pub fn detect_dns() -> Vec<String> {
610 #[cfg(any(target_os = "linux", target_os = "macos"))]
611 {
612 if let Ok(content) = std::fs::read_to_string("/etc/resolv.conf") {
613 return parse_resolv_conf(&content);
614 }
615 }
616 #[cfg(target_os = "windows")]
617 {
618 if let Ok(output) = std::process::Command::new("powershell")
619 .args([
620 "-Command",
621 "Get-DnsClientServerAddress -AddressFamily IPv4 | Select-Object -ExpandProperty ServerAddresses | Sort-Object -Unique",
622 ])
623 .output()
624 {
625 if let Ok(stdout) = String::from_utf8(output.stdout) {
626 let servers: Vec<String> = stdout
627 .lines()
628 .map(|l| l.trim().to_string())
629 .filter(|l| !l.is_empty())
630 .collect();
631 if !servers.is_empty() {
632 return servers;
633 }
634 }
635 }
636 }
637 Vec::new()
638}
639
640#[cfg(any(target_os = "linux", target_os = "macos", test))]
641pub fn parse_resolv_conf(content: &str) -> Vec<String> {
642 content
643 .lines()
644 .filter_map(|line| {
645 let line = line.trim();
646 if line.starts_with('#') || line.starts_with(';') {
647 return None;
648 }
649 let mut parts = line.split_whitespace();
650 if parts.next()? == "nameserver" {
651 parts.next().map(|s| s.to_string())
652 } else {
653 None
654 }
655 })
656 .collect()
657}
658
659pub fn detect_domain() -> Option<String> {
672 #[cfg(target_os = "linux")]
673 {
674 if let (Some(iface), Some(status)) = (default_route_interface(), resolvectl_status()) {
678 if let DefaultRouteDomain::Managed(domain) =
679 resolve_default_route_domain(&parse_resolvectl_domains(status), &iface)
680 {
681 return domain;
682 }
683 }
684 read_resolv_conf_domain()
685 }
686 #[cfg(target_os = "macos")]
687 {
688 read_resolv_conf_domain()
689 }
690 #[cfg(target_os = "windows")]
691 {
692 detect_domain_windows()
693 }
694 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
695 {
696 None
697 }
698}
699
700#[cfg(any(target_os = "linux", target_os = "macos"))]
702fn read_resolv_conf_domain() -> Option<String> {
703 std::fs::read_to_string("/etc/resolv.conf")
704 .ok()
705 .and_then(|content| parse_domain_from_resolv_conf(&content))
706}
707
708#[cfg(target_os = "linux")]
715fn default_route_interface() -> Option<String> {
716 std::fs::read_to_string("/proc/net/route")
717 .ok()
718 .and_then(|content| parse_proc_net_route(&content))
719}
720
721#[cfg(target_os = "linux")]
723static RESOLVECTL_STATUS: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
724
725#[cfg(target_os = "linux")]
731fn resolvectl_status() -> Option<&'static str> {
732 RESOLVECTL_STATUS
733 .get_or_init(|| {
734 let output = std::process::Command::new("resolvectl")
735 .args(["status", "--no-pager"])
736 .output()
737 .ok()?;
738 if !output.status.success() {
739 return None;
740 }
741 Some(String::from_utf8_lossy(&output.stdout).into_owned())
742 })
743 .as_deref()
744}
745
746#[cfg(any(target_os = "windows", test))]
748#[derive(Debug, Clone, PartialEq, Eq)]
749struct WinAdapterDnsInfo {
750 friendly_name: String,
751 dns_suffix: String,
752 is_up: bool,
753 is_loopback: bool,
754}
755
756#[cfg(any(target_os = "windows", test))]
762fn resolve_windows_default_domain(
763 active_iface: Option<&str>,
764 adapters: &[WinAdapterDnsInfo],
765 global_domain: Option<&str>,
766) -> Option<String> {
767 if let Some(iface) = active_iface {
768 if let Some(adapter) = adapters
769 .iter()
770 .find(|a| a.friendly_name.eq_ignore_ascii_case(iface))
771 {
772 if let Some(suffix) = clean_domain(&adapter.dns_suffix) {
773 return Some(suffix);
774 }
775 }
776 }
777
778 if let Some(global) = global_domain.and_then(clean_domain) {
779 return Some(global);
780 }
781
782 None
783}
784
785#[cfg(any(target_os = "windows", test))]
787fn parse_windows_domain_search(
788 global_search_list: Option<&str>,
789 adapters: &[WinAdapterDnsInfo],
790) -> Vec<String> {
791 let mut results = Vec::new();
792
793 if let Some(raw) = global_search_list {
794 let global_domains: Vec<String> = raw
795 .split(&[',', ' '][..])
796 .filter_map(clean_domain)
797 .collect();
798 if !global_domains.is_empty() {
799 results.extend(format_global_search_domains(&global_domains));
800 }
801 }
802
803 for adapter in adapters {
804 if adapter.is_up && !adapter.is_loopback {
805 if let Some(suffix) = clean_domain(&adapter.dns_suffix) {
806 results.push(format!("{}: {}", adapter.friendly_name, suffix));
807 }
808 }
809 }
810
811 results
812}
813
814#[cfg(target_os = "windows")]
816fn detect_domain_windows() -> Option<String> {
817 let (_, active_iface) = detect_active_interface_and_local_ip();
818 let adapters = get_windows_adapters_dns_info();
819 let global_domain = crate::win_reg::get_reg_string(
820 crate::win_reg::HKEY_LOCAL_MACHINE,
821 "SYSTEM\\CurrentControlSet\\Services\\Tcpip\\Parameters",
822 "Domain",
823 );
824 resolve_windows_default_domain(active_iface.as_deref(), &adapters, global_domain.as_deref())
825}
826
827#[cfg(target_os = "windows")]
829fn get_windows_adapters_dns_info() -> Vec<WinAdapterDnsInfo> {
830 use std::ffi::OsString;
831 use std::os::windows::ffi::OsStringExt;
832 use std::ptr;
833
834 #[repr(C)]
835 #[allow(non_snake_case)]
836 struct IpAdapterAddresses {
837 Length: u32,
838 IfIndex: u32,
839 Next: *mut IpAdapterAddresses,
840 AdapterName: *const i8,
841 FirstUnicastAddress: *const std::ffi::c_void,
842 FirstAnycastAddress: *const std::ffi::c_void,
843 FirstMulticastAddress: *const std::ffi::c_void,
844 FirstDnsServerAddress: *const std::ffi::c_void,
845 DnsSuffix: *const u16,
846 Description: *const u16,
847 FriendlyName: *const u16,
848 PhysicalAddress: [u8; 8],
849 PhysicalAddressLength: u32,
850 Flags: u32,
851 Mtu: u32,
852 IfType: u32,
853 OperStatus: u32,
854 }
855
856 const AF_UNSPEC: u32 = 0;
857 const GAA_FLAGS: u32 = 0x0E;
858 const IF_TYPE_SOFTWARE_LOOPBACK: u32 = 24;
859 const IF_OPER_STATUS_UP: u32 = 1;
860
861 #[link(name = "iphlpapi")]
862 extern "system" {
863 fn GetAdaptersAddresses(
864 family: u32,
865 flags: u32,
866 reserved: *mut std::ffi::c_void,
867 adapter_addresses: *mut IpAdapterAddresses,
868 size_pointer: *mut u32,
869 ) -> u32;
870 }
871
872 let mut size: u32 = 0;
873 unsafe {
875 GetAdaptersAddresses(
876 AF_UNSPEC,
877 GAA_FLAGS,
878 ptr::null_mut(),
879 ptr::null_mut(),
880 &mut size,
881 );
882 }
883 if size == 0 {
884 return Vec::new();
885 }
886
887 let mut buf = vec![0u8; size as usize];
888 let ret = unsafe {
890 GetAdaptersAddresses(
891 AF_UNSPEC,
892 GAA_FLAGS,
893 ptr::null_mut(),
894 buf.as_mut_ptr() as *mut IpAdapterAddresses,
895 &mut size,
896 )
897 };
898 if ret != 0 {
899 return Vec::new();
900 }
901
902 let mut result = Vec::new();
903 let mut curr = buf.as_ptr() as *const IpAdapterAddresses;
904
905 unsafe {
906 while !curr.is_null() {
907 let adapter = &*curr;
908
909 let friendly_name = if !adapter.FriendlyName.is_null() {
910 let mut len = 0;
911 while *adapter.FriendlyName.add(len) != 0 {
912 len += 1;
913 }
914 let slice = std::slice::from_raw_parts(adapter.FriendlyName, len);
915 OsString::from_wide(slice).to_string_lossy().into_owned()
916 } else {
917 String::new()
918 };
919
920 let adapter_name = if !adapter.AdapterName.is_null() {
921 std::ffi::CStr::from_ptr(adapter.AdapterName)
922 .to_string_lossy()
923 .into_owned()
924 } else {
925 String::new()
926 };
927
928 let mut dns_suffix = if !adapter.DnsSuffix.is_null() {
929 let mut len = 0;
930 while *adapter.DnsSuffix.add(len) != 0 {
931 len += 1;
932 }
933 let slice = std::slice::from_raw_parts(adapter.DnsSuffix, len);
934 OsString::from_wide(slice).to_string_lossy().into_owned()
935 } else {
936 String::new()
937 };
938
939 if dns_suffix.trim().is_empty() && !adapter_name.is_empty() {
940 let subkey = format!(
941 "SYSTEM\\CurrentControlSet\\Services\\Tcpip\\Parameters\\Interfaces\\{}",
942 adapter_name
943 );
944 if let Some(s) = crate::win_reg::get_reg_string(
945 crate::win_reg::HKEY_LOCAL_MACHINE,
946 &subkey,
947 "SearchList",
948 ) {
949 dns_suffix = s;
950 } else if let Some(s) = crate::win_reg::get_reg_string(
951 crate::win_reg::HKEY_LOCAL_MACHINE,
952 &subkey,
953 "DhcpSearchList",
954 ) {
955 dns_suffix = s;
956 } else if let Some(s) = crate::win_reg::get_reg_string(
957 crate::win_reg::HKEY_LOCAL_MACHINE,
958 &subkey,
959 "Domain",
960 ) {
961 dns_suffix = s;
962 } else if let Some(s) = crate::win_reg::get_reg_string(
963 crate::win_reg::HKEY_LOCAL_MACHINE,
964 &subkey,
965 "DhcpDomain",
966 ) {
967 dns_suffix = s;
968 }
969 }
970
971 result.push(WinAdapterDnsInfo {
972 friendly_name,
973 dns_suffix,
974 is_up: adapter.OperStatus == IF_OPER_STATUS_UP,
975 is_loopback: adapter.IfType == IF_TYPE_SOFTWARE_LOOPBACK,
976 });
977
978 curr = adapter.Next;
979 }
980 }
981
982 result
983}
984
985#[cfg(any(target_os = "windows", test))]
990fn clean_domain(raw: &str) -> Option<String> {
991 let trimmed = raw.trim();
992 if trimmed.is_empty() {
993 None
994 } else {
995 Some(trimmed.to_string())
996 }
997}
998
999pub fn detect_domain_search() -> Vec<String> {
1007 #[cfg(target_os = "linux")]
1008 {
1009 if let Some(status) = resolvectl_status() {
1011 let result = parse_resolvectl_search(status);
1012 if !result.is_empty() {
1013 return result;
1014 }
1015 }
1016 if let Ok(content) = std::fs::read_to_string("/etc/resolv.conf") {
1017 return format_global_search_domains(&parse_search_from_resolv_conf(&content));
1018 }
1019 }
1020 #[cfg(target_os = "macos")]
1021 {
1022 if let Ok(content) = std::fs::read_to_string("/etc/resolv.conf") {
1023 return format_global_search_domains(&parse_search_from_resolv_conf(&content));
1024 }
1025 }
1026 #[cfg(target_os = "windows")]
1027 {
1028 let adapters = get_windows_adapters_dns_info();
1029 let global_search_list = crate::win_reg::get_reg_string(
1030 crate::win_reg::HKEY_LOCAL_MACHINE,
1031 "SYSTEM\\CurrentControlSet\\Services\\Tcpip\\Parameters",
1032 "SearchList",
1033 );
1034 let results = parse_windows_domain_search(global_search_list.as_deref(), &adapters);
1035 if !results.is_empty() {
1036 return results;
1037 }
1038 }
1039 Vec::new()
1040}
1041
1042#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))]
1048const GLOBAL_SEARCH_SCOPE: &str = "global";
1049
1050#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))]
1053pub fn format_global_search_domains(domains: &[String]) -> Vec<String> {
1054 if domains.is_empty() {
1055 Vec::new()
1056 } else {
1057 vec![format!("{}: {}", GLOBAL_SEARCH_SCOPE, domains.join(", "))]
1058 }
1059}
1060
1061#[cfg(any(target_os = "linux", target_os = "macos", test))]
1064pub fn parse_domain_from_resolv_conf(content: &str) -> Option<String> {
1065 let mut first_search: Option<String> = None;
1066 for line in content.lines() {
1067 let line = line.trim();
1068 if line.starts_with('#') || line.starts_with(';') {
1069 continue;
1070 }
1071 let mut parts = line.split_whitespace();
1072 match parts.next() {
1073 Some("domain") => {
1074 if let Some(d) = parts.next() {
1075 return Some(d.to_string());
1076 }
1077 }
1078 Some("search") if first_search.is_none() => {
1079 if let Some(d) = parts.next() {
1080 first_search = Some(d.to_string());
1081 }
1082 }
1083 _ => {}
1084 }
1085 }
1086 first_search
1087}
1088
1089#[cfg(any(target_os = "linux", target_os = "macos", test))]
1091pub fn parse_search_from_resolv_conf(content: &str) -> Vec<String> {
1092 for line in content.lines() {
1093 let line = line.trim();
1094 if line.starts_with('#') || line.starts_with(';') {
1095 continue;
1096 }
1097 let mut parts = line.split_whitespace();
1098 if parts.next() == Some("search") {
1099 let domains: Vec<String> = parts.map(|s| s.to_string()).collect();
1100 if !domains.is_empty() {
1101 return domains;
1102 }
1103 }
1104 }
1105 Vec::new()
1106}
1107
1108#[derive(Debug, Clone, Default, PartialEq, Eq)]
1110#[cfg(any(target_os = "linux", test))]
1111pub struct LinkDomains {
1112 pub interface: String,
1114 pub search: Vec<String>,
1117}
1118
1119#[derive(Debug, Clone, Default, PartialEq, Eq)]
1121#[cfg(any(target_os = "linux", test))]
1122pub struct ResolvectlDomains {
1123 pub global: Vec<String>,
1126 pub links: Vec<LinkDomains>,
1128}
1129
1130#[derive(Debug, Clone, PartialEq, Eq)]
1132#[cfg(any(target_os = "linux", test))]
1133pub enum DefaultRouteDomain {
1134 Managed(Option<String>),
1139 Unmanaged,
1142}
1143
1144#[cfg(any(target_os = "linux", test))]
1150pub fn resolve_default_route_domain(
1151 domains: &ResolvectlDomains,
1152 interface: &str,
1153) -> DefaultRouteDomain {
1154 match domains.links.iter().find(|l| l.interface == interface) {
1155 Some(link) => DefaultRouteDomain::Managed(
1156 link.search
1157 .first()
1158 .or_else(|| domains.global.first())
1159 .cloned(),
1160 ),
1161 None => DefaultRouteDomain::Unmanaged,
1162 }
1163}
1164
1165#[cfg(any(target_os = "linux", test))]
1179pub fn parse_resolvectl_domains(content: &str) -> ResolvectlDomains {
1180 let mut out = ResolvectlDomains::default();
1181 let mut section: Option<String> = None;
1183 let mut in_domain_value = false;
1185
1186 for line in content.lines() {
1187 let trimmed = line.trim();
1188 if trimmed.is_empty() {
1189 in_domain_value = false;
1190 continue;
1191 }
1192
1193 if let Some(iface) = trimmed
1196 .strip_prefix("Link ")
1197 .and_then(|rest| rest.split_once('('))
1198 .and_then(|(_, rest)| rest.split_once(')'))
1199 .map(|(iface, _)| iface)
1200 {
1201 in_domain_value = false;
1202 section = Some(iface.to_string());
1203 if !out.links.iter().any(|l| l.interface == iface) {
1205 out.links.push(LinkDomains {
1206 interface: iface.to_string(),
1207 search: Vec::new(),
1208 });
1209 }
1210 continue;
1211 }
1212
1213 if trimmed == "Global" {
1214 in_domain_value = false;
1215 section = None;
1216 continue;
1217 }
1218
1219 if let Some(value) = trimmed
1220 .strip_prefix("DNS Domain:")
1221 .or_else(|| trimmed.strip_prefix("DNS Search Domains:"))
1222 {
1223 in_domain_value = true;
1224 push_resolvectl_domains(&mut out, section.as_deref(), value);
1225 continue;
1226 }
1227
1228 if in_domain_value && !trimmed.contains(':') {
1231 push_resolvectl_domains(&mut out, section.as_deref(), trimmed);
1232 continue;
1233 }
1234 in_domain_value = false;
1235 }
1236 out
1237}
1238
1239#[cfg(any(target_os = "linux", test))]
1242fn push_resolvectl_domains(out: &mut ResolvectlDomains, section: Option<&str>, value: &str) {
1243 let domains = value
1244 .split_whitespace()
1245 .filter(|d| !d.starts_with('~'))
1246 .map(|d| d.to_string());
1247 match section {
1248 Some(iface) => match out.links.iter_mut().find(|l| l.interface == iface) {
1249 Some(link) => link.search.extend(domains),
1250 None => out.links.push(LinkDomains {
1251 interface: iface.to_string(),
1252 search: domains.collect(),
1253 }),
1254 },
1255 None => out.global.extend(domains),
1256 }
1257}
1258
1259#[cfg(any(target_os = "linux", test))]
1264pub fn parse_resolvectl_search(content: &str) -> Vec<String> {
1265 parse_resolvectl_domains(content)
1266 .links
1267 .into_iter()
1268 .filter(|link| !link.search.is_empty())
1269 .map(|link| format!("{}: {}", link.interface, link.search.join(", ")))
1270 .collect()
1271}
1272
1273#[cfg(test)]
1274mod tests {
1275 use super::*;
1276
1277 #[test]
1278 fn test_clean_domain() {
1279 assert_eq!(
1281 clean_domain("corp.example.com"),
1282 Some("corp.example.com".to_string())
1283 );
1284 assert_eq!(
1286 clean_domain(" example.org \n"),
1287 Some("example.org".to_string())
1288 );
1289 assert_eq!(clean_domain(""), None);
1291 assert_eq!(clean_domain(" "), None);
1292 }
1293
1294 #[test]
1295 fn test_match_active_interface() {
1296 use std::net::IpAddr;
1297 let target: IpAddr = "192.168.1.50".parse().unwrap();
1298 let ifaces = vec![
1299 ("lo".to_string(), vec!["127.0.0.1".parse().unwrap()]),
1300 (
1301 "Ethernet".to_string(),
1302 vec!["192.168.1.50".parse().unwrap(), "fe80::1".parse().unwrap()],
1303 ),
1304 ("Wi-Fi".to_string(), vec!["10.0.0.2".parse().unwrap()]),
1305 ];
1306 assert_eq!(
1308 match_active_interface(ifaces.into_iter(), target),
1309 Some("Ethernet".to_string())
1310 );
1311
1312 let orphan: IpAddr = "8.8.8.8".parse().unwrap();
1314 let ifaces2 = vec![(
1315 "lo".to_string(),
1316 vec!["127.0.0.1".parse::<IpAddr>().unwrap()],
1317 )];
1318 assert_eq!(match_active_interface(ifaces2.into_iter(), orphan), None);
1319 }
1320
1321 #[test]
1322 fn test_format_bytes() {
1323 assert_eq!(format_bytes(500), "500 B");
1324 assert_eq!(format_bytes(1024), "1.0 KB");
1325 assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
1326 assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
1327 assert_eq!(format_bytes(1536), "1.5 KB");
1328 }
1329
1330 #[test]
1331 fn test_parse_proc_net_route() {
1332 let sample =
1333 "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n\
1334 wlan0\t0000A8C0\t00000000\t0001\t0\t0\t600\t0000FFFF\t0\t0\t0\n\
1335 wlan0\t00000000\t0100A8C0\t0003\t0\t0\t600\t00000000\t0\t0\t0\n";
1336 assert_eq!(parse_proc_net_route(sample), Some("wlan0".to_string()));
1337
1338 let sample_no_default =
1339 "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n\
1340 wlan0\t0000A8C0\t00000000\t0001\t0\t0\t600\t0000FFFF\t0\t0\t0\n";
1341 assert_eq!(parse_proc_net_route(sample_no_default), None);
1342 }
1343
1344 #[test]
1345 fn test_parse_netsh_output() {
1346 let sample = " Name : Wi-Fi\n State : connected\n SSID : Office_Wi-Fi\n Receive rate (Mbps) : 433\n Transmit rate (Mbps) : 866\n Band : 5 GHz\n";
1347 assert_eq!(
1348 parse_netsh_output(sample),
1349 Some("Office_Wi-Fi (5 GHz [↓433 Mbps ↑866 Mbps])".to_string())
1350 );
1351 }
1352
1353 #[test]
1354 fn test_parse_iw_link_output() {
1355 let sample = "Connected to 84:78:48:dc:97:23 (on wlp2s0)\n SSID: OfficeNet\n freq: 6135.0\n rx bitrate: 6.0 MBit/s\n tx bitrate: 864.6 MBit/s 160MHz HE-MCS 4\n";
1356 let (ssid, links) = parse_iw_link_output(sample);
1357 assert_eq!(ssid, Some("OfficeNet".to_string()));
1358 assert_eq!(links.len(), 1);
1359 assert_eq!(links[0].freq, Some(6135.0));
1360 assert_eq!(links[0].rx_rate, Some("6.0 MBit/s".to_string()));
1361 assert_eq!(links[0].tx_rate, Some("864.6 MBit/s".to_string()));
1362
1363 let sample_mlo = "Connected to aa:bb:cc:dd:ee:ff (on wlan0)\n SSID: HomeWiFi\n freq: 5180.0\n rx bitrate: 866.0 MBit/s\n tx bitrate: 866.0 MBit/s\nConnected to aa:bb:cc:dd:ee:01 (on wlan0)\n freq: 6135.0\n rx bitrate: 1200.0 MBit/s\n tx bitrate: 1200.0 MBit/s\n";
1365 let (ssid_mlo, links_mlo) = parse_iw_link_output(sample_mlo);
1366 assert_eq!(ssid_mlo, Some("HomeWiFi".to_string()));
1367 assert_eq!(links_mlo.len(), 2);
1368 assert_eq!(links_mlo[0].freq, Some(5180.0));
1369 assert_eq!(links_mlo[1].freq, Some(6135.0));
1370 }
1371
1372 #[test]
1373 fn test_parse_resolv_conf() {
1374 let sample = "# Generated by NetworkManager\ndomain home\nsearch home\nnameserver 192.168.1.1\nnameserver 8.8.8.8\n; comment\nnameserver 2001:db8::1\n";
1375 assert_eq!(
1376 parse_resolv_conf(sample),
1377 vec!["192.168.1.1", "8.8.8.8", "2001:db8::1"]
1378 );
1379
1380 let empty = "# no nameservers\nsearch local\n";
1381 assert_eq!(parse_resolv_conf(empty), Vec::<String>::new());
1382 }
1383
1384 #[test]
1385 fn test_parse_domain_from_resolv_conf_domain_directive() {
1386 let s = "# test\ndomain example.com\nsearch fallback.com\nnameserver 1.1.1.1\n";
1387 assert_eq!(
1388 parse_domain_from_resolv_conf(s),
1389 Some("example.com".to_string())
1390 );
1391 }
1392
1393 #[test]
1394 fn test_parse_domain_from_resolv_conf_search_fallback() {
1395 let s = "# no domain directive\nsearch local.lan other.lan\nnameserver 1.1.1.1\n";
1396 assert_eq!(
1397 parse_domain_from_resolv_conf(s),
1398 Some("local.lan".to_string())
1399 );
1400 }
1401
1402 #[test]
1403 fn test_parse_domain_from_resolv_conf_none() {
1404 let s = "# no domain or search\nnameserver 1.1.1.1\n";
1405 assert_eq!(parse_domain_from_resolv_conf(s), None);
1406 }
1407
1408 #[test]
1409 fn test_parse_search_from_resolv_conf() {
1410 let s = "search home.local corp.example.com\nnameserver 1.1.1.1\n";
1411 assert_eq!(
1412 parse_search_from_resolv_conf(s),
1413 vec!["home.local", "corp.example.com"]
1414 );
1415 }
1416
1417 #[test]
1418 fn test_parse_resolvectl_search_basic() {
1419 let sample = "Global\n\
1420 Link 2 (lo)\n\
1421 Current Scopes: none\n\
1422 Link 3 (wlan0)\n\
1423 Current Scopes: DNS\n\
1424 DNS Domain: home.local\n\
1425 Link 4 (eth0)\n\
1426 Current Scopes: DNS\n\
1427 DNS Search Domains: corp.example.com internal.net\n";
1428 let result = parse_resolvectl_search(sample);
1429 assert_eq!(
1430 result,
1431 vec!["wlan0: home.local", "eth0: corp.example.com, internal.net"]
1432 );
1433 }
1434
1435 #[test]
1436 fn test_parse_resolvectl_search_skips_routing_domain() {
1437 let sample = "Link 2 (wlan0)\n DNS Domain: ~.\nLink 3 (eth0)\n DNS Domain: corp.net\n";
1438 let result = parse_resolvectl_search(sample);
1439 assert_eq!(result, vec!["eth0: corp.net"]);
1440 }
1441
1442 #[test]
1443 fn test_parse_resolvectl_search_empty() {
1444 let sample = "Global\n DNS Servers: 1.1.1.1\n";
1445 assert!(parse_resolvectl_search(sample).is_empty());
1446 }
1447
1448 const RESOLVECTL_VPN_SAMPLE: &str = concat!(
1456 "Global\n",
1457 " Protocols: LLMNR=resolve -mDNS -DNSOverTLS DNSSEC=no/unsupported\n",
1458 " resolv.conf mode: stub\n",
1459 "\n",
1460 "Link 2 (wlp194s0)\n",
1461 " Current Scopes: DNS LLMNR/IPv4 LLMNR/IPv6\n",
1462 " Protocols: +DefaultRoute LLMNR=resolve -mDNS -DNSOverTLS\n",
1463 " DNSSEC=no/unsupported\n",
1464 "Current DNS Server: 192.168.86.1\n",
1465 " DNS Servers: 192.168.86.1\n",
1466 " DNS Domain: lan\n",
1467 " Default Route: yes\n",
1468 "\n",
1469 "Link 3 (wt0)\n",
1470 " Current Scopes: DNS\n",
1471 " Protocols: +DefaultRoute LLMNR=resolve -mDNS -DNSOverTLS\n",
1472 " DNSSEC=no/unsupported\n",
1473 "Current DNS Server: 100.101.32.155\n",
1474 " DNS Servers: 100.101.32.155\n",
1475 " DNS Domain: netbird.cloud ~gammatile.com ~101.100.in-addr.arpa\n",
1476 " ~f.f.0.0.3.2.b.5.b.c.8.5.7.f.d.f.ip6.arpa ~.\n",
1477 " Default Route: yes\n",
1478 );
1479
1480 #[test]
1481 fn test_domain_prefers_default_route_over_vpn() {
1482 let parsed = parse_resolvectl_domains(RESOLVECTL_VPN_SAMPLE);
1486 assert_eq!(
1487 resolve_default_route_domain(&parsed, "wlp194s0"),
1488 DefaultRouteDomain::Managed(Some("lan".to_string()))
1489 );
1490 }
1491
1492 #[test]
1493 fn test_domain_reports_vpn_when_vpn_is_the_default_route() {
1494 let parsed = parse_resolvectl_domains(RESOLVECTL_VPN_SAMPLE);
1496 assert_eq!(
1497 resolve_default_route_domain(&parsed, "wt0"),
1498 DefaultRouteDomain::Managed(Some("netbird.cloud".to_string()))
1499 );
1500 }
1501
1502 #[test]
1503 fn test_domain_routing_only_entries_are_not_domains() {
1504 let parsed = parse_resolvectl_domains(RESOLVECTL_VPN_SAMPLE);
1507 let wt0 = parsed.links.iter().find(|l| l.interface == "wt0").unwrap();
1508 assert_eq!(wt0.search, vec!["netbird.cloud"]);
1509 assert!(wt0.search.iter().all(|d| !d.starts_with('~')));
1510
1511 let routing_only = "Link 5 (tun0)\n DNS Domain: ~corp.example.com ~.\n";
1512 let parsed = parse_resolvectl_domains(routing_only);
1513 assert_eq!(
1514 resolve_default_route_domain(&parsed, "tun0"),
1515 DefaultRouteDomain::Managed(None)
1516 );
1517 }
1518
1519 #[test]
1520 fn test_parse_resolvectl_domains_reads_wrapped_continuation_lines() {
1521 let sample = concat!(
1524 "Link 2 (eth0)\n",
1525 " DNS Domain: one.example.com two.example.com\n",
1526 " three.example.com ~routing.example.com\n",
1527 " Default Route: yes\n",
1528 );
1529 let parsed = parse_resolvectl_domains(sample);
1530 assert_eq!(
1531 parsed.links[0].search,
1532 vec!["one.example.com", "two.example.com", "three.example.com"]
1533 );
1534 assert!(!parsed.links[0].search.iter().any(|d| d.contains("yes")));
1536 }
1537
1538 #[test]
1539 fn test_parse_resolvectl_domains_ignores_other_wrapped_fields() {
1540 let sample = concat!(
1542 "Link 2 (eth0)\n",
1543 " Protocols: +DefaultRoute LLMNR=resolve -mDNS -DNSOverTLS\n",
1544 " DNSSEC=no/unsupported\n",
1545 " DNS Domain: real.example.com\n",
1546 );
1547 let parsed = parse_resolvectl_domains(sample);
1548 assert_eq!(parsed.links[0].search, vec!["real.example.com"]);
1549 }
1550
1551 #[test]
1552 fn test_domain_unmanaged_link_falls_back() {
1553 let parsed = parse_resolvectl_domains(RESOLVECTL_VPN_SAMPLE);
1556 assert_eq!(
1557 resolve_default_route_domain(&parsed, "ppp0"),
1558 DefaultRouteDomain::Unmanaged
1559 );
1560 }
1561
1562 #[test]
1563 fn test_domain_managed_without_domain_does_not_borrow_from_other_links() {
1564 let sample = concat!(
1567 "Link 2 (wlp194s0)\n",
1568 " Default Route: yes\n",
1569 "Link 3 (wt0)\n",
1570 " DNS Domain: netbird.cloud\n",
1571 );
1572 let parsed = parse_resolvectl_domains(sample);
1573 assert_eq!(
1574 resolve_default_route_domain(&parsed, "wlp194s0"),
1575 DefaultRouteDomain::Managed(None)
1576 );
1577 }
1578
1579 #[test]
1580 fn test_domain_falls_back_to_global_but_not_to_another_link() {
1581 let sample = concat!(
1584 "Global\n",
1585 " DNS Domain: corp.example.com\n",
1586 "Link 2 (wlp194s0)\n",
1587 " Default Route: yes\n",
1588 "Link 3 (wt0)\n",
1589 " DNS Domain: netbird.cloud\n",
1590 );
1591 let parsed = parse_resolvectl_domains(sample);
1592 assert_eq!(parsed.global, vec!["corp.example.com"]);
1593 assert_eq!(
1594 resolve_default_route_domain(&parsed, "wlp194s0"),
1595 DefaultRouteDomain::Managed(Some("corp.example.com".to_string()))
1596 );
1597 }
1598
1599 #[test]
1600 fn test_parse_resolvectl_domains_merges_both_domain_labels() {
1601 let sample = concat!(
1603 "Link 2 (eth0)\n",
1604 " DNS Domain: a.example.com\n",
1605 "DNS Search Domains: b.example.com\n",
1606 );
1607 let parsed = parse_resolvectl_domains(sample);
1608 assert_eq!(parsed.links.len(), 1);
1609 assert_eq!(
1610 parsed.links[0].search,
1611 vec!["a.example.com", "b.example.com"]
1612 );
1613 assert_eq!(
1614 parse_resolvectl_search(sample),
1615 vec!["eth0: a.example.com, b.example.com"]
1616 );
1617 }
1618
1619 #[test]
1622 fn test_global_search_domains_match_per_link_shape() {
1623 let per_link = parse_resolvectl_search("Link 2 (eth0)\n DNS Domain: a.example.com\n");
1627 assert_eq!(per_link, vec!["eth0: a.example.com"]);
1628
1629 let global = format_global_search_domains(&["a.example.com".to_string()]);
1630 assert_eq!(global, vec!["global: a.example.com"]);
1631
1632 assert_eq!(per_link.len(), global.len());
1634 for entry in per_link.iter().chain(global.iter()) {
1635 let (scope, domains) = entry.split_once(": ").expect("entry must carry a scope");
1636 assert!(!scope.is_empty() && !domains.is_empty());
1637 }
1638 }
1639
1640 #[test]
1641 fn test_global_search_domains_group_into_one_entry() {
1642 let parsed = parse_search_from_resolv_conf("search a.example.com b.example.com c.net\n");
1646 assert_eq!(parsed.len(), 3); assert_eq!(
1648 format_global_search_domains(&parsed),
1649 vec!["global: a.example.com, b.example.com, c.net"]
1650 );
1651 }
1652
1653 #[test]
1654 fn test_global_search_domains_empty_yields_no_line() {
1655 assert!(format_global_search_domains(&[]).is_empty());
1657 assert!(format_global_search_domains(&parse_search_from_resolv_conf(
1658 "nameserver 1.1.1.1\n"
1659 ))
1660 .is_empty());
1661 }
1662
1663 #[test]
1664 fn test_default_route_interface_selection_matches_routing_table() {
1665 let proc_net_route = concat!(
1669 "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n",
1670 "wlp194s0\t00000000\t0156A8C0\t0003\t0\t0\t100\t00000000\t0\t0\t0\n",
1671 "wt0\t00006564\t00000000\t0001\t0\t0\t0\t0000FFFF\t0\t0\t0\n",
1672 "wlp194s0\t0056A8C0\t00000000\t0001\t0\t0\t100\t00FFFFFF\t0\t0\t0\n",
1673 );
1674 assert_eq!(
1675 parse_proc_net_route(proc_net_route),
1676 Some("wlp194s0".to_string())
1677 );
1678 }
1679
1680 #[test]
1681 fn test_resolve_windows_default_domain() {
1682 let adapters = vec![
1683 WinAdapterDnsInfo {
1684 friendly_name: "Wi-Fi".to_string(),
1685 dns_suffix: "lan.home".to_string(),
1686 is_up: true,
1687 is_loopback: false,
1688 },
1689 WinAdapterDnsInfo {
1690 friendly_name: "Ethernet".to_string(),
1691 dns_suffix: "corp.internal".to_string(),
1692 is_up: true,
1693 is_loopback: false,
1694 },
1695 ];
1696
1697 assert_eq!(
1699 resolve_windows_default_domain(Some("Wi-Fi"), &adapters, None),
1700 Some("lan.home".to_string())
1701 );
1702
1703 assert_eq!(
1705 resolve_windows_default_domain(Some("wi-fi"), &adapters, None),
1706 Some("lan.home".to_string())
1707 );
1708
1709 let adapters_no_suffix = vec![WinAdapterDnsInfo {
1711 friendly_name: "Wi-Fi".to_string(),
1712 dns_suffix: "".to_string(),
1713 is_up: true,
1714 is_loopback: false,
1715 }];
1716 assert_eq!(
1717 resolve_windows_default_domain(
1718 Some("Wi-Fi"),
1719 &adapters_no_suffix,
1720 Some("global.example.com")
1721 ),
1722 Some("global.example.com".to_string())
1723 );
1724
1725 assert_eq!(
1727 resolve_windows_default_domain(Some("Unknown"), &adapters, Some("global.example.com")),
1728 Some("global.example.com".to_string())
1729 );
1730 }
1731
1732 #[test]
1733 fn test_parse_windows_domain_search() {
1734 let adapters = vec![
1735 WinAdapterDnsInfo {
1736 friendly_name: "Wi-Fi".to_string(),
1737 dns_suffix: "lan.home".to_string(),
1738 is_up: true,
1739 is_loopback: false,
1740 },
1741 WinAdapterDnsInfo {
1742 friendly_name: "vEthernet".to_string(),
1743 dns_suffix: "netbird.cloud".to_string(),
1744 is_up: true,
1745 is_loopback: false,
1746 },
1747 WinAdapterDnsInfo {
1748 friendly_name: "Loopback Pseudo-Interface 1".to_string(),
1749 dns_suffix: "ignore.me".to_string(),
1750 is_up: true,
1751 is_loopback: true,
1752 },
1753 WinAdapterDnsInfo {
1754 friendly_name: "Disconnected".to_string(),
1755 dns_suffix: "offline.local".to_string(),
1756 is_up: false,
1757 is_loopback: false,
1758 },
1759 ];
1760
1761 let result = parse_windows_domain_search(Some("search1.com, search2.com"), &adapters);
1762 assert_eq!(
1763 result,
1764 vec![
1765 "global: search1.com, search2.com",
1766 "Wi-Fi: lan.home",
1767 "vEthernet: netbird.cloud"
1768 ]
1769 );
1770 }
1771
1772 #[test]
1773 #[cfg(target_os = "windows")]
1774 fn test_ip_adapter_addresses_layout() {
1775 use std::mem::{offset_of, size_of};
1776
1777 #[repr(C)]
1778 #[allow(non_snake_case)]
1779 struct IpAdapterAddresses {
1780 Length: u32,
1781 IfIndex: u32,
1782 Next: *mut IpAdapterAddresses,
1783 AdapterName: *const i8,
1784 FirstUnicastAddress: *const std::ffi::c_void,
1785 FirstAnycastAddress: *const std::ffi::c_void,
1786 FirstMulticastAddress: *const std::ffi::c_void,
1787 FirstDnsServerAddress: *const std::ffi::c_void,
1788 DnsSuffix: *const u16,
1789 Description: *const u16,
1790 FriendlyName: *const u16,
1791 PhysicalAddress: [u8; 8],
1792 PhysicalAddressLength: u32,
1793 Flags: u32,
1794 Mtu: u32,
1795 IfType: u32,
1796 OperStatus: u32,
1797 }
1798
1799 if cfg!(target_pointer_width = "64") {
1800 assert_eq!(offset_of!(IpAdapterAddresses, Next), 8);
1801 assert_eq!(offset_of!(IpAdapterAddresses, AdapterName), 16);
1802 assert_eq!(offset_of!(IpAdapterAddresses, DnsSuffix), 56);
1803 assert_eq!(offset_of!(IpAdapterAddresses, FriendlyName), 72);
1804 assert_eq!(offset_of!(IpAdapterAddresses, OperStatus), 104);
1805 assert_eq!(size_of::<IpAdapterAddresses>(), 112);
1806 }
1807 }
1808}