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>
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
//! 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());
|
||||
}
|
||||
Reference in New Issue
Block a user