diff --git a/Cargo.lock b/Cargo.lock index d66e682..7a355cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -397,6 +397,7 @@ dependencies = [ "netlink-packet-wireguard", "netlink-sys", "nix", + "regex", "serde", "serde_test", "thiserror 2.0.17", diff --git a/Cargo.toml b/Cargo.toml index 78d3c58..c6f3bcb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,7 @@ windows = { version = "0.62", features = [ "Win32_NetworkManagement_Ndis", "Win32_Networking_WinSock", "Win32_System_Com", -]} +] } wireguard-nt = "0.5.0" [target.'cfg(target_os = "linux")'.dependencies] @@ -49,6 +49,9 @@ netlink-packet-utils = "0.6" netlink-packet-wireguard = "0.2" netlink-sys = "0.8" +[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "netbsd"))'.dependencies] +regex = "1.12" + [features] default = ["serde"] check_dependencies = [] diff --git a/src/dependencies.rs b/src/dependencies.rs index 2e0d33c..0dbf577 100644 --- a/src/dependencies.rs +++ b/src/dependencies.rs @@ -1,6 +1,6 @@ use std::env; -use crate::error::WireguardInterfaceError; +use crate::{error::WireguardInterfaceError, utils::get_command_path}; #[cfg(target_os = "linux")] const COMMANDS: [&str; 2] = ["resolvconf", "ip"]; @@ -16,37 +16,19 @@ const COMMANDS: [&str; 1] = ["resolvconf"]; pub(crate) fn check_external_dependencies() -> Result<(), WireguardInterfaceError> { debug!("Checking if all commands required by wireguard-rs are available"); - let paths = env::var_os("PATH").ok_or(WireguardInterfaceError::MissingDependency( - "Environment variable `PATH` not found".into(), - ))?; - - // Find the missing command to provide a more informative error message later. - let missing = COMMANDS.iter().find(|cmd| { - !env::split_paths(&paths).any(|dir| { - trace!("Trying to find {cmd} in {dir:?}"); - match dir.join(cmd).try_exists() { - Ok(true) => { - debug!("{cmd} found in {dir:?}"); - true - } - Ok(false) => { - trace!("{cmd} not found in {dir:?}"); - false - } - Err(err) => { - warn!("Error while checking for {cmd} in {dir:?}: {err}"); - false - } - } - }) + let paths = env::var_os("PATH").ok_or_else(|| { + WireguardInterfaceError::MissingDependency("Environment variable `PATH` not found".into()) }); - if let Some(cmd) = missing { - Err(WireguardInterfaceError::MissingDependency(format!( - "Command `{cmd}` required by wireguard-rs couldn't be found. The following directories were checked: {paths:?}" - ))) - } else { - debug!("All commands required by wireguard-rs are available"); - Ok(()) - } + // Find the missing command to provide a more informative error message later. + let missing_command = COMMANDS + .iter() + .find(|cmd| get_command_path(cmd).map_or(true, |path_opt| path_opt.is_none())); + + missing_command.map_or_else(|| { + debug!("All commands required by wireguard-rs are available"); + Ok(()) + }, |cmd| Err(WireguardInterfaceError::MissingDependency(format!( + "Command `{cmd}` required by wireguard-rs couldn't be found. The following directories were checked: {paths:?}" + )))) } diff --git a/src/utils.rs b/src/utils.rs index 8f8852f..6826af8 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,12 +1,17 @@ #[cfg(target_os = "macos")] -use std::io::{BufRead, BufReader, Cursor, Error as IoError}; +use std::io::{Cursor, Error as IoError}; #[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "netbsd"))] use std::net::{Ipv4Addr, Ipv6Addr}; -use std::net::{SocketAddr, ToSocketAddrs}; +#[cfg(any(target_os = "freebsd", target_os = "linux", target_os = "netbsd"))] +use std::path::Path; #[cfg(target_os = "linux")] use std::{collections::HashSet, fs::OpenOptions}; #[cfg(any(target_os = "freebsd", target_os = "linux", target_os = "netbsd"))] use std::{io::Write, process::Stdio}; +use std::{ + io::{BufRead, BufReader}, + net::{SocketAddr, ToSocketAddrs}, +}; #[cfg(not(target_os = "windows"))] use std::{net::IpAddr, process::Command}; @@ -24,6 +29,55 @@ use crate::{ #[cfg(target_os = "linux")] use crate::{IpVersion, check_command_output_status, netlink}; +#[cfg(any(target_os = "freebsd", target_os = "linux", target_os = "netbsd"))] +const IFACE_ORDER_PATH: &str = "/etc/resolvconf/interface-order"; + +/// Constructs the resolvconf interface name for manipulating DNS settings. +/// Resolvconf may be symlinked to resolvectl on some systems that use systemd-resolved. +/// In such cases there is no need to prefix the interface name and this function just returns +/// the base interface name. +/// +/// On other systems, especially those that don't use systemd-resolved (Debian 13) +/// resolvconf may be a binary from the "resolvconf" package. In such cases, this function +/// reads the interface-order file to find a highest priority interface prefix and constructs +/// the full interface name prefixing the base interface name with the found prefix. +#[cfg(any(target_os = "freebsd", target_os = "linux", target_os = "netbsd"))] +fn construct_resolvconf_ifname(base_ifname: &str) -> String { + let iface_order = Path::new(IFACE_ORDER_PATH); + if iface_order.exists() { + // Check if resolvconf command is a symlink or a binary + if let Ok(Some(resolvconf_path)) = get_command_path("resolvconf") { + if let Ok(metadata) = std::fs::symlink_metadata(&resolvconf_path) { + if !metadata.file_type().is_symlink() { + // It's a binary, proceed to read interface_order file + let iface_regex = regex::Regex::new(r"^([A-Za-z0-9-]+)\*$").unwrap(); + if let Ok(file) = std::fs::File::open(iface_order) { + let reader = BufReader::new(file); + if let Some(constructed_ifname) = reader.lines().map_while(Result::ok).find_map(|line| { + let iface = line.trim(); + iface_regex.captures(iface).and_then(|captures| { + captures.get(1).map(|matched_iface| { + // Output format: . + let constructed_ifname = + format!("{}.{base_ifname}", matched_iface.as_str()); + debug!( + "Constructed interface name from interface_order: {constructed_ifname}" + ); + constructed_ifname + }) + }) + }) { + return constructed_ifname; + } + } + } + } + } + } + + base_ifname.into() +} + #[cfg(any(target_os = "freebsd", target_os = "linux", target_os = "netbsd"))] pub(crate) fn configure_dns( ifname: &str, @@ -36,7 +90,8 @@ pub(crate) fn configure_dns( domains: {search_domains:?}" ); let mut cmd = Command::new("resolvconf"); - let mut args = vec!["-a", ifname, "-m", "0"]; + let ifname = construct_resolvconf_ifname(ifname); + let mut args = vec!["-a", &ifname, "-m", "0"]; // Set the exclusive flag if no search domains are provided, // making the DNS servers a preferred route for any domain if search_domains.is_empty() { @@ -170,7 +225,8 @@ pub(crate) fn configure_dns( #[cfg(any(target_os = "freebsd", target_os = "linux", target_os = "netbsd"))] pub(crate) fn clear_dns(ifname: &str) -> Result<(), WireguardInterfaceError> { debug!("Removing DNS configuration for interface {ifname}"); - let args = ["-d", ifname, "-f"]; + let ifname = construct_resolvconf_ifname(ifname); + let args = ["-d", &ifname, "-f"]; debug!("Executing resolvconf with args: {args:?}"); let mut cmd = Command::new("resolvconf"); let output = cmd.args(args).output()?; @@ -537,3 +593,33 @@ pub(crate) fn resolve(addr: &str) -> Result .next() .ok_or_else(error) } + +pub(crate) fn get_command_path( + command: &str, +) -> Result, WireguardInterfaceError> { + use std::env; + + debug!("Searching for command {command} in PATH"); + let paths = env::var_os("PATH").ok_or_else(|| { + WireguardInterfaceError::MissingDependency("Environment variable `PATH` not found".into()) + })?; + debug!("PATH variable: {paths:?}"); + + Ok(env::split_paths(&paths).find_map(|dir| { + let full_path = dir.join(command); + match full_path.try_exists() { + Ok(true) => { + debug!("Command {command} found in {dir:?}"); + Some(full_path) + } + Ok(false) => { + debug!("Command {command} not found in {dir:?}"); + None + } + Err(err) => { + warn!("Error while checking for {command} in {dir:?}: {err}"); + None + } + } + })) +}