#[cfg(any(target_os = "linux", target_os = "macos"))]
pub fn apply_scoped_host_route(
iface: &str,
address: &str,
target: IpAddr,
mtu: u16,
) -> Result<ScopedHostRoute> {
let target_str = target.to_string();
let address_ip = address
.split('/')
.next()
.ok_or_else(|| anyhow!("empty WG tunnel address"))?
.to_string();
let mtu_str = mtu.to_string();
#[cfg(target_os = "linux")]
{
run_checked(
ProcessCommand::new("ip")
.arg("address")
.arg("replace")
.arg(format!("{address_ip}/32"))
.arg("dev")
.arg(iface),
)?;
run_checked(
ProcessCommand::new("ip")
.arg("link")
.arg("set")
.arg("mtu")
.arg(&mtu_str)
.arg("up")
.arg("dev")
.arg(iface),
)?;
run_checked(
ProcessCommand::new("ip")
.arg("route")
.arg("replace")
.arg(format!("{target_str}/32"))
.arg("dev")
.arg(iface),
)?;
return Ok(ScopedHostRoute {
iface: iface.to_string(),
target,
});
}
#[cfg(target_os = "macos")]
{
run_checked(
ProcessCommand::new("ifconfig")
.arg(iface)
.arg("inet")
.arg(&address_ip)
.arg(&address_ip)
.arg("netmask")
.arg("255.255.255.255")
.arg("mtu")
.arg(&mtu_str)
.arg("up"),
)?;
run_checked(
ProcessCommand::new("route")
.arg("-n")
.arg("add")
.arg("-host")
.arg(&target_str)
.arg("-interface")
.arg(iface),
)?;
return Ok(ScopedHostRoute {
iface: iface.to_string(),
target,
});
}
#[allow(unreachable_code)]
Err(anyhow!(
"scoped host route is only implemented on Linux and macOS"
))
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub struct ScopedHostRoute {
iface: String,
target: IpAddr,
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub fn apply_full_default_route(
iface: &str,
address: &str,
upstream_endpoint: SocketAddr,
mtu: u16,
_underlay_interface: Option<&str>,
) -> Result<FullDefaultRoute> {
let address_ip = address
.split('/')
.next()
.ok_or_else(|| anyhow!("empty WG tunnel address"))?
.to_string();
let mtu_str = mtu.to_string();
#[cfg(target_os = "linux")]
{
run_checked(
ProcessCommand::new("ip")
.arg("address")
.arg("replace")
.arg(format!("{address_ip}/32"))
.arg("dev")
.arg(iface),
)?;
run_checked(
ProcessCommand::new("ip")
.arg("link")
.arg("set")
.arg("mtu")
.arg(&mtu_str)
.arg("up")
.arg("dev")
.arg(iface),
)?;
}
#[cfg(target_os = "macos")]
{
run_checked(
ProcessCommand::new("ifconfig")
.arg(iface)
.arg("inet")
.arg(&address_ip)
.arg(&address_ip)
.arg("netmask")
.arg("255.255.255.255")
.arg("mtu")
.arg(&mtu_str)
.arg("up"),
)?;
}
#[cfg(target_os = "linux")]
let original_default = capture_default_route()?;
#[cfg(target_os = "linux")]
let bypass_target = upstream_endpoint
.ip()
.is_ipv4()
.then_some(upstream_endpoint.ip());
#[cfg(target_os = "linux")]
if let Some(target) = bypass_target.as_ref() {
install_endpoint_bypass(target, &original_default)?;
}
#[cfg(target_os = "macos")]
let endpoint_bypass_routes = install_macos_wg_endpoint_bypass(
upstream_endpoint.ip(),
_underlay_interface,
&[],
)?
.into_iter()
.collect();
let mut full_route = FullDefaultRoute {
#[cfg(target_os = "macos")]
iface: iface.to_string(),
#[cfg(target_os = "macos")]
endpoint_bypass_routes,
#[cfg(target_os = "linux")]
bypass_target,
#[cfg(target_os = "linux")]
original_default,
reverted: false,
};
if let Err(error) = install_default_via_iface(iface, &address_ip) {
let cleanup = full_route.revert();
return match cleanup {
Ok(()) => Err(error),
Err(cleanup) => Err(error.context(format!(
"rollback partially applied WG routes: {cleanup:#}"
))),
};
}
Ok(full_route)
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub struct FullDefaultRoute {
#[cfg(target_os = "macos")]
iface: String,
#[cfg(target_os = "macos")]
endpoint_bypass_routes: Vec<crate::MacosManagedRoute>,
#[cfg(target_os = "linux")]
bypass_target: Option<IpAddr>,
#[cfg(target_os = "linux")]
original_default: CapturedDefaultRoute,
reverted: bool,
}
#[cfg(target_os = "macos")]
fn macos_wg_endpoint_bypass_route(
endpoint: IpAddr,
underlay: &crate::MacosRouteSpec,
) -> Option<crate::MacosManagedRoute> {
let IpAddr::V4(endpoint) = endpoint else {
return None;
};
if underlay
.gateway
.as_deref()
.and_then(|gateway| gateway.parse::<IpAddr>().ok())
== Some(IpAddr::V4(endpoint))
{
return None;
}
Some(crate::MacosManagedRoute {
target: format!("{endpoint}/32"),
gateway: underlay.gateway.clone(),
interface: Some(underlay.interface.clone()),
})
}
#[cfg(target_os = "macos")]
fn install_macos_wg_endpoint_bypass(
endpoint: IpAddr,
preferred_interface: Option<&str>,
current_routes: &[crate::MacosManagedRoute],
) -> Result<Option<crate::MacosManagedRoute>> {
if !endpoint.is_ipv4() {
return Ok(None);
}
let underlay =
crate::macos_network::macos_underlay_default_route_from_system_for_interface(
preferred_interface,
)?
.ok_or_else(|| {
anyhow!(
"no physical macOS underlay route is available for WireGuard endpoint {endpoint}"
)
})?;
let Some(route) = macos_wg_endpoint_bypass_route(endpoint, &underlay) else {
return Ok(None);
};
for current in current_routes
.iter()
.filter(|current| current.target == route.target && *current != &route)
{
if crate::macos_network::migrate_macos_owned_global_gateway_route(current, &route)? {
return Ok(Some(route));
}
}
crate::macos_network::apply_macos_route_spec(
&route.target,
route.gateway.as_deref(),
route.interface.as_deref(),
)
.with_context(|| format!("install macOS WireGuard endpoint bypass {}", route.target))?;
Ok(Some(route))
}
#[cfg(target_os = "linux")]
#[derive(Debug, Clone)]
struct CapturedDefaultRoute {
line: String,
}
#[cfg(target_os = "linux")]
fn capture_default_route() -> Result<CapturedDefaultRoute> {
let output = ProcessCommand::new("ip")
.arg("-4")
.arg("route")
.arg("show")
.arg("default")
.output()
.context("ip route show default")?;
if !output.status.success() {
return Err(anyhow!("ip route show default exited {}", output.status));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let line = stdout
.lines()
.find(|line| {
let line = line.trim();
!line.is_empty()
&& !line.contains(" dev utun")
&& !line.contains(" dev wg-")
&& !line.contains(" dev nvpn-wg")
})
.or_else(|| stdout.lines().find(|line| !line.trim().is_empty()))
.ok_or_else(|| anyhow!("no IPv4 default route found"))?
.trim()
.to_string();
Ok(CapturedDefaultRoute { line })
}
#[cfg(target_os = "linux")]
fn install_endpoint_bypass(target: &IpAddr, original: &CapturedDefaultRoute) -> Result<()> {
let target_str = target.to_string();
let after_default = original
.line
.strip_prefix("default ")
.unwrap_or(&original.line)
.trim();
let mut command = ProcessCommand::new("ip");
command
.arg("route")
.arg("replace")
.arg(format!("{target_str}/32"));
for arg in crate::linux_route_replay_args(after_default) {
command.arg(arg);
}
run_checked(&mut command)
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn install_default_via_iface(iface: &str, _src: &str) -> Result<()> {
#[cfg(target_os = "linux")]
{
run_checked(
ProcessCommand::new("ip")
.arg("-4")
.arg("route")
.arg("replace")
.arg("default")
.arg("dev")
.arg(iface)
.arg("src")
.arg(_src),
)?;
}
#[cfg(target_os = "macos")]
{
for target in MACOS_WG_DEFAULT_ROUTE_TARGETS {
run_checked(
ProcessCommand::new("route")
.arg("-n")
.arg("add")
.arg("-net")
.arg(target)
.arg("-interface")
.arg(iface),
)?;
}
}
Ok(())
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
impl FullDefaultRoute {
pub fn revert(&mut self) -> Result<()> {
if self.reverted {
return Ok(());
}
#[cfg(target_os = "linux")]
{
let mut command = ProcessCommand::new("ip");
command.arg("route").arg("replace");
for arg in crate::linux_route_replay_args(&self.original_default.line) {
command.arg(arg);
}
run_checked(&mut command)?;
if let Some(target) = self.bypass_target {
let _ = ProcessCommand::new("ip")
.arg("route")
.arg("del")
.arg(format!("{target}/32"))
.status();
}
}
#[cfg(target_os = "macos")]
{
let mut failures = Vec::new();
if let Err(error) =
crate::macos_network::delete_macos_default_route_for_interface(&self.iface)
{
failures.push(format!(
"remove WireGuard split-default routes on {}: {error:#}",
self.iface
));
}
for route in &self.endpoint_bypass_routes {
if let Err(error) = crate::macos_network::delete_macos_managed_route(
&route.target,
route.gateway.as_deref(),
route.interface.as_deref(),
) {
failures.push(format!(
"remove WireGuard endpoint bypass {}: {error:#}",
route.target
));
}
}
if !failures.is_empty() {
return Err(anyhow!(failures.join("; ")));
}
}
self.reverted = true;
Ok(())
}
#[cfg(target_os = "macos")]
pub(crate) fn prepare_macos_endpoint_bypass(
&mut self,
endpoint: IpAddr,
underlay_interface: &str,
) -> Result<Option<crate::MacosManagedRoute>> {
let route = install_macos_wg_endpoint_bypass(
endpoint,
Some(underlay_interface),
&self.endpoint_bypass_routes,
)?;
if let Some(route) = route.as_ref()
&& !self.endpoint_bypass_routes.contains(route)
{
self.endpoint_bypass_routes.push(route.clone());
}
Ok(route)
}
#[cfg(target_os = "macos")]
pub(crate) fn commit_macos_endpoint_bypass(
&mut self,
desired: Option<&crate::MacosManagedRoute>,
) -> Result<()> {
let mut failures = Vec::new();
let stale = self
.endpoint_bypass_routes
.iter()
.filter(|route| desired != Some(*route))
.cloned()
.collect::<Vec<_>>();
for route in &stale {
if let Err(error) = crate::macos_network::delete_macos_managed_route(
&route.target,
route.gateway.as_deref(),
route.interface.as_deref(),
) {
failures.push(format!(
"remove stale WireGuard endpoint bypass {}: {error:#}",
route.target
));
}
}
if failures.is_empty() {
self.endpoint_bypass_routes
.retain(|route| desired == Some(route));
Ok(())
} else {
Err(anyhow!(failures.join("; ")))
}
}
#[cfg(target_os = "macos")]
pub(crate) fn macos_managed_routes(&self) -> Vec<crate::MacosManagedRoute> {
let mut routes = self.endpoint_bypass_routes.clone();
routes.extend(MACOS_WG_DEFAULT_ROUTE_TARGETS.iter().map(|target| {
crate::MacosManagedRoute {
target: (*target).to_string(),
gateway: None,
interface: Some(self.iface.clone()),
}
}));
routes
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
impl Drop for FullDefaultRoute {
fn drop(&mut self) {
if let Err(error) = self.revert() {
eprintln!(
"wg-upstream: WARNING — failed to restore default route on cleanup: {error}. \
You may need to run `route delete default && route add default <gateway>` \
(macOS) or `ip route replace {}` (Linux) manually.",
self.original_default_repr()
);
}
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
impl FullDefaultRoute {
fn original_default_repr(&self) -> String {
#[cfg(target_os = "linux")]
{
self.original_default.line.clone()
}
#[cfg(target_os = "macos")]
{
format!("split defaults on {}", self.iface)
}
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
impl Drop for ScopedHostRoute {
fn drop(&mut self) {
let target = self.target.to_string();
#[cfg(target_os = "linux")]
{
let _ = ProcessCommand::new("ip")
.arg("route")
.arg("del")
.arg(format!("{target}/32"))
.arg("dev")
.arg(&self.iface)
.status();
}
#[cfg(target_os = "macos")]
{
let _ = ProcessCommand::new("route")
.arg("-n")
.arg("delete")
.arg("-host")
.arg(&target)
.arg("-interface")
.arg(&self.iface)
.status();
}
}
}