feat(cli): v3.5 — coexist routing with foreign VPNs (#2)

Closes the long-standing "Aura killed the internet while Clash Verge is
running" symptom. The cause is unsurprising once you stare at the routing
table: even after the user turns Tun mode off in Clash's GUI, the
clash-verge-service daemon does NOT remove the split-tunnel routes it
installed. They linger as `1/8`, `2/7`, `4/6`, `8/5`, `16/4`, `32/3`,
`64/2`, `128.0/1` → `198.18.0.1` (Clash's dead TUN). Aura's half-Internet
routes (`0.0.0.0/1` + `128.0.0.0/1`) lose by longest-prefix-match to those
foreign /8 / /7 / ... entries — so DNS goes to a non-functional foreign
interface and the user-visible internet looks dead.

## New module: aura-cli/src/coexist.rs

`scan_foreign_routes_macos(our_iface, pool_cidr) -> Vec<ForeignRoute>` —
shells out to `netstat -rn -f inet`, parses the output (incl. macOS's
classful shorthand: `1` = `1.0.0.0/8`, `169.254` = `169.254.0.0/16`, etc),
filters out: ourselves, loopback (`lo*`), link-local, LAN interfaces
(`en*` / `eth*` / `wlan*`), reserved ranges (127/8, 169.254/16, 224/4),
and the VPN's own pool. What's left is foreign-VPN territory.

`generate_override_cidrs(foreign, max_prefix=24) -> Vec<IpNetwork>` —
for each foreign /n, emits two strictly-more-specific /(n+1) routes that
together cover exactly the same range but point at Aura's TUN. By
longest-prefix-match the kernel routes that traffic through Aura;
foreign routes stay in the table untouched (which makes rollback trivial:
OsRouteGuard's Drop only undoes what Aura installed).

Routes /24 or narrower are skipped — those typically are LAN segments
operators don't want hijacked.

## Wired through SplitRoutes

`SplitRoutes` gains a `force_vpn_cidrs: Vec<IpNetwork>` field for the
override list. `macos_apply_plan`'s `DefaultAction::Vpn` arm now installs
them between the direct-host bypasses (most specific — server IP) and the
half-Internet catch-alls (least specific). Plan ordering becomes:

  [0..N]      direct CIDR / direct host bypasses (server IP, user-direct CIDRs)
  [N..N+2K]   override routes (2 per foreign /n the scan found)
  [N+2K..]    0.0.0.0/1 + 128.0.0.0/1 catch-alls

## Wired through client.rs

After the existing bypass-injection block, when `default == VPN` and we're
on macOS, scan foreign routes and append the generated overrides to
`split.force_vpn_cidrs`. Logged at INFO level so the operator can see in
the journal exactly which foreign VPN was detected and how many overrides
were emitted.

## Tests

9 new unit tests in `coexist::tests`: macOS shorthand parsing (`1` /
`2/7` / `192.168.1`), bare IP host routes, garbage rejection, full-table
netstat-output parsing against a real captured sample (the user's
machine's actual routing table with Clash Verge running), half-splitting,
classful Clash pattern coverage, the /24 skip rule, and the doubling
property of generate_override_cidrs.

All workspace tests still pass; `cargo clippy --workspace --all-targets
-- -D warnings` is clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
xah30
2026-05-29 22:35:46 +03:00
parent b904d40fba
commit d2d3bc3e3c
4 changed files with 446 additions and 0 deletions
+34
View File
@@ -389,6 +389,40 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> {
split.direct_hosts.push(ip);
}
}
// v3.5 coexist routing — scan the host's routing table for OTHER VPN interfaces'
// CIDR claims (Clash Verge / OpenVPN / WireGuard split-tunnels), and generate
// strictly-more-specific override routes for each so we beat them by longest-prefix
// match. Without this, Aura's `0/1` + `128/1` half-Internet routes lose to anything
// a foreign VPN installed at /8 / /7 / /6 / ... → DNS goes to the dead foreign TUN
// → "Aura killed the internet" symptom. Macos-only for now; Linux's metric-based
// routing handles overrides differently and is not yet wired here.
#[cfg(target_os = "macos")]
{
// Derive the VPN pool CIDR from the client's own assigned address + prefix —
// the client config doesn't carry `pool_cidr` (server.toml does), but the
// network mask + the local IP let us reconstruct it for the "don't override
// our own pool" check.
let pool_cidr = ipnetwork::IpNetwork::new(local_ip, cfg.tunnel.prefix).ok();
let foreign = crate::coexist::scan_foreign_routes_macos(
&actual_tun_name,
pool_cidr,
);
if foreign.is_empty() {
tracing::info!(
"v3.5 coexist scan: no foreign VPN routes detected; using plain half-Internet routes"
);
} else {
let overrides = crate::coexist::generate_override_cidrs(&foreign, 24);
tracing::info!(
foreign_count = foreign.len(),
override_count = overrides.len(),
sample_foreign = ?foreign.first().map(|f| &f.cidr),
"v3.5 coexist scan: detected foreign VPN routes; installing more-specific overrides via Aura's TUN"
);
split.force_vpn_cidrs.extend(overrides);
}
}
}
let guard = OsRouteGuard::install(
&actual_tun_name,
+386
View File
@@ -0,0 +1,386 @@
//! v3.5: coexist with other VPNs already-installed on the host.
//!
//! Use case: the user has Clash Verge / OpenVPN / WireGuard already running as their main
//! VPN. They want to turn on AuraVPN *alongside* the other one — without having to
//! shut down the other VPN, without playing chicken with the system default route, and
//! without the result being "the slowest VPN wins". The user-stated goal is:
//!
//! > турну тун режим в клеш, но сам клеш остаётся жив и резервирует себе всё, что было занято
//! > до выключения; при включении ауры она найдёт всё, что свободно и будет работать по ним,
//! > и у нас так же должно писаться везде, что мы якобы из германии
//!
//! In practice this means: even when Clash's TUN process is disabled in its GUI, Clash's
//! split-tunnel routes (`1/8`, `2/7`, `4/6`, ...) stay in the kernel routing table because the
//! daemon never explicitly removes them. AuraVPN's half-Internet routes (`0.0.0.0/1` and
//! `128.0.0.0/1`) lose by longest-prefix-match to those /8 / /7 / /6 / ... entries, so Aura
//! captures only the holes — and most pop IPs (1.1.1.1, 8.8.8.8, etc) end up routed to a dead
//! Clash TUN. The end result, before this fix, was the user reporting "Aura killed the
//! internet" while Aura's data plane was actually completely healthy and idle.
//!
//! ## Strategy: override foreign routes with strictly-more-specific ones
//!
//! For each "foreign" route — a non-loopback non-LAN route pointing at an interface that is
//! NOT ours — we install **two routes at prefix+1** covering exactly the same address range,
//! but pointing at Aura's TUN. Longest-prefix-match guarantees those /(n+1) routes win against
//! the foreign /n; the foreign routes stay in the table, untouched (so Drop is simple: we
//! only remove what we installed). When the user disconnects Aura, Clash's split routes are
//! still where they were, and the user goes back to their original setup.
//!
//! The overrides are only meaningful when the user is running in `default = "VPN"` mode — they
//! exist to push traffic that Clash is reserving back into Aura. In `default = "DIRECT"` mode
//! the user explicitly opted out of full-VPN takeover and we leave foreign routes alone.
use std::net::{IpAddr, Ipv4Addr};
use std::process::Command;
use std::str::FromStr;
use ipnetwork::{IpNetwork, Ipv4Network};
/// One foreign route discovered in the host's routing table.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ForeignRoute {
/// CIDR the foreign route claims.
pub cidr: IpNetwork,
/// Interface the route points at.
pub iface: String,
}
/// Scan `netstat -rn -f inet` and return every route that:
///
/// * is not the system default,
/// * is not on `our_iface` (so we don't override ourselves),
/// * does not target loopback (`lo*`), link-local (`link#*`), or a physical LAN interface
/// (`en*`, `eth*`, `wlan*`, etc — we keep a small allow-list because operators may have
/// exotic interface names),
/// * does not target a reserved range (loopback `127/8`, link-local `169.254/16`, the LAN
/// itself per the `lan_cidr` hint, or our own VPN pool per `pool_cidr`).
///
/// Empty result on a host with no other VPN — that's the normal case and we just install the
/// half-Internet routes verbatim.
pub fn scan_foreign_routes_macos(
our_iface: &str,
pool_cidr: Option<IpNetwork>,
) -> Vec<ForeignRoute> {
let out = match Command::new("netstat").args(["-rn", "-f", "inet"]).output() {
Ok(o) if o.status.success() => o,
_ => return Vec::new(),
};
let text = String::from_utf8_lossy(&out.stdout);
parse_macos_routes(&text, our_iface, pool_cidr)
}
/// Parse the output of `netstat -rn -f inet` — extracted as a pure function for unit testing.
pub fn parse_macos_routes(
text: &str,
our_iface: &str,
pool_cidr: Option<IpNetwork>,
) -> Vec<ForeignRoute> {
let mut out = Vec::new();
let mut in_inet_section = false;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with("Internet:") {
in_inet_section = true;
continue;
}
if trimmed.starts_with("Internet6:") {
in_inet_section = false; // we only care about IPv4 for now
continue;
}
if !in_inet_section {
continue;
}
if trimmed.starts_with("Destination") || trimmed.is_empty() {
continue;
}
let cols: Vec<&str> = trimmed.split_whitespace().collect();
if cols.len() < 4 {
continue;
}
let dest = cols[0];
let netif = cols[3];
if dest == "default" {
continue;
}
if netif == our_iface {
continue;
}
if is_local_iface(netif) {
continue;
}
let cidr = match parse_macos_dest(dest) {
Some(c) => c,
None => continue,
};
if is_reserved_range(&cidr, pool_cidr) {
continue;
}
out.push(ForeignRoute {
cidr,
iface: netif.to_string(),
});
}
out
}
/// Translate macOS netstat's classful shorthand into a proper [`IpNetwork`].
///
/// macOS prints classful destinations with trailing zeros and the prefix elided when it
/// matches the class boundary:
///
/// * `"1"` → `1.0.0.0/8`
/// * `"127"` → `127.0.0.0/8`
/// * `"169.254"` → `169.254.0.0/16`
/// * `"192.168.1"` → `192.168.1.0/24`
///
/// Explicit CIDRs (`"2/7"`, `"128.0/1"`, `"192.168.1.64/32"`) and bare IPs (`"192.168.1.254"` —
/// a host route) parse via the standard `IpNetwork::from_str`.
pub fn parse_macos_dest(s: &str) -> Option<IpNetwork> {
// Explicit CIDR.
if s.contains('/') {
// macOS shortens the network part too — e.g. `2/7` = `2.0.0.0/7`. Expand any partial
// dotted prefix before parsing.
let (net_part, prefix_part) = s.split_once('/')?;
let dots = net_part.matches('.').count();
let expanded: String = match dots {
0 => format!("{net_part}.0.0.0"),
1 => format!("{net_part}.0.0"),
2 => format!("{net_part}.0"),
_ => net_part.to_string(),
};
let full = format!("{expanded}/{prefix_part}");
return IpNetwork::from_str(&full).ok();
}
// Bare IPv4 address — could be host route OR classful shorthand.
if let Ok(ip) = s.parse::<IpAddr>() {
return IpNetwork::new(ip, if ip.is_ipv4() { 32 } else { 128 }).ok();
}
// Partial dotted — classful shorthand: count dots to pick the prefix.
let dots = s.matches('.').count();
let (expanded, prefix) = match dots {
0 => (format!("{s}.0.0.0"), 8u8),
1 => (format!("{s}.0.0"), 16u8),
2 => (format!("{s}.0"), 24u8),
_ => return None,
};
let ip = expanded.parse::<Ipv4Addr>().ok()?;
IpNetwork::new(IpAddr::V4(ip), prefix).ok()
}
fn is_local_iface(name: &str) -> bool {
name == "lo0"
|| name.starts_with("link#")
|| name.starts_with("en")
|| name.starts_with("eth")
|| name.starts_with("wlan")
|| name.starts_with("bridge")
|| name == "anpi0"
}
fn is_reserved_range(cidr: &IpNetwork, pool_cidr: Option<IpNetwork>) -> bool {
// 127/8 loopback, 169.254/16 link-local, 224/4 multicast, 255.255.255.255/32 broadcast.
let reserved = [
IpNetwork::from_str("127.0.0.0/8").unwrap(),
IpNetwork::from_str("169.254.0.0/16").unwrap(),
IpNetwork::from_str("224.0.0.0/4").unwrap(),
IpNetwork::from_str("255.255.255.255/32").unwrap(),
];
for r in &reserved {
if r.contains(cidr.network()) {
return true;
}
}
if let Some(p) = pool_cidr {
if p.contains(cidr.network()) {
return true;
}
}
false
}
/// Generate strictly-more-specific override CIDRs for each foreign route.
///
/// For a foreign `/n` we emit two `/(n+1)` routes that together cover exactly the same range.
/// Skip foreign routes with prefix `>= max_prefix` — at that level they are already so specific
/// that subdividing produces tiny routes the kernel doesn't appreciate. The `max_prefix` default
/// is 24: anything narrower than a /24 stays alone (typical /24 LAN segments shouldn't be
/// hijacked even if a foreign VPN claims them — that would break local connectivity).
///
/// Empty input → empty output. Skips IPv6 routes (we don't currently handle them; the codepath
/// is here so a future v3.6 can extend it without restructuring).
pub fn generate_override_cidrs(foreign: &[ForeignRoute], max_prefix: u8) -> Vec<IpNetwork> {
let mut out = Vec::new();
for f in foreign {
let p = f.cidr.prefix();
if p >= max_prefix {
continue;
}
let v4 = match f.cidr {
IpNetwork::V4(v4) => v4,
IpNetwork::V6(_) => continue,
};
if let Some((a, b)) = split_v4_in_half(v4) {
out.push(IpNetwork::V4(a));
out.push(IpNetwork::V4(b));
}
}
out
}
/// Split a `/n` IPv4 network into its two `/(n+1)` halves. Returns `None` if `n >= 32` (no
/// room to subdivide).
fn split_v4_in_half(net: Ipv4Network) -> Option<(Ipv4Network, Ipv4Network)> {
let n = net.prefix();
if n >= 32 {
return None;
}
let new_prefix = n + 1;
let base = u32::from(net.network());
let half_size = 1u32 << (32 - new_prefix);
let lo = Ipv4Addr::from(base);
let hi = Ipv4Addr::from(base.wrapping_add(half_size));
let a = Ipv4Network::new(lo, new_prefix).ok()?;
let b = Ipv4Network::new(hi, new_prefix).ok()?;
Some((a, b))
}
#[cfg(test)]
mod tests {
use super::*;
fn n(s: &str) -> IpNetwork {
IpNetwork::from_str(s).expect("net")
}
#[test]
fn parses_macos_classful_shorthand() {
assert_eq!(parse_macos_dest("1"), Some(n("1.0.0.0/8")));
assert_eq!(parse_macos_dest("127"), Some(n("127.0.0.0/8")));
assert_eq!(parse_macos_dest("169.254"), Some(n("169.254.0.0/16")));
assert_eq!(parse_macos_dest("192.168.1"), Some(n("192.168.1.0/24")));
}
#[test]
fn parses_macos_explicit_cidr_shorthand() {
// `2/7` = 2.0.0.0/7 (the network half is also classful-shortened).
assert_eq!(parse_macos_dest("2/7"), Some(n("2.0.0.0/7")));
assert_eq!(parse_macos_dest("4/6"), Some(n("4.0.0.0/6")));
assert_eq!(parse_macos_dest("128.0/1"), Some(n("128.0.0.0/1")));
assert_eq!(parse_macos_dest("10.7/24"), Some(n("10.7.0.0/24")));
assert_eq!(parse_macos_dest("192.168.1.64/32"), Some(n("192.168.1.64/32")));
}
#[test]
fn parses_bare_ip_as_host_route() {
assert_eq!(parse_macos_dest("192.168.1.254"), Some(n("192.168.1.254/32")));
}
#[test]
fn ignores_garbage_destination() {
assert_eq!(parse_macos_dest("link#14"), None);
assert_eq!(parse_macos_dest(""), None);
}
/// The sample netstat output the v3.5 design was based on. Confirms we extract exactly
/// the routes belonging to Clash Verge's `utun4` and nothing else.
#[test]
fn scans_foreign_routes_from_real_netstat_sample() {
let sample = r#"
Routing tables
Internet:
Destination Gateway Flags Netif Expire
default 192.168.1.254 UGScg en0
1 198.18.0.1 UGSc utun4
2/7 198.18.0.1 UGSc utun4
4/6 198.18.0.1 UGSc utun4
8/5 198.18.0.1 UGSc utun4
10.7/24 utun5 USc utun5
16/4 198.18.0.1 UGSc utun4
32/3 198.18.0.1 UGSc utun4
64/2 198.18.0.1 UGSc utun4
127 127.0.0.1 UCS lo0
127.0.0.1 127.0.0.1 UH lo0
128.0/1 198.18.0.1 UGSc utun4
169.254 link#14 UCS en0 !
192.168.1 link#14 UCS en0 !
192.168.1.64/32 link#14 UCS en0 !
198.18.0.1 198.18.0.1 UH utun4
Internet6:
Destination Gateway Flags Netif Expire
ignored ignored ignored utun4
"#;
let foreign = parse_macos_routes(sample, "utun5", Some(n("10.7.0.0/24")));
// We should pick up: 1/8, 2/7, 4/6, 8/5, 16/4, 32/3, 64/2, 128.0/1, 198.18.0.1/32 — but
// NOT 10.7/24 (our pool), NOT 127* (loopback), NOT 169.254 (link-local), NOT 192.168.*
// (LAN), NOT default, NOT Internet6 entries.
let cidrs: Vec<IpNetwork> = foreign.iter().map(|f| f.cidr).collect();
assert!(cidrs.contains(&n("1.0.0.0/8")), "missing 1/8: {cidrs:?}");
assert!(cidrs.contains(&n("2.0.0.0/7")), "missing 2/7");
assert!(cidrs.contains(&n("4.0.0.0/6")), "missing 4/6");
assert!(cidrs.contains(&n("8.0.0.0/5")), "missing 8/5");
assert!(cidrs.contains(&n("16.0.0.0/4")), "missing 16/4");
assert!(cidrs.contains(&n("32.0.0.0/3")), "missing 32/3");
assert!(cidrs.contains(&n("64.0.0.0/2")), "missing 64/2");
assert!(cidrs.contains(&n("128.0.0.0/1")), "missing 128/1");
assert!(cidrs.contains(&n("198.18.0.1/32")), "missing 198.18.0.1 host");
assert!(!cidrs.contains(&n("10.7.0.0/24")), "must skip our own pool");
assert!(!cidrs.contains(&n("127.0.0.0/8")), "must skip loopback");
assert!(!cidrs.contains(&n("169.254.0.0/16")), "must skip link-local");
assert!(
!cidrs.iter().any(|c| n("192.168.0.0/16").contains(c.network())),
"must skip LAN"
);
}
#[test]
fn split_half_doubles_specificity() {
let (a, b) = split_v4_in_half(Ipv4Network::from_str("0.0.0.0/1").unwrap()).unwrap();
assert_eq!(IpNetwork::V4(a), n("0.0.0.0/2"));
assert_eq!(IpNetwork::V4(b), n("64.0.0.0/2"));
}
#[test]
fn split_half_for_classful_clash_ranges() {
// 1.0.0.0/8 → 1.0.0.0/9 + 1.128.0.0/9
let (a, b) = split_v4_in_half(Ipv4Network::from_str("1.0.0.0/8").unwrap()).unwrap();
assert_eq!(IpNetwork::V4(a), n("1.0.0.0/9"));
assert_eq!(IpNetwork::V4(b), n("1.128.0.0/9"));
// 2.0.0.0/7 → 2.0.0.0/8 + 3.0.0.0/8
let (a, b) = split_v4_in_half(Ipv4Network::from_str("2.0.0.0/7").unwrap()).unwrap();
assert_eq!(IpNetwork::V4(a), n("2.0.0.0/8"));
assert_eq!(IpNetwork::V4(b), n("3.0.0.0/8"));
}
#[test]
fn generate_override_cidrs_skips_too_specific() {
let foreign = vec![ForeignRoute {
cidr: n("192.168.1.0/24"),
iface: "utun4".into(),
}];
let out = generate_override_cidrs(&foreign, 24);
assert!(out.is_empty(), "must skip /24+ to avoid hijacking LAN");
}
#[test]
fn generate_override_cidrs_doubles_each_foreign() {
// Real Clash pattern.
let foreign = vec![
ForeignRoute { cidr: n("1.0.0.0/8"), iface: "utun4".into() },
ForeignRoute { cidr: n("2.0.0.0/7"), iface: "utun4".into() },
ForeignRoute { cidr: n("128.0.0.0/1"), iface: "utun4".into() },
];
let out = generate_override_cidrs(&foreign, 24);
// Each input → two outputs.
assert_eq!(out.len(), 6);
assert!(out.contains(&n("1.0.0.0/9")));
assert!(out.contains(&n("1.128.0.0/9")));
assert!(out.contains(&n("2.0.0.0/8")));
assert!(out.contains(&n("3.0.0.0/8")));
assert!(out.contains(&n("128.0.0.0/2")));
assert!(out.contains(&n("192.0.0.0/2")));
}
}
+1
View File
@@ -18,6 +18,7 @@ pub mod bridges;
pub mod cells;
pub mod circuit;
pub mod client;
pub mod coexist;
pub mod config;
pub mod crl_push;
pub mod dial_targets;
+25
View File
@@ -96,6 +96,13 @@ pub struct SplitRoutes {
pub direct_hosts: Vec<IpAddr>,
/// Resolved host IPs that must go through the VPN. Programmed as `/32` or `/128`.
pub vpn_hosts: Vec<IpAddr>,
/// v3.5: extra CIDRs to route through the VPN's TUN **even in `Vpn` mode** — used by the
/// [`crate::coexist`] override path. These are strictly-more-specific overrides of foreign
/// VPN routes (Clash Verge, OpenVPN, etc) the client.rs install path detected at startup;
/// they get installed after the bypasses but before the half-Internet catch-alls so that
/// the kernel's longest-prefix-match picks them over the foreign /n routes. Empty in
/// `Direct` mode (no half-Internet routes are installed there).
pub force_vpn_cidrs: Vec<IpNetwork>,
}
impl Default for DefaultAction {
@@ -811,6 +818,24 @@ fn macos_apply_plan(tun_name: &str, routes: &SplitRoutes, gateway: IpAddr) -> Ve
],
));
}
// v3.5 coexist overrides — install strictly-more-specific routes that beat foreign
// VPN entries (Clash Verge's `1/8`, `2/7`, ...) by longest-prefix-match. These come
// BEFORE the half-Internet catch-alls so the kernel sees them as more specific than
// foreign /n routes AND more specific than our /1s; for each input foreign /n the
// upstream coexist module generated two /(n+1) overrides. See
// [`crate::coexist::generate_override_cidrs`].
for cidr in &routes.force_vpn_cidrs {
plan.push(PlannedCommand::new(
"route",
vec![
"add".into(),
"-net".into(),
cidr.to_string(),
"-interface".into(),
tun_name.into(),
],
));
}
// THEN the half-Internet routes. macOS `route add -net 0.0.0.0/0 -interface utunN`
// does NOT override the kernel's existing default route (it accepts the add but the
// new entry never wins routing decisions). WireGuard / OpenVPN / Tailscale all work