From d2d3bc3e3c474000086d11d638feb00bb39ca195 Mon Sep 17 00:00:00 2001 From: xah30 Date: Fri, 29 May 2026 22:35:46 +0300 Subject: [PATCH] =?UTF-8?q?feat(cli):=20v3.5=20=E2=80=94=20coexist=20routi?= =?UTF-8?q?ng=20with=20foreign=20VPNs=20(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` — 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` — 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` 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 --- crates/aura-cli/src/client.rs | 34 +++ crates/aura-cli/src/coexist.rs | 386 +++++++++++++++++++++++++++++++ crates/aura-cli/src/lib.rs | 1 + crates/aura-cli/src/os_routes.rs | 25 ++ 4 files changed, 446 insertions(+) create mode 100644 crates/aura-cli/src/coexist.rs diff --git a/crates/aura-cli/src/client.rs b/crates/aura-cli/src/client.rs index 1b4ed0b..9e91fb2 100644 --- a/crates/aura-cli/src/client.rs +++ b/crates/aura-cli/src/client.rs @@ -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, diff --git a/crates/aura-cli/src/coexist.rs b/crates/aura-cli/src/coexist.rs new file mode 100644 index 0000000..8895d4a --- /dev/null +++ b/crates/aura-cli/src/coexist.rs @@ -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, +) -> Vec { + 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, +) -> Vec { + 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 { + // 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::() { + 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::().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) -> 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 { + 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 = 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"))); + } +} diff --git a/crates/aura-cli/src/lib.rs b/crates/aura-cli/src/lib.rs index bccacf9..5ad2db7 100644 --- a/crates/aura-cli/src/lib.rs +++ b/crates/aura-cli/src/lib.rs @@ -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; diff --git a/crates/aura-cli/src/os_routes.rs b/crates/aura-cli/src/os_routes.rs index 2a27801..a983883 100644 --- a/crates/aura-cli/src/os_routes.rs +++ b/crates/aura-cli/src/os_routes.rs @@ -96,6 +96,13 @@ pub struct SplitRoutes { pub direct_hosts: Vec, /// Resolved host IPs that must go through the VPN. Programmed as `/32` or `/128`. pub vpn_hosts: Vec, + /// 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, } 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