From 16f461fa87e0dba5e95fd24bf8a6552824a051c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Ciarcin=CC=81ski?= Date: Mon, 26 Oct 2026 15:25:49 +0100 Subject: [PATCH 1/4] Fix endpoint routing on Linux --- Cargo.lock | 76 ++++++++--------- src/dns/tests.rs | 7 +- src/net.rs | 6 ++ src/netlink.rs | 77 ++++++++++++++++- src/utils.rs | 185 ++++++++++++++++++----------------------- src/wgapi.rs | 47 +++++++++++ src/wgapi_freebsd.rs | 9 +- src/wgapi_linux.rs | 48 ++++++++++- src/wgapi_userspace.rs | 21 ++--- 9 files changed, 308 insertions(+), 168 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3658cc1..cfab6e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,9 +121,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.5.1" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" dependencies = [ "find-msvc-tools", "shlex", @@ -210,9 +210,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "fe3e9d21d39e6f39ff46e03d5b621424f88ee23d8a1672c6bfc9ccc7f2da4f6a" dependencies = [ "cmov", ] @@ -270,7 +270,7 @@ dependencies = [ [[package]] name = "defguard_wireguard_rs" -version = "0.12.2" +version = "0.12.3" dependencies = [ "base64", "defguard_boringtun", @@ -296,9 +296,9 @@ dependencies = [ [[package]] name = "defmt" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" dependencies = [ "bitflags 1.3.2", "defmt-macros", @@ -306,21 +306,21 @@ dependencies = [ [[package]] name = "defmt-macros" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" dependencies = [ "defmt-parser", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] name = "defmt-parser" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" dependencies = [ "thiserror 2.0.21", ] @@ -367,9 +367,9 @@ checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" [[package]] name = "find-msvc-tools" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "getrandom" @@ -420,9 +420,9 @@ dependencies = [ [[package]] name = "inout" -version = "0.2.2" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f" dependencies = [ "hybrid-array", ] @@ -463,9 +463,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "jiff" -version = "0.2.37" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" dependencies = [ "defmt", "jiff-core", @@ -488,9 +488,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.37" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" dependencies = [ "jiff-core", "proc-macro2", @@ -500,15 +500,15 @@ dependencies = [ [[package]] name = "lazy_static" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" [[package]] name = "libc" -version = "0.2.189" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libloading" @@ -839,7 +839,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -868,9 +868,9 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "smallvec" -version = "1.16.2" +version = "1.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" [[package]] name = "socket2" @@ -901,9 +901,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -947,7 +947,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1024,9 +1024,9 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "universal-hash" @@ -1299,18 +1299,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.59" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.59" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" dependencies = [ "proc-macro2", "quote", @@ -1319,6 +1319,6 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" diff --git a/src/dns/tests.rs b/src/dns/tests.rs index 8b5aec2..76c9b00 100644 --- a/src/dns/tests.rs +++ b/src/dns/tests.rs @@ -208,10 +208,9 @@ fn nrpt_namespaces_are_deduplicated_and_skip_empty_domains() { #[test] fn nrpt_namespaces_are_empty_without_domains() { - assert!( - DnsConfig::split_dns(&SERVERS, &[], &[]) - .nrpt_namespaces() - .is_empty() + assert_eq!( + DnsConfig::split_dns(&SERVERS, &[], &[]).nrpt_namespaces(), + [] as [String; 0] ); } diff --git a/src/net.rs b/src/net.rs index 0cdeca9..4188d46 100644 --- a/src/net.rs +++ b/src/net.rs @@ -88,6 +88,12 @@ impl IpAddrMask { } } + /// Returns `true` if the address is a default route (`0.0.0.0/0` or `::/0`). + #[must_use] + pub(crate) fn is_default_route(&self) -> bool { + self.address.is_unspecified() && self.cidr == 0 + } + /// Returns `IpVersion` for this address. #[must_use] pub fn ip_version(&self) -> IpVersion { diff --git a/src/netlink.rs b/src/netlink.rs index 6eb308a..66337dd 100644 --- a/src/netlink.rs +++ b/src/netlink.rs @@ -485,12 +485,11 @@ pub(crate) fn set_link_up(if_name: &str) -> NetlinkResult<()> { } /// Get default route for a given address family. +#[cfg_attr(not(test), allow(dead_code))] pub(crate) fn get_gateway(ip_version: IpVersion) -> NetlinkResult> { let header = RouteHeader { address_family: ip_version.address_family(), table: RouteHeader::RT_TABLE_MAIN, - // protocol: RouteProtocol::Boot, // doesn't filter - // scope: RouteScope::Universe, // doesn't filter ..Default::default() }; let mut message = RouteMessage::default(); @@ -536,9 +535,10 @@ fn route_address(address: IpAddr) -> RouteAddress { } } -/// Set a route to `dest` through `gateway`, replacing an existing one, if any. +/// Add a route to `dest` through `gateway`, keeping an existing one, if any. /// /// When `is_blackhole` is set, traffic to `dest` is dropped and `gateway` is ignored. +#[allow(dead_code)] pub(crate) fn add_gateway( dest: &IpAddrMask, gateway: IpAddr, @@ -574,10 +574,11 @@ pub(crate) fn add_gateway( match netlink_request( RouteNetlinkMessage::NewRoute(message), - NLM_F_REQUEST | NLM_F_ACK | NLM_F_CREATE | NLM_F_REPLACE, + NLM_F_REQUEST | NLM_F_ACK | NLM_F_CREATE | NLM_F_EXCL, NETLINK_ROUTE, ) { Ok(_msg) => Ok(()), + Err(NetlinkError::FileAlreadyExists) => Ok(()), Err(err) => { error!("Failed to set gateway for {dest}: {err}"); Err(NetlinkError::AddRouteError) @@ -585,6 +586,56 @@ pub(crate) fn add_gateway( } } +/// Find a route to `dest` in the main routing table. +#[cfg(test)] +pub(crate) fn get_route(dest: &IpAddrMask) -> NetlinkResult> { + let mut message = RouteMessage::default(); + message.header.address_family = dest.address_family(); + message.header.table = RouteHeader::RT_TABLE_MAIN; + let responses = netlink_request( + RouteNetlinkMessage::GetRoute(message), + NLM_F_REQUEST | NLM_F_DUMP, + NETLINK_ROUTE, + )?; + + let destination = RouteAttribute::Destination(route_address(dest.address)); + for nlmsg in responses { + if let NetlinkMessage { + payload: NetlinkPayload::InnerMessage(RouteNetlinkMessage::NewRoute(message)), + .. + } = nlmsg + && message.header.table == RouteHeader::RT_TABLE_MAIN + && message.header.destination_prefix_length == dest.cidr + && message.attributes.contains(&destination) + { + return Ok(Some(message)); + } + } + + Ok(None) +} + +/// Delete a route to `dest` from the main routing table. +#[cfg(test)] +pub(crate) fn delete_route(dest: &IpAddrMask) -> NetlinkResult<()> { + let mut message = RouteMessage::default(); + message.header = RouteHeader { + address_family: dest.address_family(), + destination_prefix_length: dest.cidr, + table: RouteHeader::RT_TABLE_MAIN, + ..Default::default() + }; + message + .attributes + .push(RouteAttribute::Destination(route_address(dest.address))); + netlink_request( + RouteNetlinkMessage::DelRoute(message), + NLM_F_REQUEST | NLM_F_ACK, + NETLINK_ROUTE, + )?; + Ok(()) +} + /// Add a route for an interface. pub(crate) fn add_route( ifname: &str, @@ -811,6 +862,8 @@ pub(crate) fn set_mtu(if_name: &str, mtu: u32) -> NetlinkResult<()> { #[cfg(test)] mod tests { + use std::net::Ipv4Addr; + use super::*; /// Get IP addresses for an interface. @@ -976,6 +1029,22 @@ mod tests { assert!(gateway.is_some()); } + #[ignore = "destructive"] + #[test] + fn docker_add_gateway_keeps_route() { + let dest = IpAddrMask::host(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1))); + let gateway = get_gateway(IpVersion::IPv4).unwrap().unwrap(); + + // Pre-existing route. + add_gateway(&dest, gateway, true).unwrap(); + let result = add_gateway(&dest, gateway, false); + let route = get_route(&dest).unwrap(); + delete_route(&dest).unwrap(); + + result.unwrap(); + assert_eq!(route.unwrap().header.kind, RouteType::BlackHole); + } + // For this test, execute: // - `ip link add dev wg0 type wireguard` // - `ip link set up dev wg0` diff --git a/src/utils.rs b/src/utils.rs index 2889c2c..b146cd9 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,3 +1,7 @@ +#[cfg(not(target_os = "windows"))] +use std::collections::HashSet; +#[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "netbsd"))] +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::net::{SocketAddr, ToSocketAddrs}; #[cfg(any( feature = "check_dependencies", @@ -6,11 +10,6 @@ use std::net::{SocketAddr, ToSocketAddrs}; target_os = "netbsd" ))] use std::path::PathBuf; -#[cfg(not(target_os = "windows"))] -use std::{ - collections::HashSet, - net::{IpAddr, Ipv4Addr, Ipv6Addr}, -}; #[cfg(target_os = "linux")] use std::{fs::OpenOptions, io::Write}; @@ -18,7 +17,7 @@ use crate::WireguardInterfaceError; #[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "netbsd"))] use crate::bsd::{IoError, add_gateway, add_linked_route, delete_gateway, get_gateway}; #[cfg(target_os = "linux")] -use crate::netlink::{self, add_gateway, get_gateway}; +use crate::netlink; #[cfg(not(target_os = "windows"))] use crate::{IpVersion, Peer, net::IpAddrMask}; @@ -154,7 +153,7 @@ pub(crate) fn add_peer_routing( // Gather allowed IPs and default routes for peer in peers { for addr in &peer.allowed_ips { - if addr.address.is_unspecified() && addr.cidr == 0 { + if addr.is_default_route() { // Default route - store for later if addr.address.is_ipv4() { default_route_v4 = Some(addr); @@ -222,117 +221,97 @@ pub(crate) fn add_peer_routing( Ok(()) } -/// Condfigure peers with `endpoint` field set. -#[cfg(not(target_os = "windows"))] -pub(crate) fn configure_endpoints(peers: &[Peer]) { - let gateway_v4 = get_gateway(IpVersion::IPv4); - if let Ok(Some(gateway)) = gateway_v4 { - debug!("Default gateway for IPv4: {gateway}"); - } - let gateway_v6 = get_gateway(IpVersion::IPv6); - if let Ok(Some(gateway)) = gateway_v6 { - debug!("Default gateway for IPv6: {gateway}"); - } +/// Route peer endpoints through the default gateway when allowed IPs contain a default route. +/// +/// `peers` should be all peers of the interface. Routes added are tracked in `routes`; +/// existing routes are kept and not tracked. +#[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "netbsd"))] +pub(crate) fn configure_endpoints(peers: &[Peer], routes: &mut HashSet) { + remove_endpoint_routes(routes); + + // Default gateway for each IP version with a default route in allowed IPs. + let has_default_route = |is_ipv4: bool| { + peers + .iter() + .flat_map(|peer| &peer.allowed_ips) + .any(|addr| addr.is_default_route() && addr.address.is_ipv4() == is_ipv4) + }; + let gateway_v4 = has_default_route(true).then(|| get_gateway(IpVersion::IPv4)); + let gateway_v6 = has_default_route(false).then(|| get_gateway(IpVersion::IPv6)); for peer in peers { - // Logic below is valid only in case an endpoint has been configured for the peer. let Some(endpoint) = peer.endpoint else { continue; }; + let endpoint_ip = endpoint.ip(); + let (Some(gateway), localhost) = (match endpoint_ip { + IpAddr::V4(_) => (&gateway_v4, IpAddr::V4(Ipv4Addr::LOCALHOST)), + IpAddr::V6(_) => (&gateway_v6, IpAddr::V6(Ipv6Addr::LOCALHOST)), + }) else { + continue; + }; + if routes.contains(&endpoint_ip) { + continue; + } - let endpoint_ip = IpAddrMask::host(endpoint.ip()); - // Note: unlike on BSD, there is no need to delete a gateway on Linux. - #[cfg(not(target_os = "linux"))] - match delete_gateway(&endpoint_ip) { - Ok(()) => { - debug!("Former route to {endpoint_ip} has been removed, if it existed."); + let dest = IpAddrMask::host(endpoint_ip); + let result = match gateway { + Ok(Some(gateway)) => { + debug!("Routing the traffic destined to {dest} through gateway {gateway}."); + add_gateway(&dest, *gateway, false) + } + Ok(None) => { + debug!( + "Default gateway has not been found, routing the traffic destined to {dest} \ + through localhost as a blackhole route." + ); + add_gateway(&dest, localhost, true) } Err(err) => { - debug!("Former route to {endpoint_ip} has not been removed: {err}"); + error!("Failed to get default gateway for {dest}: {err}"); + continue; } - } - - debug!("Default routes have been set, proceeding with further configuration."); - if endpoint.is_ipv6() { - debug!( - "Endpoint is an IPv6 address and a default IPv6 route is present in the allowed \ - IPs; proceeding with further configuration." - ); - match gateway_v6 { - Ok(Some(gateway)) => { - debug!( - "Default gateway for IPv6: {gateway}, routing the traffic destined to \ - {endpoint_ip} through it." - ); - match add_gateway(&endpoint_ip, gateway, false) { - Ok(()) => { - debug!("Route to {endpoint_ip} has been added for gateway {gateway}"); - } - Err(err) => { - error!( - "Failed to add route to {endpoint_ip} for gateway {gateway}: {err}" - ); - } - } - } - Ok(None) => { - debug!( - "Default gateway for IPv6 has not been found, routing the traffic destined \ - to {endpoint_ip} through localhost as a blackhole route." - ); - match add_gateway(&endpoint_ip, IpAddr::V6(Ipv6Addr::LOCALHOST), true) { - Ok(()) => debug!("Blackhole route to {endpoint_ip} has been added"), - Err(err) => { - error!("Failed to add blackhole route to {endpoint_ip}: {err}"); - } - } - } - Err(ref err) => { - error!("Failed to get gateway for {endpoint_ip}: {err}"); - } + }; + match result { + Ok(()) => { + debug!("Route to {dest} has been added."); + routes.insert(endpoint_ip); } - } else { - debug!( - "Endpoint is an IPv4 address and a default IPv4 route is present in the allowed \ - IPs; proceeding with further configuration." - ); - match gateway_v4 { - Ok(Some(gateway)) => { - debug!( - "Default gateway for IPv4: {gateway}, routing the traffic destined to \ - {endpoint_ip} through it." - ); - match add_gateway(&endpoint_ip, gateway, false) { - Ok(()) => { - debug!("Added route to {endpoint_ip} for gateway {gateway}"); - } - Err(err) => { - error!( - "Failed to add route to {endpoint_ip} for gateway {gateway}: {err}" - ); - } - } - } - Ok(None) => { - debug!( - "Default gateway for IPv4 has not been found, routing the traffic destined \ - to {endpoint_ip} through localhost as a blackhole route." - ); - match add_gateway(&endpoint_ip, IpAddr::V4(Ipv4Addr::LOCALHOST), true) { - Ok(()) => debug!("Blackhole route to {endpoint_ip} has been added"), - Err(err) => { - error!("Failed to add blackhole route to {endpoint_ip}: {err}"); - } - } - } - Err(ref err) => { - error!("Failed to get gateway for {endpoint_ip}: {err}"); - } + Err(IoError::Io(err)) if err.raw_os_error() == Some(libc::EEXIST) => { + debug!("Route to {dest} already exists, leaving it intact."); } + Err(err) => error!("Failed to add route to {dest}: {err}"), } } } +#[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "netbsd"))] +fn delete_endpoint_route(endpoint_ip: IpAddr) { + let dest = IpAddrMask::host(endpoint_ip); + match delete_gateway(&dest) { + Ok(()) => debug!("Route to {dest} has been removed."), + Err(err) => warn!("Failed to remove route to {dest}: {err}"), + } +} + +/// Remove a route to `endpoint_ip` added by [`configure_endpoints`]. +#[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "netbsd"))] +pub(crate) fn remove_endpoint_route(endpoint_ip: IpAddr, routes: &mut HashSet) { + if routes.remove(&endpoint_ip) { + delete_endpoint_route(endpoint_ip); + } else { + debug!("Route to {endpoint_ip} has not been added by us, leaving it intact."); + } +} + +/// Remove all routes added by [`configure_endpoints`]. +#[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "netbsd"))] +pub(crate) fn remove_endpoint_routes(routes: &mut HashSet) { + for endpoint_ip in routes.drain() { + delete_endpoint_route(endpoint_ip); + } +} + /// Clean fwmark rules while removing interface (based on wg-quick). #[cfg(target_os = "linux")] pub(crate) fn clean_fwmark_rules(fwmark: u32) -> Result<(), WireguardInterfaceError> { diff --git a/src/wgapi.rs b/src/wgapi.rs index af14116..85c7f1c 100644 --- a/src/wgapi.rs +++ b/src/wgapi.rs @@ -1,5 +1,11 @@ //! Shared multi-platform management API abstraction use std::marker::PhantomData; +#[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "netbsd"))] +use std::{ + collections::HashSet, + net::IpAddr, + sync::{Mutex, MutexGuard, PoisonError}, +}; #[cfg(unix)] use defguard_boringtun::device::DeviceHandle; @@ -9,6 +15,11 @@ use wireguard_nt::Adapter; #[cfg(feature = "check_dependencies")] use crate::dependencies::check_external_dependencies; use crate::error::WireguardInterfaceError; +#[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "netbsd"))] +use crate::{ + Peer, + utils::{configure_endpoints, remove_endpoint_route, remove_endpoint_routes, resolve}, +}; pub struct Kernel; pub struct Userspace; @@ -23,6 +34,8 @@ pub struct WGApi { pub(super) device_handle: Option, #[cfg(target_os = "windows")] pub(super) adapter: Option, + #[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "netbsd"))] + endpoint_routes: Mutex>, pub(super) _api: PhantomData, } @@ -37,7 +50,41 @@ impl WGApi { device_handle: None, #[cfg(target_os = "windows")] adapter: None, + #[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "netbsd"))] + endpoint_routes: Mutex::default(), _api: PhantomData, }) } + + /// Routes to peer endpoints added by `configure_peer_routing`. + #[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "netbsd"))] + fn endpoint_routes(&self) -> MutexGuard<'_, HashSet> { + self.endpoint_routes + .lock() + .unwrap_or_else(PoisonError::into_inner) + } + + /// Route peer endpoints outside the tunnel. See [`configure_endpoints`]. + #[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "netbsd"))] + pub(super) fn add_endpoint_routes(&self, peers: &[Peer]) { + configure_endpoints(peers, &mut self.endpoint_routes()); + } + + /// Remove the route to `endpoint`, if it has been added by `add_endpoint_routes`. + #[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "netbsd"))] + pub(super) fn delete_endpoint_route( + &self, + endpoint: &str, + ) -> Result<(), WireguardInterfaceError> { + debug!("Removing routing to {endpoint}, interface: {}", self.ifname); + let endpoint_addr = resolve(endpoint)?; + remove_endpoint_route(endpoint_addr.ip(), &mut self.endpoint_routes()); + Ok(()) + } + + /// Remove all routes added by `add_endpoint_routes`. + #[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "netbsd"))] + pub(super) fn delete_endpoint_routes(&self) { + remove_endpoint_routes(&mut self.endpoint_routes()); + } } diff --git a/src/wgapi_freebsd.rs b/src/wgapi_freebsd.rs index 3391229..3c220f2 100644 --- a/src/wgapi_freebsd.rs +++ b/src/wgapi_freebsd.rs @@ -2,7 +2,7 @@ use crate::{ Host, InterfaceConfiguration, IpAddrMask, Key, Peer, WireguardInterfaceApi, WireguardInterfaceError, bsd, dns::{DnsConfig, clear_dns}, - utils::{add_peer_routing, configure_endpoints}, + utils::add_peer_routing, wgapi::{Kernel, WGApi}, }; @@ -51,10 +51,14 @@ impl WireguardInterfaceApi for WGApi { /// Based on ip type `` will be equal to `-inet` or `-inet6` fn configure_peer_routing(&self, peers: &[Peer]) -> Result<(), WireguardInterfaceError> { add_peer_routing(peers, &self.ifname)?; - configure_endpoints(peers); + self.add_endpoint_routes(peers); Ok(()) } + fn remove_endpoint_routing(&self, endpoint: &str) -> Result<(), WireguardInterfaceError> { + self.delete_endpoint_route(endpoint) + } + fn configure_interface( &self, config: &InterfaceConfiguration, @@ -121,6 +125,7 @@ impl WireguardInterfaceApi for WGApi { fn remove_interface(&self) -> Result<(), WireguardInterfaceError> { // Clear the DNS configuration while the interface is still around. clear_dns(&self.ifname)?; + self.delete_endpoint_routes(); debug!("Removing interface {}", &self.ifname); bsd::delete_interface(&self.ifname)?; diff --git a/src/wgapi_linux.rs b/src/wgapi_linux.rs index 2bec58b..2a57593 100644 --- a/src/wgapi_linux.rs +++ b/src/wgapi_linux.rs @@ -3,7 +3,7 @@ use crate::{ WireguardInterfaceError, dns::{DnsConfig, clear_dns}, netlink, - utils::{add_peer_routing, clean_fwmark_rules, configure_endpoints}, + utils::{add_peer_routing, clean_fwmark_rules}, wgapi::{Kernel, WGApi}, }; @@ -101,10 +101,9 @@ impl WireguardInterfaceApi for WGApi { /// - A rule pushing all traffic through the WireGuard interface is added with the exception of traffic marked with 51820 (default) fwmark which /// is used for the WireGuard traffic itself (so it doesn't get stuck in a loop) /// + /// Endpoint routes aren't needed, as WireGuard traffic bypasses the tunnel thanks to fwmark. fn configure_peer_routing(&self, peers: &[Peer]) -> Result<(), WireguardInterfaceError> { - add_peer_routing(peers, &self.ifname)?; - configure_endpoints(peers); - Ok(()) + add_peer_routing(peers, &self.ifname) } fn remove_interface(&self) -> Result<(), WireguardInterfaceError> { @@ -173,3 +172,44 @@ impl WireguardInterfaceApi for WGApi { config.configure_dns(&self.ifname) } } + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + use netlink_packet_route::route::RouteType; + + use super::*; + use crate::IpVersion; + + #[ignore = "destructive"] + #[test] + fn docker_endpoint_route_kept() { + const IF_NAME: &str = "wg0"; + let endpoint = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1)); + let dest = IpAddrMask::host(endpoint); + + // Pre-existing route to the endpoint. + let gateway = netlink::get_gateway(IpVersion::IPv4).unwrap().unwrap(); + netlink::add_gateway(&dest, gateway, true).unwrap(); + + let mut wgapi = WGApi::::new(IF_NAME).unwrap(); + wgapi.create_interface().unwrap(); + let mut peer = Peer::new(Key::new([1; 32])); + peer.endpoint = Some(SocketAddr::new(endpoint, 51820)); + peer.allowed_ips + .push(IpAddrMask::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0)); + let result = wgapi.configure_peer_routing(&[peer]); + let route = netlink::get_route(&dest).unwrap(); + + // Clean up before asserting, as all traffic is now routed through the interface. + if let Some(fwmark) = netlink::get_host(IF_NAME).unwrap().fwmark { + clean_fwmark_rules(fwmark).unwrap(); + } + netlink::delete_interface(IF_NAME).unwrap(); + netlink::delete_route(&dest).unwrap(); + + result.unwrap(); + assert_eq!(route.unwrap().header.kind, RouteType::BlackHole); + } +} diff --git a/src/wgapi_userspace.rs b/src/wgapi_userspace.rs index 563f1df..e5b7a1d 100644 --- a/src/wgapi_userspace.rs +++ b/src/wgapi_userspace.rs @@ -8,6 +8,8 @@ use std::{ use defguard_boringtun::device::{DeviceConfig, DeviceHandle}; +#[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "netbsd"))] +use crate::bsd; #[cfg(any( target_os = "freebsd", target_os = "linux", @@ -21,12 +23,10 @@ use crate::{ Host, InterfaceConfiguration, IpAddrMask, Key, Peer, dns::DnsConfig, error::WireguardInterfaceError, - utils::{add_peer_routing, configure_endpoints}, + utils::add_peer_routing, wgapi::{Userspace, WGApi}, wireguard_interface::WireguardInterfaceApi, }; -#[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "netbsd"))] -use crate::{bsd, utils::resolve}; const SOCKET_TIMEOUT: Duration = Duration::new(3, 0); @@ -243,26 +243,21 @@ impl WireguardInterfaceApi for WGApi { /// ``- Gateway extracted using `netstat -nr -f `. fn configure_peer_routing(&self, peers: &[Peer]) -> Result<(), WireguardInterfaceError> { add_peer_routing(peers, &self.ifname)?; - configure_endpoints(peers); + #[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "netbsd"))] + self.add_endpoint_routes(peers); Ok(()) } #[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "netbsd"))] fn remove_endpoint_routing(&self, endpoint: &str) -> Result<(), WireguardInterfaceError> { - debug!("Removing routing to {endpoint}, interface: {}", self.ifname); - let endpoint_addr = resolve(endpoint)?; - let host = IpAddrMask::host(endpoint_addr.ip()); - match bsd::delete_gateway(&host) { - Ok(()) => debug!("Removed routing to {host}"), - Err(err) => debug!("Failed to remove routing to {host}: {err}"), - } - - Ok(()) + self.delete_endpoint_route(endpoint) } /// Remove WireGuard network interface. fn remove_interface(&self) -> Result<(), WireguardInterfaceError> { debug!("Removing interface {}", self.ifname); + #[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "netbsd"))] + self.delete_endpoint_routes(); // `wireguard-go` should by design shut down if the socket is removed debug!( "Shutting down socket for interface {}, checking if the socket to remove exists...", From 7629f6240cb3273577eb668fa2581ded95cafa74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Ciarcin=CC=81ski?= Date: Mon, 26 Oct 2026 17:01:25 +0100 Subject: [PATCH 2/4] Cleanup --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/dns.rs | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cfab6e7..c11f8c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -270,7 +270,7 @@ dependencies = [ [[package]] name = "defguard_wireguard_rs" -version = "0.12.3" +version = "0.12.4" dependencies = [ "base64", "defguard_boringtun", diff --git a/Cargo.toml b/Cargo.toml index 354ef28..80f0904 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "defguard_wireguard_rs" -version = "0.12.3" +version = "0.12.4" edition = "2024" rust-version = "1.91" description = "A unified multi-platform high-level API for managing WireGuard interfaces" diff --git a/src/dns.rs b/src/dns.rs index 6fe08bd..8f49ebd 100644 --- a/src/dns.rs +++ b/src/dns.rs @@ -11,12 +11,13 @@ #[cfg(target_os = "macos")] use std::io::{Cursor, Error as IoError}; use std::net::{IpAddr, Ipv4Addr}; +#[cfg(target_os = "linux")] +use std::path::Path; #[cfg(any(target_os = "freebsd", target_os = "linux", target_os = "netbsd"))] use std::{ fmt::Write as _, fs::{File, read_dir, read_to_string}, io::Write, - path::Path, process::Stdio, }; #[cfg(not(target_os = "windows"))] From 56f83c8c330d6caeedc11ce43f2b87fdb779178c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Ciarcin=CC=81ski?= Date: Tue, 27 Oct 2026 10:21:42 +0100 Subject: [PATCH 3/4] add_gateway() replace option --- src/netlink.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/netlink.rs b/src/netlink.rs index 66337dd..94a9ca1 100644 --- a/src/netlink.rs +++ b/src/netlink.rs @@ -543,6 +543,7 @@ pub(crate) fn add_gateway( dest: &IpAddrMask, gateway: IpAddr, is_blackhole: bool, + replace: bool, ) -> NetlinkResult<()> { if !is_blackhole && dest.address.is_ipv4() != gateway.is_ipv4() { error!("Destination {dest} and gateway {gateway} IP versions don't match"); @@ -574,7 +575,7 @@ pub(crate) fn add_gateway( match netlink_request( RouteNetlinkMessage::NewRoute(message), - NLM_F_REQUEST | NLM_F_ACK | NLM_F_CREATE | NLM_F_EXCL, + NLM_F_REQUEST | NLM_F_ACK | NLM_F_CREATE | if replace { NLM_F_REPLACE } else { NLM_F_EXCL }, NETLINK_ROUTE, ) { Ok(_msg) => Ok(()), @@ -1036,7 +1037,7 @@ mod tests { let gateway = get_gateway(IpVersion::IPv4).unwrap().unwrap(); // Pre-existing route. - add_gateway(&dest, gateway, true).unwrap(); + add_gateway(&dest, gateway, true, false).unwrap(); let result = add_gateway(&dest, gateway, false); let route = get_route(&dest).unwrap(); delete_route(&dest).unwrap(); From 17500b6776aa629605348b90a736918961ae52ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Ciarcin=CC=81ski?= Date: Tue, 27 Oct 2026 10:46:50 +0100 Subject: [PATCH 4/4] continue with the previous commit --- src/netlink.rs | 2 +- src/wgapi_linux.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/netlink.rs b/src/netlink.rs index 94a9ca1..1b3acaf 100644 --- a/src/netlink.rs +++ b/src/netlink.rs @@ -1038,7 +1038,7 @@ mod tests { // Pre-existing route. add_gateway(&dest, gateway, true, false).unwrap(); - let result = add_gateway(&dest, gateway, false); + let result = add_gateway(&dest, gateway, false, false); let route = get_route(&dest).unwrap(); delete_route(&dest).unwrap(); diff --git a/src/wgapi_linux.rs b/src/wgapi_linux.rs index 2a57593..675ec89 100644 --- a/src/wgapi_linux.rs +++ b/src/wgapi_linux.rs @@ -191,7 +191,7 @@ mod tests { // Pre-existing route to the endpoint. let gateway = netlink::get_gateway(IpVersion::IPv4).unwrap().unwrap(); - netlink::add_gateway(&dest, gateway, true).unwrap(); + netlink::add_gateway(&dest, gateway, true, false).unwrap(); let mut wgapi = WGApi::::new(IF_NAME).unwrap(); wgapi.create_interface().unwrap();