Compare commits
4 Commits
v0.1.0-bet
...
v0.1.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11d5305d8c | ||
|
|
c2d7f8d8c6 | ||
|
|
678ed158a6 | ||
|
|
56201f6b72 |
@@ -135,9 +135,7 @@ The VPN server and management UI are proprietary. Source review under NDA is ava
|
||||
|
||||
## Support
|
||||
|
||||
**Bug reports:** Use the in-app reporting feature or open an issue in this repository.
|
||||
|
||||
**Security vulnerabilities:** Do not open a public issue. Contact the maintainer directly at the address shown in the application's About screen.
|
||||
Bug reports, security vulnerabilities, and any other inquiries: use the contact form at [www.k-ops.eu](https://www.k-ops.eu).
|
||||
|
||||
**Contributing:** Core development is handled internally. We do not currently accept external pull requests.
|
||||
|
||||
|
||||
@@ -31,6 +31,31 @@ trait Resolve1Manager {
|
||||
fn revert_link(&self, ifindex: i32) -> zbus::Result<()>;
|
||||
}
|
||||
|
||||
// Per-link object (/org/freedesktop/resolve1/link/_<ifindex>), used only to
|
||||
// read back the physical interface's current DNS servers before we override
|
||||
// them, so they can be restored byte-for-byte on disconnect.
|
||||
//
|
||||
// SetLinkDefaultRoute (the "correct" API for demoting a link as a default DNS
|
||||
// route) requires interactive polkit auth (auth_admin_keep) — unlike
|
||||
// SetLinkDNS/SetLinkDomains, it does NOT get a CAP_NET_ADMIN bypass in
|
||||
// systemd-resolved, so it silently fails for this capability-only (non-root)
|
||||
// process. Reusing SetLinkDNS on the physical link's own object instead stays
|
||||
// on the bypass path.
|
||||
#[zbus::proxy(interface = "org.freedesktop.resolve1.Link", default_service = "org.freedesktop.resolve1")]
|
||||
trait Resolve1Link {
|
||||
#[zbus(property, name = "DNS")]
|
||||
fn dns(&self) -> zbus::Result<Vec<(i32, Vec<u8>)>>;
|
||||
}
|
||||
|
||||
fn link_object_path(if_index: u32) -> String {
|
||||
let mut path = String::from("/org/freedesktop/resolve1/link/");
|
||||
for c in if_index.to_string().chars() {
|
||||
path.push_str("_3");
|
||||
path.push(c);
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -38,6 +63,7 @@ trait Resolve1Manager {
|
||||
pub async fn set_dns(
|
||||
interface: &str,
|
||||
if_index: Option<u32>,
|
||||
physical_if_index: Option<u32>,
|
||||
dns_servers: &[IpAddr],
|
||||
_assigned_ip: IpAddr,
|
||||
state: &mut DnsState,
|
||||
@@ -59,6 +85,31 @@ pub async fn set_dns(
|
||||
match result {
|
||||
Ok(_) => {
|
||||
state.configured = true;
|
||||
// Both the physical link and the tunnel link end up with
|
||||
// Default Route: yes in systemd-resolved (the physical link
|
||||
// never loses that flag just because another link gains it).
|
||||
// With two "default route" resolvers, systemd-resolved can
|
||||
// race queries between the VPN's DNS server and the ISP's,
|
||||
// leaking lookups outside the tunnel and returning
|
||||
// inconsistent/partial answers (e.g. AAAA-only responses).
|
||||
// Blank out the physical link's own DNS server list so it has
|
||||
// nothing to answer with, leaving the tunnel as the sole
|
||||
// practical resolver; its original servers are saved and
|
||||
// restored on disconnect.
|
||||
if let Some(phys_idx) = physical_if_index {
|
||||
match clear_physical_link_dns(phys_idx).await {
|
||||
Ok(original) => {
|
||||
state.demoted_physical_if_index = Some(phys_idx);
|
||||
state.physical_dns_backup = original;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Failed to clear physical interface {} DNS servers: {}",
|
||||
phys_idx, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
@@ -112,6 +163,18 @@ pub async fn restore(
|
||||
// Always run resolvectl revert as a second pass.
|
||||
revert_resolvectl(interface).await.ok();
|
||||
|
||||
// Restore the physical interface's original DNS servers we blanked out
|
||||
// in set_dns, or it's left with no resolver after the VPN disconnects.
|
||||
if let Some(phys_idx) = state.demoted_physical_if_index.take() {
|
||||
let backup = std::mem::take(&mut state.physical_dns_backup);
|
||||
if let Err(e) = restore_physical_link_dns(phys_idx, backup).await {
|
||||
warn!(
|
||||
"Failed to restore physical interface {} DNS servers: {}",
|
||||
phys_idx, e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
state.configured = false;
|
||||
Ok(())
|
||||
}
|
||||
@@ -165,6 +228,62 @@ async fn set_dns_dbus(if_index: u32, dns_servers: &[IpAddr]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads the physical link's current DNS servers, then blanks them out so it
|
||||
/// stops acting as a competing resolver. Returns the original list to restore
|
||||
/// later. Both the read and the clear go through the same CAP_NET_ADMIN
|
||||
/// bypass path as the tunnel's own SetLinkDNS call.
|
||||
async fn clear_physical_link_dns(if_index: u32) -> Result<Vec<(i32, Vec<u8>)>> {
|
||||
let conn = zbus::Connection::system()
|
||||
.await
|
||||
.context("Failed to connect to D-Bus system bus")?;
|
||||
|
||||
let link_proxy = Resolve1LinkProxy::builder(&conn)
|
||||
.path(link_object_path(if_index))
|
||||
.context("Invalid link object path")?
|
||||
.build()
|
||||
.await
|
||||
.context("Failed to create resolve1 link proxy")?;
|
||||
|
||||
let original = link_proxy
|
||||
.dns()
|
||||
.await
|
||||
.context("Failed to read physical link DNS servers")?;
|
||||
|
||||
let manager_proxy = Resolve1ManagerProxy::new(&conn)
|
||||
.await
|
||||
.context("Failed to create resolve1 proxy")?;
|
||||
|
||||
manager_proxy
|
||||
.set_link_dns(if_index as i32, vec![])
|
||||
.await
|
||||
.context("SetLinkDNS (clear) failed")?;
|
||||
|
||||
info!(
|
||||
"Cleared physical interface {} DNS servers ({} saved for restore)",
|
||||
if_index,
|
||||
original.len()
|
||||
);
|
||||
Ok(original)
|
||||
}
|
||||
|
||||
async fn restore_physical_link_dns(if_index: u32, original: Vec<(i32, Vec<u8>)>) -> Result<()> {
|
||||
let conn = zbus::Connection::system()
|
||||
.await
|
||||
.context("Failed to connect to D-Bus system bus")?;
|
||||
|
||||
let proxy = Resolve1ManagerProxy::new(&conn)
|
||||
.await
|
||||
.context("Failed to create resolve1 proxy")?;
|
||||
|
||||
proxy
|
||||
.set_link_dns(if_index as i32, original)
|
||||
.await
|
||||
.context("SetLinkDNS (restore) failed")?;
|
||||
|
||||
info!("Restored physical interface {} DNS servers", if_index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn revert_link_dbus(if_index: u32) -> Result<()> {
|
||||
let conn = zbus::Connection::system()
|
||||
.await
|
||||
|
||||
@@ -16,6 +16,9 @@ pub struct DnsManager {
|
||||
interface: String,
|
||||
/// Interface index (if available/relevant)
|
||||
if_index: Option<u32>,
|
||||
/// Pre-VPN default-gateway interface index, demoted as DNS default route
|
||||
/// while connected (Linux only)
|
||||
physical_if_index: Option<u32>,
|
||||
/// IP address assigned to the TUN interface
|
||||
assigned_ip: IpAddr,
|
||||
/// State to track if we modified DNS
|
||||
@@ -26,14 +29,29 @@ pub struct DnsManager {
|
||||
struct DnsState {
|
||||
configured: bool,
|
||||
_original_dns: Vec<IpAddr>,
|
||||
// Physical interface demoted from systemd-resolved's default DNS route
|
||||
// (Linux only) while the tunnel is the default; restored on disconnect.
|
||||
demoted_physical_if_index: Option<u32>,
|
||||
// Physical interface's original DNS servers (address_family, raw_bytes),
|
||||
// saved before blanking them out; restored verbatim on disconnect.
|
||||
physical_dns_backup: Vec<(i32, Vec<u8>)>,
|
||||
}
|
||||
|
||||
impl DnsManager {
|
||||
/// Create a new DNS manager for a specific interface
|
||||
pub fn new(interface: String, if_index: Option<u32>, assigned_ip: IpAddr) -> Self {
|
||||
/// Create a new DNS manager for a specific interface.
|
||||
/// `physical_if_index` is the pre-VPN default-gateway interface (Linux
|
||||
/// only) — needed so the tunnel can become the sole default DNS
|
||||
/// resolver instead of racing systemd-resolved queries against it.
|
||||
pub fn new(
|
||||
interface: String,
|
||||
if_index: Option<u32>,
|
||||
physical_if_index: Option<u32>,
|
||||
assigned_ip: IpAddr,
|
||||
) -> Self {
|
||||
Self {
|
||||
interface,
|
||||
if_index,
|
||||
physical_if_index,
|
||||
assigned_ip,
|
||||
state: Arc::new(Mutex::new(DnsState::default())),
|
||||
}
|
||||
@@ -51,7 +69,7 @@ impl DnsManager {
|
||||
// Platform specific implementation
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
linux::set_dns(&self.interface, self.if_index, dns_servers, self.assigned_ip, &mut state).await?;
|
||||
linux::set_dns(&self.interface, self.if_index, self.physical_if_index, dns_servers, self.assigned_ip, &mut state).await?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use anyhow::{Context, Result};
|
||||
use futures::stream::TryStreamExt;
|
||||
use netlink_packet_route::route::RouteProtocol;
|
||||
use netlink_packet_route::route::{RouteProtocol, RouteType};
|
||||
use rtnetlink::{new_connection, Handle, IpVersion};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
use tracing::{info, warn};
|
||||
|
||||
// Added with priority lower than typical policy-routing stacks (e.g., xray uses 9001).
|
||||
@@ -25,10 +25,21 @@ pub struct RouteManager {
|
||||
// Saved during setup for re-adding the server host route after resume.
|
||||
server_ip: Option<Ipv4Addr>,
|
||||
wifi_gateway: Option<Ipv4Addr>,
|
||||
// Physical interface the server host route must be pinned to. Without this,
|
||||
// if the VPN's assigned tun subnet happens to contain the physical gateway
|
||||
// IP (e.g. both are 10.x.0.1), the kernel resolves the gateway via tun0's
|
||||
// own directly-connected route instead of the physical NIC, looping the
|
||||
// server route back into the tunnel it's meant to bypass.
|
||||
wifi_iface_index: Option<u32>,
|
||||
// Saved for re-adding the VPN default route after reconnect.
|
||||
vpn_gateway: Option<Ipv4Addr>,
|
||||
// True when we installed an ip rule to override policy-routing stacks.
|
||||
added_policy_rule: bool,
|
||||
// True when we installed an IPv6 "unreachable" default route to block
|
||||
// IPv6 leaks (full-tunnel mode only pushes an IPv4 default route via
|
||||
// tun0, so without this, any AAAA-resolved destination would route out
|
||||
// the physical NIC directly, bypassing the tunnel entirely).
|
||||
added_ipv6_blackhole: bool,
|
||||
}
|
||||
|
||||
impl RouteManager {
|
||||
@@ -43,11 +54,13 @@ impl RouteManager {
|
||||
added_routes: Vec::new(),
|
||||
tun_interface: tun_interface.clone(),
|
||||
tun_index: None,
|
||||
dns_manager: crate::dns::DnsManager::new(tun_interface, None, IpAddr::V4(Ipv4Addr::UNSPECIFIED)),
|
||||
dns_manager: crate::dns::DnsManager::new(tun_interface, None, None, IpAddr::V4(Ipv4Addr::UNSPECIFIED)),
|
||||
server_ip: None,
|
||||
wifi_gateway: None,
|
||||
wifi_iface_index: None,
|
||||
vpn_gateway: None,
|
||||
added_policy_rule: false,
|
||||
added_ipv6_blackhole: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -66,14 +79,26 @@ impl RouteManager {
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_default_gateway(&self) -> Result<Ipv4Addr> {
|
||||
/// Returns the physical default gateway and the interface it's reachable on.
|
||||
/// The interface index must be used to pin any route meant to bypass the
|
||||
/// tunnel — relying on gateway-only resolution lets the kernel route it via
|
||||
/// tun0 instead if the VPN's assigned subnet happens to contain this gateway IP.
|
||||
async fn get_default_gateway(&self) -> Result<(Ipv4Addr, u32)> {
|
||||
use netlink_packet_route::route::RouteAddress;
|
||||
|
||||
let mut routes = self.handle.route().get(IpVersion::V4).execute();
|
||||
// Collect all default-route gateways with their metrics.
|
||||
// VPN default routes have low metric (50); physical/DHCP routes have high metric (600+).
|
||||
// We want the physical gateway, so take the one with the highest metric.
|
||||
let mut candidates: Vec<(u32, Ipv4Addr)> = Vec::new();
|
||||
//
|
||||
// The metric comparison alone is not reliable: the kernel does not always
|
||||
// round-trip the Priority attribute on route dumps (see the Oif-based match
|
||||
// in suspend_default_route() below, which exists for the same reason), and a
|
||||
// leftover default route on a reused tun ifindex from an imperfectly torn
|
||||
// down previous session can win the metric comparison by accident. Since we
|
||||
// already know our own tun interface's index at this point, exclude it
|
||||
// outright rather than trusting metric alone to steer around it.
|
||||
let mut candidates: Vec<(u32, Ipv4Addr, u32)> = Vec::new();
|
||||
|
||||
while let Some(route) = routes.try_next().await? {
|
||||
if route.header.destination_prefix_length != 0 {
|
||||
@@ -81,25 +106,30 @@ impl RouteManager {
|
||||
}
|
||||
let mut metric = 0u32;
|
||||
let mut gateway: Option<Ipv4Addr> = None;
|
||||
let mut oif: Option<u32> = None;
|
||||
for nla in route.attributes.iter() {
|
||||
match nla {
|
||||
netlink_packet_route::route::RouteAttribute::Gateway(
|
||||
RouteAddress::Inet(ip),
|
||||
) => gateway = Some(*ip),
|
||||
netlink_packet_route::route::RouteAttribute::Priority(m) => metric = *m,
|
||||
netlink_packet_route::route::RouteAttribute::Oif(idx) => oif = Some(*idx),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(gw) = gateway {
|
||||
candidates.push((metric, gw));
|
||||
if let (Some(gw), Some(idx)) = (gateway, oif) {
|
||||
if Some(idx) == self.tun_index {
|
||||
continue;
|
||||
}
|
||||
candidates.push((metric, gw, idx));
|
||||
}
|
||||
}
|
||||
|
||||
// Highest metric = physical/DHCP interface (not VPN).
|
||||
candidates
|
||||
.into_iter()
|
||||
.max_by_key(|(m, _)| *m)
|
||||
.map(|(_, gw)| gw)
|
||||
.max_by_key(|(m, _, _)| *m)
|
||||
.map(|(_, gw, idx)| (gw, idx))
|
||||
.ok_or_else(|| anyhow::anyhow!("No default gateway found"))
|
||||
}
|
||||
|
||||
@@ -126,11 +156,15 @@ impl RouteManager {
|
||||
self.tun_index.unwrap()
|
||||
);
|
||||
|
||||
let current_gateway = self
|
||||
let (current_gateway, wifi_iface_index) = self
|
||||
.get_default_gateway()
|
||||
.await
|
||||
.context("Failed to get current gateway")?;
|
||||
info!("Current gateway: {}", current_gateway);
|
||||
self.wifi_iface_index = Some(wifi_iface_index);
|
||||
info!(
|
||||
"Current gateway: {} (interface index {})",
|
||||
current_gateway, wifi_iface_index
|
||||
);
|
||||
|
||||
// Resolve server address to IP
|
||||
let server_ip = if let Ok(ip) = server_addr.parse::<IpAddr>() {
|
||||
@@ -160,6 +194,7 @@ impl RouteManager {
|
||||
.v4()
|
||||
.destination_prefix(server_v4, 32)
|
||||
.gateway(current_gateway)
|
||||
.output_interface(wifi_iface_index)
|
||||
.priority(VPN_ROUTE_METRIC)
|
||||
.execute()
|
||||
.await;
|
||||
@@ -207,6 +242,7 @@ impl RouteManager {
|
||||
self.added_routes.push((Ipv4Addr::new(0, 0, 0, 0), 0));
|
||||
|
||||
self.install_policy_rule().await;
|
||||
self.block_ipv6_leak().await;
|
||||
} else {
|
||||
info!(
|
||||
"Split-tunnel mode: adding {} routes via VPN...",
|
||||
@@ -314,14 +350,59 @@ impl RouteManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Block outbound IPv6 in full-tunnel mode by installing an "unreachable"
|
||||
/// default route (::/0). Full-tunnel only pushes an IPv4 default route
|
||||
/// via the VPN gateway (VPN servers here don't provide IPv6 transport),
|
||||
/// so without this, any destination that resolves to an AAAA record
|
||||
/// leaks straight out the physical interface instead of the tunnel —
|
||||
/// bypassing it entirely rather than merely failing closed. Failing the
|
||||
/// IPv6 attempt fast also lets Happy-Eyeballs clients fall back to IPv4
|
||||
/// (through the tunnel) quickly instead of hanging on an IPv6 path that
|
||||
/// silently goes nowhere.
|
||||
async fn block_ipv6_leak(&mut self) {
|
||||
let result = self
|
||||
.handle
|
||||
.route()
|
||||
.add()
|
||||
.v6()
|
||||
.destination_prefix(Ipv6Addr::UNSPECIFIED, 0)
|
||||
.kind(RouteType::Unreachable)
|
||||
.priority(VPN_ROUTE_METRIC)
|
||||
.execute()
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
self.added_ipv6_blackhole = true;
|
||||
info!("✅ IPv6 default route blocked (prevents leaking outside tunnel)");
|
||||
}
|
||||
Err(e) => {
|
||||
let err_msg = format!("{:?}", e);
|
||||
if err_msg.contains("File exists")
|
||||
|| err_msg.contains("EEXIST")
|
||||
|| err_msg.contains("code: Some(-17)")
|
||||
{
|
||||
self.added_ipv6_blackhole = true;
|
||||
warn!("IPv6 blackhole route already exists, continuing...");
|
||||
} else {
|
||||
warn!("Failed to block IPv6 default route: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Setup DNS configuration
|
||||
pub async fn setup_dns(&mut self, dns_servers: &[IpAddr], assigned_ip: IpAddr) -> Result<()> {
|
||||
if dns_servers.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.dns_manager =
|
||||
crate::dns::DnsManager::new(self.tun_interface.clone(), self.tun_index, assigned_ip);
|
||||
self.dns_manager = crate::dns::DnsManager::new(
|
||||
self.tun_interface.clone(),
|
||||
self.tun_index,
|
||||
self.wifi_iface_index,
|
||||
assigned_ip,
|
||||
);
|
||||
|
||||
self.dns_manager.set_dns(dns_servers).await
|
||||
}
|
||||
@@ -344,16 +425,18 @@ impl RouteManager {
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
let result = self
|
||||
let mut req = self
|
||||
.handle
|
||||
.route()
|
||||
.add()
|
||||
.v4()
|
||||
.destination_prefix(server_ip, 32)
|
||||
.gateway(gateway)
|
||||
.priority(VPN_ROUTE_METRIC)
|
||||
.execute()
|
||||
.await;
|
||||
.priority(VPN_ROUTE_METRIC);
|
||||
if let Some(idx) = self.wifi_iface_index {
|
||||
req = req.output_interface(idx);
|
||||
}
|
||||
let result = req.execute().await;
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
@@ -484,6 +567,28 @@ impl RouteManager {
|
||||
self.added_policy_rule = false;
|
||||
}
|
||||
|
||||
if self.added_ipv6_blackhole {
|
||||
let mut routes = self.handle.route().get(IpVersion::V6).execute();
|
||||
let mut to_delete = None;
|
||||
while let Some(route) = routes.try_next().await? {
|
||||
if route.header.destination_prefix_length == 0
|
||||
&& route.header.kind == RouteType::Unreachable
|
||||
{
|
||||
to_delete = Some(route);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
match to_delete {
|
||||
Some(route) => match self.handle.route().del(route).execute().await {
|
||||
Ok(()) => info!("Removed IPv6 blackhole route"),
|
||||
Err(e) => warn!("Failed to delete IPv6 blackhole route: {:?}", e),
|
||||
},
|
||||
None => warn!("IPv6 blackhole route not found, nothing to remove"),
|
||||
}
|
||||
self.added_ipv6_blackhole = false;
|
||||
}
|
||||
|
||||
if self.added_routes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -7,6 +7,12 @@ pub struct MacOsNetManager {
|
||||
server_host: String,
|
||||
original_gateway: String,
|
||||
primary_service: String,
|
||||
// Physical interface the server host route must be pinned to via -ifscope.
|
||||
// Without this, if the VPN's assigned tun subnet happens to contain the
|
||||
// physical gateway IP, the OS can resolve the gateway via the tun
|
||||
// interface's own directly-connected route instead of the physical NIC,
|
||||
// looping the server route back into the tunnel it's meant to bypass.
|
||||
wifi_iface: String,
|
||||
}
|
||||
|
||||
impl MacOsNetManager {
|
||||
@@ -42,13 +48,18 @@ impl MacOsNetManager {
|
||||
server_host,
|
||||
original_gateway,
|
||||
primary_service,
|
||||
wifi_iface: iface,
|
||||
})
|
||||
}
|
||||
|
||||
// Always adds split-default (0/1 + 128/1) for full-tunnel mode.
|
||||
// Split-tunnel (per-route from ServerConfig) is not yet supported on macOS.
|
||||
pub async fn setup_vpn_routes(&self) -> Result<()> {
|
||||
run("route", &["add", "-host", &self.server_host, &self.original_gateway]).await;
|
||||
run(
|
||||
"route",
|
||||
&["add", "-host", &self.server_host, &self.original_gateway, "-ifscope", &self.wifi_iface],
|
||||
)
|
||||
.await;
|
||||
run("route", &["add", "-net", "0.0.0.0/1", "-interface", &self.tun_name]).await;
|
||||
run("route", &["add", "-net", "128.0.0.0/1", "-interface", &self.tun_name]).await;
|
||||
info!("macOS VPN routes added via {}", self.tun_name);
|
||||
@@ -89,7 +100,11 @@ impl MacOsNetManager {
|
||||
// the wrong gateway until the next reconnect that creates a new MacOsNetManager.
|
||||
pub async fn ensure_server_route(&self) -> Result<()> {
|
||||
run("route", &["delete", "-host", &self.server_host]).await;
|
||||
run("route", &["add", "-host", &self.server_host, &self.original_gateway]).await;
|
||||
run(
|
||||
"route",
|
||||
&["add", "-host", &self.server_host, &self.original_gateway, "-ifscope", &self.wifi_iface],
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@ pub struct RouteManager {
|
||||
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 {
|
||||
@@ -21,23 +27,30 @@ impl RouteManager {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
fn get_default_gateway() -> Result<Ipv4Addr> {
|
||||
/// 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);
|
||||
return Ok((*gw4, adapter.friendly_name().to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,29 +58,26 @@ impl RouteManager {
|
||||
anyhow::bail!("No default gateway found")
|
||||
}
|
||||
|
||||
fn prefix_to_mask(prefix: u8) -> Ipv4Addr {
|
||||
let bits = if prefix == 0 {
|
||||
0u32
|
||||
} else {
|
||||
!0u32 << (32 - prefix)
|
||||
};
|
||||
let [a, b, c, d] = bits.to_be_bytes();
|
||||
Ipv4Addr::new(a, b, c, d)
|
||||
}
|
||||
|
||||
// Add/delete server host route through WiFi gateway via `route` command.
|
||||
// Specifying gateway on delete ensures we only remove our entry, not any other path.
|
||||
fn wifi_route_add(dest: Ipv4Addr, prefix: u8, gateway: Ipv4Addr) {
|
||||
let mask = Self::prefix_to_mask(prefix);
|
||||
let out = std::process::Command::new("route")
|
||||
// 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",
|
||||
&dest.to_string(),
|
||||
"MASK",
|
||||
&mask.to_string(),
|
||||
&gateway.to_string(),
|
||||
"METRIC",
|
||||
"1",
|
||||
"route",
|
||||
&prefix_str,
|
||||
iface,
|
||||
&format!("nexthop={}", gateway),
|
||||
"metric=1",
|
||||
"store=active",
|
||||
])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output();
|
||||
@@ -76,25 +86,19 @@ impl RouteManager {
|
||||
let stdout = String::from_utf8_lossy(&o.stdout);
|
||||
if !o.status.success() {
|
||||
// "already exists" is acceptable — a previous run may have left it
|
||||
warn!("route add {}/{} via {}: {}", dest, prefix, gateway, stdout.trim());
|
||||
warn!("netsh add route {}/{} via {} on {}: {}", dest, prefix, gateway, iface, stdout.trim());
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("route add {}/{} via {}: {}", dest, prefix, gateway, e),
|
||||
Err(e) => warn!("netsh add route {}/{} via {} on {}: {}", dest, prefix, gateway, iface, e),
|
||||
}
|
||||
}
|
||||
|
||||
fn wifi_route_delete(dest: Ipv4Addr, prefix: u8, gateway: Ipv4Addr) {
|
||||
let mask = Self::prefix_to_mask(prefix);
|
||||
let _ = std::process::Command::new("route")
|
||||
.args([
|
||||
"delete",
|
||||
&dest.to_string(),
|
||||
"MASK",
|
||||
&mask.to_string(),
|
||||
&gateway.to_string(),
|
||||
])
|
||||
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)
|
||||
.status();
|
||||
.output();
|
||||
}
|
||||
|
||||
// Add/delete routes through the TUN interface using netsh.
|
||||
@@ -169,8 +173,9 @@ impl RouteManager {
|
||||
) -> Result<()> {
|
||||
info!("Setting up Windows VPN routes...");
|
||||
|
||||
let current_gateway = Self::get_default_gateway()?;
|
||||
info!("Current gateway: {}", current_gateway);
|
||||
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);
|
||||
@@ -189,7 +194,7 @@ impl RouteManager {
|
||||
// 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(server_ip, 32, 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");
|
||||
|
||||
@@ -233,7 +238,7 @@ impl RouteManager {
|
||||
if dns_servers.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
self.dns_manager = DnsManager::new(self.tun_interface.clone(), None, assigned_ip);
|
||||
self.dns_manager = DnsManager::new(self.tun_interface.clone(), None, None, assigned_ip);
|
||||
self.dns_manager.set_dns(dns_servers).await
|
||||
}
|
||||
|
||||
@@ -242,8 +247,10 @@ impl RouteManager {
|
||||
}
|
||||
|
||||
pub async fn ensure_server_route(&mut self) -> Result<()> {
|
||||
if let (Some(server_ip), Some(gateway)) = (self.server_ip, self.wifi_gateway) {
|
||||
Self::wifi_route_add(server_ip, 32, gateway);
|
||||
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(())
|
||||
}
|
||||
@@ -280,8 +287,9 @@ impl RouteManager {
|
||||
}
|
||||
// Always clean up the server host route — a previous unclean exit may have
|
||||
// left it behind even if our add was skipped.
|
||||
if let (Some(server_ip), Some(gateway)) = (self.server_ip.take(), self.wifi_gateway.take()) {
|
||||
Self::wifi_route_delete(server_ip, 32, gateway);
|
||||
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(())
|
||||
|
||||
Reference in New Issue
Block a user