4 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
3 changed files with 641 additions and 54 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

@@ -207,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));
}
@@ -458,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.
@@ -534,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...",
@@ -541,58 +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_ipv6_blackhole {
let mut routes = self.handle.route().get(IpVersion::V6).execute();
let mut to_delete = None;
while let Some(route) = routes.try_next().await? {
if route.header.destination_prefix_length == 0
&& route.header.kind == RouteType::Unreachable
{
to_delete = Some(route);
break;
}
}
match to_delete {
Some(route) => match self.handle.route().del(route).execute().await {
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),
},
None => warn!("IPv6 blackhole route not found, nothing to remove"),
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;
}
if self.added_routes.is_empty() {
return Ok(());
// 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();
@@ -636,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)
}
}
@@ -659,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::*;
@@ -723,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");
}
}