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> {
113 let out = std::process::Command::new("curl")
114 .args(["-sf", "--max-time", "2", "https://ipinfo.io/ip"])
115 .output()
116 .ok()?;
117 if !out.status.success() {
118 return None;
119 }
120 parse_public_ip(&String::from_utf8_lossy(&out.stdout))
121}
122
123fn parse_public_ip(body: &str) -> Option<String> {
125 body.trim()
126 .parse::<std::net::IpAddr>()
127 .ok()
128 .map(|ip| ip.to_string())
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct NetworkInterface {
146 pub name: String,
149 pub is_up: bool,
151 pub line: String,
153}
154
155pub fn detect_networks(
156 active_interface: Option<&str>,
157 local_ip: Option<&str>,
158) -> Vec<NetworkInterface> {
159 #[cfg(target_os = "windows")]
165 let excluded = crate::win_iftable::excluded_interface_names();
166
167 Networks::new_with_refreshed_list()
168 .iter()
169 .filter(|(name, _)| {
170 #[cfg(target_os = "windows")]
171 {
172 !excluded.iter().any(|e| e == *name)
173 }
174 #[cfg(not(target_os = "windows"))]
175 {
176 let _ = name;
177 true
178 }
179 })
180 .map(|(name, data)| {
181 let rx = format_bytes(data.total_received());
182 let tx = format_bytes(data.total_transmitted());
183 let is_up = data.operational_state() == sysinfo::InterfaceOperationalState::Up
184 || data.total_received() > 0
185 || data.total_transmitted() > 0;
186 let status = if is_up {
187 "Up".green().to_string()
188 } else {
189 "Down".red().to_string()
190 };
191
192 let mut ipv4_addresses = Vec::new();
193 let mut ipv6_addresses = Vec::new();
194
195 if is_up {
196 for ip_net in data.ip_networks() {
197 let ip = ip_net.addr;
198 let name_lower = name.to_lowercase();
199 let is_loopback_iface =
200 name_lower.starts_with("lo") || name_lower.contains("loopback");
201 if ip.is_loopback() && !is_loopback_iface {
202 continue;
203 }
204 match ip {
205 std::net::IpAddr::V4(v4) => {
206 ipv4_addresses.push(v4.to_string());
207 }
208 std::net::IpAddr::V6(v6) => {
209 if !v6.is_unicast_link_local() {
210 ipv6_addresses.push(v6.to_string());
211 }
212 }
213 }
214 }
215
216 if ipv4_addresses.is_empty() && ipv6_addresses.is_empty() {
218 if let (Some(active), Some(ip)) = (active_interface, local_ip) {
219 if name == active {
220 ipv4_addresses.push(ip.to_string());
221 }
222 }
223 }
224 }
225
226 let ip_str = if !ipv4_addresses.is_empty() || !ipv6_addresses.is_empty() {
227 let mut combined = Vec::new();
228 if !ipv4_addresses.is_empty() {
229 combined.push(ipv4_addresses.join(", "));
230 }
231 if !ipv6_addresses.is_empty() {
232 combined.push(ipv6_addresses.join(", "));
233 }
234 format!(" ({})", combined.join(", "))
235 } else {
236 String::new()
237 };
238
239 NetworkInterface {
240 name: name.to_string(),
241 is_up,
242 line: format!("{}{} [{}] RX: {} TX: {}", name, ip_str, status, rx, tx),
243 }
244 })
245 .collect()
246}
247
248pub fn format_bytes(bytes: u64) -> String {
250 const KB: u64 = 1024;
251 const MB: u64 = KB * 1024;
252 const GB: u64 = MB * 1024;
253
254 if bytes >= GB {
255 format!("{:.1} GB", bytes as f64 / GB as f64)
256 } else if bytes >= MB {
257 format!("{:.1} MB", bytes as f64 / MB as f64)
258 } else if bytes >= KB {
259 format!("{:.1} KB", bytes as f64 / KB as f64)
260 } else {
261 format!("{} B", bytes)
262 }
263}
264
265pub fn lookup_pci_vendor(vendor_id: &str) -> Option<String> {
269 let vendor_id = vendor_id.trim_start_matches("0x").to_lowercase();
270 let paths = ["/usr/share/hwdata/pci.ids", "/usr/share/misc/pci.ids"];
271 for path in &paths {
272 if let Ok(content) = std::fs::read_to_string(path) {
273 for line in content.lines() {
274 if line.starts_with('#') || line.is_empty() {
275 continue;
276 }
277 if !line.starts_with('\t') {
278 let parts: Vec<&str> = line.split_whitespace().collect();
279 if parts.len() >= 2 && parts[0].to_lowercase() == vendor_id {
280 let name = line.strip_prefix(parts[0]).unwrap().trim();
281 return Some(name.to_string());
282 }
283 }
284 }
285 }
286 }
287 None
288}
289
290pub fn detect_wifi() -> Option<String> {
292 #[cfg(target_os = "linux")]
293 {
294 let mut wifi_interface = None;
295 if let Ok(entries) = std::fs::read_dir("/sys/class/net") {
296 for entry in entries.filter_map(|e| e.ok()) {
297 let path = entry.path();
298 if path.join("wireless").exists() || path.join("phy80211").exists() {
299 wifi_interface = Some(entry.file_name().to_string_lossy().to_string());
300 break;
301 }
302 }
303 }
304
305 if let Some(ref iface) = wifi_interface {
306 if let Ok(output) = std::process::Command::new("iw")
307 .args(["dev", iface, "link"])
308 .output()
309 {
310 if let Ok(stdout) = String::from_utf8(output.stdout) {
311 let (ssid, links) = parse_iw_link_output(&stdout);
312 if let Some(s) = ssid {
313 let card_model = get_wifi_card_model(iface);
314 let prefix = if let Some(m) = card_model {
315 format!("{} [{}] - ", m, iface)
316 } else {
317 format!("[{}] - ", iface)
318 };
319
320 if !links.is_empty() {
321 let mut link_strs = Vec::new();
322 for link in links {
323 let freq_str = link.freq.map(|f| {
324 let ghz_mhz = if f >= 1000.0 {
325 format!("{:.1} GHz", f / 1000.0)
326 } else {
327 format!("{} MHz", f)
328 };
329 if let Some(ch) = freq_to_channel(f) {
330 format!("{} ch{}", ghz_mhz, ch)
331 } else {
332 ghz_mhz
333 }
334 });
335
336 let mut rx_tx = Vec::new();
337 if let Some(rx) = link.rx_rate {
338 if rx != "0"
339 && !rx.starts_with("0 ")
340 && rx != "0 Mbps"
341 && rx != "0 MBit/s"
342 {
343 rx_tx.push(format!("↓{}", clean_rate(&rx)));
344 }
345 }
346 if let Some(tx) = link.tx_rate {
347 if tx != "0"
348 && !tx.starts_with("0 ")
349 && tx != "0 Mbps"
350 && tx != "0 MBit/s"
351 {
352 rx_tx.push(format!("↑{}", clean_rate(&tx)));
353 }
354 }
355
356 match (freq_str, rx_tx.is_empty()) {
357 (Some(f), false) => {
358 link_strs.push(format!("{} [{}]", f, rx_tx.join(" ")))
359 }
360 (Some(f), true) => link_strs.push(f),
361 (None, false) => link_strs.push(rx_tx.join(" ")),
362 _ => {}
363 }
364 }
365 if !link_strs.is_empty() {
366 return Some(format!(
367 "{}{}{} ({})",
368 prefix,
369 s,
370 "",
371 link_strs.join(", ")
372 ));
373 } else {
374 return Some(format!("{}{}", prefix, s));
375 }
376 }
377 return Some(format!("{}{}", prefix, s));
378 }
379 }
380 }
381 }
382
383 if let Ok(output) = std::process::Command::new("nmcli")
385 .args([
386 "-t",
387 "-f",
388 "active,ssid,rate",
389 "device",
390 "wifi",
391 "list",
392 "--rescan",
393 "no",
394 ])
395 .output()
396 {
397 if let Ok(stdout) = String::from_utf8(output.stdout) {
398 for line in stdout.lines() {
399 let line = line.trim();
400 if let Some(rest) = line.strip_prefix("yes:") {
401 if let Some(colon_idx) = rest.rfind(':') {
402 let ssid = &rest[..colon_idx];
403 let rate = rest[colon_idx + 1..].trim();
404 if !ssid.is_empty() {
405 if !rate.is_empty()
406 && rate != "0"
407 && !rate.starts_with("0 ")
408 && rate != "0 Mbit/s"
409 && rate != "0 Mbps"
410 {
411 return Some(format!("{} ({})", ssid, clean_rate(rate)));
412 } else {
413 return Some(ssid.to_string());
414 }
415 }
416 } else if !rest.is_empty() {
417 return Some(rest.to_string());
418 }
419 }
420 }
421 }
422 }
423
424 if let Ok(output) = std::process::Command::new("iwgetid").arg("-r").output() {
426 if let Ok(stdout) = String::from_utf8(output.stdout) {
427 let ssid = stdout.trim();
428 if !ssid.is_empty() {
429 return Some(ssid.to_string());
430 }
431 }
432 }
433 None
434 }
435
436 #[cfg(target_os = "macos")]
437 {
438 crate::macos_ffi::get_wifi_info().map(|(ssid, rate)| match rate {
439 Some(r) if r > 0 => format!("{} (↑{} Mbps)", ssid, r),
440 _ => ssid,
441 })
442 }
443
444 #[cfg(target_os = "windows")]
445 {
446 if let Ok(output) = std::process::Command::new("netsh")
447 .args(["wlan", "show", "interfaces"])
448 .output()
449 {
450 if let Ok(stdout) = String::from_utf8(output.stdout) {
451 return parse_netsh_output(&stdout);
452 }
453 }
454 None
455 }
456
457 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
458 {
459 None
460 }
461}
462
463#[cfg(any(target_os = "linux", test))]
464pub fn parse_proc_net_route(content: &str) -> Option<String> {
465 for line in content.lines().skip(1) {
466 let parts: Vec<&str> = line.split_whitespace().collect();
467 if parts.len() >= 8 {
468 let dest = parts[1];
469 let mask = parts[7];
470 if dest == "00000000" && mask == "00000000" {
471 return Some(parts[0].to_string());
472 }
473 }
474 }
475 None
476}
477
478#[allow(
479 clippy::manual_is_multiple_of,
480 clippy::manual_range_contains,
481 dead_code
482)]
483fn freq_to_channel(freq_mhz: f64) -> Option<u32> {
484 let freq = freq_mhz.round() as u32;
485 if freq >= 2412 && freq <= 2472 {
486 Some((freq - 2407) / 5)
487 } else if freq == 2484 {
488 Some(14)
489 } else if freq >= 5160 && freq <= 5885 {
490 if (freq - 5000) % 5 == 0 {
491 Some((freq - 5000) / 5)
492 } else {
493 None
494 }
495 } else if freq >= 5955 && freq <= 7115 {
496 if (freq - 5950) % 5 == 0 {
497 Some((freq - 5950) / 5)
498 } else {
499 None
500 }
501 } else {
502 None
503 }
504}
505
506#[allow(dead_code)]
507fn get_wifi_card_model(iface: &str) -> Option<String> {
508 let vendor = std::fs::read_to_string(format!("/sys/class/net/{}/device/vendor", iface)).ok()?;
509 let device = std::fs::read_to_string(format!("/sys/class/net/{}/device/device", iface)).ok()?;
510 let vendor_clean = vendor.trim().trim_start_matches("0x").to_lowercase();
511 let device_clean = device.trim().trim_start_matches("0x").to_lowercase();
512
513 let vendor_name = lookup_pci_vendor(&vendor_clean);
514 let model_name = crate::gpu::lookup_pci_device(&vendor_clean, &device_clean);
515
516 match (vendor_name, model_name) {
517 (Some(v), Some(m)) => {
518 let v_clean = v.replace(", Inc.", "").replace(" Corporation", "");
519 if m.to_lowercase().contains(&v_clean.to_lowercase())
520 || m.to_lowercase().contains(
521 &v_clean
522 .split_whitespace()
523 .next()
524 .unwrap_or("")
525 .to_lowercase(),
526 )
527 {
528 Some(m)
529 } else {
530 Some(format!("{} {}", v_clean, m))
531 }
532 }
533 (None, Some(m)) => Some(m),
534 _ => None,
535 }
536}
537
538#[allow(dead_code)]
539fn clean_rate(rate: &str) -> String {
540 rate.replace("MBit/s", "Mbps")
541 .replace("GBit/s", "Gbps")
542 .replace("Bit/s", "bps")
543}
544
545#[derive(Debug, Clone)]
546pub struct WifiLink {
547 pub freq: Option<f64>,
548 pub rx_rate: Option<String>,
549 pub tx_rate: Option<String>,
550}
551
552#[allow(dead_code)]
553pub fn parse_iw_link_output(stdout: &str) -> (Option<String>, Vec<WifiLink>) {
554 let mut ssid = None;
555 let mut links = Vec::new();
556 let mut current_link = None;
557
558 for line in stdout.lines() {
559 let trimmed = line.trim();
560 if trimmed.starts_with("Connected to") || trimmed.starts_with("link") {
561 if let Some(link) = current_link.take() {
562 links.push(link);
563 }
564 current_link = Some(WifiLink {
565 freq: None,
566 rx_rate: None,
567 tx_rate: None,
568 });
569 } else if trimmed.starts_with("SSID:") {
570 ssid = Some(trimmed.strip_prefix("SSID:").unwrap().trim().to_string());
571 } else if trimmed.starts_with("freq:") {
572 if let Some(ref mut link) = current_link {
573 let freq_str = trimmed.strip_prefix("freq:").unwrap().trim();
574 link.freq = freq_str.parse::<f64>().ok();
575 }
576 } else if trimmed.starts_with("rx bitrate:") {
577 if let Some(ref mut link) = current_link {
578 let rx_str = trimmed.strip_prefix("rx bitrate:").unwrap().trim();
579 let rate = rx_str
580 .split_whitespace()
581 .take(2)
582 .collect::<Vec<&str>>()
583 .join(" ");
584 link.rx_rate = Some(rate);
585 }
586 } else if trimmed.starts_with("tx bitrate:") {
587 if let Some(ref mut link) = current_link {
588 let tx_str = trimmed.strip_prefix("tx bitrate:").unwrap().trim();
589 let rate = tx_str
590 .split_whitespace()
591 .take(2)
592 .collect::<Vec<&str>>()
593 .join(" ");
594 link.tx_rate = Some(rate);
595 }
596 }
597 }
598 if let Some(link) = current_link {
599 links.push(link);
600 }
601 (ssid, links)
602}
603
604#[allow(dead_code)]
605pub fn parse_netsh_output(stdout: &str) -> Option<String> {
606 let mut ssid = None;
607 let mut rx = None;
608 let mut tx = None;
609 let mut band = None;
610 for line in stdout.lines() {
611 let trimmed = line.trim();
612 if trimmed.starts_with("SSID") {
613 if let Some(idx) = trimmed.find(':') {
614 let val = trimmed[idx + 1..].trim().to_string();
615 if !val.is_empty() {
616 ssid = Some(val);
617 }
618 }
619 } else if trimmed.starts_with("Receive rate (Mbps)") {
620 if let Some(idx) = trimmed.find(':') {
621 let val = trimmed[idx + 1..].trim().to_string();
622 if !val.is_empty() {
623 rx = Some(val);
624 }
625 }
626 } else if trimmed.starts_with("Transmit rate (Mbps)") {
627 if let Some(idx) = trimmed.find(':') {
628 let val = trimmed[idx + 1..].trim().to_string();
629 if !val.is_empty() {
630 tx = Some(val);
631 }
632 }
633 } else if trimmed.starts_with("Band") {
634 if let Some(idx) = trimmed.find(':') {
635 let val = trimmed[idx + 1..].trim().to_string();
636 if !val.is_empty() {
637 band = Some(val);
638 }
639 }
640 }
641 }
642 if let Some(s) = ssid {
643 let mut rate_strs = Vec::new();
644 if let Some(rx_val) = rx {
645 if rx_val != "0" {
646 rate_strs.push(format!("↓{} Mbps", rx_val));
647 }
648 }
649 if let Some(tx_val) = tx {
650 if tx_val != "0" {
651 rate_strs.push(format!("↑{} Mbps", tx_val));
652 }
653 }
654 let info = match (band, rate_strs.is_empty()) {
655 (Some(b), false) => format!("{} [{}]", b, rate_strs.join(" ")),
656 (Some(b), true) => b,
657 (None, false) => rate_strs.join(" "),
658 _ => String::new(),
659 };
660 if !info.is_empty() {
661 Some(format!("{} ({})", s, info))
662 } else {
663 Some(s)
664 }
665 } else {
666 None
667 }
668}
669
670pub fn detect_dns() -> Vec<String> {
685 #[cfg(target_os = "macos")]
686 {
687 if let Some(config) = crate::macos_ffi::get_primary_service_dns() {
694 return config.servers;
695 }
696 }
697 #[cfg(any(target_os = "linux", target_os = "macos"))]
698 {
699 if let Ok(content) = std::fs::read_to_string("/etc/resolv.conf") {
700 return parse_resolv_conf(&content);
701 }
702 }
703 #[cfg(target_os = "windows")]
704 {
705 windows_dns_servers()
706 }
707 #[cfg(not(target_os = "windows"))]
708 Vec::new()
709}
710
711#[cfg(any(target_os = "linux", target_os = "macos", test))]
712pub fn parse_resolv_conf(content: &str) -> Vec<String> {
713 content
714 .lines()
715 .filter_map(|line| {
716 let line = line.trim();
717 if line.starts_with('#') || line.starts_with(';') {
718 return None;
719 }
720 let mut parts = line.split_whitespace();
721 if parts.next()? == "nameserver" {
722 parts.next().map(|s| s.to_string())
723 } else {
724 None
725 }
726 })
727 .collect()
728}
729
730pub fn detect_domain() -> Option<String> {
743 #[cfg(target_os = "linux")]
744 {
745 if let (Some(iface), Some(status)) = (default_route_interface(), resolvectl_status()) {
749 if let DefaultRouteDomain::Managed(domain) =
750 resolve_default_route_domain(&parse_resolvectl_domains(status), &iface)
751 {
752 return domain;
753 }
754 }
755 read_resolv_conf_domain()
756 }
757 #[cfg(target_os = "macos")]
758 {
759 if let Some(config) = crate::macos_ffi::get_primary_service_dns() {
764 return config.domain;
765 }
766 read_resolv_conf_domain()
767 }
768 #[cfg(target_os = "windows")]
769 {
770 detect_domain_windows()
771 }
772 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
773 {
774 None
775 }
776}
777
778#[cfg(any(target_os = "linux", target_os = "macos"))]
780fn read_resolv_conf_domain() -> Option<String> {
781 std::fs::read_to_string("/etc/resolv.conf")
782 .ok()
783 .and_then(|content| parse_domain_from_resolv_conf(&content))
784}
785
786#[cfg(target_os = "linux")]
793fn default_route_interface() -> Option<String> {
794 std::fs::read_to_string("/proc/net/route")
795 .ok()
796 .and_then(|content| parse_proc_net_route(&content))
797}
798
799#[cfg(target_os = "linux")]
801static RESOLVECTL_STATUS: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
802
803#[cfg(target_os = "linux")]
809fn resolvectl_status() -> Option<&'static str> {
810 RESOLVECTL_STATUS
811 .get_or_init(|| {
812 let output = std::process::Command::new("resolvectl")
813 .args(["status", "--no-pager"])
814 .output()
815 .ok()?;
816 if !output.status.success() {
817 return None;
818 }
819 Some(String::from_utf8_lossy(&output.stdout).into_owned())
820 })
821 .as_deref()
822}
823
824#[cfg(any(target_os = "windows", test))]
826#[derive(Debug, Clone, PartialEq, Eq)]
827struct WinAdapterDnsInfo {
828 friendly_name: String,
829 dns_suffix: String,
830 is_up: bool,
831 is_loopback: bool,
832 dns_servers: Vec<std::net::IpAddr>,
834}
835
836#[cfg(any(target_os = "windows", test))]
838const AF_INET: u16 = 2;
839#[cfg(any(target_os = "windows", test))]
841const AF_INET6: u16 = 23;
842
843#[cfg(any(target_os = "windows", test))]
854fn parse_sockaddr(bytes: &[u8]) -> Option<std::net::IpAddr> {
855 let family = u16::from_ne_bytes([*bytes.first()?, *bytes.get(1)?]);
856 match family {
857 AF_INET => {
858 let octets: [u8; 4] = bytes.get(4..8)?.try_into().ok()?;
859 Some(std::net::IpAddr::V4(octets.into()))
860 }
861 AF_INET6 => {
862 let octets: [u8; 16] = bytes.get(8..24)?.try_into().ok()?;
863 Some(std::net::IpAddr::V6(octets.into()))
864 }
865 _ => None,
866 }
867}
868
869#[cfg(target_os = "windows")]
882fn windows_dns_servers() -> Vec<String> {
883 let mut servers: Vec<String> = get_windows_adapters_dns_info()
884 .into_iter()
885 .flat_map(|adapter| adapter.dns_servers)
886 .filter(|ip| ip.is_ipv4())
887 .map(|ip| ip.to_string())
888 .collect();
889 servers.sort();
890 servers.dedup();
891 servers
892}
893
894#[cfg(any(target_os = "windows", test))]
900fn resolve_windows_default_domain(
901 active_iface: Option<&str>,
902 adapters: &[WinAdapterDnsInfo],
903 global_domain: Option<&str>,
904) -> Option<String> {
905 if let Some(iface) = active_iface {
906 if let Some(adapter) = adapters
907 .iter()
908 .find(|a| a.friendly_name.eq_ignore_ascii_case(iface))
909 {
910 if let Some(suffix) = clean_domain(&adapter.dns_suffix) {
911 return Some(suffix);
912 }
913 }
914 }
915
916 if let Some(global) = global_domain.and_then(clean_domain) {
917 return Some(global);
918 }
919
920 None
921}
922
923#[cfg(any(target_os = "windows", test))]
925fn parse_windows_domain_search(
926 global_search_list: Option<&str>,
927 adapters: &[WinAdapterDnsInfo],
928) -> Vec<String> {
929 let mut results = Vec::new();
930
931 if let Some(raw) = global_search_list {
932 let global_domains: Vec<String> = raw
933 .split(&[',', ' '][..])
934 .filter_map(clean_domain)
935 .collect();
936 if !global_domains.is_empty() {
937 results.extend(format_global_search_domains(&global_domains));
938 }
939 }
940
941 for adapter in adapters {
942 if adapter.is_up && !adapter.is_loopback {
943 if let Some(suffix) = clean_domain(&adapter.dns_suffix) {
944 results.push(format!("{}: {}", adapter.friendly_name, suffix));
945 }
946 }
947 }
948
949 results
950}
951
952#[cfg(target_os = "windows")]
954fn detect_domain_windows() -> Option<String> {
955 let (_, active_iface) = detect_active_interface_and_local_ip();
956 let adapters = get_windows_adapters_dns_info();
957 let global_domain = crate::win_reg::get_reg_string(
958 crate::win_reg::HKEY_LOCAL_MACHINE,
959 "SYSTEM\\CurrentControlSet\\Services\\Tcpip\\Parameters",
960 "Domain",
961 );
962 resolve_windows_default_domain(active_iface.as_deref(), &adapters, global_domain.as_deref())
963}
964
965#[cfg(target_os = "windows")]
967fn get_windows_adapters_dns_info() -> Vec<WinAdapterDnsInfo> {
968 use std::ffi::OsString;
969 use std::os::windows::ffi::OsStringExt;
970 use std::ptr;
971
972 #[repr(C)]
973 #[allow(non_snake_case)]
974 struct IpAdapterAddresses {
975 Length: u32,
976 IfIndex: u32,
977 Next: *mut IpAdapterAddresses,
978 AdapterName: *const i8,
979 FirstUnicastAddress: *const std::ffi::c_void,
980 FirstAnycastAddress: *const std::ffi::c_void,
981 FirstMulticastAddress: *const std::ffi::c_void,
982 FirstDnsServerAddress: *const IpAdapterDnsServerAddress,
983 DnsSuffix: *const u16,
984 Description: *const u16,
985 FriendlyName: *const u16,
986 PhysicalAddress: [u8; 8],
987 PhysicalAddressLength: u32,
988 Flags: u32,
989 Mtu: u32,
990 IfType: u32,
991 OperStatus: u32,
992 }
993
994 #[repr(C)]
996 #[allow(non_snake_case)]
997 struct SocketAddress {
998 lpSockaddr: *const u8,
999 iSockaddrLength: i32,
1000 }
1001
1002 #[repr(C)]
1007 #[allow(non_snake_case)]
1008 struct IpAdapterDnsServerAddress {
1009 Length: u32,
1010 Reserved: u32,
1011 Next: *const IpAdapterDnsServerAddress,
1012 Address: SocketAddress,
1013 }
1014
1015 const AF_UNSPEC: u32 = 0;
1016 const GAA_FLAGS: u32 = 0x06;
1024 const IF_TYPE_SOFTWARE_LOOPBACK: u32 = 24;
1025 const IF_OPER_STATUS_UP: u32 = 1;
1026
1027 #[link(name = "iphlpapi")]
1028 extern "system" {
1029 fn GetAdaptersAddresses(
1030 family: u32,
1031 flags: u32,
1032 reserved: *mut std::ffi::c_void,
1033 adapter_addresses: *mut IpAdapterAddresses,
1034 size_pointer: *mut u32,
1035 ) -> u32;
1036 }
1037
1038 let mut size: u32 = 0;
1039 unsafe {
1041 GetAdaptersAddresses(
1042 AF_UNSPEC,
1043 GAA_FLAGS,
1044 ptr::null_mut(),
1045 ptr::null_mut(),
1046 &mut size,
1047 );
1048 }
1049 if size == 0 {
1050 return Vec::new();
1051 }
1052
1053 let mut buf = vec![0u8; size as usize];
1054 let ret = unsafe {
1056 GetAdaptersAddresses(
1057 AF_UNSPEC,
1058 GAA_FLAGS,
1059 ptr::null_mut(),
1060 buf.as_mut_ptr() as *mut IpAdapterAddresses,
1061 &mut size,
1062 )
1063 };
1064 if ret != 0 {
1065 return Vec::new();
1066 }
1067
1068 let mut result = Vec::new();
1069 let mut curr = buf.as_ptr() as *const IpAdapterAddresses;
1070
1071 unsafe {
1072 while !curr.is_null() {
1073 let adapter = &*curr;
1074
1075 let friendly_name = if !adapter.FriendlyName.is_null() {
1076 let mut len = 0;
1077 while *adapter.FriendlyName.add(len) != 0 {
1078 len += 1;
1079 }
1080 let slice = std::slice::from_raw_parts(adapter.FriendlyName, len);
1081 OsString::from_wide(slice).to_string_lossy().into_owned()
1082 } else {
1083 String::new()
1084 };
1085
1086 let adapter_name = if !adapter.AdapterName.is_null() {
1087 std::ffi::CStr::from_ptr(adapter.AdapterName)
1088 .to_string_lossy()
1089 .into_owned()
1090 } else {
1091 String::new()
1092 };
1093
1094 let mut dns_suffix = if !adapter.DnsSuffix.is_null() {
1095 let mut len = 0;
1096 while *adapter.DnsSuffix.add(len) != 0 {
1097 len += 1;
1098 }
1099 let slice = std::slice::from_raw_parts(adapter.DnsSuffix, len);
1100 OsString::from_wide(slice).to_string_lossy().into_owned()
1101 } else {
1102 String::new()
1103 };
1104
1105 if dns_suffix.trim().is_empty() && !adapter_name.is_empty() {
1106 let subkey = format!(
1107 "SYSTEM\\CurrentControlSet\\Services\\Tcpip\\Parameters\\Interfaces\\{}",
1108 adapter_name
1109 );
1110 if let Some(s) = crate::win_reg::get_reg_string(
1111 crate::win_reg::HKEY_LOCAL_MACHINE,
1112 &subkey,
1113 "SearchList",
1114 ) {
1115 dns_suffix = s;
1116 } else if let Some(s) = crate::win_reg::get_reg_string(
1117 crate::win_reg::HKEY_LOCAL_MACHINE,
1118 &subkey,
1119 "DhcpSearchList",
1120 ) {
1121 dns_suffix = s;
1122 } else if let Some(s) = crate::win_reg::get_reg_string(
1123 crate::win_reg::HKEY_LOCAL_MACHINE,
1124 &subkey,
1125 "Domain",
1126 ) {
1127 dns_suffix = s;
1128 } else if let Some(s) = crate::win_reg::get_reg_string(
1129 crate::win_reg::HKEY_LOCAL_MACHINE,
1130 &subkey,
1131 "DhcpDomain",
1132 ) {
1133 dns_suffix = s;
1134 }
1135 }
1136
1137 let mut dns_servers = Vec::new();
1142 let mut dns_entry = adapter.FirstDnsServerAddress;
1143 while !dns_entry.is_null() {
1144 let entry = &*dns_entry;
1145 if !entry.Address.lpSockaddr.is_null() && entry.Address.iSockaddrLength > 0 {
1146 let len = entry.Address.iSockaddrLength as usize;
1147 let bytes = std::slice::from_raw_parts(entry.Address.lpSockaddr, len);
1148 if let Some(ip) = parse_sockaddr(bytes) {
1149 dns_servers.push(ip);
1150 }
1151 }
1152 dns_entry = entry.Next;
1153 }
1154
1155 result.push(WinAdapterDnsInfo {
1156 friendly_name,
1157 dns_suffix,
1158 is_up: adapter.OperStatus == IF_OPER_STATUS_UP,
1159 is_loopback: adapter.IfType == IF_TYPE_SOFTWARE_LOOPBACK,
1160 dns_servers,
1161 });
1162
1163 curr = adapter.Next;
1164 }
1165 }
1166
1167 result
1168}
1169
1170#[cfg(any(target_os = "windows", test))]
1175fn clean_domain(raw: &str) -> Option<String> {
1176 let trimmed = raw.trim();
1177 if trimmed.is_empty() {
1178 None
1179 } else {
1180 Some(trimmed.to_string())
1181 }
1182}
1183
1184pub fn detect_domain_search() -> Vec<String> {
1192 #[cfg(target_os = "linux")]
1193 {
1194 if let Some(status) = resolvectl_status() {
1196 let result = parse_resolvectl_search(status);
1197 if !result.is_empty() {
1198 return result;
1199 }
1200 }
1201 if let Ok(content) = std::fs::read_to_string("/etc/resolv.conf") {
1202 return format_global_search_domains(&parse_search_from_resolv_conf(&content));
1203 }
1204 }
1205 #[cfg(target_os = "macos")]
1206 {
1207 if let Ok(content) = std::fs::read_to_string("/etc/resolv.conf") {
1208 return format_global_search_domains(&parse_search_from_resolv_conf(&content));
1209 }
1210 }
1211 #[cfg(target_os = "windows")]
1212 {
1213 let adapters = get_windows_adapters_dns_info();
1214 let global_search_list = crate::win_reg::get_reg_string(
1215 crate::win_reg::HKEY_LOCAL_MACHINE,
1216 "SYSTEM\\CurrentControlSet\\Services\\Tcpip\\Parameters",
1217 "SearchList",
1218 );
1219 let results = parse_windows_domain_search(global_search_list.as_deref(), &adapters);
1220 if !results.is_empty() {
1221 return results;
1222 }
1223 }
1224 Vec::new()
1225}
1226
1227#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))]
1233const GLOBAL_SEARCH_SCOPE: &str = "global";
1234
1235#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))]
1238pub fn format_global_search_domains(domains: &[String]) -> Vec<String> {
1239 if domains.is_empty() {
1240 Vec::new()
1241 } else {
1242 vec![format!("{}: {}", GLOBAL_SEARCH_SCOPE, domains.join(", "))]
1243 }
1244}
1245
1246#[cfg(any(target_os = "linux", target_os = "macos", test))]
1249pub fn parse_domain_from_resolv_conf(content: &str) -> Option<String> {
1250 let mut first_search: Option<String> = None;
1251 for line in content.lines() {
1252 let line = line.trim();
1253 if line.starts_with('#') || line.starts_with(';') {
1254 continue;
1255 }
1256 let mut parts = line.split_whitespace();
1257 match parts.next() {
1258 Some("domain") => {
1259 if let Some(d) = parts.next() {
1260 return Some(d.to_string());
1261 }
1262 }
1263 Some("search") if first_search.is_none() => {
1264 if let Some(d) = parts.next() {
1265 first_search = Some(d.to_string());
1266 }
1267 }
1268 _ => {}
1269 }
1270 }
1271 first_search
1272}
1273
1274#[cfg(any(target_os = "linux", target_os = "macos", test))]
1276pub fn parse_search_from_resolv_conf(content: &str) -> Vec<String> {
1277 for line in content.lines() {
1278 let line = line.trim();
1279 if line.starts_with('#') || line.starts_with(';') {
1280 continue;
1281 }
1282 let mut parts = line.split_whitespace();
1283 if parts.next() == Some("search") {
1284 let domains: Vec<String> = parts.map(|s| s.to_string()).collect();
1285 if !domains.is_empty() {
1286 return domains;
1287 }
1288 }
1289 }
1290 Vec::new()
1291}
1292
1293#[derive(Debug, Clone, Default, PartialEq, Eq)]
1295#[cfg(any(target_os = "linux", test))]
1296pub struct LinkDomains {
1297 pub interface: String,
1299 pub search: Vec<String>,
1302}
1303
1304#[derive(Debug, Clone, Default, PartialEq, Eq)]
1306#[cfg(any(target_os = "linux", test))]
1307pub struct ResolvectlDomains {
1308 pub global: Vec<String>,
1311 pub links: Vec<LinkDomains>,
1313}
1314
1315#[derive(Debug, Clone, PartialEq, Eq)]
1317#[cfg(any(target_os = "linux", test))]
1318pub enum DefaultRouteDomain {
1319 Managed(Option<String>),
1324 Unmanaged,
1327}
1328
1329#[cfg(any(target_os = "linux", test))]
1335pub fn resolve_default_route_domain(
1336 domains: &ResolvectlDomains,
1337 interface: &str,
1338) -> DefaultRouteDomain {
1339 match domains.links.iter().find(|l| l.interface == interface) {
1340 Some(link) => DefaultRouteDomain::Managed(
1341 link.search
1342 .first()
1343 .or_else(|| domains.global.first())
1344 .cloned(),
1345 ),
1346 None => DefaultRouteDomain::Unmanaged,
1347 }
1348}
1349
1350#[cfg(any(target_os = "linux", test))]
1364pub fn parse_resolvectl_domains(content: &str) -> ResolvectlDomains {
1365 let mut out = ResolvectlDomains::default();
1366 let mut section: Option<String> = None;
1368 let mut in_domain_value = false;
1370
1371 for line in content.lines() {
1372 let trimmed = line.trim();
1373 if trimmed.is_empty() {
1374 in_domain_value = false;
1375 continue;
1376 }
1377
1378 if let Some(iface) = trimmed
1381 .strip_prefix("Link ")
1382 .and_then(|rest| rest.split_once('('))
1383 .and_then(|(_, rest)| rest.split_once(')'))
1384 .map(|(iface, _)| iface)
1385 {
1386 in_domain_value = false;
1387 section = Some(iface.to_string());
1388 if !out.links.iter().any(|l| l.interface == iface) {
1390 out.links.push(LinkDomains {
1391 interface: iface.to_string(),
1392 search: Vec::new(),
1393 });
1394 }
1395 continue;
1396 }
1397
1398 if trimmed == "Global" {
1399 in_domain_value = false;
1400 section = None;
1401 continue;
1402 }
1403
1404 if let Some(value) = trimmed
1405 .strip_prefix("DNS Domain:")
1406 .or_else(|| trimmed.strip_prefix("DNS Search Domains:"))
1407 {
1408 in_domain_value = true;
1409 push_resolvectl_domains(&mut out, section.as_deref(), value);
1410 continue;
1411 }
1412
1413 if in_domain_value && !trimmed.contains(':') {
1416 push_resolvectl_domains(&mut out, section.as_deref(), trimmed);
1417 continue;
1418 }
1419 in_domain_value = false;
1420 }
1421 out
1422}
1423
1424#[cfg(any(target_os = "linux", test))]
1427fn push_resolvectl_domains(out: &mut ResolvectlDomains, section: Option<&str>, value: &str) {
1428 let domains = value
1429 .split_whitespace()
1430 .filter(|d| !d.starts_with('~'))
1431 .map(|d| d.to_string());
1432 match section {
1433 Some(iface) => match out.links.iter_mut().find(|l| l.interface == iface) {
1434 Some(link) => link.search.extend(domains),
1435 None => out.links.push(LinkDomains {
1436 interface: iface.to_string(),
1437 search: domains.collect(),
1438 }),
1439 },
1440 None => out.global.extend(domains),
1441 }
1442}
1443
1444#[cfg(any(target_os = "linux", test))]
1449pub fn parse_resolvectl_search(content: &str) -> Vec<String> {
1450 parse_resolvectl_domains(content)
1451 .links
1452 .into_iter()
1453 .filter(|link| !link.search.is_empty())
1454 .map(|link| format!("{}: {}", link.interface, link.search.join(", ")))
1455 .collect()
1456}
1457
1458#[cfg(all(test, target_os = "macos"))]
1459mod macos_dns_tests {
1460 use super::*;
1461
1462 #[test]
1472 fn test_dns_comes_from_the_primary_service_not_resolv_conf() {
1473 let Some(config) = crate::macos_ffi::get_primary_service_dns() else {
1474 return;
1477 };
1478 assert_eq!(
1479 detect_dns(),
1480 config.servers,
1481 "detect_dns must report the default route's own servers"
1482 );
1483 assert_eq!(
1484 detect_domain(),
1485 config.domain,
1486 "detect_domain must report the default route's own domain"
1487 );
1488 }
1489
1490 #[test]
1497 fn test_empty_primary_config_is_not_a_fallback_signal() {
1498 let empty = crate::macos_ffi::ScDnsConfig::default();
1499 assert!(empty.servers.is_empty());
1500 assert!(empty.domain.is_none());
1501 let authoritative: Option<crate::macos_ffi::ScDnsConfig> = Some(empty);
1505 assert!(authoritative.is_some());
1506 }
1507}
1508
1509#[cfg(test)]
1510mod tests {
1511 use super::*;
1512
1513 #[test]
1516 fn parse_public_ip_accepts_one_address() {
1517 assert_eq!(
1519 parse_public_ip("47.148.63.226").as_deref(),
1520 Some("47.148.63.226")
1521 );
1522 assert_eq!(
1523 parse_public_ip(" 203.0.113.7\n").as_deref(),
1524 Some("203.0.113.7")
1525 );
1526 assert_eq!(
1527 parse_public_ip("2001:db8::1").as_deref(),
1528 Some("2001:db8::1")
1529 );
1530 }
1531
1532 #[test]
1533 fn parse_public_ip_rejects_anything_else() {
1534 assert_eq!(
1536 parse_public_ip(r#"{"status":429,"error":{"title":"Rate limit exceeded"}}"#),
1537 None
1538 );
1539 assert_eq!(
1540 parse_public_ip("<html><body>Please sign in to the Wi-Fi</body></html>"),
1541 None
1542 );
1543 assert_eq!(parse_public_ip(""), None);
1544 assert_eq!(
1545 parse_public_ip("203.0.113.7 203.0.113.8"),
1546 None,
1547 "two values"
1548 );
1549 assert_eq!(parse_public_ip("203.0.113.999"), None, "not an address");
1550 }
1551
1552 fn sockaddr_in(octets: [u8; 4]) -> Vec<u8> {
1560 let mut v = Vec::new();
1561 v.extend_from_slice(&AF_INET.to_ne_bytes());
1562 v.extend_from_slice(&53u16.to_be_bytes()); v.extend_from_slice(&octets); v.extend_from_slice(&[0u8; 8]); v
1566 }
1567
1568 fn sockaddr_in6(octets: [u8; 16]) -> Vec<u8> {
1570 let mut v = Vec::new();
1571 v.extend_from_slice(&AF_INET6.to_ne_bytes());
1572 v.extend_from_slice(&53u16.to_be_bytes());
1573 v.extend_from_slice(&0u32.to_ne_bytes()); v.extend_from_slice(&octets); v.extend_from_slice(&0u32.to_ne_bytes()); v
1577 }
1578
1579 #[test]
1580 fn test_parse_sockaddr_reads_ipv4_at_offset_four() {
1581 assert_eq!(
1583 parse_sockaddr(&sockaddr_in([10, 10, 1, 1])),
1584 Some("10.10.1.1".parse().unwrap())
1585 );
1586 assert_eq!(
1587 parse_sockaddr(&sockaddr_in([100, 101, 255, 254])),
1588 Some("100.101.255.254".parse().unwrap())
1589 );
1590 }
1591
1592 #[test]
1593 fn test_parse_sockaddr_reads_ipv6_at_offset_eight() {
1594 let mut o = [0u8; 16];
1597 o[0] = 0xfe;
1598 o[1] = 0xc0;
1599 o[6] = 0xff;
1600 o[7] = 0xff;
1601 o[15] = 1;
1602 assert_eq!(
1603 parse_sockaddr(&sockaddr_in6(o)),
1604 Some("fec0:0:0:ffff::1".parse().unwrap())
1605 );
1606 }
1607
1608 #[test]
1609 fn test_parse_sockaddr_rejects_short_and_unknown_buffers() {
1610 assert_eq!(parse_sockaddr(&[]), None);
1613 assert_eq!(parse_sockaddr(&AF_INET.to_ne_bytes()), None);
1614 assert_eq!(parse_sockaddr(&sockaddr_in([1, 2, 3, 4])[..7]), None);
1615 assert_eq!(parse_sockaddr(&sockaddr_in6([0; 16])[..20]), None);
1616 let mut wrong_family = sockaddr_in6([0; 16]);
1618 wrong_family[0] = 10;
1619 wrong_family[1] = 0;
1620 assert_eq!(parse_sockaddr(&wrong_family), None);
1621 }
1622
1623 #[test]
1624 fn test_clean_domain() {
1625 assert_eq!(
1627 clean_domain("corp.example.com"),
1628 Some("corp.example.com".to_string())
1629 );
1630 assert_eq!(
1632 clean_domain(" example.org \n"),
1633 Some("example.org".to_string())
1634 );
1635 assert_eq!(clean_domain(""), None);
1637 assert_eq!(clean_domain(" "), None);
1638 }
1639
1640 #[test]
1641 fn test_match_active_interface() {
1642 use std::net::IpAddr;
1643 let target: IpAddr = "192.168.1.50".parse().unwrap();
1644 let ifaces = vec![
1645 ("lo".to_string(), vec!["127.0.0.1".parse().unwrap()]),
1646 (
1647 "Ethernet".to_string(),
1648 vec!["192.168.1.50".parse().unwrap(), "fe80::1".parse().unwrap()],
1649 ),
1650 ("Wi-Fi".to_string(), vec!["10.0.0.2".parse().unwrap()]),
1651 ];
1652 assert_eq!(
1654 match_active_interface(ifaces.into_iter(), target),
1655 Some("Ethernet".to_string())
1656 );
1657
1658 let orphan: IpAddr = "8.8.8.8".parse().unwrap();
1660 let ifaces2 = vec![(
1661 "lo".to_string(),
1662 vec!["127.0.0.1".parse::<IpAddr>().unwrap()],
1663 )];
1664 assert_eq!(match_active_interface(ifaces2.into_iter(), orphan), None);
1665 }
1666
1667 #[test]
1668 fn test_format_bytes() {
1669 assert_eq!(format_bytes(500), "500 B");
1670 assert_eq!(format_bytes(1024), "1.0 KB");
1671 assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
1672 assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
1673 assert_eq!(format_bytes(1536), "1.5 KB");
1674 }
1675
1676 #[test]
1677 fn test_parse_proc_net_route() {
1678 let sample =
1679 "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n\
1680 wlan0\t0000A8C0\t00000000\t0001\t0\t0\t600\t0000FFFF\t0\t0\t0\n\
1681 wlan0\t00000000\t0100A8C0\t0003\t0\t0\t600\t00000000\t0\t0\t0\n";
1682 assert_eq!(parse_proc_net_route(sample), Some("wlan0".to_string()));
1683
1684 let sample_no_default =
1685 "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n\
1686 wlan0\t0000A8C0\t00000000\t0001\t0\t0\t600\t0000FFFF\t0\t0\t0\n";
1687 assert_eq!(parse_proc_net_route(sample_no_default), None);
1688 }
1689
1690 #[test]
1691 fn test_parse_netsh_output() {
1692 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";
1693 assert_eq!(
1694 parse_netsh_output(sample),
1695 Some("Office_Wi-Fi (5 GHz [↓433 Mbps ↑866 Mbps])".to_string())
1696 );
1697 }
1698
1699 #[test]
1700 fn test_parse_iw_link_output() {
1701 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";
1702 let (ssid, links) = parse_iw_link_output(sample);
1703 assert_eq!(ssid, Some("OfficeNet".to_string()));
1704 assert_eq!(links.len(), 1);
1705 assert_eq!(links[0].freq, Some(6135.0));
1706 assert_eq!(links[0].rx_rate, Some("6.0 MBit/s".to_string()));
1707 assert_eq!(links[0].tx_rate, Some("864.6 MBit/s".to_string()));
1708
1709 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";
1711 let (ssid_mlo, links_mlo) = parse_iw_link_output(sample_mlo);
1712 assert_eq!(ssid_mlo, Some("HomeWiFi".to_string()));
1713 assert_eq!(links_mlo.len(), 2);
1714 assert_eq!(links_mlo[0].freq, Some(5180.0));
1715 assert_eq!(links_mlo[1].freq, Some(6135.0));
1716 }
1717
1718 #[test]
1719 fn test_parse_resolv_conf() {
1720 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";
1721 assert_eq!(
1722 parse_resolv_conf(sample),
1723 vec!["192.168.1.1", "8.8.8.8", "2001:db8::1"]
1724 );
1725
1726 let empty = "# no nameservers\nsearch local\n";
1727 assert_eq!(parse_resolv_conf(empty), Vec::<String>::new());
1728 }
1729
1730 #[test]
1731 fn test_parse_domain_from_resolv_conf_domain_directive() {
1732 let s = "# test\ndomain example.com\nsearch fallback.com\nnameserver 1.1.1.1\n";
1733 assert_eq!(
1734 parse_domain_from_resolv_conf(s),
1735 Some("example.com".to_string())
1736 );
1737 }
1738
1739 #[test]
1740 fn test_parse_domain_from_resolv_conf_search_fallback() {
1741 let s = "# no domain directive\nsearch local.lan other.lan\nnameserver 1.1.1.1\n";
1742 assert_eq!(
1743 parse_domain_from_resolv_conf(s),
1744 Some("local.lan".to_string())
1745 );
1746 }
1747
1748 #[test]
1749 fn test_parse_domain_from_resolv_conf_none() {
1750 let s = "# no domain or search\nnameserver 1.1.1.1\n";
1751 assert_eq!(parse_domain_from_resolv_conf(s), None);
1752 }
1753
1754 #[test]
1755 fn test_parse_search_from_resolv_conf() {
1756 let s = "search home.local corp.example.com\nnameserver 1.1.1.1\n";
1757 assert_eq!(
1758 parse_search_from_resolv_conf(s),
1759 vec!["home.local", "corp.example.com"]
1760 );
1761 }
1762
1763 #[test]
1764 fn test_parse_resolvectl_search_basic() {
1765 let sample = "Global\n\
1766 Link 2 (lo)\n\
1767 Current Scopes: none\n\
1768 Link 3 (wlan0)\n\
1769 Current Scopes: DNS\n\
1770 DNS Domain: home.local\n\
1771 Link 4 (eth0)\n\
1772 Current Scopes: DNS\n\
1773 DNS Search Domains: corp.example.com internal.net\n";
1774 let result = parse_resolvectl_search(sample);
1775 assert_eq!(
1776 result,
1777 vec!["wlan0: home.local", "eth0: corp.example.com, internal.net"]
1778 );
1779 }
1780
1781 #[test]
1782 fn test_parse_resolvectl_search_skips_routing_domain() {
1783 let sample = "Link 2 (wlan0)\n DNS Domain: ~.\nLink 3 (eth0)\n DNS Domain: corp.net\n";
1784 let result = parse_resolvectl_search(sample);
1785 assert_eq!(result, vec!["eth0: corp.net"]);
1786 }
1787
1788 #[test]
1789 fn test_parse_resolvectl_search_empty() {
1790 let sample = "Global\n DNS Servers: 1.1.1.1\n";
1791 assert!(parse_resolvectl_search(sample).is_empty());
1792 }
1793
1794 const RESOLVECTL_VPN_SAMPLE: &str = concat!(
1802 "Global\n",
1803 " Protocols: LLMNR=resolve -mDNS -DNSOverTLS DNSSEC=no/unsupported\n",
1804 " resolv.conf mode: stub\n",
1805 "\n",
1806 "Link 2 (wlp194s0)\n",
1807 " Current Scopes: DNS LLMNR/IPv4 LLMNR/IPv6\n",
1808 " Protocols: +DefaultRoute LLMNR=resolve -mDNS -DNSOverTLS\n",
1809 " DNSSEC=no/unsupported\n",
1810 "Current DNS Server: 192.168.86.1\n",
1811 " DNS Servers: 192.168.86.1\n",
1812 " DNS Domain: lan\n",
1813 " Default Route: yes\n",
1814 "\n",
1815 "Link 3 (wt0)\n",
1816 " Current Scopes: DNS\n",
1817 " Protocols: +DefaultRoute LLMNR=resolve -mDNS -DNSOverTLS\n",
1818 " DNSSEC=no/unsupported\n",
1819 "Current DNS Server: 100.101.32.155\n",
1820 " DNS Servers: 100.101.32.155\n",
1821 " DNS Domain: netbird.cloud ~gammatile.com ~101.100.in-addr.arpa\n",
1822 " ~f.f.0.0.3.2.b.5.b.c.8.5.7.f.d.f.ip6.arpa ~.\n",
1823 " Default Route: yes\n",
1824 );
1825
1826 #[test]
1827 fn test_domain_prefers_default_route_over_vpn() {
1828 let parsed = parse_resolvectl_domains(RESOLVECTL_VPN_SAMPLE);
1832 assert_eq!(
1833 resolve_default_route_domain(&parsed, "wlp194s0"),
1834 DefaultRouteDomain::Managed(Some("lan".to_string()))
1835 );
1836 }
1837
1838 #[test]
1839 fn test_domain_reports_vpn_when_vpn_is_the_default_route() {
1840 let parsed = parse_resolvectl_domains(RESOLVECTL_VPN_SAMPLE);
1842 assert_eq!(
1843 resolve_default_route_domain(&parsed, "wt0"),
1844 DefaultRouteDomain::Managed(Some("netbird.cloud".to_string()))
1845 );
1846 }
1847
1848 #[test]
1849 fn test_domain_routing_only_entries_are_not_domains() {
1850 let parsed = parse_resolvectl_domains(RESOLVECTL_VPN_SAMPLE);
1853 let wt0 = parsed.links.iter().find(|l| l.interface == "wt0").unwrap();
1854 assert_eq!(wt0.search, vec!["netbird.cloud"]);
1855 assert!(wt0.search.iter().all(|d| !d.starts_with('~')));
1856
1857 let routing_only = "Link 5 (tun0)\n DNS Domain: ~corp.example.com ~.\n";
1858 let parsed = parse_resolvectl_domains(routing_only);
1859 assert_eq!(
1860 resolve_default_route_domain(&parsed, "tun0"),
1861 DefaultRouteDomain::Managed(None)
1862 );
1863 }
1864
1865 #[test]
1866 fn test_parse_resolvectl_domains_reads_wrapped_continuation_lines() {
1867 let sample = concat!(
1870 "Link 2 (eth0)\n",
1871 " DNS Domain: one.example.com two.example.com\n",
1872 " three.example.com ~routing.example.com\n",
1873 " Default Route: yes\n",
1874 );
1875 let parsed = parse_resolvectl_domains(sample);
1876 assert_eq!(
1877 parsed.links[0].search,
1878 vec!["one.example.com", "two.example.com", "three.example.com"]
1879 );
1880 assert!(!parsed.links[0].search.iter().any(|d| d.contains("yes")));
1882 }
1883
1884 #[test]
1885 fn test_parse_resolvectl_domains_ignores_other_wrapped_fields() {
1886 let sample = concat!(
1888 "Link 2 (eth0)\n",
1889 " Protocols: +DefaultRoute LLMNR=resolve -mDNS -DNSOverTLS\n",
1890 " DNSSEC=no/unsupported\n",
1891 " DNS Domain: real.example.com\n",
1892 );
1893 let parsed = parse_resolvectl_domains(sample);
1894 assert_eq!(parsed.links[0].search, vec!["real.example.com"]);
1895 }
1896
1897 #[test]
1898 fn test_domain_unmanaged_link_falls_back() {
1899 let parsed = parse_resolvectl_domains(RESOLVECTL_VPN_SAMPLE);
1902 assert_eq!(
1903 resolve_default_route_domain(&parsed, "ppp0"),
1904 DefaultRouteDomain::Unmanaged
1905 );
1906 }
1907
1908 #[test]
1909 fn test_domain_managed_without_domain_does_not_borrow_from_other_links() {
1910 let sample = concat!(
1913 "Link 2 (wlp194s0)\n",
1914 " Default Route: yes\n",
1915 "Link 3 (wt0)\n",
1916 " DNS Domain: netbird.cloud\n",
1917 );
1918 let parsed = parse_resolvectl_domains(sample);
1919 assert_eq!(
1920 resolve_default_route_domain(&parsed, "wlp194s0"),
1921 DefaultRouteDomain::Managed(None)
1922 );
1923 }
1924
1925 #[test]
1926 fn test_domain_falls_back_to_global_but_not_to_another_link() {
1927 let sample = concat!(
1930 "Global\n",
1931 " DNS Domain: corp.example.com\n",
1932 "Link 2 (wlp194s0)\n",
1933 " Default Route: yes\n",
1934 "Link 3 (wt0)\n",
1935 " DNS Domain: netbird.cloud\n",
1936 );
1937 let parsed = parse_resolvectl_domains(sample);
1938 assert_eq!(parsed.global, vec!["corp.example.com"]);
1939 assert_eq!(
1940 resolve_default_route_domain(&parsed, "wlp194s0"),
1941 DefaultRouteDomain::Managed(Some("corp.example.com".to_string()))
1942 );
1943 }
1944
1945 #[test]
1946 fn test_parse_resolvectl_domains_merges_both_domain_labels() {
1947 let sample = concat!(
1949 "Link 2 (eth0)\n",
1950 " DNS Domain: a.example.com\n",
1951 "DNS Search Domains: b.example.com\n",
1952 );
1953 let parsed = parse_resolvectl_domains(sample);
1954 assert_eq!(parsed.links.len(), 1);
1955 assert_eq!(
1956 parsed.links[0].search,
1957 vec!["a.example.com", "b.example.com"]
1958 );
1959 assert_eq!(
1960 parse_resolvectl_search(sample),
1961 vec!["eth0: a.example.com, b.example.com"]
1962 );
1963 }
1964
1965 #[test]
1968 fn test_global_search_domains_match_per_link_shape() {
1969 let per_link = parse_resolvectl_search("Link 2 (eth0)\n DNS Domain: a.example.com\n");
1973 assert_eq!(per_link, vec!["eth0: a.example.com"]);
1974
1975 let global = format_global_search_domains(&["a.example.com".to_string()]);
1976 assert_eq!(global, vec!["global: a.example.com"]);
1977
1978 assert_eq!(per_link.len(), global.len());
1980 for entry in per_link.iter().chain(global.iter()) {
1981 let (scope, domains) = entry.split_once(": ").expect("entry must carry a scope");
1982 assert!(!scope.is_empty() && !domains.is_empty());
1983 }
1984 }
1985
1986 #[test]
1987 fn test_global_search_domains_group_into_one_entry() {
1988 let parsed = parse_search_from_resolv_conf("search a.example.com b.example.com c.net\n");
1992 assert_eq!(parsed.len(), 3); assert_eq!(
1994 format_global_search_domains(&parsed),
1995 vec!["global: a.example.com, b.example.com, c.net"]
1996 );
1997 }
1998
1999 #[test]
2000 fn test_global_search_domains_empty_yields_no_line() {
2001 assert!(format_global_search_domains(&[]).is_empty());
2003 assert!(format_global_search_domains(&parse_search_from_resolv_conf(
2004 "nameserver 1.1.1.1\n"
2005 ))
2006 .is_empty());
2007 }
2008
2009 #[test]
2010 fn test_default_route_interface_selection_matches_routing_table() {
2011 let proc_net_route = concat!(
2015 "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n",
2016 "wlp194s0\t00000000\t0156A8C0\t0003\t0\t0\t100\t00000000\t0\t0\t0\n",
2017 "wt0\t00006564\t00000000\t0001\t0\t0\t0\t0000FFFF\t0\t0\t0\n",
2018 "wlp194s0\t0056A8C0\t00000000\t0001\t0\t0\t100\t00FFFFFF\t0\t0\t0\n",
2019 );
2020 assert_eq!(
2021 parse_proc_net_route(proc_net_route),
2022 Some("wlp194s0".to_string())
2023 );
2024 }
2025
2026 #[test]
2027 fn test_resolve_windows_default_domain() {
2028 let adapters = vec![
2029 WinAdapterDnsInfo {
2030 friendly_name: "Wi-Fi".to_string(),
2031 dns_suffix: "lan.home".to_string(),
2032 is_up: true,
2033 is_loopback: false,
2034 dns_servers: Vec::new(),
2035 },
2036 WinAdapterDnsInfo {
2037 friendly_name: "Ethernet".to_string(),
2038 dns_suffix: "corp.internal".to_string(),
2039 is_up: true,
2040 is_loopback: false,
2041 dns_servers: Vec::new(),
2042 },
2043 ];
2044
2045 assert_eq!(
2047 resolve_windows_default_domain(Some("Wi-Fi"), &adapters, None),
2048 Some("lan.home".to_string())
2049 );
2050
2051 assert_eq!(
2053 resolve_windows_default_domain(Some("wi-fi"), &adapters, None),
2054 Some("lan.home".to_string())
2055 );
2056
2057 let adapters_no_suffix = vec![WinAdapterDnsInfo {
2059 friendly_name: "Wi-Fi".to_string(),
2060 dns_suffix: "".to_string(),
2061 is_up: true,
2062 is_loopback: false,
2063 dns_servers: Vec::new(),
2064 }];
2065 assert_eq!(
2066 resolve_windows_default_domain(
2067 Some("Wi-Fi"),
2068 &adapters_no_suffix,
2069 Some("global.example.com")
2070 ),
2071 Some("global.example.com".to_string())
2072 );
2073
2074 assert_eq!(
2076 resolve_windows_default_domain(Some("Unknown"), &adapters, Some("global.example.com")),
2077 Some("global.example.com".to_string())
2078 );
2079 }
2080
2081 #[test]
2082 fn test_parse_windows_domain_search() {
2083 let adapters = vec![
2084 WinAdapterDnsInfo {
2085 friendly_name: "Wi-Fi".to_string(),
2086 dns_suffix: "lan.home".to_string(),
2087 is_up: true,
2088 is_loopback: false,
2089 dns_servers: Vec::new(),
2090 },
2091 WinAdapterDnsInfo {
2092 friendly_name: "vEthernet".to_string(),
2093 dns_suffix: "netbird.cloud".to_string(),
2094 is_up: true,
2095 is_loopback: false,
2096 dns_servers: Vec::new(),
2097 },
2098 WinAdapterDnsInfo {
2099 friendly_name: "Loopback Pseudo-Interface 1".to_string(),
2100 dns_suffix: "ignore.me".to_string(),
2101 is_up: true,
2102 is_loopback: true,
2103 dns_servers: Vec::new(),
2104 },
2105 WinAdapterDnsInfo {
2106 friendly_name: "Disconnected".to_string(),
2107 dns_suffix: "offline.local".to_string(),
2108 is_up: false,
2109 is_loopback: false,
2110 dns_servers: Vec::new(),
2111 },
2112 ];
2113
2114 let result = parse_windows_domain_search(Some("search1.com, search2.com"), &adapters);
2115 assert_eq!(
2116 result,
2117 vec![
2118 "global: search1.com, search2.com",
2119 "Wi-Fi: lan.home",
2120 "vEthernet: netbird.cloud"
2121 ]
2122 );
2123 }
2124
2125 #[test]
2126 #[cfg(target_os = "windows")]
2127 fn test_ip_adapter_addresses_layout() {
2128 use std::mem::{offset_of, size_of};
2129
2130 #[repr(C)]
2131 #[allow(non_snake_case)]
2132 struct IpAdapterAddresses {
2133 Length: u32,
2134 IfIndex: u32,
2135 Next: *mut IpAdapterAddresses,
2136 AdapterName: *const i8,
2137 FirstUnicastAddress: *const std::ffi::c_void,
2138 FirstAnycastAddress: *const std::ffi::c_void,
2139 FirstMulticastAddress: *const std::ffi::c_void,
2140 FirstDnsServerAddress: *const std::ffi::c_void,
2141 DnsSuffix: *const u16,
2142 Description: *const u16,
2143 FriendlyName: *const u16,
2144 PhysicalAddress: [u8; 8],
2145 PhysicalAddressLength: u32,
2146 Flags: u32,
2147 Mtu: u32,
2148 IfType: u32,
2149 OperStatus: u32,
2150 }
2151
2152 if cfg!(target_pointer_width = "64") {
2153 assert_eq!(offset_of!(IpAdapterAddresses, Next), 8);
2154 assert_eq!(offset_of!(IpAdapterAddresses, AdapterName), 16);
2155 assert_eq!(offset_of!(IpAdapterAddresses, DnsSuffix), 56);
2156 assert_eq!(offset_of!(IpAdapterAddresses, FriendlyName), 72);
2157 assert_eq!(offset_of!(IpAdapterAddresses, OperStatus), 104);
2158 assert_eq!(size_of::<IpAdapterAddresses>(), 112);
2159 }
2160 }
2161}