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(target_os = "macos")]
669 {
670 if let Some(config) = crate::macos_ffi::get_primary_service_dns() {
677 return config.servers;
678 }
679 }
680 #[cfg(any(target_os = "linux", target_os = "macos"))]
681 {
682 if let Ok(content) = std::fs::read_to_string("/etc/resolv.conf") {
683 return parse_resolv_conf(&content);
684 }
685 }
686 #[cfg(target_os = "windows")]
687 {
688 windows_dns_servers()
689 }
690 #[cfg(not(target_os = "windows"))]
691 Vec::new()
692}
693
694#[cfg(any(target_os = "linux", target_os = "macos", test))]
695pub fn parse_resolv_conf(content: &str) -> Vec<String> {
696 content
697 .lines()
698 .filter_map(|line| {
699 let line = line.trim();
700 if line.starts_with('#') || line.starts_with(';') {
701 return None;
702 }
703 let mut parts = line.split_whitespace();
704 if parts.next()? == "nameserver" {
705 parts.next().map(|s| s.to_string())
706 } else {
707 None
708 }
709 })
710 .collect()
711}
712
713pub fn detect_domain() -> Option<String> {
726 #[cfg(target_os = "linux")]
727 {
728 if let (Some(iface), Some(status)) = (default_route_interface(), resolvectl_status()) {
732 if let DefaultRouteDomain::Managed(domain) =
733 resolve_default_route_domain(&parse_resolvectl_domains(status), &iface)
734 {
735 return domain;
736 }
737 }
738 read_resolv_conf_domain()
739 }
740 #[cfg(target_os = "macos")]
741 {
742 if let Some(config) = crate::macos_ffi::get_primary_service_dns() {
747 return config.domain;
748 }
749 read_resolv_conf_domain()
750 }
751 #[cfg(target_os = "windows")]
752 {
753 detect_domain_windows()
754 }
755 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
756 {
757 None
758 }
759}
760
761#[cfg(any(target_os = "linux", target_os = "macos"))]
763fn read_resolv_conf_domain() -> Option<String> {
764 std::fs::read_to_string("/etc/resolv.conf")
765 .ok()
766 .and_then(|content| parse_domain_from_resolv_conf(&content))
767}
768
769#[cfg(target_os = "linux")]
776fn default_route_interface() -> Option<String> {
777 std::fs::read_to_string("/proc/net/route")
778 .ok()
779 .and_then(|content| parse_proc_net_route(&content))
780}
781
782#[cfg(target_os = "linux")]
784static RESOLVECTL_STATUS: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
785
786#[cfg(target_os = "linux")]
792fn resolvectl_status() -> Option<&'static str> {
793 RESOLVECTL_STATUS
794 .get_or_init(|| {
795 let output = std::process::Command::new("resolvectl")
796 .args(["status", "--no-pager"])
797 .output()
798 .ok()?;
799 if !output.status.success() {
800 return None;
801 }
802 Some(String::from_utf8_lossy(&output.stdout).into_owned())
803 })
804 .as_deref()
805}
806
807#[cfg(any(target_os = "windows", test))]
809#[derive(Debug, Clone, PartialEq, Eq)]
810struct WinAdapterDnsInfo {
811 friendly_name: String,
812 dns_suffix: String,
813 is_up: bool,
814 is_loopback: bool,
815 dns_servers: Vec<std::net::IpAddr>,
817}
818
819#[cfg(any(target_os = "windows", test))]
821const AF_INET: u16 = 2;
822#[cfg(any(target_os = "windows", test))]
824const AF_INET6: u16 = 23;
825
826#[cfg(any(target_os = "windows", test))]
837fn parse_sockaddr(bytes: &[u8]) -> Option<std::net::IpAddr> {
838 let family = u16::from_ne_bytes([*bytes.first()?, *bytes.get(1)?]);
839 match family {
840 AF_INET => {
841 let octets: [u8; 4] = bytes.get(4..8)?.try_into().ok()?;
842 Some(std::net::IpAddr::V4(octets.into()))
843 }
844 AF_INET6 => {
845 let octets: [u8; 16] = bytes.get(8..24)?.try_into().ok()?;
846 Some(std::net::IpAddr::V6(octets.into()))
847 }
848 _ => None,
849 }
850}
851
852#[cfg(target_os = "windows")]
865fn windows_dns_servers() -> Vec<String> {
866 let mut servers: Vec<String> = get_windows_adapters_dns_info()
867 .into_iter()
868 .flat_map(|adapter| adapter.dns_servers)
869 .filter(|ip| ip.is_ipv4())
870 .map(|ip| ip.to_string())
871 .collect();
872 servers.sort();
873 servers.dedup();
874 servers
875}
876
877#[cfg(any(target_os = "windows", test))]
883fn resolve_windows_default_domain(
884 active_iface: Option<&str>,
885 adapters: &[WinAdapterDnsInfo],
886 global_domain: Option<&str>,
887) -> Option<String> {
888 if let Some(iface) = active_iface {
889 if let Some(adapter) = adapters
890 .iter()
891 .find(|a| a.friendly_name.eq_ignore_ascii_case(iface))
892 {
893 if let Some(suffix) = clean_domain(&adapter.dns_suffix) {
894 return Some(suffix);
895 }
896 }
897 }
898
899 if let Some(global) = global_domain.and_then(clean_domain) {
900 return Some(global);
901 }
902
903 None
904}
905
906#[cfg(any(target_os = "windows", test))]
908fn parse_windows_domain_search(
909 global_search_list: Option<&str>,
910 adapters: &[WinAdapterDnsInfo],
911) -> Vec<String> {
912 let mut results = Vec::new();
913
914 if let Some(raw) = global_search_list {
915 let global_domains: Vec<String> = raw
916 .split(&[',', ' '][..])
917 .filter_map(clean_domain)
918 .collect();
919 if !global_domains.is_empty() {
920 results.extend(format_global_search_domains(&global_domains));
921 }
922 }
923
924 for adapter in adapters {
925 if adapter.is_up && !adapter.is_loopback {
926 if let Some(suffix) = clean_domain(&adapter.dns_suffix) {
927 results.push(format!("{}: {}", adapter.friendly_name, suffix));
928 }
929 }
930 }
931
932 results
933}
934
935#[cfg(target_os = "windows")]
937fn detect_domain_windows() -> Option<String> {
938 let (_, active_iface) = detect_active_interface_and_local_ip();
939 let adapters = get_windows_adapters_dns_info();
940 let global_domain = crate::win_reg::get_reg_string(
941 crate::win_reg::HKEY_LOCAL_MACHINE,
942 "SYSTEM\\CurrentControlSet\\Services\\Tcpip\\Parameters",
943 "Domain",
944 );
945 resolve_windows_default_domain(active_iface.as_deref(), &adapters, global_domain.as_deref())
946}
947
948#[cfg(target_os = "windows")]
950fn get_windows_adapters_dns_info() -> Vec<WinAdapterDnsInfo> {
951 use std::ffi::OsString;
952 use std::os::windows::ffi::OsStringExt;
953 use std::ptr;
954
955 #[repr(C)]
956 #[allow(non_snake_case)]
957 struct IpAdapterAddresses {
958 Length: u32,
959 IfIndex: u32,
960 Next: *mut IpAdapterAddresses,
961 AdapterName: *const i8,
962 FirstUnicastAddress: *const std::ffi::c_void,
963 FirstAnycastAddress: *const std::ffi::c_void,
964 FirstMulticastAddress: *const std::ffi::c_void,
965 FirstDnsServerAddress: *const IpAdapterDnsServerAddress,
966 DnsSuffix: *const u16,
967 Description: *const u16,
968 FriendlyName: *const u16,
969 PhysicalAddress: [u8; 8],
970 PhysicalAddressLength: u32,
971 Flags: u32,
972 Mtu: u32,
973 IfType: u32,
974 OperStatus: u32,
975 }
976
977 #[repr(C)]
979 #[allow(non_snake_case)]
980 struct SocketAddress {
981 lpSockaddr: *const u8,
982 iSockaddrLength: i32,
983 }
984
985 #[repr(C)]
990 #[allow(non_snake_case)]
991 struct IpAdapterDnsServerAddress {
992 Length: u32,
993 Reserved: u32,
994 Next: *const IpAdapterDnsServerAddress,
995 Address: SocketAddress,
996 }
997
998 const AF_UNSPEC: u32 = 0;
999 const GAA_FLAGS: u32 = 0x06;
1007 const IF_TYPE_SOFTWARE_LOOPBACK: u32 = 24;
1008 const IF_OPER_STATUS_UP: u32 = 1;
1009
1010 #[link(name = "iphlpapi")]
1011 extern "system" {
1012 fn GetAdaptersAddresses(
1013 family: u32,
1014 flags: u32,
1015 reserved: *mut std::ffi::c_void,
1016 adapter_addresses: *mut IpAdapterAddresses,
1017 size_pointer: *mut u32,
1018 ) -> u32;
1019 }
1020
1021 let mut size: u32 = 0;
1022 unsafe {
1024 GetAdaptersAddresses(
1025 AF_UNSPEC,
1026 GAA_FLAGS,
1027 ptr::null_mut(),
1028 ptr::null_mut(),
1029 &mut size,
1030 );
1031 }
1032 if size == 0 {
1033 return Vec::new();
1034 }
1035
1036 let mut buf = vec![0u8; size as usize];
1037 let ret = unsafe {
1039 GetAdaptersAddresses(
1040 AF_UNSPEC,
1041 GAA_FLAGS,
1042 ptr::null_mut(),
1043 buf.as_mut_ptr() as *mut IpAdapterAddresses,
1044 &mut size,
1045 )
1046 };
1047 if ret != 0 {
1048 return Vec::new();
1049 }
1050
1051 let mut result = Vec::new();
1052 let mut curr = buf.as_ptr() as *const IpAdapterAddresses;
1053
1054 unsafe {
1055 while !curr.is_null() {
1056 let adapter = &*curr;
1057
1058 let friendly_name = if !adapter.FriendlyName.is_null() {
1059 let mut len = 0;
1060 while *adapter.FriendlyName.add(len) != 0 {
1061 len += 1;
1062 }
1063 let slice = std::slice::from_raw_parts(adapter.FriendlyName, len);
1064 OsString::from_wide(slice).to_string_lossy().into_owned()
1065 } else {
1066 String::new()
1067 };
1068
1069 let adapter_name = if !adapter.AdapterName.is_null() {
1070 std::ffi::CStr::from_ptr(adapter.AdapterName)
1071 .to_string_lossy()
1072 .into_owned()
1073 } else {
1074 String::new()
1075 };
1076
1077 let mut dns_suffix = if !adapter.DnsSuffix.is_null() {
1078 let mut len = 0;
1079 while *adapter.DnsSuffix.add(len) != 0 {
1080 len += 1;
1081 }
1082 let slice = std::slice::from_raw_parts(adapter.DnsSuffix, len);
1083 OsString::from_wide(slice).to_string_lossy().into_owned()
1084 } else {
1085 String::new()
1086 };
1087
1088 if dns_suffix.trim().is_empty() && !adapter_name.is_empty() {
1089 let subkey = format!(
1090 "SYSTEM\\CurrentControlSet\\Services\\Tcpip\\Parameters\\Interfaces\\{}",
1091 adapter_name
1092 );
1093 if let Some(s) = crate::win_reg::get_reg_string(
1094 crate::win_reg::HKEY_LOCAL_MACHINE,
1095 &subkey,
1096 "SearchList",
1097 ) {
1098 dns_suffix = s;
1099 } else if let Some(s) = crate::win_reg::get_reg_string(
1100 crate::win_reg::HKEY_LOCAL_MACHINE,
1101 &subkey,
1102 "DhcpSearchList",
1103 ) {
1104 dns_suffix = s;
1105 } else if let Some(s) = crate::win_reg::get_reg_string(
1106 crate::win_reg::HKEY_LOCAL_MACHINE,
1107 &subkey,
1108 "Domain",
1109 ) {
1110 dns_suffix = s;
1111 } else if let Some(s) = crate::win_reg::get_reg_string(
1112 crate::win_reg::HKEY_LOCAL_MACHINE,
1113 &subkey,
1114 "DhcpDomain",
1115 ) {
1116 dns_suffix = s;
1117 }
1118 }
1119
1120 let mut dns_servers = Vec::new();
1125 let mut dns_entry = adapter.FirstDnsServerAddress;
1126 while !dns_entry.is_null() {
1127 let entry = &*dns_entry;
1128 if !entry.Address.lpSockaddr.is_null() && entry.Address.iSockaddrLength > 0 {
1129 let len = entry.Address.iSockaddrLength as usize;
1130 let bytes = std::slice::from_raw_parts(entry.Address.lpSockaddr, len);
1131 if let Some(ip) = parse_sockaddr(bytes) {
1132 dns_servers.push(ip);
1133 }
1134 }
1135 dns_entry = entry.Next;
1136 }
1137
1138 result.push(WinAdapterDnsInfo {
1139 friendly_name,
1140 dns_suffix,
1141 is_up: adapter.OperStatus == IF_OPER_STATUS_UP,
1142 is_loopback: adapter.IfType == IF_TYPE_SOFTWARE_LOOPBACK,
1143 dns_servers,
1144 });
1145
1146 curr = adapter.Next;
1147 }
1148 }
1149
1150 result
1151}
1152
1153#[cfg(any(target_os = "windows", test))]
1158fn clean_domain(raw: &str) -> Option<String> {
1159 let trimmed = raw.trim();
1160 if trimmed.is_empty() {
1161 None
1162 } else {
1163 Some(trimmed.to_string())
1164 }
1165}
1166
1167pub fn detect_domain_search() -> Vec<String> {
1175 #[cfg(target_os = "linux")]
1176 {
1177 if let Some(status) = resolvectl_status() {
1179 let result = parse_resolvectl_search(status);
1180 if !result.is_empty() {
1181 return result;
1182 }
1183 }
1184 if let Ok(content) = std::fs::read_to_string("/etc/resolv.conf") {
1185 return format_global_search_domains(&parse_search_from_resolv_conf(&content));
1186 }
1187 }
1188 #[cfg(target_os = "macos")]
1189 {
1190 if let Ok(content) = std::fs::read_to_string("/etc/resolv.conf") {
1191 return format_global_search_domains(&parse_search_from_resolv_conf(&content));
1192 }
1193 }
1194 #[cfg(target_os = "windows")]
1195 {
1196 let adapters = get_windows_adapters_dns_info();
1197 let global_search_list = crate::win_reg::get_reg_string(
1198 crate::win_reg::HKEY_LOCAL_MACHINE,
1199 "SYSTEM\\CurrentControlSet\\Services\\Tcpip\\Parameters",
1200 "SearchList",
1201 );
1202 let results = parse_windows_domain_search(global_search_list.as_deref(), &adapters);
1203 if !results.is_empty() {
1204 return results;
1205 }
1206 }
1207 Vec::new()
1208}
1209
1210#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))]
1216const GLOBAL_SEARCH_SCOPE: &str = "global";
1217
1218#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))]
1221pub fn format_global_search_domains(domains: &[String]) -> Vec<String> {
1222 if domains.is_empty() {
1223 Vec::new()
1224 } else {
1225 vec![format!("{}: {}", GLOBAL_SEARCH_SCOPE, domains.join(", "))]
1226 }
1227}
1228
1229#[cfg(any(target_os = "linux", target_os = "macos", test))]
1232pub fn parse_domain_from_resolv_conf(content: &str) -> Option<String> {
1233 let mut first_search: Option<String> = None;
1234 for line in content.lines() {
1235 let line = line.trim();
1236 if line.starts_with('#') || line.starts_with(';') {
1237 continue;
1238 }
1239 let mut parts = line.split_whitespace();
1240 match parts.next() {
1241 Some("domain") => {
1242 if let Some(d) = parts.next() {
1243 return Some(d.to_string());
1244 }
1245 }
1246 Some("search") if first_search.is_none() => {
1247 if let Some(d) = parts.next() {
1248 first_search = Some(d.to_string());
1249 }
1250 }
1251 _ => {}
1252 }
1253 }
1254 first_search
1255}
1256
1257#[cfg(any(target_os = "linux", target_os = "macos", test))]
1259pub fn parse_search_from_resolv_conf(content: &str) -> Vec<String> {
1260 for line in content.lines() {
1261 let line = line.trim();
1262 if line.starts_with('#') || line.starts_with(';') {
1263 continue;
1264 }
1265 let mut parts = line.split_whitespace();
1266 if parts.next() == Some("search") {
1267 let domains: Vec<String> = parts.map(|s| s.to_string()).collect();
1268 if !domains.is_empty() {
1269 return domains;
1270 }
1271 }
1272 }
1273 Vec::new()
1274}
1275
1276#[derive(Debug, Clone, Default, PartialEq, Eq)]
1278#[cfg(any(target_os = "linux", test))]
1279pub struct LinkDomains {
1280 pub interface: String,
1282 pub search: Vec<String>,
1285}
1286
1287#[derive(Debug, Clone, Default, PartialEq, Eq)]
1289#[cfg(any(target_os = "linux", test))]
1290pub struct ResolvectlDomains {
1291 pub global: Vec<String>,
1294 pub links: Vec<LinkDomains>,
1296}
1297
1298#[derive(Debug, Clone, PartialEq, Eq)]
1300#[cfg(any(target_os = "linux", test))]
1301pub enum DefaultRouteDomain {
1302 Managed(Option<String>),
1307 Unmanaged,
1310}
1311
1312#[cfg(any(target_os = "linux", test))]
1318pub fn resolve_default_route_domain(
1319 domains: &ResolvectlDomains,
1320 interface: &str,
1321) -> DefaultRouteDomain {
1322 match domains.links.iter().find(|l| l.interface == interface) {
1323 Some(link) => DefaultRouteDomain::Managed(
1324 link.search
1325 .first()
1326 .or_else(|| domains.global.first())
1327 .cloned(),
1328 ),
1329 None => DefaultRouteDomain::Unmanaged,
1330 }
1331}
1332
1333#[cfg(any(target_os = "linux", test))]
1347pub fn parse_resolvectl_domains(content: &str) -> ResolvectlDomains {
1348 let mut out = ResolvectlDomains::default();
1349 let mut section: Option<String> = None;
1351 let mut in_domain_value = false;
1353
1354 for line in content.lines() {
1355 let trimmed = line.trim();
1356 if trimmed.is_empty() {
1357 in_domain_value = false;
1358 continue;
1359 }
1360
1361 if let Some(iface) = trimmed
1364 .strip_prefix("Link ")
1365 .and_then(|rest| rest.split_once('('))
1366 .and_then(|(_, rest)| rest.split_once(')'))
1367 .map(|(iface, _)| iface)
1368 {
1369 in_domain_value = false;
1370 section = Some(iface.to_string());
1371 if !out.links.iter().any(|l| l.interface == iface) {
1373 out.links.push(LinkDomains {
1374 interface: iface.to_string(),
1375 search: Vec::new(),
1376 });
1377 }
1378 continue;
1379 }
1380
1381 if trimmed == "Global" {
1382 in_domain_value = false;
1383 section = None;
1384 continue;
1385 }
1386
1387 if let Some(value) = trimmed
1388 .strip_prefix("DNS Domain:")
1389 .or_else(|| trimmed.strip_prefix("DNS Search Domains:"))
1390 {
1391 in_domain_value = true;
1392 push_resolvectl_domains(&mut out, section.as_deref(), value);
1393 continue;
1394 }
1395
1396 if in_domain_value && !trimmed.contains(':') {
1399 push_resolvectl_domains(&mut out, section.as_deref(), trimmed);
1400 continue;
1401 }
1402 in_domain_value = false;
1403 }
1404 out
1405}
1406
1407#[cfg(any(target_os = "linux", test))]
1410fn push_resolvectl_domains(out: &mut ResolvectlDomains, section: Option<&str>, value: &str) {
1411 let domains = value
1412 .split_whitespace()
1413 .filter(|d| !d.starts_with('~'))
1414 .map(|d| d.to_string());
1415 match section {
1416 Some(iface) => match out.links.iter_mut().find(|l| l.interface == iface) {
1417 Some(link) => link.search.extend(domains),
1418 None => out.links.push(LinkDomains {
1419 interface: iface.to_string(),
1420 search: domains.collect(),
1421 }),
1422 },
1423 None => out.global.extend(domains),
1424 }
1425}
1426
1427#[cfg(any(target_os = "linux", test))]
1432pub fn parse_resolvectl_search(content: &str) -> Vec<String> {
1433 parse_resolvectl_domains(content)
1434 .links
1435 .into_iter()
1436 .filter(|link| !link.search.is_empty())
1437 .map(|link| format!("{}: {}", link.interface, link.search.join(", ")))
1438 .collect()
1439}
1440
1441#[cfg(all(test, target_os = "macos"))]
1442mod macos_dns_tests {
1443 use super::*;
1444
1445 #[test]
1455 fn test_dns_comes_from_the_primary_service_not_resolv_conf() {
1456 let Some(config) = crate::macos_ffi::get_primary_service_dns() else {
1457 return;
1460 };
1461 assert_eq!(
1462 detect_dns(),
1463 config.servers,
1464 "detect_dns must report the default route's own servers"
1465 );
1466 assert_eq!(
1467 detect_domain(),
1468 config.domain,
1469 "detect_domain must report the default route's own domain"
1470 );
1471 }
1472
1473 #[test]
1480 fn test_empty_primary_config_is_not_a_fallback_signal() {
1481 let empty = crate::macos_ffi::ScDnsConfig::default();
1482 assert!(empty.servers.is_empty());
1483 assert!(empty.domain.is_none());
1484 let authoritative: Option<crate::macos_ffi::ScDnsConfig> = Some(empty);
1488 assert!(authoritative.is_some());
1489 }
1490}
1491
1492#[cfg(test)]
1493mod tests {
1494 use super::*;
1495
1496 fn sockaddr_in(octets: [u8; 4]) -> Vec<u8> {
1504 let mut v = Vec::new();
1505 v.extend_from_slice(&AF_INET.to_ne_bytes());
1506 v.extend_from_slice(&53u16.to_be_bytes()); v.extend_from_slice(&octets); v.extend_from_slice(&[0u8; 8]); v
1510 }
1511
1512 fn sockaddr_in6(octets: [u8; 16]) -> Vec<u8> {
1514 let mut v = Vec::new();
1515 v.extend_from_slice(&AF_INET6.to_ne_bytes());
1516 v.extend_from_slice(&53u16.to_be_bytes());
1517 v.extend_from_slice(&0u32.to_ne_bytes()); v.extend_from_slice(&octets); v.extend_from_slice(&0u32.to_ne_bytes()); v
1521 }
1522
1523 #[test]
1524 fn test_parse_sockaddr_reads_ipv4_at_offset_four() {
1525 assert_eq!(
1527 parse_sockaddr(&sockaddr_in([10, 10, 1, 1])),
1528 Some("10.10.1.1".parse().unwrap())
1529 );
1530 assert_eq!(
1531 parse_sockaddr(&sockaddr_in([100, 101, 255, 254])),
1532 Some("100.101.255.254".parse().unwrap())
1533 );
1534 }
1535
1536 #[test]
1537 fn test_parse_sockaddr_reads_ipv6_at_offset_eight() {
1538 let mut o = [0u8; 16];
1541 o[0] = 0xfe;
1542 o[1] = 0xc0;
1543 o[6] = 0xff;
1544 o[7] = 0xff;
1545 o[15] = 1;
1546 assert_eq!(
1547 parse_sockaddr(&sockaddr_in6(o)),
1548 Some("fec0:0:0:ffff::1".parse().unwrap())
1549 );
1550 }
1551
1552 #[test]
1553 fn test_parse_sockaddr_rejects_short_and_unknown_buffers() {
1554 assert_eq!(parse_sockaddr(&[]), None);
1557 assert_eq!(parse_sockaddr(&AF_INET.to_ne_bytes()), None);
1558 assert_eq!(parse_sockaddr(&sockaddr_in([1, 2, 3, 4])[..7]), None);
1559 assert_eq!(parse_sockaddr(&sockaddr_in6([0; 16])[..20]), None);
1560 let mut wrong_family = sockaddr_in6([0; 16]);
1562 wrong_family[0] = 10;
1563 wrong_family[1] = 0;
1564 assert_eq!(parse_sockaddr(&wrong_family), None);
1565 }
1566
1567 #[test]
1568 fn test_clean_domain() {
1569 assert_eq!(
1571 clean_domain("corp.example.com"),
1572 Some("corp.example.com".to_string())
1573 );
1574 assert_eq!(
1576 clean_domain(" example.org \n"),
1577 Some("example.org".to_string())
1578 );
1579 assert_eq!(clean_domain(""), None);
1581 assert_eq!(clean_domain(" "), None);
1582 }
1583
1584 #[test]
1585 fn test_match_active_interface() {
1586 use std::net::IpAddr;
1587 let target: IpAddr = "192.168.1.50".parse().unwrap();
1588 let ifaces = vec![
1589 ("lo".to_string(), vec!["127.0.0.1".parse().unwrap()]),
1590 (
1591 "Ethernet".to_string(),
1592 vec!["192.168.1.50".parse().unwrap(), "fe80::1".parse().unwrap()],
1593 ),
1594 ("Wi-Fi".to_string(), vec!["10.0.0.2".parse().unwrap()]),
1595 ];
1596 assert_eq!(
1598 match_active_interface(ifaces.into_iter(), target),
1599 Some("Ethernet".to_string())
1600 );
1601
1602 let orphan: IpAddr = "8.8.8.8".parse().unwrap();
1604 let ifaces2 = vec![(
1605 "lo".to_string(),
1606 vec!["127.0.0.1".parse::<IpAddr>().unwrap()],
1607 )];
1608 assert_eq!(match_active_interface(ifaces2.into_iter(), orphan), None);
1609 }
1610
1611 #[test]
1612 fn test_format_bytes() {
1613 assert_eq!(format_bytes(500), "500 B");
1614 assert_eq!(format_bytes(1024), "1.0 KB");
1615 assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
1616 assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
1617 assert_eq!(format_bytes(1536), "1.5 KB");
1618 }
1619
1620 #[test]
1621 fn test_parse_proc_net_route() {
1622 let sample =
1623 "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n\
1624 wlan0\t0000A8C0\t00000000\t0001\t0\t0\t600\t0000FFFF\t0\t0\t0\n\
1625 wlan0\t00000000\t0100A8C0\t0003\t0\t0\t600\t00000000\t0\t0\t0\n";
1626 assert_eq!(parse_proc_net_route(sample), Some("wlan0".to_string()));
1627
1628 let sample_no_default =
1629 "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n\
1630 wlan0\t0000A8C0\t00000000\t0001\t0\t0\t600\t0000FFFF\t0\t0\t0\n";
1631 assert_eq!(parse_proc_net_route(sample_no_default), None);
1632 }
1633
1634 #[test]
1635 fn test_parse_netsh_output() {
1636 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";
1637 assert_eq!(
1638 parse_netsh_output(sample),
1639 Some("Office_Wi-Fi (5 GHz [↓433 Mbps ↑866 Mbps])".to_string())
1640 );
1641 }
1642
1643 #[test]
1644 fn test_parse_iw_link_output() {
1645 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";
1646 let (ssid, links) = parse_iw_link_output(sample);
1647 assert_eq!(ssid, Some("OfficeNet".to_string()));
1648 assert_eq!(links.len(), 1);
1649 assert_eq!(links[0].freq, Some(6135.0));
1650 assert_eq!(links[0].rx_rate, Some("6.0 MBit/s".to_string()));
1651 assert_eq!(links[0].tx_rate, Some("864.6 MBit/s".to_string()));
1652
1653 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";
1655 let (ssid_mlo, links_mlo) = parse_iw_link_output(sample_mlo);
1656 assert_eq!(ssid_mlo, Some("HomeWiFi".to_string()));
1657 assert_eq!(links_mlo.len(), 2);
1658 assert_eq!(links_mlo[0].freq, Some(5180.0));
1659 assert_eq!(links_mlo[1].freq, Some(6135.0));
1660 }
1661
1662 #[test]
1663 fn test_parse_resolv_conf() {
1664 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";
1665 assert_eq!(
1666 parse_resolv_conf(sample),
1667 vec!["192.168.1.1", "8.8.8.8", "2001:db8::1"]
1668 );
1669
1670 let empty = "# no nameservers\nsearch local\n";
1671 assert_eq!(parse_resolv_conf(empty), Vec::<String>::new());
1672 }
1673
1674 #[test]
1675 fn test_parse_domain_from_resolv_conf_domain_directive() {
1676 let s = "# test\ndomain example.com\nsearch fallback.com\nnameserver 1.1.1.1\n";
1677 assert_eq!(
1678 parse_domain_from_resolv_conf(s),
1679 Some("example.com".to_string())
1680 );
1681 }
1682
1683 #[test]
1684 fn test_parse_domain_from_resolv_conf_search_fallback() {
1685 let s = "# no domain directive\nsearch local.lan other.lan\nnameserver 1.1.1.1\n";
1686 assert_eq!(
1687 parse_domain_from_resolv_conf(s),
1688 Some("local.lan".to_string())
1689 );
1690 }
1691
1692 #[test]
1693 fn test_parse_domain_from_resolv_conf_none() {
1694 let s = "# no domain or search\nnameserver 1.1.1.1\n";
1695 assert_eq!(parse_domain_from_resolv_conf(s), None);
1696 }
1697
1698 #[test]
1699 fn test_parse_search_from_resolv_conf() {
1700 let s = "search home.local corp.example.com\nnameserver 1.1.1.1\n";
1701 assert_eq!(
1702 parse_search_from_resolv_conf(s),
1703 vec!["home.local", "corp.example.com"]
1704 );
1705 }
1706
1707 #[test]
1708 fn test_parse_resolvectl_search_basic() {
1709 let sample = "Global\n\
1710 Link 2 (lo)\n\
1711 Current Scopes: none\n\
1712 Link 3 (wlan0)\n\
1713 Current Scopes: DNS\n\
1714 DNS Domain: home.local\n\
1715 Link 4 (eth0)\n\
1716 Current Scopes: DNS\n\
1717 DNS Search Domains: corp.example.com internal.net\n";
1718 let result = parse_resolvectl_search(sample);
1719 assert_eq!(
1720 result,
1721 vec!["wlan0: home.local", "eth0: corp.example.com, internal.net"]
1722 );
1723 }
1724
1725 #[test]
1726 fn test_parse_resolvectl_search_skips_routing_domain() {
1727 let sample = "Link 2 (wlan0)\n DNS Domain: ~.\nLink 3 (eth0)\n DNS Domain: corp.net\n";
1728 let result = parse_resolvectl_search(sample);
1729 assert_eq!(result, vec!["eth0: corp.net"]);
1730 }
1731
1732 #[test]
1733 fn test_parse_resolvectl_search_empty() {
1734 let sample = "Global\n DNS Servers: 1.1.1.1\n";
1735 assert!(parse_resolvectl_search(sample).is_empty());
1736 }
1737
1738 const RESOLVECTL_VPN_SAMPLE: &str = concat!(
1746 "Global\n",
1747 " Protocols: LLMNR=resolve -mDNS -DNSOverTLS DNSSEC=no/unsupported\n",
1748 " resolv.conf mode: stub\n",
1749 "\n",
1750 "Link 2 (wlp194s0)\n",
1751 " Current Scopes: DNS LLMNR/IPv4 LLMNR/IPv6\n",
1752 " Protocols: +DefaultRoute LLMNR=resolve -mDNS -DNSOverTLS\n",
1753 " DNSSEC=no/unsupported\n",
1754 "Current DNS Server: 192.168.86.1\n",
1755 " DNS Servers: 192.168.86.1\n",
1756 " DNS Domain: lan\n",
1757 " Default Route: yes\n",
1758 "\n",
1759 "Link 3 (wt0)\n",
1760 " Current Scopes: DNS\n",
1761 " Protocols: +DefaultRoute LLMNR=resolve -mDNS -DNSOverTLS\n",
1762 " DNSSEC=no/unsupported\n",
1763 "Current DNS Server: 100.101.32.155\n",
1764 " DNS Servers: 100.101.32.155\n",
1765 " DNS Domain: netbird.cloud ~gammatile.com ~101.100.in-addr.arpa\n",
1766 " ~f.f.0.0.3.2.b.5.b.c.8.5.7.f.d.f.ip6.arpa ~.\n",
1767 " Default Route: yes\n",
1768 );
1769
1770 #[test]
1771 fn test_domain_prefers_default_route_over_vpn() {
1772 let parsed = parse_resolvectl_domains(RESOLVECTL_VPN_SAMPLE);
1776 assert_eq!(
1777 resolve_default_route_domain(&parsed, "wlp194s0"),
1778 DefaultRouteDomain::Managed(Some("lan".to_string()))
1779 );
1780 }
1781
1782 #[test]
1783 fn test_domain_reports_vpn_when_vpn_is_the_default_route() {
1784 let parsed = parse_resolvectl_domains(RESOLVECTL_VPN_SAMPLE);
1786 assert_eq!(
1787 resolve_default_route_domain(&parsed, "wt0"),
1788 DefaultRouteDomain::Managed(Some("netbird.cloud".to_string()))
1789 );
1790 }
1791
1792 #[test]
1793 fn test_domain_routing_only_entries_are_not_domains() {
1794 let parsed = parse_resolvectl_domains(RESOLVECTL_VPN_SAMPLE);
1797 let wt0 = parsed.links.iter().find(|l| l.interface == "wt0").unwrap();
1798 assert_eq!(wt0.search, vec!["netbird.cloud"]);
1799 assert!(wt0.search.iter().all(|d| !d.starts_with('~')));
1800
1801 let routing_only = "Link 5 (tun0)\n DNS Domain: ~corp.example.com ~.\n";
1802 let parsed = parse_resolvectl_domains(routing_only);
1803 assert_eq!(
1804 resolve_default_route_domain(&parsed, "tun0"),
1805 DefaultRouteDomain::Managed(None)
1806 );
1807 }
1808
1809 #[test]
1810 fn test_parse_resolvectl_domains_reads_wrapped_continuation_lines() {
1811 let sample = concat!(
1814 "Link 2 (eth0)\n",
1815 " DNS Domain: one.example.com two.example.com\n",
1816 " three.example.com ~routing.example.com\n",
1817 " Default Route: yes\n",
1818 );
1819 let parsed = parse_resolvectl_domains(sample);
1820 assert_eq!(
1821 parsed.links[0].search,
1822 vec!["one.example.com", "two.example.com", "three.example.com"]
1823 );
1824 assert!(!parsed.links[0].search.iter().any(|d| d.contains("yes")));
1826 }
1827
1828 #[test]
1829 fn test_parse_resolvectl_domains_ignores_other_wrapped_fields() {
1830 let sample = concat!(
1832 "Link 2 (eth0)\n",
1833 " Protocols: +DefaultRoute LLMNR=resolve -mDNS -DNSOverTLS\n",
1834 " DNSSEC=no/unsupported\n",
1835 " DNS Domain: real.example.com\n",
1836 );
1837 let parsed = parse_resolvectl_domains(sample);
1838 assert_eq!(parsed.links[0].search, vec!["real.example.com"]);
1839 }
1840
1841 #[test]
1842 fn test_domain_unmanaged_link_falls_back() {
1843 let parsed = parse_resolvectl_domains(RESOLVECTL_VPN_SAMPLE);
1846 assert_eq!(
1847 resolve_default_route_domain(&parsed, "ppp0"),
1848 DefaultRouteDomain::Unmanaged
1849 );
1850 }
1851
1852 #[test]
1853 fn test_domain_managed_without_domain_does_not_borrow_from_other_links() {
1854 let sample = concat!(
1857 "Link 2 (wlp194s0)\n",
1858 " Default Route: yes\n",
1859 "Link 3 (wt0)\n",
1860 " DNS Domain: netbird.cloud\n",
1861 );
1862 let parsed = parse_resolvectl_domains(sample);
1863 assert_eq!(
1864 resolve_default_route_domain(&parsed, "wlp194s0"),
1865 DefaultRouteDomain::Managed(None)
1866 );
1867 }
1868
1869 #[test]
1870 fn test_domain_falls_back_to_global_but_not_to_another_link() {
1871 let sample = concat!(
1874 "Global\n",
1875 " DNS Domain: corp.example.com\n",
1876 "Link 2 (wlp194s0)\n",
1877 " Default Route: yes\n",
1878 "Link 3 (wt0)\n",
1879 " DNS Domain: netbird.cloud\n",
1880 );
1881 let parsed = parse_resolvectl_domains(sample);
1882 assert_eq!(parsed.global, vec!["corp.example.com"]);
1883 assert_eq!(
1884 resolve_default_route_domain(&parsed, "wlp194s0"),
1885 DefaultRouteDomain::Managed(Some("corp.example.com".to_string()))
1886 );
1887 }
1888
1889 #[test]
1890 fn test_parse_resolvectl_domains_merges_both_domain_labels() {
1891 let sample = concat!(
1893 "Link 2 (eth0)\n",
1894 " DNS Domain: a.example.com\n",
1895 "DNS Search Domains: b.example.com\n",
1896 );
1897 let parsed = parse_resolvectl_domains(sample);
1898 assert_eq!(parsed.links.len(), 1);
1899 assert_eq!(
1900 parsed.links[0].search,
1901 vec!["a.example.com", "b.example.com"]
1902 );
1903 assert_eq!(
1904 parse_resolvectl_search(sample),
1905 vec!["eth0: a.example.com, b.example.com"]
1906 );
1907 }
1908
1909 #[test]
1912 fn test_global_search_domains_match_per_link_shape() {
1913 let per_link = parse_resolvectl_search("Link 2 (eth0)\n DNS Domain: a.example.com\n");
1917 assert_eq!(per_link, vec!["eth0: a.example.com"]);
1918
1919 let global = format_global_search_domains(&["a.example.com".to_string()]);
1920 assert_eq!(global, vec!["global: a.example.com"]);
1921
1922 assert_eq!(per_link.len(), global.len());
1924 for entry in per_link.iter().chain(global.iter()) {
1925 let (scope, domains) = entry.split_once(": ").expect("entry must carry a scope");
1926 assert!(!scope.is_empty() && !domains.is_empty());
1927 }
1928 }
1929
1930 #[test]
1931 fn test_global_search_domains_group_into_one_entry() {
1932 let parsed = parse_search_from_resolv_conf("search a.example.com b.example.com c.net\n");
1936 assert_eq!(parsed.len(), 3); assert_eq!(
1938 format_global_search_domains(&parsed),
1939 vec!["global: a.example.com, b.example.com, c.net"]
1940 );
1941 }
1942
1943 #[test]
1944 fn test_global_search_domains_empty_yields_no_line() {
1945 assert!(format_global_search_domains(&[]).is_empty());
1947 assert!(format_global_search_domains(&parse_search_from_resolv_conf(
1948 "nameserver 1.1.1.1\n"
1949 ))
1950 .is_empty());
1951 }
1952
1953 #[test]
1954 fn test_default_route_interface_selection_matches_routing_table() {
1955 let proc_net_route = concat!(
1959 "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n",
1960 "wlp194s0\t00000000\t0156A8C0\t0003\t0\t0\t100\t00000000\t0\t0\t0\n",
1961 "wt0\t00006564\t00000000\t0001\t0\t0\t0\t0000FFFF\t0\t0\t0\n",
1962 "wlp194s0\t0056A8C0\t00000000\t0001\t0\t0\t100\t00FFFFFF\t0\t0\t0\n",
1963 );
1964 assert_eq!(
1965 parse_proc_net_route(proc_net_route),
1966 Some("wlp194s0".to_string())
1967 );
1968 }
1969
1970 #[test]
1971 fn test_resolve_windows_default_domain() {
1972 let adapters = vec![
1973 WinAdapterDnsInfo {
1974 friendly_name: "Wi-Fi".to_string(),
1975 dns_suffix: "lan.home".to_string(),
1976 is_up: true,
1977 is_loopback: false,
1978 dns_servers: Vec::new(),
1979 },
1980 WinAdapterDnsInfo {
1981 friendly_name: "Ethernet".to_string(),
1982 dns_suffix: "corp.internal".to_string(),
1983 is_up: true,
1984 is_loopback: false,
1985 dns_servers: Vec::new(),
1986 },
1987 ];
1988
1989 assert_eq!(
1991 resolve_windows_default_domain(Some("Wi-Fi"), &adapters, None),
1992 Some("lan.home".to_string())
1993 );
1994
1995 assert_eq!(
1997 resolve_windows_default_domain(Some("wi-fi"), &adapters, None),
1998 Some("lan.home".to_string())
1999 );
2000
2001 let adapters_no_suffix = vec![WinAdapterDnsInfo {
2003 friendly_name: "Wi-Fi".to_string(),
2004 dns_suffix: "".to_string(),
2005 is_up: true,
2006 is_loopback: false,
2007 dns_servers: Vec::new(),
2008 }];
2009 assert_eq!(
2010 resolve_windows_default_domain(
2011 Some("Wi-Fi"),
2012 &adapters_no_suffix,
2013 Some("global.example.com")
2014 ),
2015 Some("global.example.com".to_string())
2016 );
2017
2018 assert_eq!(
2020 resolve_windows_default_domain(Some("Unknown"), &adapters, Some("global.example.com")),
2021 Some("global.example.com".to_string())
2022 );
2023 }
2024
2025 #[test]
2026 fn test_parse_windows_domain_search() {
2027 let adapters = vec![
2028 WinAdapterDnsInfo {
2029 friendly_name: "Wi-Fi".to_string(),
2030 dns_suffix: "lan.home".to_string(),
2031 is_up: true,
2032 is_loopback: false,
2033 dns_servers: Vec::new(),
2034 },
2035 WinAdapterDnsInfo {
2036 friendly_name: "vEthernet".to_string(),
2037 dns_suffix: "netbird.cloud".to_string(),
2038 is_up: true,
2039 is_loopback: false,
2040 dns_servers: Vec::new(),
2041 },
2042 WinAdapterDnsInfo {
2043 friendly_name: "Loopback Pseudo-Interface 1".to_string(),
2044 dns_suffix: "ignore.me".to_string(),
2045 is_up: true,
2046 is_loopback: true,
2047 dns_servers: Vec::new(),
2048 },
2049 WinAdapterDnsInfo {
2050 friendly_name: "Disconnected".to_string(),
2051 dns_suffix: "offline.local".to_string(),
2052 is_up: false,
2053 is_loopback: false,
2054 dns_servers: Vec::new(),
2055 },
2056 ];
2057
2058 let result = parse_windows_domain_search(Some("search1.com, search2.com"), &adapters);
2059 assert_eq!(
2060 result,
2061 vec![
2062 "global: search1.com, search2.com",
2063 "Wi-Fi: lan.home",
2064 "vEthernet: netbird.cloud"
2065 ]
2066 );
2067 }
2068
2069 #[test]
2070 #[cfg(target_os = "windows")]
2071 fn test_ip_adapter_addresses_layout() {
2072 use std::mem::{offset_of, size_of};
2073
2074 #[repr(C)]
2075 #[allow(non_snake_case)]
2076 struct IpAdapterAddresses {
2077 Length: u32,
2078 IfIndex: u32,
2079 Next: *mut IpAdapterAddresses,
2080 AdapterName: *const i8,
2081 FirstUnicastAddress: *const std::ffi::c_void,
2082 FirstAnycastAddress: *const std::ffi::c_void,
2083 FirstMulticastAddress: *const std::ffi::c_void,
2084 FirstDnsServerAddress: *const std::ffi::c_void,
2085 DnsSuffix: *const u16,
2086 Description: *const u16,
2087 FriendlyName: *const u16,
2088 PhysicalAddress: [u8; 8],
2089 PhysicalAddressLength: u32,
2090 Flags: u32,
2091 Mtu: u32,
2092 IfType: u32,
2093 OperStatus: u32,
2094 }
2095
2096 if cfg!(target_pointer_width = "64") {
2097 assert_eq!(offset_of!(IpAdapterAddresses, Next), 8);
2098 assert_eq!(offset_of!(IpAdapterAddresses, AdapterName), 16);
2099 assert_eq!(offset_of!(IpAdapterAddresses, DnsSuffix), 56);
2100 assert_eq!(offset_of!(IpAdapterAddresses, FriendlyName), 72);
2101 assert_eq!(offset_of!(IpAdapterAddresses, OperStatus), 104);
2102 assert_eq!(size_of::<IpAdapterAddresses>(), 112);
2103 }
2104 }
2105}