Files
AuraVPN/crates/aura-cli/tests/os_routes.rs
T
xah30 65b26b555d feat(cli): OS-level split-tunnel routes (removes send_direct stub)
DIRECT-destination traffic now bypasses the TUN entirely via OS routing
table edits, instead of going through user-space and hitting the v1
send_direct stub. The user-space router only sees VPN-bound packets,
making the split-tunnel real.

- aura_cli::os_routes::OsRouteGuard: RAII install + rollback of OS routes.
  Linux: `ip route show default` parser -> DIRECT CIDRs via original gw,
  VPN default via TUN with metric 50. macOS: `route -n get default`
  parser -> `route add -net/-host ... <gw>` for DIRECT, `route add -net
  ... -interface <tun>` for VPN. Windows: stub + warning (v3).
- dry_run works on every platform (logs `would run: ...`); useful for
  tests and operator confidence-checks.
- SplitRoutes::from_config folds [[tunnel.split.direct]]/[[...vpn]] +
  resolved domains (via AuraDns) into one declarative plan.
- New [tunnel.os_routes] {enabled (default true), dry_run, gateway,
  egress_iface}; absent section = old user-space behavior (back-compat).
- client::run installs routes after AuraTun::create, before privdrop;
  guard's Drop reverts everything on shutdown.
- aura-tunnel::router unchanged; AuraRouter::send_direct kept as a
  defensive fallback (in v2 it should never fire — OS routes prevent
  DIRECT packets from reaching the TUN at all).

20 new tests (linux/macos parser unit tests, install dry-run, config
back-compat). Workspace: 174 tests passed (+19), clippy -D warnings
clean, fmt clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:20:30 +03:00

153 lines
4.6 KiB
Rust

//! Integration tests for the OS-level split-tunnel helper (`aura_cli::os_routes::OsRouteGuard`).
//!
//! These tests only exercise the dry-run path: real `ip` / `route` programming needs root and a
//! live network stack, which is inappropriate for the unit test runner. The dry-run path is
//! platform-portable: it logs `would run: ...` for both the Linux and macOS plans (plus the
//! Windows-stub notice) and never touches the host.
use std::net::IpAddr;
use aura_cli::config::{ClientConfigFile, OsRoutesSection, SplitRule, SplitSection};
use aura_cli::os_routes::{DefaultAction, OsRouteGuard, SplitRoutes};
/// Dry-run install must succeed on every host (Linux, macOS, Windows) regardless of which
/// gateway / egress hints are provided. Drop must not panic.
#[test]
fn dry_run_install_succeeds_on_any_platform() {
let split = SplitRoutes {
default: DefaultAction::Vpn,
direct_cidrs: vec!["192.168.0.0/16".parse().unwrap()],
vpn_cidrs: Vec::new(),
direct_hosts: vec!["1.2.3.4".parse().unwrap()],
vpn_hosts: Vec::new(),
};
let guard = OsRouteGuard::install("aura0", &split, None, None, true)
.expect("dry_run install must succeed everywhere");
drop(guard);
}
/// Dry-run also accepts explicit gateway / egress overrides — they are rendered into the
/// `would run: ...` lines without needing to invoke the host's `ip`/`route` binary.
#[test]
fn dry_run_install_accepts_explicit_overrides() {
let split = SplitRoutes {
default: DefaultAction::Direct,
vpn_cidrs: vec!["10.7.0.0/24".parse().unwrap()],
..Default::default()
};
let guard = OsRouteGuard::install(
"utun4",
&split,
Some("10.0.0.1"),
Some("en0"),
/* dry_run */ true,
)
.expect("dry_run install with explicit gateway/egress must succeed");
drop(guard);
}
/// `SplitRoutes::from_config` collects CIDRs from both branches and any resolved domain hosts.
#[test]
fn split_routes_from_config_collects_everything() {
let split = SplitSection {
default: "VPN".into(),
direct: vec![SplitRule {
cidr: Some("192.168.0.0/16".into()),
domain: None,
}],
vpn: vec![SplitRule {
cidr: Some("10.7.0.0/24".into()),
domain: None,
}],
};
let resolved: Vec<(String, aura_tunnel::RouteAction, Vec<IpAddr>)> = vec![(
"intranet.example.com".into(),
aura_tunnel::RouteAction::Direct,
vec!["1.2.3.4".parse().unwrap()],
)];
let r = SplitRoutes::from_config(&split, &resolved);
assert_eq!(r.default, DefaultAction::Vpn);
assert_eq!(r.direct_cidrs.len(), 1);
assert_eq!(r.vpn_cidrs.len(), 1);
assert_eq!(r.direct_hosts.len(), 1);
assert!(r.vpn_hosts.is_empty());
}
/// A `client.toml` without a `[tunnel.os_routes]` section still parses; the field is `None`.
/// This is the explicit back-compat check — old configs do not need to know about the new
/// section.
#[test]
fn client_toml_without_os_routes_section_parses() {
let minimal = r#"
[client]
name = "x"
server_addr = "1.2.3.4:443"
sni = "a"
[pki]
ca_cert = "a"
cert = "b"
key = "c"
[tunnel]
local_ip = "10.7.0.2"
[tunnel.split]
default = "VPN"
"#;
let cfg = ClientConfigFile::parse(minimal).expect("parses minimal client.toml");
assert!(
cfg.tunnel.os_routes.is_none(),
"without the section, os_routes is None — runtime falls back to enabled = true default"
);
}
/// `[tunnel.os_routes]` with `enabled = true, dry_run = true` parses end-to-end and exposes the
/// flags to the client startup path.
#[test]
fn client_toml_parses_os_routes_section() {
let s = r#"
[client]
name = "x"
server_addr = "1.2.3.4:443"
sni = "a"
[pki]
ca_cert = "a"
cert = "b"
key = "c"
[tunnel]
local_ip = "10.7.0.2"
[tunnel.os_routes]
enabled = true
dry_run = true
gateway = "192.168.1.1"
egress_iface = "en0"
[tunnel.split]
default = "VPN"
"#;
let cfg = ClientConfigFile::parse(s).expect("parses client.toml with [tunnel.os_routes]");
let os = cfg.tunnel.os_routes.expect("section present");
assert!(os.enabled);
assert!(os.dry_run);
assert_eq!(os.gateway.as_deref(), Some("192.168.1.1"));
assert_eq!(os.egress_iface.as_deref(), Some("en0"));
}
/// `OsRoutesSection::default()` matches the documented v2 semantics: enabled by default with
/// no dry_run and no explicit gateway/egress (auto-detected at runtime).
#[test]
fn os_routes_section_default_values() {
let d = OsRoutesSection::default();
assert!(
d.enabled,
"default is enabled = true (v2 eliminates the v1 stub)"
);
assert!(!d.dry_run);
assert!(d.gateway.is_none());
assert!(d.egress_iface.is_none());
}