//! OS-level split-tunnel routing for the client (project §8.4, v2). //! //! ## Why this module exists //! //! v1 implemented split-tunnelling entirely in user-space: the TUN intercepted *all* traffic, and //! the [`AuraRouter`](aura_tunnel::AuraRouter) classified each outbound packet and either sent it //! through the encrypted [`PacketConnection`](aura_proto::PacketConnection) or handed it to a //! `send_direct` *stub* that simply logged + dropped the packet (a real raw-socket egress was //! deferred). That meant DIRECT-classified packets effectively went nowhere. //! //! v2 fixes this by programming the **system routing table** so DIRECT destinations never reach //! the TUN in the first place — they continue to use the host's original default gateway. The //! TUN only receives traffic that is *supposed* to be tunnelled, and the user-space classifier //! degenerates into a fallback (it should not see DIRECT packets at all). //! //! ## Semantics //! //! On [`OsRouteGuard::install`]: //! * Snapshot the host's current default gateway / egress interface (or read them from config). //! * Apply the [`SplitRoutes`] plan: //! * `default = Vpn`: install a default route via the TUN (`0.0.0.0/0 -> tun`). For every //! DIRECT CIDR / host, install a more-specific route via the original default gateway so it //! bypasses the TUN. //! * `default = Direct`: leave the OS default route untouched. For every VPN CIDR / host, //! install a route via the TUN. //! //! On `Drop`: undo each installed route in reverse order. Failures are logged at WARN; the //! rollback continues so a single bad route does not strand the rest. //! //! ## Supported platforms //! //! * **Linux**: `ip route add ... via dev ` for DIRECT bypasses and //! `ip route add ... dev ` for VPN routes. The VPN default route gets a low metric //! ([`VPN_DEFAULT_METRIC`]) so it wins over the host's pre-existing default. //! * **macOS**: `route add -net|-host ... ` for DIRECT bypasses and //! `route add -net|-host ... -interface ` for VPN routes. //! * **Windows**: stub — logs a warning and returns an empty guard. Full implementation is v3. //! //! ## dry_run //! //! `dry_run = true` logs every apply / rollback step as `would run: ...` and never executes //! anything. It works on every platform (including Windows) and is what the unit tests rely on. use std::net::IpAddr; use std::process::Command; use std::str::FromStr; use anyhow::{anyhow, Result}; use ipnetwork::IpNetwork; use crate::config::{ClientTunnelSection, SplitSection}; /// Routing metric assigned to the VPN default route on Linux. Lower-is-better; this is below the /// usual DHCP-installed default (metric 100..600) so the VPN default wins. Made `pub` so tests can /// reference it and operators can grep for it in their logs. pub const VPN_DEFAULT_METRIC: u32 = 50; // ---- Public API ----------------------------------------------------------------------------- /// The default action when no split-tunnel rule matches a destination. /// /// Mirrors [`aura_tunnel::RouteAction`] but for the *whole-table* default (the OS programming /// step needs to know whether to set up a default-via-TUN or leave the host default alone). #[derive(Copy, Clone, PartialEq, Eq, Debug)] pub enum DefaultAction { /// Everything that does not match a DIRECT rule is tunnelled. Install a default-via-TUN /// route plus a more-specific bypass route per DIRECT rule. Vpn, /// Everything that does not match a VPN rule egresses directly. Leave the OS default route /// alone and install a per-rule route-via-TUN for each VPN entry. Direct, } /// A fully resolved split-tunnel plan ready to be programmed into the OS routing table. /// /// The CLI builds this from `[tunnel.split]` (the CIDR rules) plus any domain rules that have /// already been resolved via [`aura_tunnel::AuraDns`] (the resolved host addresses appear in /// `*_hosts`). The two `_hosts` lists become `/32` (IPv4) or `/128` (IPv6) host routes. #[derive(Clone, Debug, Default)] pub struct SplitRoutes { /// Default action: `Vpn` (catch-all through the tunnel) or `Direct` (catch-all bypasses). pub default: DefaultAction, /// CIDRs that must egress directly (only meaningful when `default = Vpn`). pub direct_cidrs: Vec, /// CIDRs that must go through the VPN (only meaningful when `default = Direct`). pub vpn_cidrs: Vec, /// Resolved host IPs that must egress directly. Programmed as `/32` or `/128`. pub direct_hosts: Vec, /// Resolved host IPs that must go through the VPN. Programmed as `/32` or `/128`. pub vpn_hosts: Vec, } impl Default for DefaultAction { fn default() -> Self { Self::Vpn } } impl SplitRoutes { /// Build a [`SplitRoutes`] from the parsed config plus any resolved domain hosts. /// /// `resolved_domains` is a slice of `(domain, action, ips)` triples — the CLI populates `ips` /// from [`aura_tunnel::AuraDns::resolve_and_register`]. Invalid CIDR strings are silently /// skipped here (the config layer already validates them in /// [`crate::config::ClientConfigFile::build_route_table`]). pub fn from_config( split: &SplitSection, resolved_domains: &[(String, aura_tunnel::RouteAction, Vec)], ) -> Self { let default = match split.default.trim().to_ascii_lowercase().as_str() { "direct" => DefaultAction::Direct, _ => DefaultAction::Vpn, }; let mut out = Self { default, ..Self::default() }; for rule in &split.direct { if let Some(cidr) = rule.cidr.as_deref() { if let Ok(net) = IpNetwork::from_str(cidr) { out.direct_cidrs.push(net); } } } for rule in &split.vpn { if let Some(cidr) = rule.cidr.as_deref() { if let Ok(net) = IpNetwork::from_str(cidr) { out.vpn_cidrs.push(net); } } } for (_, action, ips) in resolved_domains { for ip in ips { match action { aura_tunnel::RouteAction::Direct => out.direct_hosts.push(*ip), aura_tunnel::RouteAction::Vpn => out.vpn_hosts.push(*ip), } } } out } /// Convenience for tests / call sites that have no resolved domains yet. pub fn from_tunnel_section(tunnel: &ClientTunnelSection) -> Self { Self::from_config(&tunnel.split, &[]) } } /// RAII handle that has programmed the OS routing table for the lifetime of the VPN session. /// /// Dropping the guard rolls every change back in reverse order. The guard intentionally has no /// public fields — the rollback is encoded as a vector of "undo" commands recorded at apply time. pub struct OsRouteGuard { /// Stack of "undo" commands, applied in REVERSE order at drop time. rollback: Vec, /// dry_run state — when true, neither apply nor Drop runs commands, they only log. dry_run: bool, } impl OsRouteGuard { /// Program the OS routing table from `routes` and return the RAII guard. /// /// * `tun_name`: the name of the freshly created TUN device (e.g. `"aura0"` on Linux, /// `"utun4"` on macOS — see [`aura_tunnel::AuraTun::name`]). /// * `routes`: the resolved split-tunnel plan. /// * `explicit_gw`: optional override for the host's default gateway (IPv4 in v2). When /// `None`, the gateway is auto-detected per platform; if auto-detection fails an error is /// returned (unless `dry_run = true`). /// * `explicit_egress`: optional override for the egress interface name (e.g. `"eth0"`, /// `"en0"`). When `None`, derived from the same auto-detection step. /// * `dry_run`: when true, log every command as `would run: ...` and execute none. Returns an /// empty guard; Drop also logs `would undo: ...` and runs nothing. pub fn install( tun_name: &str, routes: &SplitRoutes, explicit_gw: Option<&str>, explicit_egress: Option<&str>, dry_run: bool, ) -> Result { // dry_run uses a portable, infallible plan-render path that works on every platform. // It is what the unit tests exercise (no real `ip` / `route` invocation). if dry_run { return Self::install_dry_run(tun_name, routes, explicit_gw, explicit_egress); } Self::install_real(tun_name, routes, explicit_gw, explicit_egress) } /// Real (non-dry-run) install: dispatched per target_os. Windows is a no-op + warning. /// Kept as a separate helper so the public [`install`](Self::install) does not need /// overlapping `cfg` branches that confuse `clippy::needless_return`. fn install_real( tun_name: &str, routes: &SplitRoutes, explicit_gw: Option<&str>, explicit_egress: Option<&str>, ) -> Result { #[cfg(target_os = "linux")] { Self::install_linux(tun_name, routes, explicit_gw, explicit_egress) } #[cfg(target_os = "macos")] { Self::install_macos(tun_name, routes, explicit_gw, explicit_egress) } #[cfg(target_os = "windows")] { let _ = (tun_name, routes, explicit_gw, explicit_egress); tracing::warn!( target: "aura::os_routes", "OS routes not implemented on Windows (v1); falling back to user-space classification only" ); Ok(Self { rollback: Vec::new(), dry_run: false, }) } #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] { let _ = (tun_name, routes, explicit_gw, explicit_egress); Err(anyhow!( "OS routes supported on linux/macos/windows only; this platform has no implementation" )) } } #[cfg(target_os = "linux")] fn install_linux( tun_name: &str, routes: &SplitRoutes, explicit_gw: Option<&str>, explicit_egress: Option<&str>, ) -> Result { let (gw, egress) = resolve_gateway(explicit_gw, explicit_egress)?; let plan = linux_apply_plan(tun_name, routes, gw, &egress); install_with_plan(plan, linux_undo_for) } #[cfg(target_os = "macos")] fn install_macos( tun_name: &str, routes: &SplitRoutes, explicit_gw: Option<&str>, explicit_egress: Option<&str>, ) -> Result { let (gw, _egress) = resolve_gateway(explicit_gw, explicit_egress)?; let plan = macos_apply_plan(tun_name, routes, gw); install_with_plan(plan, macos_undo_for) } /// dry_run install: emits the plans for *both* Linux and macOS (so the operator sees the full /// picture regardless of host) plus the Windows-stub warning, and records no rollback. The /// gateway / egress hints are still passed through so the rendered commands are realistic. fn install_dry_run( tun_name: &str, routes: &SplitRoutes, explicit_gw: Option<&str>, explicit_egress: Option<&str>, ) -> Result { // For dry_run we substitute plausible defaults when no explicit gateway/egress is given // so the log output is still informative. We do NOT actually run `ip route show default` // here (that's a side effect of "real" mode). let gw: IpAddr = explicit_gw .and_then(|s| s.parse().ok()) .unwrap_or_else(|| IpAddr::from([192, 168, 1, 1])); let egress = explicit_egress.unwrap_or("eth0"); let macos_egress = explicit_egress.unwrap_or("en0"); for cmd in linux_apply_plan(tun_name, routes, gw, egress) { tracing::info!(target: "aura::os_routes", "would run (linux): {}", cmd.render()); } for cmd in macos_apply_plan(tun_name, routes, gw) { tracing::info!(target: "aura::os_routes", "would run (macos): {}", cmd.render()); } let _ = macos_egress; // hinted but unused in the apply plan (macOS uses -interface ) tracing::info!( target: "aura::os_routes", "would run (windows): no-op stub (OS routes not implemented on Windows in v1)" ); Ok(Self { rollback: Vec::new(), dry_run: true, }) } /// Execute the rollback stack now, in REVERSE order, logging (not bubbling) any failures. fn rollback_now(&mut self) { if self.dry_run { for cmd in self.rollback.drain(..).rev() { tracing::info!(target: "aura::os_routes", "would undo: {}", cmd.render()); } return; } for cmd in self.rollback.drain(..).rev() { tracing::info!(target: "aura::os_routes", "undo: {}", cmd.render()); if let Err(e) = cmd.run() { tracing::warn!(target: "aura::os_routes", error = %e, "route rollback step failed"); } } } } impl Drop for OsRouteGuard { fn drop(&mut self) { self.rollback_now(); } } // ---- internal helpers ----------------------------------------------------------------------- /// Plan / log / undo a single shell command issued by [`OsRouteGuard`]. /// /// Mirrors [`crate::nat::NatGuard`]'s `PlannedCommand` shape so the two RAII helpers feel /// consistent. `args` does NOT include the program name. #[derive(Clone, Debug)] struct PlannedCommand { prog: &'static str, args: Vec, } impl PlannedCommand { fn new(prog: &'static str, args: Vec) -> Self { Self { prog, args } } /// Render the command as a single shell-ish string for logs only. fn render(&self) -> String { let mut s = String::from(self.prog); for a in &self.args { s.push(' '); s.push_str(a); } s } /// Run the command synchronously; on a non-zero exit, return an error including stderr. fn run(&self) -> Result<()> { let out = Command::new(self.prog) .args(self.args.iter().map(String::as_str)) .output() .map_err(|e| anyhow!("spawning `{}`: {e}", self.prog))?; if !out.status.success() { let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); return Err(anyhow!( "`{}` exited with {}: {stderr}", self.render(), out.status )); } Ok(()) } } /// Apply each command in `plan` in order; pair every successful apply with its undo and roll /// back on the first failure. Returns the populated guard on success. #[cfg(any(target_os = "linux", target_os = "macos"))] fn install_with_plan(plan: Vec, undo_for: F) -> Result where F: Fn(&PlannedCommand) -> PlannedCommand, { let mut rollback: Vec = Vec::with_capacity(plan.len()); for cmd in plan { tracing::info!(target: "aura::os_routes", "running: {}", cmd.render()); if let Err(e) = cmd.run() { tracing::warn!(target: "aura::os_routes", error = %e, "route step failed; rolling back"); let mut g = OsRouteGuard { rollback, dry_run: false, }; g.rollback_now(); return Err(e); } rollback.push(undo_for(&cmd)); } Ok(OsRouteGuard { rollback, dry_run: false, }) } /// Resolve the host's default gateway / egress interface, honouring explicit overrides. /// /// Returns an error when auto-detection fails and no override was supplied. The combinator form /// keeps Linux and macOS branches sharing the same fallback / validation logic. #[cfg(any(target_os = "linux", target_os = "macos"))] fn resolve_gateway( explicit_gw: Option<&str>, explicit_egress: Option<&str>, ) -> Result<(IpAddr, String)> { if let (Some(gw_str), Some(eg)) = (explicit_gw, explicit_egress) { let gw: IpAddr = gw_str .parse() .map_err(|e| anyhow!("invalid [tunnel.os_routes] gateway '{gw_str}': {e}"))?; return Ok((gw, eg.to_string())); } let detected = detect_default_gateway()?; let gw: IpAddr = match explicit_gw { Some(s) => s .parse() .map_err(|e| anyhow!("invalid [tunnel.os_routes] gateway '{s}': {e}"))?, None => detected.0, }; let egress = explicit_egress.map(String::from).unwrap_or(detected.1); Ok((gw, egress)) } /// Auto-detect the host's IPv4 default gateway + egress interface. #[cfg(target_os = "linux")] fn detect_default_gateway() -> Result<(IpAddr, String)> { let out = Command::new("ip") .args(["route", "show", "default"]) .output() .map_err(|e| anyhow!("spawning `ip route show default`: {e}"))?; if !out.status.success() { let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); return Err(anyhow!( "`ip route show default` exited with {}: {stderr}; \ set [tunnel.os_routes] gateway and egress_iface in client.toml", out.status )); } let s = String::from_utf8_lossy(&out.stdout); parse_linux_ip_route_default(&s).ok_or_else(|| { anyhow!( "could not parse Linux default route from `ip route show default` output: {:?}; \ set [tunnel.os_routes] gateway and egress_iface in client.toml", s ) }) } /// Auto-detect the host's IPv4 default gateway + egress interface. #[cfg(target_os = "macos")] fn detect_default_gateway() -> Result<(IpAddr, String)> { let out = Command::new("route") .args(["-n", "get", "default"]) .output() .map_err(|e| anyhow!("spawning `route -n get default`: {e}"))?; if !out.status.success() { let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); return Err(anyhow!( "`route -n get default` exited with {}: {stderr}; \ set [tunnel.os_routes] gateway and egress_iface in client.toml", out.status )); } let s = String::from_utf8_lossy(&out.stdout); parse_macos_route_default(&s).ok_or_else(|| { anyhow!( "could not parse macOS default route from `route -n get default` output: {:?}; \ set [tunnel.os_routes] gateway and egress_iface in client.toml", s ) }) } /// Parse the first `default via dev ` line out of `ip route show default` output. /// /// Returns `(gateway, iface)` or `None` if the output didn't contain a parseable default. Made /// `pub(crate)` so the unit tests in `tests/os_routes.rs` can exercise it without needing a real /// `ip` binary on the host. On non-Linux hosts the production path never calls it, but the unit /// tests still do (the parser is platform-independent), hence the `dead_code` allow off-Linux. /// /// Example input: `default via 192.168.1.1 dev eth0 proto dhcp metric 100`. #[cfg_attr(not(target_os = "linux"), allow(dead_code))] pub(crate) fn parse_linux_ip_route_default(s: &str) -> Option<(IpAddr, String)> { for line in s.lines() { let mut toks = line.split_ascii_whitespace(); if toks.next() != Some("default") { continue; } // Expect `via ` then `dev ` somewhere after. let mut gw: Option = None; let mut dev: Option = None; let mut peekable = toks.peekable(); while let Some(tok) = peekable.next() { match tok { "via" => { if let Some(addr) = peekable.next() { gw = addr.parse().ok(); } } "dev" => { dev = peekable.next().map(String::from); } _ => {} } } if let (Some(g), Some(d)) = (gw, dev) { return Some((g, d)); } } None } /// Parse the `gateway:` and `interface:` lines out of `route -n get default` output (macOS). /// /// Returns `(gateway, iface)` or `None` if both lines weren't found / parseable. On non-macOS /// hosts the production path never calls it (the equivalent Linux parser is used instead), but /// the unit tests still do — the parser logic is platform-independent. /// /// Example input: /// ```text /// route to: default /// destination: default /// mask: default /// gateway: 192.168.1.1 /// interface: en0 /// ``` #[cfg_attr(not(target_os = "macos"), allow(dead_code))] pub(crate) fn parse_macos_route_default(s: &str) -> Option<(IpAddr, String)> { let mut gw: Option = None; let mut iface: Option = None; for line in s.lines() { let line = line.trim(); if let Some(rest) = line.strip_prefix("gateway:") { if let Ok(addr) = rest.trim().parse::() { gw = Some(addr); } } else if let Some(rest) = line.strip_prefix("interface:") { iface = Some(rest.trim().to_string()); } } match (gw, iface) { (Some(g), Some(i)) => Some((g, i)), _ => None, } } // ---- Linux plan ----------------------------------------------------------------------------- /// Format an IP host as its `/32` (v4) or `/128` (v6) CIDR string. fn host_to_cidr(ip: IpAddr) -> String { match ip { IpAddr::V4(v4) => format!("{v4}/32"), IpAddr::V6(v6) => format!("{v6}/128"), } } /// Build the Linux apply plan from a [`SplitRoutes`]. /// /// * `default = Vpn`: install `0.0.0.0/0 dev metric ` plus a /// ` via dev ` per DIRECT entry. /// * `default = Direct`: only ` dev ` per VPN entry; the host's existing default is /// left alone. fn linux_apply_plan( tun_name: &str, routes: &SplitRoutes, gateway: IpAddr, egress: &str, ) -> Vec { let mut plan = Vec::new(); match routes.default { DefaultAction::Vpn => { // Default-via-TUN, with a low metric so we win over the host's pre-existing default // (DHCP-installed defaults are typically metric 100+). plan.push(PlannedCommand::new( "ip", vec![ "route".into(), "add".into(), "0.0.0.0/0".into(), "dev".into(), tun_name.into(), "metric".into(), VPN_DEFAULT_METRIC.to_string(), ], )); // DIRECT bypass routes through the original default gateway. for cidr in &routes.direct_cidrs { plan.push(PlannedCommand::new( "ip", vec![ "route".into(), "add".into(), cidr.to_string(), "via".into(), gateway.to_string(), "dev".into(), egress.into(), ], )); } for ip in &routes.direct_hosts { plan.push(PlannedCommand::new( "ip", vec![ "route".into(), "add".into(), host_to_cidr(*ip), "via".into(), gateway.to_string(), "dev".into(), egress.into(), ], )); } } DefaultAction::Direct => { // Default left alone; only the explicit VPN routes go through the TUN. for cidr in &routes.vpn_cidrs { plan.push(PlannedCommand::new( "ip", vec![ "route".into(), "add".into(), cidr.to_string(), "dev".into(), tun_name.into(), ], )); } for ip in &routes.vpn_hosts { plan.push(PlannedCommand::new( "ip", vec![ "route".into(), "add".into(), host_to_cidr(*ip), "dev".into(), tun_name.into(), ], )); } } } plan } /// Build the Linux undo command for a given apply step. Every apply uses `ip route add`, so the /// undo just substitutes `del`. #[cfg(target_os = "linux")] fn linux_undo_for(applied: &PlannedCommand) -> PlannedCommand { assert_eq!(applied.prog, "ip", "linux apply plan only uses `ip`"); let mut args = applied.args.clone(); if let Some(slot) = args.get_mut(1) { if slot == "add" { *slot = "del".to_string(); } } PlannedCommand::new("ip", args) } // ---- macOS plan ----------------------------------------------------------------------------- /// Build the macOS apply plan from a [`SplitRoutes`]. /// /// macOS uses `route add -net ` for DIRECT bypasses (gateway-routed) and /// `route add -net -interface ` for VPN routes. Host entries use `-host ` instead /// of `-net`. fn macos_apply_plan(tun_name: &str, routes: &SplitRoutes, gateway: IpAddr) -> Vec { let mut plan = Vec::new(); match routes.default { DefaultAction::Vpn => { // Default-via-TUN. macOS allows multiple default routes; the most-recently-added // generally wins by priority, which suits us here (the VPN default must override the // host's pre-existing default for the lifetime of the session). plan.push(PlannedCommand::new( "route", vec![ "add".into(), "-net".into(), "0.0.0.0/0".into(), "-interface".into(), tun_name.into(), ], )); for cidr in &routes.direct_cidrs { plan.push(PlannedCommand::new( "route", vec![ "add".into(), "-net".into(), cidr.to_string(), gateway.to_string(), ], )); } for ip in &routes.direct_hosts { plan.push(PlannedCommand::new( "route", vec![ "add".into(), "-host".into(), ip.to_string(), gateway.to_string(), ], )); } } DefaultAction::Direct => { for cidr in &routes.vpn_cidrs { plan.push(PlannedCommand::new( "route", vec![ "add".into(), "-net".into(), cidr.to_string(), "-interface".into(), tun_name.into(), ], )); } for ip in &routes.vpn_hosts { plan.push(PlannedCommand::new( "route", vec![ "add".into(), "-host".into(), ip.to_string(), "-interface".into(), tun_name.into(), ], )); } } } plan } /// Build the macOS undo command for a given apply step. Every apply uses `route add`; the undo /// substitutes `delete` and reuses the rest of the args verbatim. #[cfg(target_os = "macos")] fn macos_undo_for(applied: &PlannedCommand) -> PlannedCommand { assert_eq!(applied.prog, "route", "macos apply plan only uses `route`"); let mut args = applied.args.clone(); if let Some(slot) = args.first_mut() { if slot == "add" { *slot = "delete".to_string(); } } PlannedCommand::new("route", args) } #[cfg(test)] mod tests { use super::*; /// dry_run on any platform succeeds, builds no rollback, and is safe to drop. #[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()], ..Default::default() }; let g = OsRouteGuard::install("aura0", &split, None, None, true) .expect("dry_run install must succeed everywhere"); assert!(g.dry_run); assert!(g.rollback.is_empty()); drop(g); } /// Linux apply plan with `default = Vpn` has the default-via-TUN as its first command and a /// bypass route per DIRECT entry afterwards. #[test] fn linux_plan_default_vpn() { let split = SplitRoutes { default: DefaultAction::Vpn, direct_cidrs: vec!["192.168.0.0/16".parse().unwrap()], direct_hosts: vec!["1.2.3.4".parse().unwrap()], ..Default::default() }; let plan = linux_apply_plan("aura0", &split, "10.0.0.1".parse().unwrap(), "eth0"); assert_eq!(plan.len(), 3); // Default first. assert_eq!(plan[0].prog, "ip"); assert!(plan[0].args.contains(&"add".to_string())); assert!(plan[0].args.contains(&"0.0.0.0/0".to_string())); assert!(plan[0].args.contains(&"aura0".to_string())); assert!(plan[0].args.contains(&VPN_DEFAULT_METRIC.to_string())); // CIDR bypass via gateway. assert!(plan[1].args.contains(&"192.168.0.0/16".to_string())); assert!(plan[1].args.contains(&"10.0.0.1".to_string())); assert!(plan[1].args.contains(&"eth0".to_string())); // Host bypass becomes /32 via gateway. assert!(plan[2].args.contains(&"1.2.3.4/32".to_string())); assert!(plan[2].args.contains(&"10.0.0.1".to_string())); } /// Linux `default = Direct`: no default override, only the explicit VPN routes via TUN. #[test] fn linux_plan_default_direct() { let split = SplitRoutes { default: DefaultAction::Direct, vpn_cidrs: vec!["10.7.0.0/24".parse().unwrap()], vpn_hosts: vec!["10.7.0.5".parse().unwrap()], ..Default::default() }; let plan = linux_apply_plan("aura0", &split, "10.0.0.1".parse().unwrap(), "eth0"); assert_eq!(plan.len(), 2); // No default route in the plan. assert!(!plan .iter() .any(|c| c.args.contains(&"0.0.0.0/0".to_string()))); // All entries go via the TUN. for cmd in &plan { assert!(cmd.args.contains(&"aura0".to_string())); assert!(cmd.args.contains(&"dev".to_string())); // No gateway in `default = Direct` plan. assert!(!cmd.args.contains(&"via".to_string())); } } /// macOS apply plan with `default = Vpn`: default-via-TUN first, then `-net ` per /// DIRECT entry, then `-host ` per direct host. #[test] fn macos_plan_default_vpn() { let split = SplitRoutes { default: DefaultAction::Vpn, direct_cidrs: vec!["192.168.0.0/16".parse().unwrap()], direct_hosts: vec!["1.2.3.4".parse().unwrap()], ..Default::default() }; let plan = macos_apply_plan("utun4", &split, "10.0.0.1".parse().unwrap()); assert_eq!(plan.len(), 3); // Default first via -interface. assert_eq!(plan[0].prog, "route"); assert!(plan[0].args.contains(&"-interface".to_string())); assert!(plan[0].args.contains(&"utun4".to_string())); assert!(plan[0].args.contains(&"0.0.0.0/0".to_string())); // CIDR via gateway. assert!(plan[1].args.contains(&"192.168.0.0/16".to_string())); assert!(plan[1].args.contains(&"10.0.0.1".to_string())); // Host via gateway (-host). assert!(plan[2].args.contains(&"-host".to_string())); assert!(plan[2].args.contains(&"1.2.3.4".to_string())); } /// Undo flips `add` -> `del` on Linux and reuses the rest of the args (so the route is /// removed against the same iface / gateway / metric). #[cfg(target_os = "linux")] #[test] fn linux_undo_flips_add_to_del() { let apply = PlannedCommand::new( "ip", vec![ "route".into(), "add".into(), "192.168.0.0/16".into(), "via".into(), "10.0.0.1".into(), "dev".into(), "eth0".into(), ], ); let undo = linux_undo_for(&apply); assert_eq!(undo.prog, "ip"); assert_eq!(undo.args[1], "del"); assert_eq!(undo.args[2], "192.168.0.0/16"); assert!(undo.args.contains(&"10.0.0.1".to_string())); } /// Undo flips `add` -> `delete` on macOS. #[cfg(target_os = "macos")] #[test] fn macos_undo_flips_add_to_delete() { let apply = PlannedCommand::new( "route", vec![ "add".into(), "-net".into(), "192.168.0.0/16".into(), "10.0.0.1".into(), ], ); let undo = macos_undo_for(&apply); assert_eq!(undo.prog, "route"); assert_eq!(undo.args[0], "delete"); assert!(undo.args.contains(&"192.168.0.0/16".to_string())); } /// `parse_linux_ip_route_default` handles the typical DHCP-installed default line. #[test] fn parse_linux_default_basic() { let s = "default via 192.168.1.1 dev eth0 proto dhcp metric 100\n"; let (gw, dev) = parse_linux_ip_route_default(s).expect("parses"); assert_eq!(gw, IpAddr::from([192, 168, 1, 1])); assert_eq!(dev, "eth0"); } /// `parse_linux_ip_route_default` finds the default among multiple route lines and tolerates /// the `onlink` / `src` decorations that some distributions emit. #[test] fn parse_linux_default_among_multiple_lines() { let s = "10.0.0.0/8 dev wg0 scope link\n\ default via 10.1.2.1 dev wlan0 proto static onlink src 10.1.2.99\n\ 169.254.0.0/16 dev eth0 scope link metric 1000\n"; let (gw, dev) = parse_linux_ip_route_default(s).expect("parses"); assert_eq!(gw, IpAddr::from([10, 1, 2, 1])); assert_eq!(dev, "wlan0"); } /// `parse_linux_ip_route_default` returns None when no default line is present. #[test] fn parse_linux_default_missing() { let s = "10.0.0.0/8 dev wg0 scope link\n"; assert!(parse_linux_ip_route_default(s).is_none()); } /// `parse_macos_route_default` extracts gateway + interface from `route -n get default`. #[test] fn parse_macos_default_basic() { let s = " route to: default\n\ destination: default\n\ mask: default\n\ gateway: 192.168.1.1\n\ interface: en0\n\ flags: \n"; let (gw, dev) = parse_macos_route_default(s).expect("parses"); assert_eq!(gw, IpAddr::from([192, 168, 1, 1])); assert_eq!(dev, "en0"); } /// `parse_macos_route_default` returns None when either field is missing. #[test] fn parse_macos_default_missing() { let s = "no useful content here\n"; assert!(parse_macos_route_default(s).is_none()); } /// [`SplitRoutes::from_config`] picks up direct + vpn CIDRs and resolved domain hosts. #[test] fn split_routes_from_config_collects_everything() { use crate::config::{SplitRule, SplitSection}; let split = SplitSection { default: "VPN".to_string(), direct: vec![ SplitRule { cidr: Some("192.168.0.0/16".to_string()), domain: None, }, SplitRule { cidr: Some("invalid-cidr".to_string()), domain: None, }, ], vpn: vec![SplitRule { cidr: Some("10.7.0.0/24".to_string()), domain: None, }], }; let resolved: Vec<(String, aura_tunnel::RouteAction, Vec)> = vec![ ( "intranet.example.com".to_string(), aura_tunnel::RouteAction::Direct, vec!["1.2.3.4".parse().unwrap()], ), ( "vpn-only.example.com".to_string(), aura_tunnel::RouteAction::Vpn, vec!["10.7.0.99".parse().unwrap()], ), ]; let routes = SplitRoutes::from_config(&split, &resolved); assert_eq!(routes.default, DefaultAction::Vpn); // Invalid CIDR was silently skipped. assert_eq!(routes.direct_cidrs.len(), 1); assert_eq!(routes.vpn_cidrs.len(), 1); assert_eq!( routes.direct_hosts, vec!["1.2.3.4".parse::().unwrap()] ); assert_eq!( routes.vpn_hosts, vec!["10.7.0.99".parse::().unwrap()] ); } /// `default = "DIRECT"` (case-insensitive) maps to `DefaultAction::Direct`. #[test] fn split_routes_default_direct() { use crate::config::SplitSection; let split = SplitSection { default: "direct".to_string(), direct: Vec::new(), vpn: Vec::new(), }; let routes = SplitRoutes::from_config(&split, &[]); assert_eq!(routes.default, DefaultAction::Direct); } /// IPv6 host route formatting goes through `/128`. #[test] fn host_to_cidr_v6() { let v6: IpAddr = "2001:db8::1".parse().unwrap(); assert_eq!(host_to_cidr(v6), "2001:db8::1/128"); } }