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:
@@ -389,6 +389,40 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> {
|
|||||||
split.direct_hosts.push(ip);
|
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(
|
let guard = OsRouteGuard::install(
|
||||||
&actual_tun_name,
|
&actual_tun_name,
|
||||||
|
|||||||
@@ -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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ pub mod bridges;
|
|||||||
pub mod cells;
|
pub mod cells;
|
||||||
pub mod circuit;
|
pub mod circuit;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
|
pub mod coexist;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod crl_push;
|
pub mod crl_push;
|
||||||
pub mod dial_targets;
|
pub mod dial_targets;
|
||||||
|
|||||||
@@ -96,6 +96,13 @@ pub struct SplitRoutes {
|
|||||||
pub direct_hosts: Vec<IpAddr>,
|
pub direct_hosts: Vec<IpAddr>,
|
||||||
/// Resolved host IPs that must go through the VPN. Programmed as `/32` or `/128`.
|
/// Resolved host IPs that must go through the VPN. Programmed as `/32` or `/128`.
|
||||||
pub vpn_hosts: Vec<IpAddr>,
|
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 {
|
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`
|
// 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
|
// 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
|
// new entry never wins routing decisions). WireGuard / OpenVPN / Tailscale all work
|
||||||
|
|||||||
Reference in New Issue
Block a user