7 Commits

Author SHA1 Message Date
E. Kaparulin
7d96bdac7b chore: sync konduit-platform v0.1.0-beta.12 2026-10-06 18:31:54 +03:00
E. Kaparulin
7c24351f4a fix: correct the CLI examples for --use-proxy and --listen-socks
Both sections showed

  ./konduit connect --config client.toml --use-proxy ...

which cannot work. `--config` is declared on the top-level Args struct,
while `--use-proxy` and `--listen-socks` belong to the `connect`
subcommand, so clap rejects the combination outright:

  $ konduit connect --config client.toml --use-proxy socks5://...
  error: unexpected argument '--config' found
  $ konduit --config client.toml --use-proxy socks5://...
  error: unexpected argument '--use-proxy' found

Neither ordering works — an invocation has to pick one style: config file
(everything from the file) or `connect` subcommand (everything as flags).
Replaced with the full flag form and said so explicitly, since "or via the
CLI" read as though you could bolt a flag onto a config-file run.

Both replacement command lines were run against the built binary to
confirm they parse; they get as far as the connection attempt.
2026-09-30 11:59:41 +03:00
E. Kaparulin
4a4ab9d781 Remove a false key-storage claim; fix a broken CLI example and a stale artifact table
Three defects in the README, all found while cross-checking it against the
source and the published releases.

## 1. The key-storage claim was false, not merely wrong about platforms

"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."

There is no OS-keychain integration in Konduit at all. Nothing in the Rust
workspace or the Flutter GUI references a keyring or secure-storage crate --
the GUI's only persistence dependency is shared_preferences. And
core/src/config.rs writes the client identity key straight into the config
file the docs tell you to pass with --config:

    client.toml:  identity_key = "<hex>"

so "never written to disk in plaintext" was false by construction, for the
one thing the claim was about. It also named two platforms that do not ship.

Replaced with what actually happens, and the consequence: the key is
generated locally, never sent to K-Ops, and the config file is itself the
secret -- so restrict its permissions, do not commit it, do not paste it
into a bug report. Deliberately not reworded into another claim I could not
verify.

## 2. The CLI example does not run

    ./konduit connect --config client.toml

--config is a top-level flag on Args (cli/src/main.rs:15), not on the
Connect subcommand, and it is not marked global, so clap rejects it:

    error: unexpected argument '--config' found

Fixed to `./konduit --config client.toml`, which is the form
docs/client-quickstart.md uses and the binary accepts. Documented the
`connect` subcommand's actual flags --server/--peer-id/--identity/
--server-pub -- checked against `konduit connect --help`.

## 3. The artifact table lists assets that are not in the current release

Checked against the Gitea releases API rather than the Makefile. v0.1.0-beta.11
ships five assets: the AppImage, the three Linux binaries, and
konduit-gui-...-windows-x86_64-portable.zip. It has no Windows CLI zip and
no Android AAB; beta.9 and beta.10 had both, and the Windows GUI zip was
only named -portable.zip in beta.11.

So the Windows GUI row is now the published name, and the two rows that
appear in some releases but not others are marked as such with the
variation spelled out. The Releases page is called authoritative, since
which assets ship is a per-release decision.

No claim here is reworded from assumption -- each one was checked against
the code or the published release first.
2026-09-30 11:38:48 +03:00
E. Kaparulin
3b212bbed9 docs(www): align support/vulnerability disclosure with koridor pattern 2026-07-31 12:42:51 +03:00
E. Kaparulin
11d5305d8c chore: sync konduit-platform v0.1.0-beta.11 2026-07-25 18:00:40 +03:00
E. Kaparulin
c2d7f8d8c6 chore: sync konduit-platform v0.1.0-beta.10 2026-07-23 18:44:50 +03:00
E. Kaparulin
678ed158a6 chore: sync konduit-platform 2026-07-23 18:33:05 +03:00
7 changed files with 958 additions and 106 deletions

View File

@@ -48,9 +48,14 @@ Releases are published on the [Releases](../../releases) page. Each release incl
| `konduit-*-linux-x86_64` | Linux | CLI client |
| `konduit-ctl-*-linux-x86_64` | Linux | Server provisioning tool |
| `Konduit-*-x86_64.AppImage` | Linux | GUI client (AppImage) |
| `konduit-cli-*-windows-x86_64.zip` | Windows | CLI client + wintun.dll |
| `konduit-gui-*-windows-x86_64.zip` | Windows | GUI client |
| `konduit-*-android.aab` | Android | Android app (sideload via bundletool) |
| `konduit-gui-*-windows-x86_64-portable.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 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)
@@ -62,7 +67,15 @@ chmod +x konduit konduit-ctl
echo "your secret mantra" | ./konduit-ctl bootstrap -l vpn.example.com:443 -p -
# 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)
@@ -131,11 +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.
**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
Bug reports, security vulnerabilities, and any other inquiries: use the contact form at [www.k-ops.eu](https://www.k-ops.eu).
**Bug reports:** file an issue on [Gitea Issues](https://git.k-ops.eu/konduit/konduit-public/issues).
**Vulnerabilities:** report directly to konduit@k-ops.eu.
**Contributing:** Core development is handled internally. We do not currently accept external pull requests.

View File

@@ -14,12 +14,19 @@ server_endpoint = "vpn.example.com:443"
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
./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.
## 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"
```
Or via the CLI:
Or via the `connect` subcommand, as above — `--listen-socks` takes a bare address:
```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.

View File

@@ -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

View File

@@ -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")]

View File

@@ -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;
@@ -172,7 +207,18 @@ impl RouteManager {
|| err_msg.contains("EEXIST")
|| 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 {
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.install_policy_rule().await;
self.block_ipv6_leak().await;
} else {
info!(
"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
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 +436,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(_) => {
@@ -375,6 +469,169 @@ impl RouteManager {
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
/// traffic falls through to the WiFi default route (metric 600).
/// 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`.
/// 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<()> {
info!(
"Removing {} tracked VPN routes...",
@@ -458,36 +729,94 @@ impl RouteManager {
);
// 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 {
let mut rules = self.handle.rule().get(IpVersion::V4).execute();
let mut to_delete = None;
while let Some(rule) = rules.try_next().await? {
let matches_priority = rule
.attributes
.iter()
.any(|attr| matches!(attr, netlink_packet_route::rule::RuleAttribute::Priority(p) if *p == VPN_RULE_PRIORITY));
if matches_priority {
to_delete = Some(rule);
break;
}
}
match to_delete {
Some(rule) => match self.handle.rule().del(rule).execute().await {
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),
},
None => warn!("ip rule priority {} not found, nothing to remove", VPN_RULE_PRIORITY),
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_routes.is_empty() {
return Ok(());
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();
while let Some(rule) = rules.try_next().await? {
let matches_priority = rule
.attributes
.iter()
.any(|attr| matches!(attr, netlink_packet_route::rule::RuleAttribute::Priority(p) if *p == VPN_RULE_PRIORITY));
if matches_priority {
// `rule().del()` needs the exact existing RuleMessage, which is
// why this is a lookup rather than a bare delete by priority.
return Ok(Some(rule));
}
}
Ok(None)
}
/// Finds the IPv6 `unreachable default` blackhole installed to stop IPv6
/// leaking outside the tunnel.
async fn find_ipv6_blackhole(&self) -> Result<Option<netlink_packet_route::route::RouteMessage>> {
let mut routes = self.handle.route().get(IpVersion::V6).execute();
while let Some(route) = routes.try_next().await? {
if route.header.destination_prefix_length == 0
&& route.header.kind == RouteType::Unreachable
{
return Ok(Some(route));
}
}
Ok(None)
}
/// 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 = self.handle.route().get(IpVersion::V4).execute();
@@ -531,15 +860,7 @@ impl RouteManager {
}
}
for route in 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(())
Ok(routes_to_delete)
}
}
@@ -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)]
mod tests {
use super::*;
@@ -618,4 +953,334 @@ mod tests {
}
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");
}
}

View File

@@ -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(())
}

View File

@@ -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(())