Compare commits
8 Commits
v0.1.0-bet
...
v0.1.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d96bdac7b | ||
|
|
7c24351f4a | ||
|
|
4a4ab9d781 | ||
|
|
3b212bbed9 | ||
|
|
11d5305d8c | ||
|
|
c2d7f8d8c6 | ||
|
|
678ed158a6 | ||
|
|
56201f6b72 |
27
README.md
27
README.md
@@ -48,9 +48,14 @@ Releases are published on the [Releases](../../releases) page. Each release incl
|
|||||||
| `konduit-*-linux-x86_64` | Linux | CLI client |
|
| `konduit-*-linux-x86_64` | Linux | CLI client |
|
||||||
| `konduit-ctl-*-linux-x86_64` | Linux | Server provisioning tool |
|
| `konduit-ctl-*-linux-x86_64` | Linux | Server provisioning tool |
|
||||||
| `Konduit-*-x86_64.AppImage` | Linux | GUI client (AppImage) |
|
| `Konduit-*-x86_64.AppImage` | Linux | GUI client (AppImage) |
|
||||||
| `konduit-cli-*-windows-x86_64.zip` | Windows | CLI client + wintun.dll |
|
| `konduit-gui-*-windows-x86_64-portable.zip` | Windows | GUI client |
|
||||||
| `konduit-gui-*-windows-x86_64.zip` | Windows | GUI client |
|
| `konduit-cli-*-windows-x86_64.zip` | Windows | CLI client + wintun.dll — **not in every release** |
|
||||||
| `konduit-*-android.aab` | Android | Android app (sideload via bundletool) |
|
| `konduit-*-android.aab` | Android | Android app for bundletool sideload — **not in every release** |
|
||||||
|
|
||||||
|
Which Windows and Android assets are attached varies between releases: v0.1.0-beta.9
|
||||||
|
and v0.1.0-beta.10 carried both, v0.1.0-beta.11 shipped the Windows GUI zip only (as
|
||||||
|
`-portable.zip`). **The [Releases](../../releases) page is authoritative** — check it
|
||||||
|
rather than assuming a row is present. Android is on Google Play either way.
|
||||||
|
|
||||||
### Linux (CLI)
|
### Linux (CLI)
|
||||||
|
|
||||||
@@ -62,7 +67,15 @@ chmod +x konduit konduit-ctl
|
|||||||
echo "your secret mantra" | ./konduit-ctl bootstrap -l vpn.example.com:443 -p -
|
echo "your secret mantra" | ./konduit-ctl bootstrap -l vpn.example.com:443 -p -
|
||||||
|
|
||||||
# Connect a client
|
# Connect a client
|
||||||
./konduit connect --config client.toml
|
./konduit --config client.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
`--config`/`-c` is a top-level flag, not a `connect` flag. The `connect`
|
||||||
|
subcommand takes the peer details individually instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./konduit connect --server vpn.example.com:443 \
|
||||||
|
--peer-id <peer-id> --identity <key-hex> --server-pub <server-pub-hex>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Linux (GUI)
|
### Linux (GUI)
|
||||||
@@ -131,13 +144,13 @@ The VPN server and management UI are proprietary. Source review under NDA is ava
|
|||||||
|
|
||||||
**No UDP dependency:** Konduit does not require UDP at any layer.
|
**No UDP dependency:** Konduit does not require UDP at any layer.
|
||||||
|
|
||||||
**Key storage:** Private keys are stored in the OS secure enclave on every platform (iOS Keychain, macOS Keychain, Android Keystore). They are never written to disk in plaintext.
|
**Key handling:** Your private identity key is generated locally by `konduit-ctl` and written into the client configuration file you pass with `--config` — it is never transmitted to K-Ops, and K-Ops operates no server. **That file is the secret.** Treat it like a private key file: readable only by its owner, and the only thing standing between an attacker and a tunnel into your VPN is its permissions. Do not commit it or paste it into a bug report.
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
**Bug reports:** Use the in-app reporting feature or open an issue in this repository.
|
**Bug reports:** file an issue on [Gitea Issues](https://git.k-ops.eu/konduit/konduit-public/issues).
|
||||||
|
|
||||||
**Security vulnerabilities:** Do not open a public issue. Contact the maintainer directly at the address shown in the application's About screen.
|
**Vulnerabilities:** report directly to konduit@k-ops.eu.
|
||||||
|
|
||||||
**Contributing:** Core development is handled internally. We do not currently accept external pull requests.
|
**Contributing:** Core development is handled internally. We do not currently accept external pull requests.
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,19 @@ server_endpoint = "vpn.example.com:443"
|
|||||||
use_proxy = "socks5://127.0.0.1:1080"
|
use_proxy = "socks5://127.0.0.1:1080"
|
||||||
```
|
```
|
||||||
|
|
||||||
Or via the CLI:
|
Or, if you are passing everything on the command line rather than via a config file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./konduit connect --config client.toml --use-proxy socks5://127.0.0.1:1080
|
./konduit connect \
|
||||||
|
--server vpn.example.com:443 \
|
||||||
|
--peer-id "$PEER_ID" \
|
||||||
|
--identity "$IDENTITY_HEX" \
|
||||||
|
--server-pub "$SERVER_PUB_HEX" \
|
||||||
|
--use-proxy socks5://127.0.0.1:1080
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`--config` and `--use-proxy` cannot be combined. `--config` is a top-level flag, while `--use-proxy` belongs to the `connect` subcommand, so a single invocation has to pick one style. Mixing them fails with `error: unexpected argument '--use-proxy' found`.
|
||||||
|
|
||||||
Konduit dials the SOCKS5 proxy, asks it to `CONNECT` to your server, and then runs its normal protocol over that connection — the proxy is otherwise transparent to it. If the proxy is unreachable, or requires authentication konduit doesn't support, the connection attempt fails immediately; there is no silent fallback to a direct connection.
|
Konduit dials the SOCKS5 proxy, asks it to `CONNECT` to your server, and then runs its normal protocol over that connection — the proxy is otherwise transparent to it. If the proxy is unreachable, or requires authentication konduit doesn't support, the connection attempt fails immediately; there is no silent fallback to a direct connection.
|
||||||
|
|
||||||
## Listener: exposing a SOCKS5 proxy after the tunnel is up (`listen_socks`)
|
## Listener: exposing a SOCKS5 proxy after the tunnel is up (`listen_socks`)
|
||||||
@@ -31,10 +38,15 @@ Once connected, konduit can also expose a local SOCKS5 listener so other tools (
|
|||||||
listen_socks = "127.0.0.1:1080"
|
listen_socks = "127.0.0.1:1080"
|
||||||
```
|
```
|
||||||
|
|
||||||
Or via the CLI:
|
Or via the `connect` subcommand, as above — `--listen-socks` takes a bare address:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./konduit connect --config client.toml --listen-socks 127.0.0.1:1080
|
./konduit connect \
|
||||||
|
--server vpn.example.com:443 \
|
||||||
|
--peer-id "$PEER_ID" \
|
||||||
|
--identity "$IDENTITY_HEX" \
|
||||||
|
--server-pub "$SERVER_PUB_HEX" \
|
||||||
|
--listen-socks 127.0.0.1:1080
|
||||||
```
|
```
|
||||||
|
|
||||||
Connections accepted by the listener are plain outbound TCP connections, routed through the tunnel by the kernel's routing table — the same routes any other tunnelled traffic uses. The SOCKS code itself has no VPN-specific logic.
|
Connections accepted by the listener are plain outbound TCP connections, routed through the tunnel by the kernel's routing table — the same routes any other tunnelled traffic uses. The SOCKS code itself has no VPN-specific logic.
|
||||||
|
|||||||
@@ -31,6 +31,31 @@ trait Resolve1Manager {
|
|||||||
fn revert_link(&self, ifindex: i32) -> zbus::Result<()>;
|
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
|
// Public API
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -38,6 +63,7 @@ trait Resolve1Manager {
|
|||||||
pub async fn set_dns(
|
pub async fn set_dns(
|
||||||
interface: &str,
|
interface: &str,
|
||||||
if_index: Option<u32>,
|
if_index: Option<u32>,
|
||||||
|
physical_if_index: Option<u32>,
|
||||||
dns_servers: &[IpAddr],
|
dns_servers: &[IpAddr],
|
||||||
_assigned_ip: IpAddr,
|
_assigned_ip: IpAddr,
|
||||||
state: &mut DnsState,
|
state: &mut DnsState,
|
||||||
@@ -59,6 +85,31 @@ pub async fn set_dns(
|
|||||||
match result {
|
match result {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
state.configured = true;
|
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) => {
|
Err(e) => {
|
||||||
warn!(
|
warn!(
|
||||||
@@ -112,6 +163,18 @@ pub async fn restore(
|
|||||||
// Always run resolvectl revert as a second pass.
|
// Always run resolvectl revert as a second pass.
|
||||||
revert_resolvectl(interface).await.ok();
|
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;
|
state.configured = false;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -165,6 +228,62 @@ async fn set_dns_dbus(if_index: u32, dns_servers: &[IpAddr]) -> Result<()> {
|
|||||||
Ok(())
|
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<()> {
|
async fn revert_link_dbus(if_index: u32) -> Result<()> {
|
||||||
let conn = zbus::Connection::system()
|
let conn = zbus::Connection::system()
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ pub struct DnsManager {
|
|||||||
interface: String,
|
interface: String,
|
||||||
/// Interface index (if available/relevant)
|
/// Interface index (if available/relevant)
|
||||||
if_index: Option<u32>,
|
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
|
/// IP address assigned to the TUN interface
|
||||||
assigned_ip: IpAddr,
|
assigned_ip: IpAddr,
|
||||||
/// State to track if we modified DNS
|
/// State to track if we modified DNS
|
||||||
@@ -26,14 +29,29 @@ pub struct DnsManager {
|
|||||||
struct DnsState {
|
struct DnsState {
|
||||||
configured: bool,
|
configured: bool,
|
||||||
_original_dns: Vec<IpAddr>,
|
_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 {
|
impl DnsManager {
|
||||||
/// Create a new DNS manager for a specific interface
|
/// Create a new DNS manager for a specific interface.
|
||||||
pub fn new(interface: String, if_index: Option<u32>, assigned_ip: IpAddr) -> Self {
|
/// `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 {
|
Self {
|
||||||
interface,
|
interface,
|
||||||
if_index,
|
if_index,
|
||||||
|
physical_if_index,
|
||||||
assigned_ip,
|
assigned_ip,
|
||||||
state: Arc::new(Mutex::new(DnsState::default())),
|
state: Arc::new(Mutex::new(DnsState::default())),
|
||||||
}
|
}
|
||||||
@@ -51,7 +69,7 @@ impl DnsManager {
|
|||||||
// Platform specific implementation
|
// Platform specific implementation
|
||||||
#[cfg(target_os = "linux")]
|
#[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")]
|
#[cfg(target_os = "windows")]
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use futures::stream::TryStreamExt;
|
use futures::stream::TryStreamExt;
|
||||||
use netlink_packet_route::route::RouteProtocol;
|
use netlink_packet_route::route::{RouteProtocol, RouteType};
|
||||||
use rtnetlink::{new_connection, Handle, IpVersion};
|
use rtnetlink::{new_connection, Handle, IpVersion};
|
||||||
use std::net::{IpAddr, Ipv4Addr};
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
// Added with priority lower than typical policy-routing stacks (e.g., xray uses 9001).
|
// 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.
|
// Saved during setup for re-adding the server host route after resume.
|
||||||
server_ip: Option<Ipv4Addr>,
|
server_ip: Option<Ipv4Addr>,
|
||||||
wifi_gateway: 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.
|
// Saved for re-adding the VPN default route after reconnect.
|
||||||
vpn_gateway: Option<Ipv4Addr>,
|
vpn_gateway: Option<Ipv4Addr>,
|
||||||
// True when we installed an ip rule to override policy-routing stacks.
|
// True when we installed an ip rule to override policy-routing stacks.
|
||||||
added_policy_rule: bool,
|
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 {
|
impl RouteManager {
|
||||||
@@ -43,11 +54,13 @@ impl RouteManager {
|
|||||||
added_routes: Vec::new(),
|
added_routes: Vec::new(),
|
||||||
tun_interface: tun_interface.clone(),
|
tun_interface: tun_interface.clone(),
|
||||||
tun_index: None,
|
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,
|
server_ip: None,
|
||||||
wifi_gateway: None,
|
wifi_gateway: None,
|
||||||
|
wifi_iface_index: None,
|
||||||
vpn_gateway: None,
|
vpn_gateway: None,
|
||||||
added_policy_rule: false,
|
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;
|
use netlink_packet_route::route::RouteAddress;
|
||||||
|
|
||||||
let mut routes = self.handle.route().get(IpVersion::V4).execute();
|
let mut routes = self.handle.route().get(IpVersion::V4).execute();
|
||||||
// Collect all default-route gateways with their metrics.
|
// Collect all default-route gateways with their metrics.
|
||||||
// VPN default routes have low metric (50); physical/DHCP routes have high metric (600+).
|
// 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.
|
// 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? {
|
while let Some(route) = routes.try_next().await? {
|
||||||
if route.header.destination_prefix_length != 0 {
|
if route.header.destination_prefix_length != 0 {
|
||||||
@@ -81,25 +106,30 @@ impl RouteManager {
|
|||||||
}
|
}
|
||||||
let mut metric = 0u32;
|
let mut metric = 0u32;
|
||||||
let mut gateway: Option<Ipv4Addr> = None;
|
let mut gateway: Option<Ipv4Addr> = None;
|
||||||
|
let mut oif: Option<u32> = None;
|
||||||
for nla in route.attributes.iter() {
|
for nla in route.attributes.iter() {
|
||||||
match nla {
|
match nla {
|
||||||
netlink_packet_route::route::RouteAttribute::Gateway(
|
netlink_packet_route::route::RouteAttribute::Gateway(
|
||||||
RouteAddress::Inet(ip),
|
RouteAddress::Inet(ip),
|
||||||
) => gateway = Some(*ip),
|
) => gateway = Some(*ip),
|
||||||
netlink_packet_route::route::RouteAttribute::Priority(m) => metric = *m,
|
netlink_packet_route::route::RouteAttribute::Priority(m) => metric = *m,
|
||||||
|
netlink_packet_route::route::RouteAttribute::Oif(idx) => oif = Some(*idx),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(gw) = gateway {
|
if let (Some(gw), Some(idx)) = (gateway, oif) {
|
||||||
candidates.push((metric, gw));
|
if Some(idx) == self.tun_index {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
candidates.push((metric, gw, idx));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Highest metric = physical/DHCP interface (not VPN).
|
// Highest metric = physical/DHCP interface (not VPN).
|
||||||
candidates
|
candidates
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.max_by_key(|(m, _)| *m)
|
.max_by_key(|(m, _, _)| *m)
|
||||||
.map(|(_, gw)| gw)
|
.map(|(_, gw, idx)| (gw, idx))
|
||||||
.ok_or_else(|| anyhow::anyhow!("No default gateway found"))
|
.ok_or_else(|| anyhow::anyhow!("No default gateway found"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,11 +156,15 @@ impl RouteManager {
|
|||||||
self.tun_index.unwrap()
|
self.tun_index.unwrap()
|
||||||
);
|
);
|
||||||
|
|
||||||
let current_gateway = self
|
let (current_gateway, wifi_iface_index) = self
|
||||||
.get_default_gateway()
|
.get_default_gateway()
|
||||||
.await
|
.await
|
||||||
.context("Failed to get current gateway")?;
|
.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
|
// Resolve server address to IP
|
||||||
let server_ip = if let Ok(ip) = server_addr.parse::<IpAddr>() {
|
let server_ip = if let Ok(ip) = server_addr.parse::<IpAddr>() {
|
||||||
@@ -160,6 +194,7 @@ impl RouteManager {
|
|||||||
.v4()
|
.v4()
|
||||||
.destination_prefix(server_v4, 32)
|
.destination_prefix(server_v4, 32)
|
||||||
.gateway(current_gateway)
|
.gateway(current_gateway)
|
||||||
|
.output_interface(wifi_iface_index)
|
||||||
.priority(VPN_ROUTE_METRIC)
|
.priority(VPN_ROUTE_METRIC)
|
||||||
.execute()
|
.execute()
|
||||||
.await;
|
.await;
|
||||||
@@ -172,7 +207,18 @@ impl RouteManager {
|
|||||||
|| err_msg.contains("EEXIST")
|
|| err_msg.contains("EEXIST")
|
||||||
|| err_msg.contains("code: Some(-17)")
|
|| err_msg.contains("code: Some(-17)")
|
||||||
{
|
{
|
||||||
warn!("Server route already exists, continuing...");
|
// Same destination, our metric: left by an earlier run
|
||||||
|
// that never got to tear down. Adopt it, or teardown
|
||||||
|
// never removes it and every later run finds it again.
|
||||||
|
match self.is_our_physical_host_route(server_v4).await {
|
||||||
|
Ok(true) => {
|
||||||
|
warn!("Server route already exists; adopting it so teardown removes it");
|
||||||
|
self.added_routes.push((server_v4, 32));
|
||||||
|
}
|
||||||
|
// Someone else's route to the same host: use it,
|
||||||
|
// but it is not ours to delete.
|
||||||
|
_ => warn!("Server route already exists and is not ours; leaving it alone"),
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return Err(anyhow::anyhow!("Failed to add server route: {:#}", e));
|
return Err(anyhow::anyhow!("Failed to add server route: {:#}", e));
|
||||||
}
|
}
|
||||||
@@ -207,6 +253,7 @@ impl RouteManager {
|
|||||||
self.added_routes.push((Ipv4Addr::new(0, 0, 0, 0), 0));
|
self.added_routes.push((Ipv4Addr::new(0, 0, 0, 0), 0));
|
||||||
|
|
||||||
self.install_policy_rule().await;
|
self.install_policy_rule().await;
|
||||||
|
self.block_ipv6_leak().await;
|
||||||
} else {
|
} else {
|
||||||
info!(
|
info!(
|
||||||
"Split-tunnel mode: adding {} routes via VPN...",
|
"Split-tunnel mode: adding {} routes via VPN...",
|
||||||
@@ -314,14 +361,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
|
/// Setup DNS configuration
|
||||||
pub async fn setup_dns(&mut self, dns_servers: &[IpAddr], assigned_ip: IpAddr) -> Result<()> {
|
pub async fn setup_dns(&mut self, dns_servers: &[IpAddr], assigned_ip: IpAddr) -> Result<()> {
|
||||||
if dns_servers.is_empty() {
|
if dns_servers.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
self.dns_manager =
|
self.dns_manager = crate::dns::DnsManager::new(
|
||||||
crate::dns::DnsManager::new(self.tun_interface.clone(), self.tun_index, assigned_ip);
|
self.tun_interface.clone(),
|
||||||
|
self.tun_index,
|
||||||
|
self.wifi_iface_index,
|
||||||
|
assigned_ip,
|
||||||
|
);
|
||||||
|
|
||||||
self.dns_manager.set_dns(dns_servers).await
|
self.dns_manager.set_dns(dns_servers).await
|
||||||
}
|
}
|
||||||
@@ -344,16 +436,18 @@ impl RouteManager {
|
|||||||
_ => return Ok(()),
|
_ => return Ok(()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = self
|
let mut req = self
|
||||||
.handle
|
.handle
|
||||||
.route()
|
.route()
|
||||||
.add()
|
.add()
|
||||||
.v4()
|
.v4()
|
||||||
.destination_prefix(server_ip, 32)
|
.destination_prefix(server_ip, 32)
|
||||||
.gateway(gateway)
|
.gateway(gateway)
|
||||||
.priority(VPN_ROUTE_METRIC)
|
.priority(VPN_ROUTE_METRIC);
|
||||||
.execute()
|
if let Some(idx) = self.wifi_iface_index {
|
||||||
.await;
|
req = req.output_interface(idx);
|
||||||
|
}
|
||||||
|
let result = req.execute().await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
@@ -375,6 +469,169 @@ impl RouteManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pins `ip` to the physical gateway with a tracked host route, so the
|
||||||
|
/// full-tunnel default does not capture it. For the endpoint of another VPN
|
||||||
|
/// that was already up when we connected: its carrier otherwise follows the
|
||||||
|
/// default route into our tunnel, and that VPN dies.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(false)`, installing nothing, when the address does not need
|
||||||
|
/// it: our own server (already routed), an address under a more specific
|
||||||
|
/// route (the full tunnel only replaces the default, so it was never
|
||||||
|
/// captured -- and pinning it via the default gateway would hairpin it), or
|
||||||
|
/// a route that already exists. Only a route we created is tracked, so
|
||||||
|
/// teardown never removes one that was there before us.
|
||||||
|
///
|
||||||
|
/// Call after `setup_vpn_routes`, which is what finds the physical gateway.
|
||||||
|
pub async fn pin_to_physical(&mut self, ip: Ipv4Addr) -> Result<bool> {
|
||||||
|
let (Some(gateway), Some(oif)) = (self.wifi_gateway, self.wifi_iface_index) else {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"pin_to_physical called before setup_vpn_routes found the physical gateway"
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if Some(ip) == self.server_ip || self.added_routes.contains(&(ip, 32)) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
// A pin left by a run that was killed before teardown. It would be
|
||||||
|
// "covered" by itself below and skipped, then never removed.
|
||||||
|
if self.is_our_physical_host_route(ip).await? {
|
||||||
|
self.added_routes.push((ip, 32));
|
||||||
|
info!("Adopted a stranded pin for {}/32; teardown will remove it", ip);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
if let Some((net, len)) = self.covering_specific_route(ip).await? {
|
||||||
|
info!("{} is under {}/{}, which the tunnel does not capture; not pinning", ip, net, len);
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = self
|
||||||
|
.handle
|
||||||
|
.route()
|
||||||
|
.add()
|
||||||
|
.v4()
|
||||||
|
.destination_prefix(ip, 32)
|
||||||
|
.gateway(gateway)
|
||||||
|
.output_interface(oif)
|
||||||
|
.priority(VPN_ROUTE_METRIC)
|
||||||
|
.execute()
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
self.added_routes.push((ip, 32));
|
||||||
|
info!("Pinned {}/32 via {} so it stays off the tunnel", ip, gateway);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let msg = format!("{:?}", e);
|
||||||
|
if msg.contains("File exists") || msg.contains("EEXIST") || msg.contains("code: Some(-17)") {
|
||||||
|
Ok(false)
|
||||||
|
} else {
|
||||||
|
Err(anyhow::anyhow!("Failed to pin {}/32: {:#}", ip, e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a pin `pin_to_physical` installed, before teardown. For when the
|
||||||
|
/// VPN it protected has gone away: from then on that endpoint is new
|
||||||
|
/// traffic, and follows the default route like anything else.
|
||||||
|
///
|
||||||
|
/// A no-op for an address we never pinned -- a route someone else created
|
||||||
|
/// is never ours to remove.
|
||||||
|
pub async fn unpin(&mut self, ip: Ipv4Addr) -> Result<()> {
|
||||||
|
use netlink_packet_route::route::{RouteAddress, RouteAttribute};
|
||||||
|
|
||||||
|
if !self.added_routes.contains(&(ip, 32)) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let mut routes = self.handle.route().get(IpVersion::V4).execute();
|
||||||
|
while let Some(route) = routes.try_next().await? {
|
||||||
|
let ours = route.header.destination_prefix_length == 32
|
||||||
|
&& route
|
||||||
|
.attributes
|
||||||
|
.iter()
|
||||||
|
.any(|a| matches!(a, RouteAttribute::Destination(RouteAddress::Inet(d)) if *d == ip))
|
||||||
|
&& route
|
||||||
|
.attributes
|
||||||
|
.iter()
|
||||||
|
.any(|a| matches!(a, RouteAttribute::Priority(m) if *m == VPN_ROUTE_METRIC));
|
||||||
|
if ours {
|
||||||
|
self.handle
|
||||||
|
.route()
|
||||||
|
.del(route)
|
||||||
|
.execute()
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to unpin {}/32: {:?}", ip, e))?;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Tracking ends either way: if the route is already gone, there is
|
||||||
|
// nothing left for teardown to remove.
|
||||||
|
self.added_routes.retain(|r| *r != (ip, 32));
|
||||||
|
info!("Unpinned {}/32; it follows the default route again", ip);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a main-table `ip/32` route exists that is unmistakably one of
|
||||||
|
/// ours: our metric, via the physical gateway, out the physical interface.
|
||||||
|
///
|
||||||
|
/// A run killed before teardown strands such a route. "A /32 at our metric
|
||||||
|
/// exists" alone is not proof it is ours -- another tool can hold one -- so
|
||||||
|
/// the gateway and device are checked too before taking ownership of it.
|
||||||
|
async fn is_our_physical_host_route(&self, ip: Ipv4Addr) -> Result<bool> {
|
||||||
|
use netlink_packet_route::route::{RouteAddress, RouteAttribute};
|
||||||
|
const RT_TABLE_MAIN: u8 = 254;
|
||||||
|
let (Some(gateway), Some(oif)) = (self.wifi_gateway, self.wifi_iface_index) else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut routes = self.handle.route().get(IpVersion::V4).execute();
|
||||||
|
while let Some(route) = routes.try_next().await? {
|
||||||
|
if route.header.table != RT_TABLE_MAIN || route.header.destination_prefix_length != 32 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let has = |want: &dyn Fn(&RouteAttribute) -> bool| route.attributes.iter().any(want);
|
||||||
|
if has(&|a| matches!(a, RouteAttribute::Destination(RouteAddress::Inet(d)) if *d == ip))
|
||||||
|
&& has(&|a| matches!(a, RouteAttribute::Priority(m) if *m == VPN_ROUTE_METRIC))
|
||||||
|
&& has(&|a| matches!(a, RouteAttribute::Gateway(RouteAddress::Inet(g)) if *g == gateway))
|
||||||
|
&& has(&|a| matches!(a, RouteAttribute::Oif(i) if *i == oif))
|
||||||
|
{
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The most specific main-table route, other than a default and other than
|
||||||
|
/// one through our own TUN, whose prefix contains `ip`.
|
||||||
|
async fn covering_specific_route(&self, ip: Ipv4Addr) -> Result<Option<(Ipv4Addr, u8)>> {
|
||||||
|
use netlink_packet_route::route::{RouteAddress, RouteAttribute};
|
||||||
|
const RT_TABLE_MAIN: u8 = 254;
|
||||||
|
|
||||||
|
let mut prefixes = Vec::new();
|
||||||
|
let mut routes = self.handle.route().get(IpVersion::V4).execute();
|
||||||
|
while let Some(route) = routes.try_next().await? {
|
||||||
|
let len = route.header.destination_prefix_length;
|
||||||
|
if route.header.table != RT_TABLE_MAIN || len == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let through_tun = route
|
||||||
|
.attributes
|
||||||
|
.iter()
|
||||||
|
.any(|a| matches!(a, RouteAttribute::Oif(i) if Some(*i) == self.tun_index));
|
||||||
|
if through_tun {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let dst = route.attributes.iter().find_map(|a| match a {
|
||||||
|
RouteAttribute::Destination(RouteAddress::Inet(v4)) => Some(*v4),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
if let Some(dst) = dst {
|
||||||
|
prefixes.push((dst, len));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(most_specific_covering(ip, &prefixes))
|
||||||
|
}
|
||||||
|
|
||||||
/// Remove the VPN default route (0.0.0.0/0) during a reconnect window so
|
/// Remove the VPN default route (0.0.0.0/0) during a reconnect window so
|
||||||
/// traffic falls through to the WiFi default route (metric 600).
|
/// traffic falls through to the WiFi default route (metric 600).
|
||||||
/// Call `resume_default_route()` after the new session is established.
|
/// Call `resume_default_route()` after the new session is established.
|
||||||
@@ -451,6 +708,20 @@ impl RouteManager {
|
|||||||
|
|
||||||
/// Remove every route that was added by `setup_vpn_routes`.
|
/// Remove every route that was added by `setup_vpn_routes`.
|
||||||
/// Pre-existing routes are untouched.
|
/// Pre-existing routes are untouched.
|
||||||
|
/// Restores everything this manager installed: the policy rule, the IPv6
|
||||||
|
/// blackhole, and the tracked VPN routes.
|
||||||
|
///
|
||||||
|
/// Each step is independent and **none of them may skip the ones after
|
||||||
|
/// it**. A failure while *looking up* what to delete is not a reason to
|
||||||
|
/// skip deleting what we already know is installed -- this function exists
|
||||||
|
/// to put the host's network back, and abandoning it halfway does the
|
||||||
|
/// opposite, leaving a `default via` route pointing at a TUN device that no
|
||||||
|
/// longer exists and taking the machine offline entirely.
|
||||||
|
///
|
||||||
|
/// The deletions themselves have always been handled properly (logged and
|
||||||
|
/// continued); it was the lookups that used `?` and aborted the rest. That
|
||||||
|
/// is backwards, and it is what this signature now reflects: lookups return
|
||||||
|
/// `Result`, callers log the failure and continue.
|
||||||
pub async fn restore_routes(&mut self) -> Result<()> {
|
pub async fn restore_routes(&mut self) -> Result<()> {
|
||||||
info!(
|
info!(
|
||||||
"Removing {} tracked VPN routes...",
|
"Removing {} tracked VPN routes...",
|
||||||
@@ -458,36 +729,94 @@ impl RouteManager {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Remove the policy rule first so traffic stops using the VPN immediately.
|
// Remove the policy rule first so traffic stops using the VPN immediately.
|
||||||
// Same netlink-handle approach as the add side above -- `handle.rule().del()`
|
|
||||||
// needs the exact existing RuleMessage, so look it up by priority first.
|
|
||||||
if self.added_policy_rule {
|
if self.added_policy_rule {
|
||||||
|
match self.find_vpn_rule().await {
|
||||||
|
Ok(Some(rule)) => match self.handle.rule().del(rule).execute().await {
|
||||||
|
Ok(()) => info!("Removed ip rule priority {}", VPN_RULE_PRIORITY),
|
||||||
|
Err(e) => warn!("Failed to delete ip rule priority {}: {:?}", VPN_RULE_PRIORITY, e),
|
||||||
|
},
|
||||||
|
Ok(None) => warn!("ip rule priority {} not found, nothing to remove", VPN_RULE_PRIORITY),
|
||||||
|
Err(e) => warn!(
|
||||||
|
"Failed to look up ip rule priority {}: {:?} -- continuing with the rest of the cleanup",
|
||||||
|
VPN_RULE_PRIORITY, e
|
||||||
|
),
|
||||||
|
}
|
||||||
|
self.added_policy_rule = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.added_ipv6_blackhole {
|
||||||
|
match self.find_ipv6_blackhole().await {
|
||||||
|
Ok(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),
|
||||||
|
},
|
||||||
|
Ok(None) => warn!("IPv6 blackhole route not found, nothing to remove"),
|
||||||
|
Err(e) => warn!(
|
||||||
|
"Failed to look up the IPv6 blackhole route: {:?} -- continuing with the rest of the cleanup",
|
||||||
|
e
|
||||||
|
),
|
||||||
|
}
|
||||||
|
self.added_ipv6_blackhole = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No early return for an empty `added_routes` here. It looks like a
|
||||||
|
// harmless shortcut, but it also skips the bookkeeping and the final
|
||||||
|
// log, which is how "we removed everything" gets reported when some of
|
||||||
|
// it was never looked at. With nothing tracked the collect below finds
|
||||||
|
// nothing anyway.
|
||||||
|
match self.find_tracked_routes().await {
|
||||||
|
Ok(routes_to_delete) => {
|
||||||
|
for route in routes_to_delete {
|
||||||
|
if let Err(e) = self.handle.route().del(route).execute().await {
|
||||||
|
warn!("Failed to delete tracked route: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => warn!(
|
||||||
|
"Failed to look up tracked routes: {:?} -- {} may still be installed",
|
||||||
|
e,
|
||||||
|
self.added_routes.len()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
self.added_routes.clear();
|
||||||
|
info!("✅ VPN routes removed");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds the policy rule this manager installed, by priority.
|
||||||
|
async fn find_vpn_rule(&self) -> Result<Option<netlink_packet_route::rule::RuleMessage>> {
|
||||||
let mut rules = self.handle.rule().get(IpVersion::V4).execute();
|
let mut rules = self.handle.rule().get(IpVersion::V4).execute();
|
||||||
let mut to_delete = None;
|
|
||||||
while let Some(rule) = rules.try_next().await? {
|
while let Some(rule) = rules.try_next().await? {
|
||||||
let matches_priority = rule
|
let matches_priority = rule
|
||||||
.attributes
|
.attributes
|
||||||
.iter()
|
.iter()
|
||||||
.any(|attr| matches!(attr, netlink_packet_route::rule::RuleAttribute::Priority(p) if *p == VPN_RULE_PRIORITY));
|
.any(|attr| matches!(attr, netlink_packet_route::rule::RuleAttribute::Priority(p) if *p == VPN_RULE_PRIORITY));
|
||||||
if matches_priority {
|
if matches_priority {
|
||||||
to_delete = Some(rule);
|
// `rule().del()` needs the exact existing RuleMessage, which is
|
||||||
break;
|
// why this is a lookup rather than a bare delete by priority.
|
||||||
|
return Ok(Some(rule));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
match to_delete {
|
/// Finds the IPv6 `unreachable default` blackhole installed to stop IPv6
|
||||||
Some(rule) => match self.handle.rule().del(rule).execute().await {
|
/// leaking outside the tunnel.
|
||||||
Ok(()) => info!("Removed ip rule priority {}", VPN_RULE_PRIORITY),
|
async fn find_ipv6_blackhole(&self) -> Result<Option<netlink_packet_route::route::RouteMessage>> {
|
||||||
Err(e) => warn!("Failed to delete ip rule priority {}: {:?}", VPN_RULE_PRIORITY, e),
|
let mut routes = self.handle.route().get(IpVersion::V6).execute();
|
||||||
},
|
while let Some(route) = routes.try_next().await? {
|
||||||
None => warn!("ip rule priority {} not found, nothing to remove", VPN_RULE_PRIORITY),
|
if route.header.destination_prefix_length == 0
|
||||||
|
&& route.header.kind == RouteType::Unreachable
|
||||||
|
{
|
||||||
|
return Ok(Some(route));
|
||||||
}
|
}
|
||||||
self.added_policy_rule = false;
|
}
|
||||||
}
|
Ok(None)
|
||||||
|
|
||||||
if self.added_routes.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Collects the tracked routes that are still present, ready to delete.
|
||||||
|
async fn find_tracked_routes(&self) -> Result<Vec<netlink_packet_route::route::RouteMessage>> {
|
||||||
let mut routes_to_delete = Vec::new();
|
let mut routes_to_delete = Vec::new();
|
||||||
let mut routes = self.handle.route().get(IpVersion::V4).execute();
|
let mut routes = self.handle.route().get(IpVersion::V4).execute();
|
||||||
|
|
||||||
@@ -531,15 +860,7 @@ impl RouteManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for route in routes_to_delete {
|
Ok(routes_to_delete)
|
||||||
if let Err(e) = self.handle.route().del(route).execute().await {
|
|
||||||
warn!("Failed to delete tracked route: {:?}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.added_routes.clear();
|
|
||||||
info!("✅ VPN routes removed");
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,6 +875,20 @@ impl Drop for RouteManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The longest prefix in `prefixes` that contains `ip`. Pure, so the matching
|
||||||
|
/// is testable without a netlink socket.
|
||||||
|
fn most_specific_covering(ip: Ipv4Addr, prefixes: &[(Ipv4Addr, u8)]) -> Option<(Ipv4Addr, u8)> {
|
||||||
|
let contains = |net: Ipv4Addr, len: u8| {
|
||||||
|
let mask = if len == 0 { 0 } else { u32::MAX << (32 - u32::from(len.min(32))) };
|
||||||
|
u32::from(ip) & mask == u32::from(net) & mask
|
||||||
|
};
|
||||||
|
prefixes
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|&(net, len)| contains(net, len))
|
||||||
|
.max_by_key(|&(_, len)| len)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -618,4 +953,334 @@ mod tests {
|
|||||||
}
|
}
|
||||||
assert!(found, "installed policy rule not found via rule().get()");
|
assert!(found, "installed policy rule not found via rule().get()");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The teardown contract, which Taiga #165 is about: every step runs, and a
|
||||||
|
// failure in one never skips the ones after it. `restore_routes` used to
|
||||||
|
// carry three `?`s on netlink *lookups*, so a single hiccup returned `Err`
|
||||||
|
// having deleted nothing -- leaving the host with a `default via` route
|
||||||
|
// pointing at a TUN device that no longer exists, i.e. no network at all.
|
||||||
|
//
|
||||||
|
// This covers the happy path: the whole install/restore cycle really does
|
||||||
|
// clean up both the policy rule and the IPv6 blackhole, which guards the
|
||||||
|
// refactor into `find_*` helpers. The failure branch -- one step erroring
|
||||||
|
// and the later steps still running -- is covered by the separate
|
||||||
|
// `a_failing_lookup_does_not_skip_the_later_cleanup_steps` below, which
|
||||||
|
// induces a real netlink failure rather than mocking one.
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn restore_routes_clears_every_step_it_installed() {
|
||||||
|
if std::env::var(REEXEC_ENV_VAR).is_err() {
|
||||||
|
run_in_isolated_netns("routes::linux::tests::restore_routes_clears_every_step_it_installed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut mgr = RouteManager::new("lo".to_string())
|
||||||
|
.await
|
||||||
|
.expect("failed to create RouteManager in isolated netns");
|
||||||
|
|
||||||
|
// Install both pieces of state that need no TUN device to exist. The
|
||||||
|
// tracked-route step is exercised by find_tracked_routes' own filtering
|
||||||
|
// logic; it is not asserted here because adding a route needs a real
|
||||||
|
// output device and there is deliberately no interface to use.
|
||||||
|
mgr.install_policy_rule().await;
|
||||||
|
assert!(mgr.added_policy_rule, "install should have recorded the rule");
|
||||||
|
|
||||||
|
mgr.added_ipv6_blackhole = true;
|
||||||
|
// Same construction as block_ipv6_leak, so the test asserts against the
|
||||||
|
// route shape the add path really produces.
|
||||||
|
mgr.handle
|
||||||
|
.route()
|
||||||
|
.add()
|
||||||
|
.v6()
|
||||||
|
.destination_prefix(Ipv6Addr::UNSPECIFIED, 0)
|
||||||
|
.kind(RouteType::Unreachable)
|
||||||
|
.priority(VPN_ROUTE_METRIC)
|
||||||
|
.execute()
|
||||||
|
.await
|
||||||
|
.expect("failed to install IPv6 blackhole in isolated netns");
|
||||||
|
|
||||||
|
mgr.restore_routes().await.expect("restore_routes must not error");
|
||||||
|
|
||||||
|
// Bookkeeping: a step that was never reached leaves its flag set, which
|
||||||
|
// is what made Drop warn about unrestored state.
|
||||||
|
assert!(!mgr.added_policy_rule, "policy-rule step was skipped");
|
||||||
|
assert!(!mgr.added_ipv6_blackhole, "IPv6-blackhole step was skipped");
|
||||||
|
|
||||||
|
// And the kernel actually agrees it is gone.
|
||||||
|
let mut rules = mgr.handle.rule().get(IpVersion::V4).execute();
|
||||||
|
while let Some(rule) = rules.try_next().await.unwrap() {
|
||||||
|
let still_there = rule.attributes.iter().any(|attr| {
|
||||||
|
matches!(attr, netlink_packet_route::rule::RuleAttribute::Priority(p) if *p == VPN_RULE_PRIORITY)
|
||||||
|
});
|
||||||
|
assert!(!still_there, "policy rule survived restore_routes");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut routes = mgr.handle.route().get(IpVersion::V6).execute();
|
||||||
|
while let Some(route) = routes.try_next().await.unwrap() {
|
||||||
|
let still_there = route.header.destination_prefix_length == 0
|
||||||
|
&& route.header.kind == RouteType::Unreachable;
|
||||||
|
assert!(!still_there, "IPv6 blackhole survived restore_routes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The actual #165 regression. `restore_routes` used to carry `?` on its
|
||||||
|
// netlink *lookups*, so one failing read returned `Err` having deleted
|
||||||
|
// nothing and left the host with routes pointing at a dead TUN device --
|
||||||
|
// no network at all. The fix is that each step handles its own lookup
|
||||||
|
// failure and the next one still runs.
|
||||||
|
//
|
||||||
|
// The failure is induced for real rather than mocked: the netlink socket is
|
||||||
|
// closed underneath the handle by aborting the connection task, so every
|
||||||
|
// subsequent request fails with EBADF. Merely never spawning the connection
|
||||||
|
// would instead hang -- the request would be sent and wait forever for a
|
||||||
|
// reply that nothing is listening for -- hence the timeout.
|
||||||
|
//
|
||||||
|
// No netns is needed: with the socket closed no netlink message can leave
|
||||||
|
// the process, so this cannot touch the host's routing table.
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn a_failing_lookup_does_not_skip_the_later_cleanup_steps() {
|
||||||
|
let (connection, handle, _) =
|
||||||
|
new_connection().expect("failed to create netlink connection");
|
||||||
|
let task = tokio::spawn(connection);
|
||||||
|
// Aborting drops the Connection, which owns the socket and closes it.
|
||||||
|
task.abort();
|
||||||
|
let _ = task.await;
|
||||||
|
|
||||||
|
let mut mgr = RouteManager {
|
||||||
|
handle,
|
||||||
|
added_routes: vec![(Ipv4Addr::new(198, 18, 0, 0), 0)],
|
||||||
|
tun_interface: "lo".to_string(),
|
||||||
|
tun_index: None,
|
||||||
|
dns_manager: crate::dns::DnsManager::new(
|
||||||
|
"lo".to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
||||||
|
),
|
||||||
|
server_ip: None,
|
||||||
|
wifi_gateway: None,
|
||||||
|
wifi_iface_index: None,
|
||||||
|
vpn_gateway: None,
|
||||||
|
added_policy_rule: true,
|
||||||
|
added_ipv6_blackhole: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let outcome = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(10),
|
||||||
|
mgr.restore_routes(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
outcome.is_ok(),
|
||||||
|
"restore_routes hung on a closed netlink socket; every request must error instead"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Each flag is cleared only once its step has actually run, so this is
|
||||||
|
// what distinguishes "attempted every step" from "bailed after the
|
||||||
|
// first failure" -- which is the bug.
|
||||||
|
assert!(
|
||||||
|
!mgr.added_policy_rule,
|
||||||
|
"policy-rule step was skipped after the lookup failed"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!mgr.added_ipv6_blackhole,
|
||||||
|
"IPv6-blackhole step was skipped after an earlier lookup failed"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
mgr.added_routes.is_empty(),
|
||||||
|
"tracked-route step was skipped after an earlier lookup failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Another VPN that was already up when we connected must keep its carrier.
|
||||||
|
// A WireGuard endpoint with no host route of its own follows the default
|
||||||
|
// route, and our full-tunnel default would capture it -- which took down a
|
||||||
|
// WireGuard VPN (and the DNS it provided) on 2026-10-05. Pinning puts the
|
||||||
|
// endpoint back on the physical gateway, and teardown takes the pin away.
|
||||||
|
//
|
||||||
|
// An address already covered by a more specific route is left alone: the
|
||||||
|
// full tunnel only replaces the *default*, so it never captured that
|
||||||
|
// address, and a pin via the default gateway would hairpin it.
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn pin_to_physical_keeps_an_endpoint_off_the_tunnel_until_teardown() {
|
||||||
|
if std::env::var(REEXEC_ENV_VAR).is_err() {
|
||||||
|
run_in_isolated_netns(
|
||||||
|
"routes::linux::tests::pin_to_physical_keeps_an_endpoint_off_the_tunnel_until_teardown",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stand-in physical NIC, its default route, and a more specific
|
||||||
|
// route standing in for another VPN's subnet.
|
||||||
|
for cmd in [
|
||||||
|
"ip link add phys0 type dummy",
|
||||||
|
"ip link set phys0 up",
|
||||||
|
"ip addr add 192.0.2.10/24 dev phys0",
|
||||||
|
"ip route add default via 192.0.2.1 dev phys0",
|
||||||
|
"ip route add 10.60.0.0/16 via 192.0.2.1 dev phys0",
|
||||||
|
] {
|
||||||
|
let ok = std::process::Command::new("sh").args(["-c", cmd]).status().unwrap().success();
|
||||||
|
assert!(ok, "netns setup failed: {cmd}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut mgr = RouteManager::new("lo".to_string())
|
||||||
|
.await
|
||||||
|
.expect("failed to create RouteManager in isolated netns");
|
||||||
|
let (gateway, oif) = mgr.get_default_gateway().await.expect("default gateway");
|
||||||
|
mgr.wifi_gateway = Some(gateway);
|
||||||
|
mgr.wifi_iface_index = Some(oif);
|
||||||
|
|
||||||
|
let endpoint = Ipv4Addr::new(203, 0, 113, 7);
|
||||||
|
assert!(mgr.pin_to_physical(endpoint).await.unwrap(), "an endpoint on the default route must be pinned");
|
||||||
|
let route = |dst: &str| {
|
||||||
|
let out = std::process::Command::new("ip").args(["route", "show", dst]).output().unwrap();
|
||||||
|
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
route("203.0.113.7/32").contains("via 192.0.2.1 dev phys0"),
|
||||||
|
"pin missing or wrong: {:?}",
|
||||||
|
route("203.0.113.7/32")
|
||||||
|
);
|
||||||
|
|
||||||
|
let inside_vpn = Ipv4Addr::new(10, 60, 48, 10);
|
||||||
|
assert!(
|
||||||
|
!mgr.pin_to_physical(inside_vpn).await.unwrap(),
|
||||||
|
"an address under a more specific route was never captured, so must not be pinned"
|
||||||
|
);
|
||||||
|
assert_eq!(route("10.60.48.10/32"), "", "no pin should exist for a covered address");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!mgr.pin_to_physical(endpoint).await.unwrap(),
|
||||||
|
"pinning twice must not track the route twice"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The other VPN went away: the pin comes off now, not at teardown, so
|
||||||
|
// its next connection follows the default route like any new traffic.
|
||||||
|
mgr.unpin(endpoint).await.expect("unpin");
|
||||||
|
assert_eq!(route("203.0.113.7/32"), "", "unpin must remove the route");
|
||||||
|
assert!(!mgr.added_routes.contains(&(endpoint, 32)), "unpin must stop tracking it");
|
||||||
|
mgr.unpin(endpoint).await.expect("unpinning twice is a no-op, not an error");
|
||||||
|
|
||||||
|
// A route we never created is not ours to remove, even at our address.
|
||||||
|
let ok = std::process::Command::new("sh")
|
||||||
|
.args(["-c", "ip route add 203.0.113.8/32 via 192.0.2.1 dev phys0 metric 50"])
|
||||||
|
.status()
|
||||||
|
.unwrap()
|
||||||
|
.success();
|
||||||
|
assert!(ok);
|
||||||
|
mgr.unpin(Ipv4Addr::new(203, 0, 113, 8)).await.expect("unpin of an untracked address");
|
||||||
|
assert!(!route("203.0.113.8/32").is_empty(), "unpin removed a route it never installed");
|
||||||
|
|
||||||
|
assert!(mgr.pin_to_physical(endpoint).await.unwrap(), "pinning again after unpin");
|
||||||
|
mgr.restore_routes().await.expect("restore_routes");
|
||||||
|
assert_eq!(route("203.0.113.7/32"), "", "teardown must remove the pin");
|
||||||
|
assert!(
|
||||||
|
route("10.60.0.0/16").contains("via 192.0.2.1"),
|
||||||
|
"teardown must not touch the other VPN's own route"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn most_specific_covering_picks_the_longest_matching_prefix() {
|
||||||
|
let v4 = |a, b, c, d| Ipv4Addr::new(a, b, c, d);
|
||||||
|
let prefixes = [(v4(10, 0, 0, 0), 8), (v4(10, 60, 0, 0), 16), (v4(192, 168, 0, 0), 23)];
|
||||||
|
assert_eq!(most_specific_covering(v4(10, 60, 48, 10), &prefixes), Some((v4(10, 60, 0, 0), 16)));
|
||||||
|
assert_eq!(most_specific_covering(v4(10, 1, 2, 3), &prefixes), Some((v4(10, 0, 0, 0), 8)));
|
||||||
|
assert_eq!(most_specific_covering(v4(192, 168, 1, 255), &prefixes), Some((v4(192, 168, 0, 0), 23)));
|
||||||
|
assert_eq!(most_specific_covering(v4(192, 168, 2, 0), &prefixes), None);
|
||||||
|
assert_eq!(most_specific_covering(v4(34, 88, 176, 131), &prefixes), None);
|
||||||
|
assert_eq!(most_specific_covering(v4(34, 88, 176, 131), &[(v4(34, 88, 176, 131), 32)]), Some((v4(34, 88, 176, 131), 32)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A server host route stranded by an earlier run (a SIGKILL skips teardown)
|
||||||
|
// made every later connect log "already exists" and carry on without
|
||||||
|
// tracking it, so teardown never removed it -- self-perpetuating. Found
|
||||||
|
// 2026-10-05 as `89.167.29.223 via 192.168.0.1 metric 50` on a host with
|
||||||
|
// koridor down. Same destination, gateway and our metric: it is ours.
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn a_stranded_server_route_is_adopted_and_removed_at_teardown() {
|
||||||
|
if std::env::var(REEXEC_ENV_VAR).is_err() {
|
||||||
|
run_in_isolated_netns("routes::linux::tests::a_stranded_server_route_is_adopted_and_removed_at_teardown");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for cmd in [
|
||||||
|
"ip link add phys0 type dummy",
|
||||||
|
"ip link set phys0 up",
|
||||||
|
"ip addr add 192.0.2.10/24 dev phys0",
|
||||||
|
"ip route add default via 192.0.2.1 dev phys0",
|
||||||
|
"ip link add tun0 type dummy",
|
||||||
|
"ip link set tun0 up",
|
||||||
|
"ip addr add 198.18.0.2/24 dev tun0",
|
||||||
|
// The stranded route, exactly as a previous run installed it.
|
||||||
|
"ip route add 203.0.113.9/32 via 192.0.2.1 dev phys0 metric 50",
|
||||||
|
] {
|
||||||
|
let ok = std::process::Command::new("sh").args(["-c", cmd]).status().unwrap().success();
|
||||||
|
assert!(ok, "netns setup failed: {cmd}");
|
||||||
|
}
|
||||||
|
let route = |dst: &str| {
|
||||||
|
let out = std::process::Command::new("ip").args(["route", "show", dst]).output().unwrap();
|
||||||
|
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut mgr = RouteManager::new("tun0".to_string()).await.expect("RouteManager");
|
||||||
|
mgr.setup_vpn_routes("203.0.113.9", IpAddr::V4(Ipv4Addr::new(198, 18, 0, 1)), &["0.0.0.0/0".to_string()])
|
||||||
|
.await
|
||||||
|
.expect("setup_vpn_routes");
|
||||||
|
assert!(
|
||||||
|
mgr.added_routes.contains(&(Ipv4Addr::new(203, 0, 113, 9), 32)),
|
||||||
|
"the stranded server route was left untracked"
|
||||||
|
);
|
||||||
|
|
||||||
|
mgr.restore_routes().await.expect("restore_routes");
|
||||||
|
assert_eq!(route("203.0.113.9/32"), "", "teardown must remove the adopted server route");
|
||||||
|
assert!(route("default").contains("192.0.2.1"), "the physical default must survive teardown");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stranded routes are adopted only when they are unmistakably ours. A SIGKILL
|
||||||
|
// skips teardown and leaves a pin behind; the next connect used to see it,
|
||||||
|
// skip it, and never remove it. But "a /32 at our metric already exists" is
|
||||||
|
// not enough to take ownership: another tool can hold one, and deleting it at
|
||||||
|
// teardown would break that tool.
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn only_our_own_stranded_routes_are_adopted() {
|
||||||
|
if std::env::var(REEXEC_ENV_VAR).is_err() {
|
||||||
|
run_in_isolated_netns("routes::linux::tests::only_our_own_stranded_routes_are_adopted");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for cmd in [
|
||||||
|
"ip link add phys0 type dummy",
|
||||||
|
"ip link set phys0 up",
|
||||||
|
"ip addr add 192.0.2.10/24 dev phys0",
|
||||||
|
"ip route add default via 192.0.2.1 dev phys0",
|
||||||
|
"ip link add other0 type dummy",
|
||||||
|
"ip link set other0 up",
|
||||||
|
"ip addr add 198.51.100.10/24 dev other0",
|
||||||
|
// Ours, stranded by a killed run: physical gateway, our metric.
|
||||||
|
"ip route add 203.0.113.30/32 via 192.0.2.1 dev phys0 metric 50",
|
||||||
|
// Someone else's: same destination shape and metric, another device.
|
||||||
|
"ip route add 203.0.113.40/32 via 198.51.100.1 dev other0 metric 50",
|
||||||
|
] {
|
||||||
|
let ok = std::process::Command::new("sh").args(["-c", cmd]).status().unwrap().success();
|
||||||
|
assert!(ok, "netns setup failed: {cmd}");
|
||||||
|
}
|
||||||
|
let route = |dst: &str| {
|
||||||
|
let out = std::process::Command::new("ip").args(["route", "show", dst]).output().unwrap();
|
||||||
|
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut mgr = RouteManager::new("lo".to_string()).await.expect("RouteManager");
|
||||||
|
let (gateway, oif) = mgr.get_default_gateway().await.expect("default gateway");
|
||||||
|
mgr.wifi_gateway = Some(gateway);
|
||||||
|
mgr.wifi_iface_index = Some(oif);
|
||||||
|
|
||||||
|
let ours = Ipv4Addr::new(203, 0, 113, 30);
|
||||||
|
let theirs = Ipv4Addr::new(203, 0, 113, 40);
|
||||||
|
assert!(mgr.pin_to_physical(ours).await.unwrap(), "our stranded pin must be adopted");
|
||||||
|
assert!(mgr.added_routes.contains(&(ours, 32)), "adopted but not tracked");
|
||||||
|
assert!(!mgr.pin_to_physical(theirs).await.unwrap(), "another tool's route must not be taken");
|
||||||
|
assert!(!mgr.added_routes.contains(&(theirs, 32)), "another tool's route got tracked");
|
||||||
|
|
||||||
|
mgr.restore_routes().await.expect("restore_routes");
|
||||||
|
assert_eq!(route("203.0.113.30/32"), "", "the adopted pin must go at teardown");
|
||||||
|
assert!(!route("203.0.113.40/32").is_empty(), "teardown deleted a route that was never ours");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ pub struct MacOsNetManager {
|
|||||||
server_host: String,
|
server_host: String,
|
||||||
original_gateway: String,
|
original_gateway: String,
|
||||||
primary_service: 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 {
|
impl MacOsNetManager {
|
||||||
@@ -42,13 +48,18 @@ impl MacOsNetManager {
|
|||||||
server_host,
|
server_host,
|
||||||
original_gateway,
|
original_gateway,
|
||||||
primary_service,
|
primary_service,
|
||||||
|
wifi_iface: iface,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always adds split-default (0/1 + 128/1) for full-tunnel mode.
|
// Always adds split-default (0/1 + 128/1) for full-tunnel mode.
|
||||||
// Split-tunnel (per-route from ServerConfig) is not yet supported on macOS.
|
// Split-tunnel (per-route from ServerConfig) is not yet supported on macOS.
|
||||||
pub async fn setup_vpn_routes(&self) -> Result<()> {
|
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", "0.0.0.0/1", "-interface", &self.tun_name]).await;
|
||||||
run("route", &["add", "-net", "128.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);
|
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.
|
// the wrong gateway until the next reconnect that creates a new MacOsNetManager.
|
||||||
pub async fn ensure_server_route(&self) -> Result<()> {
|
pub async fn ensure_server_route(&self) -> Result<()> {
|
||||||
run("route", &["delete", "-host", &self.server_host]).await;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ pub struct RouteManager {
|
|||||||
tun_routes: Vec<(Ipv4Addr, u8)>,
|
tun_routes: Vec<(Ipv4Addr, u8)>,
|
||||||
server_ip: Option<Ipv4Addr>,
|
server_ip: Option<Ipv4Addr>,
|
||||||
wifi_gateway: 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 {
|
impl RouteManager {
|
||||||
@@ -21,23 +27,30 @@ impl RouteManager {
|
|||||||
dns_manager: DnsManager::new(
|
dns_manager: DnsManager::new(
|
||||||
tun_interface.clone(),
|
tun_interface.clone(),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
||||||
),
|
),
|
||||||
tun_interface,
|
tun_interface,
|
||||||
tun_routes: Vec::new(),
|
tun_routes: Vec::new(),
|
||||||
server_ip: None,
|
server_ip: None,
|
||||||
wifi_gateway: 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()
|
let adapters = ipconfig::get_adapters()
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to enumerate adapters: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Failed to enumerate adapters: {}", e))?;
|
||||||
for adapter in &adapters {
|
for adapter in &adapters {
|
||||||
for gw in adapter.gateways() {
|
for gw in adapter.gateways() {
|
||||||
if let IpAddr::V4(gw4) = gw {
|
if let IpAddr::V4(gw4) = gw {
|
||||||
if !gw4.is_loopback() && !gw4.is_unspecified() {
|
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")
|
anyhow::bail!("No default gateway found")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prefix_to_mask(prefix: u8) -> Ipv4Addr {
|
// Add/delete the server host route through the physical WiFi/Ethernet
|
||||||
let bits = if prefix == 0 {
|
// interface, pinned explicitly by adapter name via `netsh` rather than
|
||||||
0u32
|
// `route add`'s gateway-only resolution — same rationale and technique as
|
||||||
} else {
|
// `tun_route_add` below: without an explicit interface, an IP collision
|
||||||
!0u32 << (32 - prefix)
|
// between the VPN's assigned tun subnet and the physical gateway can make
|
||||||
};
|
// this route resolve onto the TUN adapter instead, looping the server
|
||||||
let [a, b, c, d] = bits.to_be_bytes();
|
// route back into the tunnel it's meant to bypass.
|
||||||
Ipv4Addr::new(a, b, c, d)
|
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")
|
||||||
// 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")
|
|
||||||
.args([
|
.args([
|
||||||
|
"interface",
|
||||||
|
"ipv4",
|
||||||
"add",
|
"add",
|
||||||
&dest.to_string(),
|
"route",
|
||||||
"MASK",
|
&prefix_str,
|
||||||
&mask.to_string(),
|
iface,
|
||||||
&gateway.to_string(),
|
&format!("nexthop={}", gateway),
|
||||||
"METRIC",
|
"metric=1",
|
||||||
"1",
|
"store=active",
|
||||||
])
|
])
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
.output();
|
.output();
|
||||||
@@ -76,25 +86,19 @@ impl RouteManager {
|
|||||||
let stdout = String::from_utf8_lossy(&o.stdout);
|
let stdout = String::from_utf8_lossy(&o.stdout);
|
||||||
if !o.status.success() {
|
if !o.status.success() {
|
||||||
// "already exists" is acceptable — a previous run may have left it
|
// "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) {
|
fn wifi_route_delete(iface: &str, dest: Ipv4Addr, prefix: u8) {
|
||||||
let mask = Self::prefix_to_mask(prefix);
|
let prefix_str = format!("{}/{}", dest, prefix);
|
||||||
let _ = std::process::Command::new("route")
|
let _ = std::process::Command::new("netsh")
|
||||||
.args([
|
.args(["interface", "ipv4", "delete", "route", &prefix_str, iface])
|
||||||
"delete",
|
|
||||||
&dest.to_string(),
|
|
||||||
"MASK",
|
|
||||||
&mask.to_string(),
|
|
||||||
&gateway.to_string(),
|
|
||||||
])
|
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
.status();
|
.output();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add/delete routes through the TUN interface using netsh.
|
// Add/delete routes through the TUN interface using netsh.
|
||||||
@@ -169,8 +173,9 @@ impl RouteManager {
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
info!("Setting up Windows VPN routes...");
|
info!("Setting up Windows VPN routes...");
|
||||||
|
|
||||||
let current_gateway = Self::get_default_gateway()?;
|
let (current_gateway, wifi_iface) = Self::get_default_gateway()?;
|
||||||
info!("Current gateway: {}", current_gateway);
|
info!("Current gateway: {} (interface {})", current_gateway, wifi_iface);
|
||||||
|
self.wifi_iface = Some(wifi_iface.clone());
|
||||||
|
|
||||||
let server_ip: Ipv4Addr = {
|
let server_ip: Ipv4Addr = {
|
||||||
let host = server_addr.split(':').next().unwrap_or(server_addr);
|
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.
|
// even if the add below fails because the route already exists.
|
||||||
self.server_ip = Some(server_ip);
|
self.server_ip = Some(server_ip);
|
||||||
self.wifi_gateway = Some(current_gateway);
|
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");
|
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() {
|
if dns_servers.is_empty() {
|
||||||
return Ok(());
|
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
|
self.dns_manager.set_dns(dns_servers).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,8 +247,10 @@ impl RouteManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn ensure_server_route(&mut self) -> Result<()> {
|
pub async fn ensure_server_route(&mut self) -> Result<()> {
|
||||||
if let (Some(server_ip), Some(gateway)) = (self.server_ip, self.wifi_gateway) {
|
if let (Some(server_ip), Some(gateway), Some(iface)) =
|
||||||
Self::wifi_route_add(server_ip, 32, gateway);
|
(self.server_ip, self.wifi_gateway, self.wifi_iface.as_deref())
|
||||||
|
{
|
||||||
|
Self::wifi_route_add(iface, server_ip, 32, gateway);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -280,8 +287,9 @@ impl RouteManager {
|
|||||||
}
|
}
|
||||||
// Always clean up the server host route — a previous unclean exit may have
|
// Always clean up the server host route — a previous unclean exit may have
|
||||||
// left it behind even if our add was skipped.
|
// 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_gateway.take();
|
||||||
Self::wifi_route_delete(server_ip, 32, gateway);
|
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");
|
info!("Windows VPN routes removed");
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
Reference in New Issue
Block a user