#[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,
) -> Result<FullDefaultRoute> {
let upstream_ip = upstream_endpoint.ip();
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"),
)?;
}
let original_default = capture_default_route()?;
install_endpoint_bypass(&upstream_ip, &original_default)?;
install_default_via_iface(iface, &address_ip)?;
Ok(FullDefaultRoute {
#[cfg(target_os = "macos")]
iface: iface.to_string(),
bypass_target: upstream_ip,
original_default,
})
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub struct FullDefaultRoute {
#[cfg(target_os = "macos")]
iface: String,
bypass_target: IpAddr,
original_default: CapturedDefaultRoute,
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[derive(Debug, Clone)]
struct CapturedDefaultRoute {
#[cfg(target_os = "linux")]
line: String,
#[cfg(target_os = "macos")]
gateway: String,
#[cfg(target_os = "macos")]
interface: String,
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn capture_default_route() -> Result<CapturedDefaultRoute> {
#[cfg(target_os = "linux")]
{
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 = "macos")]
{
let output = ProcessCommand::new("netstat")
.arg("-rn")
.arg("-f")
.arg("inet")
.output()
.context("netstat -rn -f inet")?;
if !output.status.success() {
return Err(anyhow!("netstat exited {}", output.status));
}
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let tokens: Vec<&str> = line.split_whitespace().collect();
if tokens.len() < 4 || tokens[0] != "default" {
continue;
}
let gateway = tokens[1];
let interface = tokens.last().copied().unwrap_or("");
if interface.starts_with("utun")
|| interface.starts_with("bridge")
|| interface == "lo0"
{
continue;
}
if gateway.starts_with("link#") {
continue;
}
return Ok(CapturedDefaultRoute {
gateway: gateway.to_string(),
interface: interface.to_string(),
});
}
Err(anyhow!(
"no underlay IPv4 default route found in netstat output"
))
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn install_endpoint_bypass(target: &IpAddr, original: &CapturedDefaultRoute) -> Result<()> {
let target_str = target.to_string();
#[cfg(target_os = "linux")]
{
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 after_default.split_whitespace() {
command.arg(arg);
}
run_checked(&mut command)?;
}
#[cfg(target_os = "macos")]
{
let _ = ProcessCommand::new("route")
.arg("-n")
.arg("delete")
.arg("-host")
.arg(&target_str)
.arg("-ifscope")
.arg(&original.interface)
.status();
let _ = ProcessCommand::new("route")
.arg("-n")
.arg("delete")
.arg("-host")
.arg(&target_str)
.status();
run_checked(
ProcessCommand::new("route")
.arg("-n")
.arg("add")
.arg("-host")
.arg(&target_str)
.arg(&original.gateway),
)?;
}
Ok(())
}
#[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<()> {
let target_str = self.bypass_target.to_string();
#[cfg(target_os = "linux")]
{
let mut command = ProcessCommand::new("ip");
command.arg("route").arg("replace");
for arg in self.original_default.line.split_whitespace() {
command.arg(arg);
}
run_checked(&mut command)?;
let _ = ProcessCommand::new("ip")
.arg("route")
.arg("del")
.arg(format!("{target_str}/32"))
.status();
}
#[cfg(target_os = "macos")]
{
let _ = ProcessCommand::new("route")
.arg("-n")
.arg("delete")
.arg("default")
.arg("-interface")
.arg(&self.iface)
.status();
for target in MACOS_WG_DEFAULT_ROUTE_TARGETS {
let _ = ProcessCommand::new("route")
.arg("-n")
.arg("delete")
.arg("-net")
.arg(target)
.arg("-interface")
.arg(&self.iface)
.status();
}
let _ = ProcessCommand::new("route")
.arg("-n")
.arg("delete")
.arg("default")
.arg("-ifscope")
.arg(&self.original_default.interface)
.status();
let mut change = ProcessCommand::new("route");
change
.arg("-n")
.arg("change")
.arg("default")
.arg(&self.original_default.gateway);
if run_checked(&mut change).is_err() {
run_checked(
ProcessCommand::new("route")
.arg("-n")
.arg("add")
.arg("default")
.arg(&self.original_default.gateway),
)?;
}
let _ = ProcessCommand::new("route")
.arg("-n")
.arg("delete")
.arg("-host")
.arg(&target_str)
.arg(&self.original_default.gateway)
.arg("-ifscope")
.arg(&self.original_default.interface)
.status();
let _ = ProcessCommand::new("route")
.arg("-n")
.arg("delete")
.arg("-host")
.arg(&target_str)
.arg("-ifscope")
.arg(&self.original_default.interface)
.status();
let _ = ProcessCommand::new("route")
.arg("-n")
.arg("delete")
.arg("-host")
.arg(&target_str)
.status();
}
Ok(())
}
}
#[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!("default via {}", self.original_default.gateway)
}
}
}
#[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();
}
}
}