309 lines
12 KiB
Rust
309 lines
12 KiB
Rust
use anyhow::Result;
|
|
use std::net::{IpAddr, Ipv4Addr};
|
|
use std::os::windows::process::CommandExt;
|
|
use tracing::{info, warn};
|
|
|
|
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
|
|
|
use crate::dns::DnsManager;
|
|
|
|
pub struct RouteManager {
|
|
tun_interface: String,
|
|
dns_manager: DnsManager,
|
|
tun_routes: Vec<(Ipv4Addr, u8)>,
|
|
server_ip: Option<Ipv4Addr>,
|
|
wifi_gateway: Option<Ipv4Addr>,
|
|
// Physical interface name the server host route must be pinned to. Without
|
|
// this, if the VPN's assigned tun subnet happens to contain the physical
|
|
// gateway IP, `route add`'s gateway-only resolution can pick the tun
|
|
// interface instead of the physical NIC, looping the server route back
|
|
// into the tunnel it's meant to bypass.
|
|
wifi_iface: Option<String>,
|
|
}
|
|
|
|
impl RouteManager {
|
|
pub async fn new(tun_interface: String) -> Result<Self> {
|
|
Ok(Self {
|
|
dns_manager: DnsManager::new(
|
|
tun_interface.clone(),
|
|
None,
|
|
None,
|
|
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
|
),
|
|
tun_interface,
|
|
tun_routes: Vec::new(),
|
|
server_ip: None,
|
|
wifi_gateway: None,
|
|
wifi_iface: None,
|
|
})
|
|
}
|
|
|
|
/// Returns the physical default gateway and the friendly name of the
|
|
/// adapter it's reachable on. The interface name must be used to pin any
|
|
/// route meant to bypass the tunnel — relying on gateway-only resolution
|
|
/// lets `route add` route it via the TUN adapter instead if the VPN's
|
|
/// assigned subnet happens to contain this gateway IP.
|
|
fn get_default_gateway() -> Result<(Ipv4Addr, String)> {
|
|
let adapters = ipconfig::get_adapters()
|
|
.map_err(|e| anyhow::anyhow!("Failed to enumerate adapters: {}", e))?;
|
|
for adapter in &adapters {
|
|
for gw in adapter.gateways() {
|
|
if let IpAddr::V4(gw4) = gw {
|
|
if !gw4.is_loopback() && !gw4.is_unspecified() {
|
|
return Ok((*gw4, adapter.friendly_name().to_string()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
anyhow::bail!("No default gateway found")
|
|
}
|
|
|
|
// Add/delete the server host route through the physical WiFi/Ethernet
|
|
// interface, pinned explicitly by adapter name via `netsh` rather than
|
|
// `route add`'s gateway-only resolution — same rationale and technique as
|
|
// `tun_route_add` below: without an explicit interface, an IP collision
|
|
// between the VPN's assigned tun subnet and the physical gateway can make
|
|
// this route resolve onto the TUN adapter instead, looping the server
|
|
// route back into the tunnel it's meant to bypass.
|
|
fn wifi_route_add(iface: &str, dest: Ipv4Addr, prefix: u8, gateway: Ipv4Addr) {
|
|
let prefix_str = format!("{}/{}", dest, prefix);
|
|
let out = std::process::Command::new("netsh")
|
|
.args([
|
|
"interface",
|
|
"ipv4",
|
|
"add",
|
|
"route",
|
|
&prefix_str,
|
|
iface,
|
|
&format!("nexthop={}", gateway),
|
|
"metric=1",
|
|
"store=active",
|
|
])
|
|
.creation_flags(CREATE_NO_WINDOW)
|
|
.output();
|
|
match out {
|
|
Ok(o) => {
|
|
let stdout = String::from_utf8_lossy(&o.stdout);
|
|
if !o.status.success() {
|
|
// "already exists" is acceptable — a previous run may have left it
|
|
warn!("netsh add route {}/{} via {} on {}: {}", dest, prefix, gateway, iface, stdout.trim());
|
|
}
|
|
}
|
|
Err(e) => warn!("netsh add route {}/{} via {} on {}: {}", dest, prefix, gateway, iface, e),
|
|
}
|
|
}
|
|
|
|
fn wifi_route_delete(iface: &str, dest: Ipv4Addr, prefix: u8) {
|
|
let prefix_str = format!("{}/{}", dest, prefix);
|
|
let _ = std::process::Command::new("netsh")
|
|
.args(["interface", "ipv4", "delete", "route", &prefix_str, iface])
|
|
.creation_flags(CREATE_NO_WINDOW)
|
|
.output();
|
|
}
|
|
|
|
// Add/delete routes through the TUN interface using netsh.
|
|
//
|
|
// `netsh interface ipv4 add route` binds routes to a named interface explicitly,
|
|
// avoiding the gateway-resolution ambiguity of the `route` command and ensuring
|
|
// cleanup never touches WiFi routing state.
|
|
fn tun_route_add(tun_name: &str, dest: Ipv4Addr, prefix: u8) {
|
|
let prefix_str = format!("{}/{}", dest, prefix);
|
|
let out = std::process::Command::new("netsh")
|
|
.args([
|
|
"interface",
|
|
"ipv4",
|
|
"add",
|
|
"route",
|
|
&prefix_str,
|
|
tun_name,
|
|
"nexthop=0.0.0.0",
|
|
"metric=1",
|
|
"store=active",
|
|
])
|
|
.creation_flags(CREATE_NO_WINDOW)
|
|
.output();
|
|
match out {
|
|
Ok(o) => {
|
|
let stdout = String::from_utf8_lossy(&o.stdout);
|
|
if o.status.success() {
|
|
info!("Added TUN route {}/{}", dest, prefix);
|
|
} else {
|
|
warn!("netsh add route {}/{} on {}: {}", dest, prefix, tun_name, stdout.trim());
|
|
}
|
|
}
|
|
Err(e) => warn!("netsh add route {}/{} on {}: {}", dest, prefix, tun_name, e),
|
|
}
|
|
}
|
|
|
|
fn tun_route_delete(tun_name: &str, dest: Ipv4Addr, prefix: u8) {
|
|
let prefix_str = format!("{}/{}", dest, prefix);
|
|
let out = std::process::Command::new("netsh")
|
|
.args([
|
|
"interface",
|
|
"ipv4",
|
|
"delete",
|
|
"route",
|
|
&prefix_str,
|
|
tun_name,
|
|
])
|
|
.creation_flags(CREATE_NO_WINDOW)
|
|
.output();
|
|
match out {
|
|
Ok(o) => {
|
|
let stdout = String::from_utf8_lossy(&o.stdout);
|
|
if !o.status.success() {
|
|
warn!(
|
|
"netsh delete route {}/{} on {}: {}",
|
|
dest,
|
|
prefix,
|
|
tun_name,
|
|
stdout.trim()
|
|
);
|
|
}
|
|
}
|
|
Err(e) => warn!("netsh delete route {}/{} on {}: {}", dest, prefix, tun_name, e),
|
|
}
|
|
}
|
|
|
|
pub async fn setup_vpn_routes(
|
|
&mut self,
|
|
server_addr: &str,
|
|
_vpn_gateway: IpAddr,
|
|
routes: &[String],
|
|
) -> Result<()> {
|
|
info!("Setting up Windows VPN routes...");
|
|
|
|
let (current_gateway, wifi_iface) = Self::get_default_gateway()?;
|
|
info!("Current gateway: {} (interface {})", current_gateway, wifi_iface);
|
|
self.wifi_iface = Some(wifi_iface.clone());
|
|
|
|
let server_ip: Ipv4Addr = {
|
|
let host = server_addr.split(':').next().unwrap_or(server_addr);
|
|
if let Ok(IpAddr::V4(v4)) = host.parse::<IpAddr>() {
|
|
v4
|
|
} else {
|
|
let addrs = tokio::net::lookup_host(format!("{}:0", host)).await?;
|
|
match addrs.map(|a| a.ip()).next() {
|
|
Some(IpAddr::V4(v4)) => v4,
|
|
_ => anyhow::bail!("Could not resolve server address to IPv4"),
|
|
}
|
|
}
|
|
};
|
|
|
|
// Always record server + gateway so cleanup can delete the host route
|
|
// even if the add below fails because the route already exists.
|
|
self.server_ip = Some(server_ip);
|
|
self.wifi_gateway = Some(current_gateway);
|
|
Self::wifi_route_add(&wifi_iface, server_ip, 32, current_gateway);
|
|
|
|
let is_full_tunnel = routes.iter().any(|r| r == "0.0.0.0/0");
|
|
|
|
if is_full_tunnel {
|
|
// Two /1 routes on the TUN interface cover the entire address space and
|
|
// always beat the existing WiFi /0 default route regardless of metric.
|
|
// netsh with explicit interface name avoids gateway-resolution ambiguity.
|
|
info!("Full-tunnel mode: adding /1 cover routes on {}...", self.tun_interface);
|
|
let lower = Ipv4Addr::new(0, 0, 0, 0);
|
|
let upper = Ipv4Addr::new(128, 0, 0, 0);
|
|
Self::tun_route_add(&self.tun_interface, lower, 1);
|
|
self.tun_routes.push((lower, 1));
|
|
Self::tun_route_add(&self.tun_interface, upper, 1);
|
|
self.tun_routes.push((upper, 1));
|
|
} else {
|
|
info!("Split-tunnel mode: adding {} routes via TUN...", routes.len());
|
|
for route_str in routes {
|
|
let Some((dest_str, prefix_str)) = route_str.split_once('/') else {
|
|
warn!("Invalid route format: {}", route_str);
|
|
continue;
|
|
};
|
|
let Ok(dest) = dest_str.parse::<Ipv4Addr>() else {
|
|
warn!("Invalid route destination: {}", dest_str);
|
|
continue;
|
|
};
|
|
let Ok(prefix_len) = prefix_str.parse::<u8>() else {
|
|
warn!("Invalid prefix length: {}", prefix_str);
|
|
continue;
|
|
};
|
|
Self::tun_route_add(&self.tun_interface, dest, prefix_len);
|
|
self.tun_routes.push((dest, prefix_len));
|
|
info!("Added split-tunnel route: {}", route_str);
|
|
}
|
|
}
|
|
|
|
info!("Windows VPN routes configured");
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn setup_dns(&mut self, dns_servers: &[IpAddr], assigned_ip: IpAddr) -> Result<()> {
|
|
if dns_servers.is_empty() {
|
|
return Ok(());
|
|
}
|
|
self.dns_manager = DnsManager::new(self.tun_interface.clone(), None, None, assigned_ip);
|
|
self.dns_manager.set_dns(dns_servers).await
|
|
}
|
|
|
|
pub async fn restore_dns(&self) -> Result<()> {
|
|
self.dns_manager.restore().await
|
|
}
|
|
|
|
pub async fn ensure_server_route(&mut self) -> Result<()> {
|
|
if let (Some(server_ip), Some(gateway), Some(iface)) =
|
|
(self.server_ip, self.wifi_gateway, self.wifi_iface.as_deref())
|
|
{
|
|
Self::wifi_route_add(iface, server_ip, 32, gateway);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn suspend_default_route(&mut self) -> Result<()> {
|
|
let lower = Ipv4Addr::new(0, 0, 0, 0);
|
|
if !self.tun_routes.contains(&(lower, 1)) {
|
|
return Ok(());
|
|
}
|
|
for (dest, prefix) in [(lower, 1u8), (Ipv4Addr::new(128, 0, 0, 0), 1u8)] {
|
|
Self::tun_route_delete(&self.tun_interface, dest, prefix);
|
|
}
|
|
info!("VPN cover routes suspended — traffic falls to WiFi during reconnect");
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn resume_default_route(&mut self) -> Result<()> {
|
|
let lower = Ipv4Addr::new(0, 0, 0, 0);
|
|
if !self.tun_routes.contains(&(lower, 1)) {
|
|
return Ok(());
|
|
}
|
|
let upper = Ipv4Addr::new(128, 0, 0, 0);
|
|
for (dest, prefix) in [(lower, 1u8), (upper, 1u8)] {
|
|
Self::tun_route_add(&self.tun_interface, dest, prefix);
|
|
}
|
|
info!("VPN cover routes restored after reconnect");
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn restore_routes(&mut self) -> Result<()> {
|
|
info!("Removing {} tracked VPN routes...", self.tun_routes.len());
|
|
for (dest, prefix) in self.tun_routes.drain(..) {
|
|
Self::tun_route_delete(&self.tun_interface, dest, prefix);
|
|
}
|
|
// Always clean up the server host route — a previous unclean exit may have
|
|
// left it behind even if our add was skipped.
|
|
self.wifi_gateway.take();
|
|
if let (Some(server_ip), Some(iface)) = (self.server_ip.take(), self.wifi_iface.take()) {
|
|
Self::wifi_route_delete(&iface, server_ip, 32);
|
|
}
|
|
info!("Windows VPN routes removed");
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Drop for RouteManager {
|
|
fn drop(&mut self) {
|
|
if !self.tun_routes.is_empty() {
|
|
warn!(
|
|
"RouteManager dropped with {} unrestored TUN routes — call restore_routes() before dropping",
|
|
self.tun_routes.len()
|
|
);
|
|
}
|
|
}
|
|
}
|