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
114#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct NetworkInterface {
129 pub name: String,
132 pub is_up: bool,
134 pub line: String,
136}
137
138pub fn detect_networks(
139 active_interface: Option<&str>,
140 local_ip: Option<&str>,
141) -> Vec<NetworkInterface> {
142 #[cfg(target_os = "windows")]
148 let excluded = crate::win_iftable::excluded_interface_names();
149
150 Networks::new_with_refreshed_list()
151 .iter()
152 .filter(|(name, _)| {
153 #[cfg(target_os = "windows")]
154 {
155 !excluded.iter().any(|e| e == *name)
156 }
157 #[cfg(not(target_os = "windows"))]
158 {
159 let _ = name;
160 true
161 }
162 })
163 .map(|(name, data)| {
164 let rx = format_bytes(data.total_received());
165 let tx = format_bytes(data.total_transmitted());
166 let is_up = data.operational_state() == sysinfo::InterfaceOperationalState::Up
167 || data.total_received() > 0
168 || data.total_transmitted() > 0;
169 let status = if is_up {
170 "Up".green().to_string()
171 } else {
172 "Down".red().to_string()
173 };
174
175 let mut ipv4_addresses = Vec::new();
176 let mut ipv6_addresses = Vec::new();
177
178 if is_up {
179 for ip_net in data.ip_networks() {
180 let ip = ip_net.addr;
181 let name_lower = name.to_lowercase();
182 let is_loopback_iface =
183 name_lower.starts_with("lo") || name_lower.contains("loopback");
184 if ip.is_loopback() && !is_loopback_iface {
185 continue;
186 }
187 match ip {
188 std::net::IpAddr::V4(v4) => {
189 ipv4_addresses.push(v4.to_string());
190 }
191 std::net::IpAddr::V6(v6) => {
192 if !v6.is_unicast_link_local() {
193 ipv6_addresses.push(v6.to_string());
194 }
195 }
196 }
197 }
198
199 if ipv4_addresses.is_empty() && ipv6_addresses.is_empty() {
201 if let (Some(active), Some(ip)) = (active_interface, local_ip) {
202 if name == active {
203 ipv4_addresses.push(ip.to_string());
204 }
205 }
206 }
207 }
208
209 let ip_str = if !ipv4_addresses.is_empty() || !ipv6_addresses.is_empty() {
210 let mut combined = Vec::new();
211 if !ipv4_addresses.is_empty() {
212 combined.push(ipv4_addresses.join(", "));
213 }
214 if !ipv6_addresses.is_empty() {
215 combined.push(ipv6_addresses.join(", "));
216 }
217 format!(" ({})", combined.join(", "))
218 } else {
219 String::new()
220 };
221
222 NetworkInterface {
223 name: name.to_string(),
224 is_up,
225 line: format!("{}{} [{}] RX: {} TX: {}", name, ip_str, status, rx, tx),
226 }
227 })
228 .collect()
229}
230
231pub fn format_bytes(bytes: u64) -> String {
233 const KB: u64 = 1024;
234 const MB: u64 = KB * 1024;
235 const GB: u64 = MB * 1024;
236
237 if bytes >= GB {
238 format!("{:.1} GB", bytes as f64 / GB as f64)
239 } else if bytes >= MB {
240 format!("{:.1} MB", bytes as f64 / MB as f64)
241 } else if bytes >= KB {
242 format!("{:.1} KB", bytes as f64 / KB as f64)
243 } else {
244 format!("{} B", bytes)
245 }
246}
247
248pub fn lookup_pci_vendor(vendor_id: &str) -> Option<String> {
252 let vendor_id = vendor_id.trim_start_matches("0x").to_lowercase();
253 let paths = ["/usr/share/hwdata/pci.ids", "/usr/share/misc/pci.ids"];
254 for path in &paths {
255 if let Ok(content) = std::fs::read_to_string(path) {
256 for line in content.lines() {
257 if line.starts_with('#') || line.is_empty() {
258 continue;
259 }
260 if !line.starts_with('\t') {
261 let parts: Vec<&str> = line.split_whitespace().collect();
262 if parts.len() >= 2 && parts[0].to_lowercase() == vendor_id {
263 let name = line.strip_prefix(parts[0]).unwrap().trim();
264 return Some(name.to_string());
265 }
266 }
267 }
268 }
269 }
270 None
271}
272
273pub fn detect_wifi() -> Option<String> {
275 #[cfg(target_os = "linux")]
276 {
277 let mut wifi_interface = None;
278 if let Ok(entries) = std::fs::read_dir("/sys/class/net") {
279 for entry in entries.filter_map(|e| e.ok()) {
280 let path = entry.path();
281 if path.join("wireless").exists() || path.join("phy80211").exists() {
282 wifi_interface = Some(entry.file_name().to_string_lossy().to_string());
283 break;
284 }
285 }
286 }
287
288 if let Some(ref iface) = wifi_interface {
289 if let Ok(output) = std::process::Command::new("iw")
290 .args(["dev", iface, "link"])
291 .output()
292 {
293 if let Ok(stdout) = String::from_utf8(output.stdout) {
294 let (ssid, links) = parse_iw_link_output(&stdout);
295 if let Some(s) = ssid {
296 let card_model = get_wifi_card_model(iface);
297 let prefix = if let Some(m) = card_model {
298 format!("{} [{}] - ", m, iface)
299 } else {
300 format!("[{}] - ", iface)
301 };
302
303 if !links.is_empty() {
304 let mut link_strs = Vec::new();
305 for link in links {
306 let freq_str = link.freq.map(|f| {
307 let ghz_mhz = if f >= 1000.0 {
308 format!("{:.1} GHz", f / 1000.0)
309 } else {
310 format!("{} MHz", f)
311 };
312 if let Some(ch) = freq_to_channel(f) {
313 format!("{} ch{}", ghz_mhz, ch)
314 } else {
315 ghz_mhz
316 }
317 });
318
319 let mut rx_tx = Vec::new();
320 if let Some(rx) = link.rx_rate {
321 if rx != "0"
322 && !rx.starts_with("0 ")
323 && rx != "0 Mbps"
324 && rx != "0 MBit/s"
325 {
326 rx_tx.push(format!("↓{}", clean_rate(&rx)));
327 }
328 }
329 if let Some(tx) = link.tx_rate {
330 if tx != "0"
331 && !tx.starts_with("0 ")
332 && tx != "0 Mbps"
333 && tx != "0 MBit/s"
334 {
335 rx_tx.push(format!("↑{}", clean_rate(&tx)));
336 }
337 }
338
339 match (freq_str, rx_tx.is_empty()) {
340 (Some(f), false) => {
341 link_strs.push(format!("{} [{}]", f, rx_tx.join(" ")))
342 }
343 (Some(f), true) => link_strs.push(f),
344 (None, false) => link_strs.push(rx_tx.join(" ")),
345 _ => {}
346 }
347 }
348 if !link_strs.is_empty() {
349 return Some(format!(
350 "{}{}{} ({})",
351 prefix,
352 s,
353 "",
354 link_strs.join(", ")
355 ));
356 } else {
357 return Some(format!("{}{}", prefix, s));
358 }
359 }
360 return Some(format!("{}{}", prefix, s));
361 }
362 }
363 }
364 }
365
366 if let Ok(output) = std::process::Command::new("nmcli")
368 .args([
369 "-t",
370 "-f",
371 "active,ssid,rate",
372 "device",
373 "wifi",
374 "list",
375 "--rescan",
376 "no",
377 ])
378 .output()
379 {
380 if let Ok(stdout) = String::from_utf8(output.stdout) {
381 for line in stdout.lines() {
382 let line = line.trim();
383 if let Some(rest) = line.strip_prefix("yes:") {
384 if let Some(colon_idx) = rest.rfind(':') {
385 let ssid = &rest[..colon_idx];
386 let rate = rest[colon_idx + 1..].trim();
387 if !ssid.is_empty() {
388 if !rate.is_empty()
389 && rate != "0"
390 && !rate.starts_with("0 ")
391 && rate != "0 Mbit/s"
392 && rate != "0 Mbps"
393 {
394 return Some(format!("{} ({})", ssid, clean_rate(rate)));
395 } else {
396 return Some(ssid.to_string());
397 }
398 }
399 } else if !rest.is_empty() {
400 return Some(rest.to_string());
401 }
402 }
403 }
404 }
405 }
406
407 if let Ok(output) = std::process::Command::new("iwgetid").arg("-r").output() {
409 if let Ok(stdout) = String::from_utf8(output.stdout) {
410 let ssid = stdout.trim();
411 if !ssid.is_empty() {
412 return Some(ssid.to_string());
413 }
414 }
415 }
416 None
417 }
418
419 #[cfg(target_os = "macos")]
420 {
421 crate::macos_ffi::get_wifi_info().map(|(ssid, rate)| match rate {
422 Some(r) if r > 0 => format!("{} (↑{} Mbps)", ssid, r),
423 _ => ssid,
424 })
425 }
426
427 #[cfg(target_os = "windows")]
428 {
429 if let Ok(output) = std::process::Command::new("netsh")
430 .args(["wlan", "show", "interfaces"])
431 .output()
432 {
433 if let Ok(stdout) = String::from_utf8(output.stdout) {
434 return parse_netsh_output(&stdout);
435 }
436 }
437 None
438 }
439
440 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
441 {
442 None
443 }
444}
445
446#[cfg(any(target_os = "linux", test))]
447pub fn parse_proc_net_route(content: &str) -> Option<String> {
448 for line in content.lines().skip(1) {
449 let parts: Vec<&str> = line.split_whitespace().collect();
450 if parts.len() >= 8 {
451 let dest = parts[1];
452 let mask = parts[7];
453 if dest == "00000000" && mask == "00000000" {
454 return Some(parts[0].to_string());
455 }
456 }
457 }
458 None
459}
460
461#[allow(
462 clippy::manual_is_multiple_of,
463 clippy::manual_range_contains,
464 dead_code
465)]
466fn freq_to_channel(freq_mhz: f64) -> Option<u32> {
467 let freq = freq_mhz.round() as u32;
468 if freq >= 2412 && freq <= 2472 {
469 Some((freq - 2407) / 5)
470 } else if freq == 2484 {
471 Some(14)
472 } else if freq >= 5160 && freq <= 5885 {
473 if (freq - 5000) % 5 == 0 {
474 Some((freq - 5000) / 5)
475 } else {
476 None
477 }
478 } else if freq >= 5955 && freq <= 7115 {
479 if (freq - 5950) % 5 == 0 {
480 Some((freq - 5950) / 5)
481 } else {
482 None
483 }
484 } else {
485 None
486 }
487}
488
489#[allow(dead_code)]
490fn get_wifi_card_model(iface: &str) -> Option<String> {
491 let vendor = std::fs::read_to_string(format!("/sys/class/net/{}/device/vendor", iface)).ok()?;
492 let device = std::fs::read_to_string(format!("/sys/class/net/{}/device/device", iface)).ok()?;
493 let vendor_clean = vendor.trim().trim_start_matches("0x").to_lowercase();
494 let device_clean = device.trim().trim_start_matches("0x").to_lowercase();
495
496 let vendor_name = lookup_pci_vendor(&vendor_clean);
497 let model_name = crate::gpu::lookup_pci_device(&vendor_clean, &device_clean);
498
499 match (vendor_name, model_name) {
500 (Some(v), Some(m)) => {
501 let v_clean = v.replace(", Inc.", "").replace(" Corporation", "");
502 if m.to_lowercase().contains(&v_clean.to_lowercase())
503 || m.to_lowercase().contains(
504 &v_clean
505 .split_whitespace()
506 .next()
507 .unwrap_or("")
508 .to_lowercase(),
509 )
510 {
511 Some(m)
512 } else {
513 Some(format!("{} {}", v_clean, m))
514 }
515 }
516 (None, Some(m)) => Some(m),
517 _ => None,
518 }
519}
520
521#[allow(dead_code)]
522fn clean_rate(rate: &str) -> String {
523 rate.replace("MBit/s", "Mbps")
524 .replace("GBit/s", "Gbps")
525 .replace("Bit/s", "bps")
526}
527
528#[derive(Debug, Clone)]
529pub struct WifiLink {
530 pub freq: Option<f64>,
531 pub rx_rate: Option<String>,
532 pub tx_rate: Option<String>,
533}
534
535#[allow(dead_code)]
536pub fn parse_iw_link_output(stdout: &str) -> (Option<String>, Vec<WifiLink>) {
537 let mut ssid = None;
538 let mut links = Vec::new();
539 let mut current_link = None;
540
541 for line in stdout.lines() {
542 let trimmed = line.trim();
543 if trimmed.starts_with("Connected to") || trimmed.starts_with("link") {
544 if let Some(link) = current_link.take() {
545 links.push(link);
546 }
547 current_link = Some(WifiLink {
548 freq: None,
549 rx_rate: None,
550 tx_rate: None,
551 });
552 } else if trimmed.starts_with("SSID:") {
553 ssid = Some(trimmed.strip_prefix("SSID:").unwrap().trim().to_string());
554 } else if trimmed.starts_with("freq:") {
555 if let Some(ref mut link) = current_link {
556 let freq_str = trimmed.strip_prefix("freq:").unwrap().trim();
557 link.freq = freq_str.parse::<f64>().ok();
558 }
559 } else if trimmed.starts_with("rx bitrate:") {
560 if let Some(ref mut link) = current_link {
561 let rx_str = trimmed.strip_prefix("rx bitrate:").unwrap().trim();
562 let rate = rx_str
563 .split_whitespace()
564 .take(2)
565 .collect::<Vec<&str>>()
566 .join(" ");
567 link.rx_rate = Some(rate);
568 }
569 } else if trimmed.starts_with("tx bitrate:") {
570 if let Some(ref mut link) = current_link {
571 let tx_str = trimmed.strip_prefix("tx bitrate:").unwrap().trim();
572 let rate = tx_str
573 .split_whitespace()
574 .take(2)
575 .collect::<Vec<&str>>()
576 .join(" ");
577 link.tx_rate = Some(rate);
578 }
579 }
580 }
581 if let Some(link) = current_link {
582 links.push(link);
583 }
584 (ssid, links)
585}
586
587#[allow(dead_code)]
588pub fn parse_netsh_output(stdout: &str) -> Option<String> {
589 let mut ssid = None;
590 let mut rx = None;
591 let mut tx = None;
592 let mut band = None;
593 for line in stdout.lines() {
594 let trimmed = line.trim();
595 if trimmed.starts_with("SSID") {
596 if let Some(idx) = trimmed.find(':') {
597 let val = trimmed[idx + 1..].trim().to_string();
598 if !val.is_empty() {
599 ssid = Some(val);
600 }
601 }
602 } else if trimmed.starts_with("Receive rate (Mbps)") {
603 if let Some(idx) = trimmed.find(':') {
604 let val = trimmed[idx + 1..].trim().to_string();
605 if !val.is_empty() {
606 rx = Some(val);
607 }
608 }
609 } else if trimmed.starts_with("Transmit rate (Mbps)") {
610 if let Some(idx) = trimmed.find(':') {
611 let val = trimmed[idx + 1..].trim().to_string();
612 if !val.is_empty() {
613 tx = Some(val);
614 }
615 }
616 } else if trimmed.starts_with("Band") {
617 if let Some(idx) = trimmed.find(':') {
618 let val = trimmed[idx + 1..].trim().to_string();
619 if !val.is_empty() {
620 band = Some(val);
621 }
622 }
623 }
624 }
625 if let Some(s) = ssid {
626 let mut rate_strs = Vec::new();
627 if let Some(rx_val) = rx {
628 if rx_val != "0" {
629 rate_strs.push(format!("↓{} Mbps", rx_val));
630 }
631 }
632 if let Some(tx_val) = tx {
633 if tx_val != "0" {
634 rate_strs.push(format!("↑{} Mbps", tx_val));
635 }
636 }
637 let info = match (band, rate_strs.is_empty()) {
638 (Some(b), false) => format!("{} [{}]", b, rate_strs.join(" ")),
639 (Some(b), true) => b,
640 (None, false) => rate_strs.join(" "),
641 _ => String::new(),
642 };
643 if !info.is_empty() {
644 Some(format!("{} ({})", s, info))
645 } else {
646 Some(s)
647 }
648 } else {
649 None
650 }
651}
652
653pub fn detect_dns() -> Vec<String> {
668 #[cfg(any(target_os = "linux", target_os = "macos"))]
669 {
670 if let Ok(content) = std::fs::read_to_string("/etc/resolv.conf") {
671 return parse_resolv_conf(&content);
672 }
673 }
674 #[cfg(target_os = "windows")]
675 {
676 windows_dns_servers()
677 }
678 #[cfg(not(target_os = "windows"))]
679 Vec::new()
680}
681
682#[cfg(any(target_os = "linux", target_os = "macos", test))]
683pub fn parse_resolv_conf(content: &str) -> Vec<String> {
684 content
685 .lines()
686 .filter_map(|line| {
687 let line = line.trim();
688 if line.starts_with('#') || line.starts_with(';') {
689 return None;
690 }
691 let mut parts = line.split_whitespace();
692 if parts.next()? == "nameserver" {
693 parts.next().map(|s| s.to_string())
694 } else {
695 None
696 }
697 })
698 .collect()
699}
700
701pub fn detect_domain() -> Option<String> {
714 #[cfg(target_os = "linux")]
715 {
716 if let (Some(iface), Some(status)) = (default_route_interface(), resolvectl_status()) {
720 if let DefaultRouteDomain::Managed(domain) =
721 resolve_default_route_domain(&parse_resolvectl_domains(status), &iface)
722 {
723 return domain;
724 }
725 }
726 read_resolv_conf_domain()
727 }
728 #[cfg(target_os = "macos")]
729 {
730 read_resolv_conf_domain()
731 }
732 #[cfg(target_os = "windows")]
733 {
734 detect_domain_windows()
735 }
736 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
737 {
738 None
739 }
740}
741
742#[cfg(any(target_os = "linux", target_os = "macos"))]
744fn read_resolv_conf_domain() -> Option<String> {
745 std::fs::read_to_string("/etc/resolv.conf")
746 .ok()
747 .and_then(|content| parse_domain_from_resolv_conf(&content))
748}
749
750#[cfg(target_os = "linux")]
757fn default_route_interface() -> Option<String> {
758 std::fs::read_to_string("/proc/net/route")
759 .ok()
760 .and_then(|content| parse_proc_net_route(&content))
761}
762
763#[cfg(target_os = "linux")]
765static RESOLVECTL_STATUS: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
766
767#[cfg(target_os = "linux")]
773fn resolvectl_status() -> Option<&'static str> {
774 RESOLVECTL_STATUS
775 .get_or_init(|| {
776 let output = std::process::Command::new("resolvectl")
777 .args(["status", "--no-pager"])
778 .output()
779 .ok()?;
780 if !output.status.success() {
781 return None;
782 }
783 Some(String::from_utf8_lossy(&output.stdout).into_owned())
784 })
785 .as_deref()
786}
787
788#[cfg(any(target_os = "windows", test))]
790#[derive(Debug, Clone, PartialEq, Eq)]
791struct WinAdapterDnsInfo {
792 friendly_name: String,
793 dns_suffix: String,
794 is_up: bool,
795 is_loopback: bool,
796 dns_servers: Vec<std::net::IpAddr>,
798}
799
800#[cfg(any(target_os = "windows", test))]
802const AF_INET: u16 = 2;
803#[cfg(any(target_os = "windows", test))]
805const AF_INET6: u16 = 23;
806
807#[cfg(any(target_os = "windows", test))]
818fn parse_sockaddr(bytes: &[u8]) -> Option<std::net::IpAddr> {
819 let family = u16::from_ne_bytes([*bytes.first()?, *bytes.get(1)?]);
820 match family {
821 AF_INET => {
822 let octets: [u8; 4] = bytes.get(4..8)?.try_into().ok()?;
823 Some(std::net::IpAddr::V4(octets.into()))
824 }
825 AF_INET6 => {
826 let octets: [u8; 16] = bytes.get(8..24)?.try_into().ok()?;
827 Some(std::net::IpAddr::V6(octets.into()))
828 }
829 _ => None,
830 }
831}
832
833#[cfg(target_os = "windows")]
846fn windows_dns_servers() -> Vec<String> {
847 let mut servers: Vec<String> = get_windows_adapters_dns_info()
848 .into_iter()
849 .flat_map(|adapter| adapter.dns_servers)
850 .filter(|ip| ip.is_ipv4())
851 .map(|ip| ip.to_string())
852 .collect();
853 servers.sort();
854 servers.dedup();
855 servers
856}
857
858#[cfg(any(target_os = "windows", test))]
864fn resolve_windows_default_domain(
865 active_iface: Option<&str>,
866 adapters: &[WinAdapterDnsInfo],
867 global_domain: Option<&str>,
868) -> Option<String> {
869 if let Some(iface) = active_iface {
870 if let Some(adapter) = adapters
871 .iter()
872 .find(|a| a.friendly_name.eq_ignore_ascii_case(iface))
873 {
874 if let Some(suffix) = clean_domain(&adapter.dns_suffix) {
875 return Some(suffix);
876 }
877 }
878 }
879
880 if let Some(global) = global_domain.and_then(clean_domain) {
881 return Some(global);
882 }
883
884 None
885}
886
887#[cfg(any(target_os = "windows", test))]
889fn parse_windows_domain_search(
890 global_search_list: Option<&str>,
891 adapters: &[WinAdapterDnsInfo],
892) -> Vec<String> {
893 let mut results = Vec::new();
894
895 if let Some(raw) = global_search_list {
896 let global_domains: Vec<String> = raw
897 .split(&[',', ' '][..])
898 .filter_map(clean_domain)
899 .collect();
900 if !global_domains.is_empty() {
901 results.extend(format_global_search_domains(&global_domains));
902 }
903 }
904
905 for adapter in adapters {
906 if adapter.is_up && !adapter.is_loopback {
907 if let Some(suffix) = clean_domain(&adapter.dns_suffix) {
908 results.push(format!("{}: {}", adapter.friendly_name, suffix));
909 }
910 }
911 }
912
913 results
914}
915
916#[cfg(target_os = "windows")]
918fn detect_domain_windows() -> Option<String> {
919 let (_, active_iface) = detect_active_interface_and_local_ip();
920 let adapters = get_windows_adapters_dns_info();
921 let global_domain = crate::win_reg::get_reg_string(
922 crate::win_reg::HKEY_LOCAL_MACHINE,
923 "SYSTEM\\CurrentControlSet\\Services\\Tcpip\\Parameters",
924 "Domain",
925 );
926 resolve_windows_default_domain(active_iface.as_deref(), &adapters, global_domain.as_deref())
927}
928
929#[cfg(target_os = "windows")]
931fn get_windows_adapters_dns_info() -> Vec<WinAdapterDnsInfo> {
932 use std::ffi::OsString;
933 use std::os::windows::ffi::OsStringExt;
934 use std::ptr;
935
936 #[repr(C)]
937 #[allow(non_snake_case)]
938 struct IpAdapterAddresses {
939 Length: u32,
940 IfIndex: u32,
941 Next: *mut IpAdapterAddresses,
942 AdapterName: *const i8,
943 FirstUnicastAddress: *const std::ffi::c_void,
944 FirstAnycastAddress: *const std::ffi::c_void,
945 FirstMulticastAddress: *const std::ffi::c_void,
946 FirstDnsServerAddress: *const IpAdapterDnsServerAddress,
947 DnsSuffix: *const u16,
948 Description: *const u16,
949 FriendlyName: *const u16,
950 PhysicalAddress: [u8; 8],
951 PhysicalAddressLength: u32,
952 Flags: u32,
953 Mtu: u32,
954 IfType: u32,
955 OperStatus: u32,
956 }
957
958 #[repr(C)]
960 #[allow(non_snake_case)]
961 struct SocketAddress {
962 lpSockaddr: *const u8,
963 iSockaddrLength: i32,
964 }
965
966 #[repr(C)]
971 #[allow(non_snake_case)]
972 struct IpAdapterDnsServerAddress {
973 Length: u32,
974 Reserved: u32,
975 Next: *const IpAdapterDnsServerAddress,
976 Address: SocketAddress,
977 }
978
979 const AF_UNSPEC: u32 = 0;
980 const GAA_FLAGS: u32 = 0x06;
988 const IF_TYPE_SOFTWARE_LOOPBACK: u32 = 24;
989 const IF_OPER_STATUS_UP: u32 = 1;
990
991 #[link(name = "iphlpapi")]
992 extern "system" {
993 fn GetAdaptersAddresses(
994 family: u32,
995 flags: u32,
996 reserved: *mut std::ffi::c_void,
997 adapter_addresses: *mut IpAdapterAddresses,
998 size_pointer: *mut u32,
999 ) -> u32;
1000 }
1001
1002 let mut size: u32 = 0;
1003 unsafe {
1005 GetAdaptersAddresses(
1006 AF_UNSPEC,
1007 GAA_FLAGS,
1008 ptr::null_mut(),
1009 ptr::null_mut(),
1010 &mut size,
1011 );
1012 }
1013 if size == 0 {
1014 return Vec::new();
1015 }
1016
1017 let mut buf = vec![0u8; size as usize];
1018 let ret = unsafe {
1020 GetAdaptersAddresses(
1021 AF_UNSPEC,
1022 GAA_FLAGS,
1023 ptr::null_mut(),
1024 buf.as_mut_ptr() as *mut IpAdapterAddresses,
1025 &mut size,
1026 )
1027 };
1028 if ret != 0 {
1029 return Vec::new();
1030 }
1031
1032 let mut result = Vec::new();
1033 let mut curr = buf.as_ptr() as *const IpAdapterAddresses;
1034
1035 unsafe {
1036 while !curr.is_null() {
1037 let adapter = &*curr;
1038
1039 let friendly_name = if !adapter.FriendlyName.is_null() {
1040 let mut len = 0;
1041 while *adapter.FriendlyName.add(len) != 0 {
1042 len += 1;
1043 }
1044 let slice = std::slice::from_raw_parts(adapter.FriendlyName, len);
1045 OsString::from_wide(slice).to_string_lossy().into_owned()
1046 } else {
1047 String::new()
1048 };
1049
1050 let adapter_name = if !adapter.AdapterName.is_null() {
1051 std::ffi::CStr::from_ptr(adapter.AdapterName)
1052 .to_string_lossy()
1053 .into_owned()
1054 } else {
1055 String::new()
1056 };
1057
1058 let mut dns_suffix = if !adapter.DnsSuffix.is_null() {
1059 let mut len = 0;
1060 while *adapter.DnsSuffix.add(len) != 0 {
1061 len += 1;
1062 }
1063 let slice = std::slice::from_raw_parts(adapter.DnsSuffix, len);
1064 OsString::from_wide(slice).to_string_lossy().into_owned()
1065 } else {
1066 String::new()
1067 };
1068
1069 if dns_suffix.trim().is_empty() && !adapter_name.is_empty() {
1070 let subkey = format!(
1071 "SYSTEM\\CurrentControlSet\\Services\\Tcpip\\Parameters\\Interfaces\\{}",
1072 adapter_name
1073 );
1074 if let Some(s) = crate::win_reg::get_reg_string(
1075 crate::win_reg::HKEY_LOCAL_MACHINE,
1076 &subkey,
1077 "SearchList",
1078 ) {
1079 dns_suffix = s;
1080 } else if let Some(s) = crate::win_reg::get_reg_string(
1081 crate::win_reg::HKEY_LOCAL_MACHINE,
1082 &subkey,
1083 "DhcpSearchList",
1084 ) {
1085 dns_suffix = s;
1086 } else if let Some(s) = crate::win_reg::get_reg_string(
1087 crate::win_reg::HKEY_LOCAL_MACHINE,
1088 &subkey,
1089 "Domain",
1090 ) {
1091 dns_suffix = s;
1092 } else if let Some(s) = crate::win_reg::get_reg_string(
1093 crate::win_reg::HKEY_LOCAL_MACHINE,
1094 &subkey,
1095 "DhcpDomain",
1096 ) {
1097 dns_suffix = s;
1098 }
1099 }
1100
1101 let mut dns_servers = Vec::new();
1106 let mut dns_entry = adapter.FirstDnsServerAddress;
1107 while !dns_entry.is_null() {
1108 let entry = &*dns_entry;
1109 if !entry.Address.lpSockaddr.is_null() && entry.Address.iSockaddrLength > 0 {
1110 let len = entry.Address.iSockaddrLength as usize;
1111 let bytes = std::slice::from_raw_parts(entry.Address.lpSockaddr, len);
1112 if let Some(ip) = parse_sockaddr(bytes) {
1113 dns_servers.push(ip);
1114 }
1115 }
1116 dns_entry = entry.Next;
1117 }
1118
1119 result.push(WinAdapterDnsInfo {
1120 friendly_name,
1121 dns_suffix,
1122 is_up: adapter.OperStatus == IF_OPER_STATUS_UP,
1123 is_loopback: adapter.IfType == IF_TYPE_SOFTWARE_LOOPBACK,
1124 dns_servers,
1125 });
1126
1127 curr = adapter.Next;
1128 }
1129 }
1130
1131 result
1132}
1133
1134#[cfg(any(target_os = "windows", test))]
1139fn clean_domain(raw: &str) -> Option<String> {
1140 let trimmed = raw.trim();
1141 if trimmed.is_empty() {
1142 None
1143 } else {
1144 Some(trimmed.to_string())
1145 }
1146}
1147
1148pub fn detect_domain_search() -> Vec<String> {
1156 #[cfg(target_os = "linux")]
1157 {
1158 if let Some(status) = resolvectl_status() {
1160 let result = parse_resolvectl_search(status);
1161 if !result.is_empty() {
1162 return result;
1163 }
1164 }
1165 if let Ok(content) = std::fs::read_to_string("/etc/resolv.conf") {
1166 return format_global_search_domains(&parse_search_from_resolv_conf(&content));
1167 }
1168 }
1169 #[cfg(target_os = "macos")]
1170 {
1171 if let Ok(content) = std::fs::read_to_string("/etc/resolv.conf") {
1172 return format_global_search_domains(&parse_search_from_resolv_conf(&content));
1173 }
1174 }
1175 #[cfg(target_os = "windows")]
1176 {
1177 let adapters = get_windows_adapters_dns_info();
1178 let global_search_list = crate::win_reg::get_reg_string(
1179 crate::win_reg::HKEY_LOCAL_MACHINE,
1180 "SYSTEM\\CurrentControlSet\\Services\\Tcpip\\Parameters",
1181 "SearchList",
1182 );
1183 let results = parse_windows_domain_search(global_search_list.as_deref(), &adapters);
1184 if !results.is_empty() {
1185 return results;
1186 }
1187 }
1188 Vec::new()
1189}
1190
1191#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))]
1197const GLOBAL_SEARCH_SCOPE: &str = "global";
1198
1199#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))]
1202pub fn format_global_search_domains(domains: &[String]) -> Vec<String> {
1203 if domains.is_empty() {
1204 Vec::new()
1205 } else {
1206 vec![format!("{}: {}", GLOBAL_SEARCH_SCOPE, domains.join(", "))]
1207 }
1208}
1209
1210#[cfg(any(target_os = "linux", target_os = "macos", test))]
1213pub fn parse_domain_from_resolv_conf(content: &str) -> Option<String> {
1214 let mut first_search: Option<String> = None;
1215 for line in content.lines() {
1216 let line = line.trim();
1217 if line.starts_with('#') || line.starts_with(';') {
1218 continue;
1219 }
1220 let mut parts = line.split_whitespace();
1221 match parts.next() {
1222 Some("domain") => {
1223 if let Some(d) = parts.next() {
1224 return Some(d.to_string());
1225 }
1226 }
1227 Some("search") if first_search.is_none() => {
1228 if let Some(d) = parts.next() {
1229 first_search = Some(d.to_string());
1230 }
1231 }
1232 _ => {}
1233 }
1234 }
1235 first_search
1236}
1237
1238#[cfg(any(target_os = "linux", target_os = "macos", test))]
1240pub fn parse_search_from_resolv_conf(content: &str) -> Vec<String> {
1241 for line in content.lines() {
1242 let line = line.trim();
1243 if line.starts_with('#') || line.starts_with(';') {
1244 continue;
1245 }
1246 let mut parts = line.split_whitespace();
1247 if parts.next() == Some("search") {
1248 let domains: Vec<String> = parts.map(|s| s.to_string()).collect();
1249 if !domains.is_empty() {
1250 return domains;
1251 }
1252 }
1253 }
1254 Vec::new()
1255}
1256
1257#[derive(Debug, Clone, Default, PartialEq, Eq)]
1259#[cfg(any(target_os = "linux", test))]
1260pub struct LinkDomains {
1261 pub interface: String,
1263 pub search: Vec<String>,
1266}
1267
1268#[derive(Debug, Clone, Default, PartialEq, Eq)]
1270#[cfg(any(target_os = "linux", test))]
1271pub struct ResolvectlDomains {
1272 pub global: Vec<String>,
1275 pub links: Vec<LinkDomains>,
1277}
1278
1279#[derive(Debug, Clone, PartialEq, Eq)]
1281#[cfg(any(target_os = "linux", test))]
1282pub enum DefaultRouteDomain {
1283 Managed(Option<String>),
1288 Unmanaged,
1291}
1292
1293#[cfg(any(target_os = "linux", test))]
1299pub fn resolve_default_route_domain(
1300 domains: &ResolvectlDomains,
1301 interface: &str,
1302) -> DefaultRouteDomain {
1303 match domains.links.iter().find(|l| l.interface == interface) {
1304 Some(link) => DefaultRouteDomain::Managed(
1305 link.search
1306 .first()
1307 .or_else(|| domains.global.first())
1308 .cloned(),
1309 ),
1310 None => DefaultRouteDomain::Unmanaged,
1311 }
1312}
1313
1314#[cfg(any(target_os = "linux", test))]
1328pub fn parse_resolvectl_domains(content: &str) -> ResolvectlDomains {
1329 let mut out = ResolvectlDomains::default();
1330 let mut section: Option<String> = None;
1332 let mut in_domain_value = false;
1334
1335 for line in content.lines() {
1336 let trimmed = line.trim();
1337 if trimmed.is_empty() {
1338 in_domain_value = false;
1339 continue;
1340 }
1341
1342 if let Some(iface) = trimmed
1345 .strip_prefix("Link ")
1346 .and_then(|rest| rest.split_once('('))
1347 .and_then(|(_, rest)| rest.split_once(')'))
1348 .map(|(iface, _)| iface)
1349 {
1350 in_domain_value = false;
1351 section = Some(iface.to_string());
1352 if !out.links.iter().any(|l| l.interface == iface) {
1354 out.links.push(LinkDomains {
1355 interface: iface.to_string(),
1356 search: Vec::new(),
1357 });
1358 }
1359 continue;
1360 }
1361
1362 if trimmed == "Global" {
1363 in_domain_value = false;
1364 section = None;
1365 continue;
1366 }
1367
1368 if let Some(value) = trimmed
1369 .strip_prefix("DNS Domain:")
1370 .or_else(|| trimmed.strip_prefix("DNS Search Domains:"))
1371 {
1372 in_domain_value = true;
1373 push_resolvectl_domains(&mut out, section.as_deref(), value);
1374 continue;
1375 }
1376
1377 if in_domain_value && !trimmed.contains(':') {
1380 push_resolvectl_domains(&mut out, section.as_deref(), trimmed);
1381 continue;
1382 }
1383 in_domain_value = false;
1384 }
1385 out
1386}
1387
1388#[cfg(any(target_os = "linux", test))]
1391fn push_resolvectl_domains(out: &mut ResolvectlDomains, section: Option<&str>, value: &str) {
1392 let domains = value
1393 .split_whitespace()
1394 .filter(|d| !d.starts_with('~'))
1395 .map(|d| d.to_string());
1396 match section {
1397 Some(iface) => match out.links.iter_mut().find(|l| l.interface == iface) {
1398 Some(link) => link.search.extend(domains),
1399 None => out.links.push(LinkDomains {
1400 interface: iface.to_string(),
1401 search: domains.collect(),
1402 }),
1403 },
1404 None => out.global.extend(domains),
1405 }
1406}
1407
1408#[cfg(any(target_os = "linux", test))]
1413pub fn parse_resolvectl_search(content: &str) -> Vec<String> {
1414 parse_resolvectl_domains(content)
1415 .links
1416 .into_iter()
1417 .filter(|link| !link.search.is_empty())
1418 .map(|link| format!("{}: {}", link.interface, link.search.join(", ")))
1419 .collect()
1420}
1421
1422#[cfg(test)]
1423mod tests {
1424 use super::*;
1425
1426 fn sockaddr_in(octets: [u8; 4]) -> Vec<u8> {
1434 let mut v = Vec::new();
1435 v.extend_from_slice(&AF_INET.to_ne_bytes());
1436 v.extend_from_slice(&53u16.to_be_bytes()); v.extend_from_slice(&octets); v.extend_from_slice(&[0u8; 8]); v
1440 }
1441
1442 fn sockaddr_in6(octets: [u8; 16]) -> Vec<u8> {
1444 let mut v = Vec::new();
1445 v.extend_from_slice(&AF_INET6.to_ne_bytes());
1446 v.extend_from_slice(&53u16.to_be_bytes());
1447 v.extend_from_slice(&0u32.to_ne_bytes()); v.extend_from_slice(&octets); v.extend_from_slice(&0u32.to_ne_bytes()); v
1451 }
1452
1453 #[test]
1454 fn test_parse_sockaddr_reads_ipv4_at_offset_four() {
1455 assert_eq!(
1457 parse_sockaddr(&sockaddr_in([10, 10, 1, 1])),
1458 Some("10.10.1.1".parse().unwrap())
1459 );
1460 assert_eq!(
1461 parse_sockaddr(&sockaddr_in([100, 101, 255, 254])),
1462 Some("100.101.255.254".parse().unwrap())
1463 );
1464 }
1465
1466 #[test]
1467 fn test_parse_sockaddr_reads_ipv6_at_offset_eight() {
1468 let mut o = [0u8; 16];
1471 o[0] = 0xfe;
1472 o[1] = 0xc0;
1473 o[6] = 0xff;
1474 o[7] = 0xff;
1475 o[15] = 1;
1476 assert_eq!(
1477 parse_sockaddr(&sockaddr_in6(o)),
1478 Some("fec0:0:0:ffff::1".parse().unwrap())
1479 );
1480 }
1481
1482 #[test]
1483 fn test_parse_sockaddr_rejects_short_and_unknown_buffers() {
1484 assert_eq!(parse_sockaddr(&[]), None);
1487 assert_eq!(parse_sockaddr(&AF_INET.to_ne_bytes()), None);
1488 assert_eq!(parse_sockaddr(&sockaddr_in([1, 2, 3, 4])[..7]), None);
1489 assert_eq!(parse_sockaddr(&sockaddr_in6([0; 16])[..20]), None);
1490 let mut wrong_family = sockaddr_in6([0; 16]);
1492 wrong_family[0] = 10;
1493 wrong_family[1] = 0;
1494 assert_eq!(parse_sockaddr(&wrong_family), None);
1495 }
1496
1497 #[test]
1498 fn test_clean_domain() {
1499 assert_eq!(
1501 clean_domain("corp.example.com"),
1502 Some("corp.example.com".to_string())
1503 );
1504 assert_eq!(
1506 clean_domain(" example.org \n"),
1507 Some("example.org".to_string())
1508 );
1509 assert_eq!(clean_domain(""), None);
1511 assert_eq!(clean_domain(" "), None);
1512 }
1513
1514 #[test]
1515 fn test_match_active_interface() {
1516 use std::net::IpAddr;
1517 let target: IpAddr = "192.168.1.50".parse().unwrap();
1518 let ifaces = vec![
1519 ("lo".to_string(), vec!["127.0.0.1".parse().unwrap()]),
1520 (
1521 "Ethernet".to_string(),
1522 vec!["192.168.1.50".parse().unwrap(), "fe80::1".parse().unwrap()],
1523 ),
1524 ("Wi-Fi".to_string(), vec!["10.0.0.2".parse().unwrap()]),
1525 ];
1526 assert_eq!(
1528 match_active_interface(ifaces.into_iter(), target),
1529 Some("Ethernet".to_string())
1530 );
1531
1532 let orphan: IpAddr = "8.8.8.8".parse().unwrap();
1534 let ifaces2 = vec![(
1535 "lo".to_string(),
1536 vec!["127.0.0.1".parse::<IpAddr>().unwrap()],
1537 )];
1538 assert_eq!(match_active_interface(ifaces2.into_iter(), orphan), None);
1539 }
1540
1541 #[test]
1542 fn test_format_bytes() {
1543 assert_eq!(format_bytes(500), "500 B");
1544 assert_eq!(format_bytes(1024), "1.0 KB");
1545 assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
1546 assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
1547 assert_eq!(format_bytes(1536), "1.5 KB");
1548 }
1549
1550 #[test]
1551 fn test_parse_proc_net_route() {
1552 let sample =
1553 "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n\
1554 wlan0\t0000A8C0\t00000000\t0001\t0\t0\t600\t0000FFFF\t0\t0\t0\n\
1555 wlan0\t00000000\t0100A8C0\t0003\t0\t0\t600\t00000000\t0\t0\t0\n";
1556 assert_eq!(parse_proc_net_route(sample), Some("wlan0".to_string()));
1557
1558 let sample_no_default =
1559 "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n\
1560 wlan0\t0000A8C0\t00000000\t0001\t0\t0\t600\t0000FFFF\t0\t0\t0\n";
1561 assert_eq!(parse_proc_net_route(sample_no_default), None);
1562 }
1563
1564 #[test]
1565 fn test_parse_netsh_output() {
1566 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";
1567 assert_eq!(
1568 parse_netsh_output(sample),
1569 Some("Office_Wi-Fi (5 GHz [↓433 Mbps ↑866 Mbps])".to_string())
1570 );
1571 }
1572
1573 #[test]
1574 fn test_parse_iw_link_output() {
1575 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";
1576 let (ssid, links) = parse_iw_link_output(sample);
1577 assert_eq!(ssid, Some("OfficeNet".to_string()));
1578 assert_eq!(links.len(), 1);
1579 assert_eq!(links[0].freq, Some(6135.0));
1580 assert_eq!(links[0].rx_rate, Some("6.0 MBit/s".to_string()));
1581 assert_eq!(links[0].tx_rate, Some("864.6 MBit/s".to_string()));
1582
1583 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";
1585 let (ssid_mlo, links_mlo) = parse_iw_link_output(sample_mlo);
1586 assert_eq!(ssid_mlo, Some("HomeWiFi".to_string()));
1587 assert_eq!(links_mlo.len(), 2);
1588 assert_eq!(links_mlo[0].freq, Some(5180.0));
1589 assert_eq!(links_mlo[1].freq, Some(6135.0));
1590 }
1591
1592 #[test]
1593 fn test_parse_resolv_conf() {
1594 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";
1595 assert_eq!(
1596 parse_resolv_conf(sample),
1597 vec!["192.168.1.1", "8.8.8.8", "2001:db8::1"]
1598 );
1599
1600 let empty = "# no nameservers\nsearch local\n";
1601 assert_eq!(parse_resolv_conf(empty), Vec::<String>::new());
1602 }
1603
1604 #[test]
1605 fn test_parse_domain_from_resolv_conf_domain_directive() {
1606 let s = "# test\ndomain example.com\nsearch fallback.com\nnameserver 1.1.1.1\n";
1607 assert_eq!(
1608 parse_domain_from_resolv_conf(s),
1609 Some("example.com".to_string())
1610 );
1611 }
1612
1613 #[test]
1614 fn test_parse_domain_from_resolv_conf_search_fallback() {
1615 let s = "# no domain directive\nsearch local.lan other.lan\nnameserver 1.1.1.1\n";
1616 assert_eq!(
1617 parse_domain_from_resolv_conf(s),
1618 Some("local.lan".to_string())
1619 );
1620 }
1621
1622 #[test]
1623 fn test_parse_domain_from_resolv_conf_none() {
1624 let s = "# no domain or search\nnameserver 1.1.1.1\n";
1625 assert_eq!(parse_domain_from_resolv_conf(s), None);
1626 }
1627
1628 #[test]
1629 fn test_parse_search_from_resolv_conf() {
1630 let s = "search home.local corp.example.com\nnameserver 1.1.1.1\n";
1631 assert_eq!(
1632 parse_search_from_resolv_conf(s),
1633 vec!["home.local", "corp.example.com"]
1634 );
1635 }
1636
1637 #[test]
1638 fn test_parse_resolvectl_search_basic() {
1639 let sample = "Global\n\
1640 Link 2 (lo)\n\
1641 Current Scopes: none\n\
1642 Link 3 (wlan0)\n\
1643 Current Scopes: DNS\n\
1644 DNS Domain: home.local\n\
1645 Link 4 (eth0)\n\
1646 Current Scopes: DNS\n\
1647 DNS Search Domains: corp.example.com internal.net\n";
1648 let result = parse_resolvectl_search(sample);
1649 assert_eq!(
1650 result,
1651 vec!["wlan0: home.local", "eth0: corp.example.com, internal.net"]
1652 );
1653 }
1654
1655 #[test]
1656 fn test_parse_resolvectl_search_skips_routing_domain() {
1657 let sample = "Link 2 (wlan0)\n DNS Domain: ~.\nLink 3 (eth0)\n DNS Domain: corp.net\n";
1658 let result = parse_resolvectl_search(sample);
1659 assert_eq!(result, vec!["eth0: corp.net"]);
1660 }
1661
1662 #[test]
1663 fn test_parse_resolvectl_search_empty() {
1664 let sample = "Global\n DNS Servers: 1.1.1.1\n";
1665 assert!(parse_resolvectl_search(sample).is_empty());
1666 }
1667
1668 const RESOLVECTL_VPN_SAMPLE: &str = concat!(
1676 "Global\n",
1677 " Protocols: LLMNR=resolve -mDNS -DNSOverTLS DNSSEC=no/unsupported\n",
1678 " resolv.conf mode: stub\n",
1679 "\n",
1680 "Link 2 (wlp194s0)\n",
1681 " Current Scopes: DNS LLMNR/IPv4 LLMNR/IPv6\n",
1682 " Protocols: +DefaultRoute LLMNR=resolve -mDNS -DNSOverTLS\n",
1683 " DNSSEC=no/unsupported\n",
1684 "Current DNS Server: 192.168.86.1\n",
1685 " DNS Servers: 192.168.86.1\n",
1686 " DNS Domain: lan\n",
1687 " Default Route: yes\n",
1688 "\n",
1689 "Link 3 (wt0)\n",
1690 " Current Scopes: DNS\n",
1691 " Protocols: +DefaultRoute LLMNR=resolve -mDNS -DNSOverTLS\n",
1692 " DNSSEC=no/unsupported\n",
1693 "Current DNS Server: 100.101.32.155\n",
1694 " DNS Servers: 100.101.32.155\n",
1695 " DNS Domain: netbird.cloud ~gammatile.com ~101.100.in-addr.arpa\n",
1696 " ~f.f.0.0.3.2.b.5.b.c.8.5.7.f.d.f.ip6.arpa ~.\n",
1697 " Default Route: yes\n",
1698 );
1699
1700 #[test]
1701 fn test_domain_prefers_default_route_over_vpn() {
1702 let parsed = parse_resolvectl_domains(RESOLVECTL_VPN_SAMPLE);
1706 assert_eq!(
1707 resolve_default_route_domain(&parsed, "wlp194s0"),
1708 DefaultRouteDomain::Managed(Some("lan".to_string()))
1709 );
1710 }
1711
1712 #[test]
1713 fn test_domain_reports_vpn_when_vpn_is_the_default_route() {
1714 let parsed = parse_resolvectl_domains(RESOLVECTL_VPN_SAMPLE);
1716 assert_eq!(
1717 resolve_default_route_domain(&parsed, "wt0"),
1718 DefaultRouteDomain::Managed(Some("netbird.cloud".to_string()))
1719 );
1720 }
1721
1722 #[test]
1723 fn test_domain_routing_only_entries_are_not_domains() {
1724 let parsed = parse_resolvectl_domains(RESOLVECTL_VPN_SAMPLE);
1727 let wt0 = parsed.links.iter().find(|l| l.interface == "wt0").unwrap();
1728 assert_eq!(wt0.search, vec!["netbird.cloud"]);
1729 assert!(wt0.search.iter().all(|d| !d.starts_with('~')));
1730
1731 let routing_only = "Link 5 (tun0)\n DNS Domain: ~corp.example.com ~.\n";
1732 let parsed = parse_resolvectl_domains(routing_only);
1733 assert_eq!(
1734 resolve_default_route_domain(&parsed, "tun0"),
1735 DefaultRouteDomain::Managed(None)
1736 );
1737 }
1738
1739 #[test]
1740 fn test_parse_resolvectl_domains_reads_wrapped_continuation_lines() {
1741 let sample = concat!(
1744 "Link 2 (eth0)\n",
1745 " DNS Domain: one.example.com two.example.com\n",
1746 " three.example.com ~routing.example.com\n",
1747 " Default Route: yes\n",
1748 );
1749 let parsed = parse_resolvectl_domains(sample);
1750 assert_eq!(
1751 parsed.links[0].search,
1752 vec!["one.example.com", "two.example.com", "three.example.com"]
1753 );
1754 assert!(!parsed.links[0].search.iter().any(|d| d.contains("yes")));
1756 }
1757
1758 #[test]
1759 fn test_parse_resolvectl_domains_ignores_other_wrapped_fields() {
1760 let sample = concat!(
1762 "Link 2 (eth0)\n",
1763 " Protocols: +DefaultRoute LLMNR=resolve -mDNS -DNSOverTLS\n",
1764 " DNSSEC=no/unsupported\n",
1765 " DNS Domain: real.example.com\n",
1766 );
1767 let parsed = parse_resolvectl_domains(sample);
1768 assert_eq!(parsed.links[0].search, vec!["real.example.com"]);
1769 }
1770
1771 #[test]
1772 fn test_domain_unmanaged_link_falls_back() {
1773 let parsed = parse_resolvectl_domains(RESOLVECTL_VPN_SAMPLE);
1776 assert_eq!(
1777 resolve_default_route_domain(&parsed, "ppp0"),
1778 DefaultRouteDomain::Unmanaged
1779 );
1780 }
1781
1782 #[test]
1783 fn test_domain_managed_without_domain_does_not_borrow_from_other_links() {
1784 let sample = concat!(
1787 "Link 2 (wlp194s0)\n",
1788 " Default Route: yes\n",
1789 "Link 3 (wt0)\n",
1790 " DNS Domain: netbird.cloud\n",
1791 );
1792 let parsed = parse_resolvectl_domains(sample);
1793 assert_eq!(
1794 resolve_default_route_domain(&parsed, "wlp194s0"),
1795 DefaultRouteDomain::Managed(None)
1796 );
1797 }
1798
1799 #[test]
1800 fn test_domain_falls_back_to_global_but_not_to_another_link() {
1801 let sample = concat!(
1804 "Global\n",
1805 " DNS Domain: corp.example.com\n",
1806 "Link 2 (wlp194s0)\n",
1807 " Default Route: yes\n",
1808 "Link 3 (wt0)\n",
1809 " DNS Domain: netbird.cloud\n",
1810 );
1811 let parsed = parse_resolvectl_domains(sample);
1812 assert_eq!(parsed.global, vec!["corp.example.com"]);
1813 assert_eq!(
1814 resolve_default_route_domain(&parsed, "wlp194s0"),
1815 DefaultRouteDomain::Managed(Some("corp.example.com".to_string()))
1816 );
1817 }
1818
1819 #[test]
1820 fn test_parse_resolvectl_domains_merges_both_domain_labels() {
1821 let sample = concat!(
1823 "Link 2 (eth0)\n",
1824 " DNS Domain: a.example.com\n",
1825 "DNS Search Domains: b.example.com\n",
1826 );
1827 let parsed = parse_resolvectl_domains(sample);
1828 assert_eq!(parsed.links.len(), 1);
1829 assert_eq!(
1830 parsed.links[0].search,
1831 vec!["a.example.com", "b.example.com"]
1832 );
1833 assert_eq!(
1834 parse_resolvectl_search(sample),
1835 vec!["eth0: a.example.com, b.example.com"]
1836 );
1837 }
1838
1839 #[test]
1842 fn test_global_search_domains_match_per_link_shape() {
1843 let per_link = parse_resolvectl_search("Link 2 (eth0)\n DNS Domain: a.example.com\n");
1847 assert_eq!(per_link, vec!["eth0: a.example.com"]);
1848
1849 let global = format_global_search_domains(&["a.example.com".to_string()]);
1850 assert_eq!(global, vec!["global: a.example.com"]);
1851
1852 assert_eq!(per_link.len(), global.len());
1854 for entry in per_link.iter().chain(global.iter()) {
1855 let (scope, domains) = entry.split_once(": ").expect("entry must carry a scope");
1856 assert!(!scope.is_empty() && !domains.is_empty());
1857 }
1858 }
1859
1860 #[test]
1861 fn test_global_search_domains_group_into_one_entry() {
1862 let parsed = parse_search_from_resolv_conf("search a.example.com b.example.com c.net\n");
1866 assert_eq!(parsed.len(), 3); assert_eq!(
1868 format_global_search_domains(&parsed),
1869 vec!["global: a.example.com, b.example.com, c.net"]
1870 );
1871 }
1872
1873 #[test]
1874 fn test_global_search_domains_empty_yields_no_line() {
1875 assert!(format_global_search_domains(&[]).is_empty());
1877 assert!(format_global_search_domains(&parse_search_from_resolv_conf(
1878 "nameserver 1.1.1.1\n"
1879 ))
1880 .is_empty());
1881 }
1882
1883 #[test]
1884 fn test_default_route_interface_selection_matches_routing_table() {
1885 let proc_net_route = concat!(
1889 "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n",
1890 "wlp194s0\t00000000\t0156A8C0\t0003\t0\t0\t100\t00000000\t0\t0\t0\n",
1891 "wt0\t00006564\t00000000\t0001\t0\t0\t0\t0000FFFF\t0\t0\t0\n",
1892 "wlp194s0\t0056A8C0\t00000000\t0001\t0\t0\t100\t00FFFFFF\t0\t0\t0\n",
1893 );
1894 assert_eq!(
1895 parse_proc_net_route(proc_net_route),
1896 Some("wlp194s0".to_string())
1897 );
1898 }
1899
1900 #[test]
1901 fn test_resolve_windows_default_domain() {
1902 let adapters = vec![
1903 WinAdapterDnsInfo {
1904 friendly_name: "Wi-Fi".to_string(),
1905 dns_suffix: "lan.home".to_string(),
1906 is_up: true,
1907 is_loopback: false,
1908 dns_servers: Vec::new(),
1909 },
1910 WinAdapterDnsInfo {
1911 friendly_name: "Ethernet".to_string(),
1912 dns_suffix: "corp.internal".to_string(),
1913 is_up: true,
1914 is_loopback: false,
1915 dns_servers: Vec::new(),
1916 },
1917 ];
1918
1919 assert_eq!(
1921 resolve_windows_default_domain(Some("Wi-Fi"), &adapters, None),
1922 Some("lan.home".to_string())
1923 );
1924
1925 assert_eq!(
1927 resolve_windows_default_domain(Some("wi-fi"), &adapters, None),
1928 Some("lan.home".to_string())
1929 );
1930
1931 let adapters_no_suffix = vec![WinAdapterDnsInfo {
1933 friendly_name: "Wi-Fi".to_string(),
1934 dns_suffix: "".to_string(),
1935 is_up: true,
1936 is_loopback: false,
1937 dns_servers: Vec::new(),
1938 }];
1939 assert_eq!(
1940 resolve_windows_default_domain(
1941 Some("Wi-Fi"),
1942 &adapters_no_suffix,
1943 Some("global.example.com")
1944 ),
1945 Some("global.example.com".to_string())
1946 );
1947
1948 assert_eq!(
1950 resolve_windows_default_domain(Some("Unknown"), &adapters, Some("global.example.com")),
1951 Some("global.example.com".to_string())
1952 );
1953 }
1954
1955 #[test]
1956 fn test_parse_windows_domain_search() {
1957 let adapters = vec![
1958 WinAdapterDnsInfo {
1959 friendly_name: "Wi-Fi".to_string(),
1960 dns_suffix: "lan.home".to_string(),
1961 is_up: true,
1962 is_loopback: false,
1963 dns_servers: Vec::new(),
1964 },
1965 WinAdapterDnsInfo {
1966 friendly_name: "vEthernet".to_string(),
1967 dns_suffix: "netbird.cloud".to_string(),
1968 is_up: true,
1969 is_loopback: false,
1970 dns_servers: Vec::new(),
1971 },
1972 WinAdapterDnsInfo {
1973 friendly_name: "Loopback Pseudo-Interface 1".to_string(),
1974 dns_suffix: "ignore.me".to_string(),
1975 is_up: true,
1976 is_loopback: true,
1977 dns_servers: Vec::new(),
1978 },
1979 WinAdapterDnsInfo {
1980 friendly_name: "Disconnected".to_string(),
1981 dns_suffix: "offline.local".to_string(),
1982 is_up: false,
1983 is_loopback: false,
1984 dns_servers: Vec::new(),
1985 },
1986 ];
1987
1988 let result = parse_windows_domain_search(Some("search1.com, search2.com"), &adapters);
1989 assert_eq!(
1990 result,
1991 vec![
1992 "global: search1.com, search2.com",
1993 "Wi-Fi: lan.home",
1994 "vEthernet: netbird.cloud"
1995 ]
1996 );
1997 }
1998
1999 #[test]
2000 #[cfg(target_os = "windows")]
2001 fn test_ip_adapter_addresses_layout() {
2002 use std::mem::{offset_of, size_of};
2003
2004 #[repr(C)]
2005 #[allow(non_snake_case)]
2006 struct IpAdapterAddresses {
2007 Length: u32,
2008 IfIndex: u32,
2009 Next: *mut IpAdapterAddresses,
2010 AdapterName: *const i8,
2011 FirstUnicastAddress: *const std::ffi::c_void,
2012 FirstAnycastAddress: *const std::ffi::c_void,
2013 FirstMulticastAddress: *const std::ffi::c_void,
2014 FirstDnsServerAddress: *const std::ffi::c_void,
2015 DnsSuffix: *const u16,
2016 Description: *const u16,
2017 FriendlyName: *const u16,
2018 PhysicalAddress: [u8; 8],
2019 PhysicalAddressLength: u32,
2020 Flags: u32,
2021 Mtu: u32,
2022 IfType: u32,
2023 OperStatus: u32,
2024 }
2025
2026 if cfg!(target_pointer_width = "64") {
2027 assert_eq!(offset_of!(IpAdapterAddresses, Next), 8);
2028 assert_eq!(offset_of!(IpAdapterAddresses, AdapterName), 16);
2029 assert_eq!(offset_of!(IpAdapterAddresses, DnsSuffix), 56);
2030 assert_eq!(offset_of!(IpAdapterAddresses, FriendlyName), 72);
2031 assert_eq!(offset_of!(IpAdapterAddresses, OperStatus), 104);
2032 assert_eq!(size_of::<IpAdapterAddresses>(), 112);
2033 }
2034 }
2035}