diff --git a/Cargo.lock b/Cargo.lock index d03f181..3f0e6a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -164,6 +164,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "byteorder" version = "1.5.0" @@ -384,7 +390,7 @@ dependencies = [ [[package]] name = "defguard_wireguard_rs" -version = "0.10.0" +version = "0.10.1" dependencies = [ "base64", "defguard_boringtun", @@ -404,7 +410,9 @@ dependencies = [ "thiserror 2.0.18", "tracing", "tracing-subscriber", + "uuid", "windows", + "windows-registry", "wireguard-nt", "x25519-dalek", ] @@ -492,6 +500,30 @@ dependencies = [ "autocfg", ] +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -670,6 +702,17 @@ dependencies = [ "syn", ] +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1047,6 +1090,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "scopeguard" version = "1.2.0" @@ -1144,6 +1193,12 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1165,6 +1220,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" @@ -1556,6 +1617,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "js-sys", + "sha1_smol", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -1592,6 +1664,51 @@ dependencies = [ "wit-bindgen 0.51.0", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -1724,6 +1841,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index 9fd902a..c70d856 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "defguard_wireguard_rs" -version = "0.10.0" +version = "0.10.1" edition = "2024" rust-version = "1.87" description = "A unified multi-platform high-level API for managing WireGuard interfaces" @@ -16,6 +16,7 @@ base64 = "0.22" log = "0.4" serde = { version = "1.0", features = ["derive"], optional = true } thiserror = "2.0" +uuid = { version = "1", features = ["v5"] } x25519-dalek = { version = "2.0", features = ["getrandom", "static_secrets"] } [dev-dependencies] @@ -38,6 +39,7 @@ windows = { version = "0.62", features = [ "Win32_Networking_WinSock", "Win32_System_Com", ] } +windows-registry = "0.6" wireguard-nt = "0.5" [target.'cfg(target_os = "linux")'.dependencies] diff --git a/src/key.rs b/src/key.rs index 2390d7b..f1e8838 100644 --- a/src/key.rs +++ b/src/key.rs @@ -218,11 +218,11 @@ impl<'de> Deserialize<'de> for Key { #[cfg(test)] mod tests { - use super::*; - #[cfg(feature = "serde")] use serde_test::{Token, assert_tokens}; + use super::*; + // Same `Key` in different representations. static KEY_B64: &str = "AAECAwQFBgcICQoLDA0OD/Dh0sO0pZaHeGlaSzwtHg8="; static KEY_HEX: &str = "000102030405060708090a0b0c0d0e0ff0e1d2c3b4a5968778695a4b3c2d1e0f"; diff --git a/src/lib.rs b/src/lib.rs index 9da738a..ac35c38 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -59,6 +59,8 @@ pub mod key; pub mod net; #[cfg(target_os = "linux")] pub(crate) mod netlink; +#[cfg(any(target_family = "windows", test))] +mod nrpt; pub mod peer; mod utils; mod wgapi; diff --git a/src/nrpt.rs b/src/nrpt.rs new file mode 100644 index 0000000..39218e4 --- /dev/null +++ b/src/nrpt.rs @@ -0,0 +1,124 @@ +use std::net::IpAddr; + +use uuid::Uuid; + +/// Namespace UUID for deriving NRPT rule GUIDs via UUIDv5. Arbitrary but fixed +/// so derived GUIDs stay stable across builds; has no meaning beyond that. +const NRPT_RULE_NAMESPACE: Uuid = Uuid::from_bytes([ + 0x45, 0x91, 0x5b, 0x78, 0xfd, 0x48, 0x40, 0xa5, 0x92, 0x74, 0x17, 0xcb, 0x15, 0x80, 0x32, 0x13, +]); + +/// Prefix written to each rule's `Comment` value so we can find and remove only +/// the rules we created for a given interface. +pub(crate) const NRPT_COMMENT_PREFIX: &str = "defguard:"; + +/// Marker written to the `Comment` value of every NRPT rule created for `ifname`. +pub(crate) fn nrpt_comment(ifname: &str) -> String { + format!("{NRPT_COMMENT_PREFIX}{ifname}") +} + +/// Normalizes a search domain into an NRPT namespace in suffix-match form. +/// `example.com` becomes `.example.com`; an already dotted value is kept as is. +/// A trailing dot (FQDN form like `example.com.`) is stripped, since NRPT treats +/// it as a literal character and the suffix match would never fire. +pub(crate) fn nrpt_namespace(domain: &str) -> String { + let trimmed = domain.trim().trim_end_matches('.'); + if trimmed.starts_with('.') { + trimmed.to_string() + } else { + format!(".{trimmed}") + } +} + +/// Joins DNS server IPs into the `;`-separated form NRPT stores in +/// `GenericDNSServers`. +pub(crate) fn nrpt_dns_servers(dns: &[IpAddr]) -> String { + dns.iter() + .map(ToString::to_string) + .collect::>() + .join(";") +} + +/// Derives a stable registry subkey GUID (in brace form) for an interface + +/// namespace pair. Deterministic so reconnecting overwrites the same subkey +/// instead of accumulating duplicates. +pub(crate) fn nrpt_rule_guid(ifname: &str, namespace: &str) -> String { + let seed = format!("{ifname}\u{0}{namespace}"); + let uuid = Uuid::new_v5(&NRPT_RULE_NAMESPACE, seed.as_bytes()); + format!("{{{uuid}}}") +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + use super::*; + + #[test] + fn namespace_gets_leading_dot() { + assert_eq!(nrpt_namespace("example.com"), ".example.com"); + assert_eq!(nrpt_namespace("corp.local"), ".corp.local"); + } + + #[test] + fn namespace_preserves_existing_dot_and_trims() { + assert_eq!(nrpt_namespace(".example.com"), ".example.com"); + assert_eq!(nrpt_namespace(" example.com "), ".example.com"); + } + + #[test] + fn namespace_strips_trailing_dot() { + assert_eq!(nrpt_namespace("example.com."), ".example.com"); + assert_eq!(nrpt_namespace(" example.com. "), ".example.com"); + assert_eq!(nrpt_namespace(".example.com."), ".example.com"); + } + + #[test] + fn dns_servers_are_semicolon_joined() { + let dns = vec![ + IpAddr::V4(Ipv4Addr::new(10, 0, 0, 53)), + IpAddr::V4(Ipv4Addr::new(10, 0, 0, 54)), + ]; + assert_eq!(nrpt_dns_servers(&dns), "10.0.0.53;10.0.0.54"); + } + + #[test] + fn dns_servers_handles_ipv6() { + let dns = vec![IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1))]; + assert_eq!(nrpt_dns_servers(&dns), "fd00::1"); + } + + #[test] + fn dns_servers_empty() { + assert_eq!(nrpt_dns_servers(&[]), ""); + } + + #[test] + fn comment_marker_includes_interface() { + assert_eq!(nrpt_comment("wg0"), "defguard:wg0"); + assert!(nrpt_comment("wg0").starts_with(NRPT_COMMENT_PREFIX)); + } + + #[test] + fn guid_is_stable_for_same_inputs() { + let a = nrpt_rule_guid("wg0", ".example.com"); + let b = nrpt_rule_guid("wg0", ".example.com"); + assert_eq!(a, b); + } + + #[test] + fn guid_differs_by_namespace_and_interface() { + let base = nrpt_rule_guid("wg0", ".example.com"); + assert_ne!(base, nrpt_rule_guid("wg0", ".other.com")); + assert_ne!(base, nrpt_rule_guid("wg1", ".example.com")); + } + + #[test] + fn guid_has_registry_brace_form() { + let guid = nrpt_rule_guid("wg0", ".example.com"); + assert!(guid.starts_with('{') && guid.ends_with('}')); + // 32 hex digits + 4 dashes + 2 braces = 38 chars. + assert_eq!(guid.len(), 38); + assert_eq!(guid.matches('-').count(), 4); + } +} diff --git a/src/wgapi_windows.rs b/src/wgapi_windows.rs index b6b279c..534062d 100644 --- a/src/wgapi_windows.rs +++ b/src/wgapi_windows.rs @@ -35,6 +35,7 @@ use crate::{ host::Host, key::Key, net::IpAddrMask, + nrpt::{nrpt_comment, nrpt_dns_servers, nrpt_namespace, nrpt_rule_guid}, peer::Peer, wgapi::{Kernel, WGApi}, }; @@ -74,6 +75,8 @@ pub enum WindowsError { WindowsCoreError(#[from] windows::core::Error), #[error("Missing peer endpoint for peer {0}")] MissingPeerEndpoint(String), + #[error("Registry error: {0}")] + Registry(String), } /// Converts a string representation of a GUID into a `windows::core::GUID`. @@ -239,6 +242,115 @@ fn str_to_wide_null_terminated(s: &str) -> Vec { OsStr::new(s).encode_wide().chain(Some(0)).collect() } +/// Registry path holding the local Name Resolution Policy Table rules. +const NRPT_REGISTRY_PATH: &str = + r"SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\DnsPolicyConfig"; +/// `ConfigOptions` bit meaning "use the DNS servers listed in `GenericDNSServers`". +const NRPT_CONFIG_OPTIONS_DNS_SERVERS: u32 = 0x8; +/// NRPT rule schema version written by modern Windows. +const NRPT_RULE_VERSION: u32 = 2; + +fn to_registry_error(err: impl std::fmt::Display) -> WindowsError { + WindowsError::Registry(err.to_string()) +} + +/// Removes every NRPT rule previously created for `ifname`, identified by the +/// `Comment` marker. +fn remove_nrpt_rules(ifname: &str) -> Result<(), WindowsError> { + let comment = nrpt_comment(ifname); + // If the policy key does not exist there is nothing to clean up. + let Ok(root) = windows_registry::LOCAL_MACHINE.open(NRPT_REGISTRY_PATH) else { + return Ok(()); + }; + let subkeys: Vec = match root.keys() { + Ok(keys) => keys.collect(), + Err(err) => { + warn!("Failed to enumerate NRPT rules for {ifname}: {err}"); + return Ok(()); + } + }; + let mut removed_any = false; + for subkey in subkeys { + let Ok(rule) = root.open(&subkey) else { + continue; + }; + if rule.get_string("Comment").ok().as_deref() == Some(comment.as_str()) { + match root.remove_tree(&subkey) { + Ok(()) => { + debug!("Removed NRPT rule {subkey} for interface {ifname}"); + removed_any = true; + } + Err(err) => warn!("Failed to remove NRPT rule {subkey}: {err}"), + } + } + } + + if removed_any { + reload_dns_cache(); + } + Ok(()) +} + +/// Creates one NRPT rule per search domain, routing that namespace (suffix) to +/// the tunnel `dns` servers. No-op when there are no DNS servers or no search +/// domains (a suffix rule needs a suffix). +fn create_nrpt_rules( + ifname: &str, + dns: &[IpAddr], + search_domains: &[&str], +) -> Result<(), WindowsError> { + if dns.is_empty() || search_domains.is_empty() { + debug!( + "Skipping NRPT rules for {ifname}: {} DNS server(s), {} search domain(s)", + dns.len(), + search_domains.len() + ); + return Ok(()); + } + let comment = nrpt_comment(ifname); + let servers = nrpt_dns_servers(dns); + let root = windows_registry::LOCAL_MACHINE + .create(NRPT_REGISTRY_PATH) + .map_err(to_registry_error)?; + for domain in search_domains { + let namespace = nrpt_namespace(domain); + if namespace.len() <= 1 { + // An empty/`.`-only namespace would be a catch-all + warn!("Skipping empty NRPT namespace derived from {domain:?}"); + continue; + } + let subkey = nrpt_rule_guid(ifname, &namespace); + let rule = root.create(&subkey).map_err(to_registry_error)?; + rule.set_u32("Version", NRPT_RULE_VERSION) + .map_err(to_registry_error)?; + rule.set_multi_string("Name", &[namespace.as_str()]) + .map_err(to_registry_error)?; + rule.set_string("GenericDNSServers", servers.as_str()) + .map_err(to_registry_error)?; + rule.set_u32("ConfigOptions", NRPT_CONFIG_OPTIONS_DNS_SERVERS) + .map_err(to_registry_error)?; + rule.set_string("IPSECCARestriction", "") + .map_err(to_registry_error)?; + rule.set_string("Comment", comment.as_str()) + .map_err(to_registry_error)?; + debug!("Created NRPT rule {subkey}: {namespace} -> {servers}"); + } + reload_dns_cache(); + Ok(()) +} + +/// Signals the DNS Client service to reload its configuration so newly written +/// NRPT rules take effect immediately. Best-effort; failures are only logged. +fn reload_dns_cache() { + match std::process::Command::new("sc.exe") + .args(["control", "dnscache", "paramchange"]) + .output() + { + Ok(_) => debug!("Requested DNS Client service configuration reload"), + Err(err) => warn!("Failed to reload DNS Client service configuration: {err}"), + } +} + /// Manages interfaces created with Windows kernel using https://git.zx2c4.com/wireguard-nt. impl WireguardInterfaceApi for WGApi { fn create_interface(&mut self) -> Result<(), WireguardInterfaceError> { @@ -425,6 +537,11 @@ impl WireguardInterfaceApi for WGApi { fn remove_interface(&mut self) -> Result<(), WireguardInterfaceError> { debug!("Removing interface {}", self.ifname); + // Interface DNS settings disappear with the adapter, but NRPT rules are + // global registry entries and must be removed explicitly. + if let Err(err) = remove_nrpt_rules(&self.ifname) { + warn!("Failed to remove NRPT rules for {}: {err}", self.ifname); + } self.adapter = None; info!("Interface {} removed successfully", self.ifname); Ok(()) @@ -512,6 +629,18 @@ impl WireguardInterfaceApi for WGApi { } } + // Pin the configured search-domain namespaces to the tunnel DNS via NRPT + // so resolution does not depend on Windows multi-homed name resolution. + if let Err(err) = remove_nrpt_rules(&self.ifname) { + warn!( + "Failed to clean up stale NRPT rules for {}: {err}", + self.ifname + ); + } + if let Err(err) = create_nrpt_rules(&self.ifname, dns, search_domains) { + error!("Failed to configure NRPT rules for {}: {err}", self.ifname); + } + info!( "Configured DNS for interface {}, using address: {dns:?}", self.ifname