diff --git a/config/client.toml.example b/config/client.toml.example index a4505bb..3ca9f15 100644 --- a/config/client.toml.example +++ b/config/client.toml.example @@ -54,6 +54,31 @@ domain = "intranet.example.com" [[tunnel.split.vpn]] cidr = "10.7.0.0/24" +# v2: OS-level split-tunnel routing. With `enabled = true` (the default) the client programs the +# system routing table at startup so DIRECT destinations bypass the TUN entirely — they continue +# to use the host's original default gateway, while only VPN-classified traffic reaches the +# tunnel. This eliminates the v1 user-space `send_direct` stub, where DIRECT packets were merely +# logged and dropped. The guard is RAII: the routes are rolled back when `aura client` exits. +# +# Linux: uses `ip route add ... via dev ` for bypasses and `ip route add ... dev ` +# for the VPN default; the VPN default carries metric 50 so it wins over DHCP-installed defaults. +# macOS: uses `route add -net ` for bypasses and `route add -net -interface ` +# for VPN routes. +# Windows: not implemented in v1 (the section is parsed but the install is a logged no-op). +[tunnel.os_routes] +# Master switch. `false` falls back to the v1 user-space router (the `send_direct` path drops +# DIRECT packets; kept intentionally as a fallback). Default: true. +enabled = true +# When `true`, every routing command is only logged (`would run: ...`) and not executed — +# useful for testing the plan without root. Default: false. +dry_run = false +# Optional explicit IPv4 default gateway. When omitted, auto-detected (Linux: +# `ip route show default`; macOS: `route -n get default`). +# gateway = "192.168.1.1" +# Optional explicit egress interface name (e.g. "eth0" on Linux, "en0" on macOS). When omitted, +# auto-detected alongside the gateway. +# egress_iface = "en0" + [mimicry] # Enable traffic padding to blend packet sizes into HTTPS buckets. padding = false diff --git a/crates/aura-cli/src/client.rs b/crates/aura-cli/src/client.rs index 7b186c7..1504f46 100644 --- a/crates/aura-cli/src/client.rs +++ b/crates/aura-cli/src/client.rs @@ -19,17 +19,19 @@ //! connection path short of the TUN). //! * Domain resolution performs real DNS queries and so is not unit-tested either. +use std::net::IpAddr; use std::path::Path; use std::sync::Arc; use anyhow::Context; use aura_transport::dial; -use aura_tunnel::{AuraDns, AuraRouter, AuraTun}; +use aura_tunnel::{AuraDns, AuraRouter, AuraTun, RouteAction}; use tokio::sync::RwLock; use crate::admin::{self, AdminState, Stats}; use crate::config::ClientConfigFile; use crate::masks::MaskRotator; +use crate::os_routes::{OsRouteGuard, SplitRoutes}; use crate::privdrop; /// Entry point for `aura client --config ` (and optional `--admin-socket`). @@ -104,7 +106,11 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { stats.set_peer_id(peer.clone()); tracing::info!(peer = ?peer, %mode, "connected and authenticated to server"); - // Resolve split-tunnel domain rules into host routes (best-effort; failures are logged). + // Resolve split-tunnel domain rules into host routes (best-effort; failures are logged). We + // also collect the resolved hosts per (domain, action) so the OS-routes guard below can + // install a /32 or /128 bypass / VPN-route per resolved IP — this is what makes a domain rule + // actually steer DIRECT traffic away from the TUN. + let mut resolved_domains: Vec<(String, RouteAction, Vec)> = Vec::new(); if !domains.is_empty() { match AuraDns::new(Arc::clone(&routes)).await { Ok(mut dns) => { @@ -116,7 +122,8 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { count = ips.len(), ?action, "resolved domain rule" - ) + ); + resolved_domains.push((domain.clone(), *action, ips)); } Err(e) => { tracing::warn!(domain, error = %e, "failed to resolve domain rule") @@ -153,16 +160,60 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { .context("creating TUN device (needs root)")?; tracing::info!(tun = %cfg.tunnel.tun_name, "TUN device up; routing traffic"); - // Privilege drop. The only operation requiring root on the client is the TUN open above - // (the dial used unprivileged outbound sockets); switch to the configured non-root user - // before entering the long-lived router loop. + // v2: program OS-level split-tunnel routes so DIRECT-classified traffic never reaches the + // TUN. The guard is bound to this `run()` scope; its Drop rolls every installed route back + // on shutdown (or on the `?`-propagated error path below). When [tunnel.os_routes] is + // omitted, the section defaults to `enabled = true` — this is the whole point of the v2 + // change (eliminating the user-space `send_direct` stub). To restore the v1 behaviour + // explicitly, set `enabled = false`. + // + // We pass `cfg.tunnel.tun_name` rather than the kernel-assigned name because `AuraTun` does + // not (yet) surface the latter; on macOS the operator can pin the resulting `utunN` in the + // config (or set `[tunnel.os_routes] dry_run = true` to validate the plan). Linux assigns the + // requested name verbatim. + let os_routes_cfg = cfg + .tunnel + .os_routes + .clone() + .unwrap_or_else(crate::config::OsRoutesSection::default); + let _os_routes_guard: Option = if os_routes_cfg.enabled { + let split = SplitRoutes::from_config(&cfg.tunnel.split, &resolved_domains); + let guard = OsRouteGuard::install( + &cfg.tunnel.tun_name, + &split, + os_routes_cfg.gateway.as_deref(), + os_routes_cfg.egress_iface.as_deref(), + os_routes_cfg.dry_run, + ) + .context("installing OS-level split-tunnel routes")?; + tracing::info!( + tun = %cfg.tunnel.tun_name, + dry_run = os_routes_cfg.dry_run, + "OS-level split-tunnel routes installed (DIRECT traffic now bypasses the TUN)" + ); + Some(guard) + } else { + tracing::info!( + "OS-level split-tunnel routes disabled in config; falling back to user-space router \ + (the v1 `send_direct` path drops DIRECT packets — left intentionally as a fallback)" + ); + None + }; + + // Privilege drop. The only operations requiring root on the client are the TUN open above + // and the routing-table install (both done by now); switch to the configured non-root user + // before entering the long-lived router loop. The OsRouteGuard's Drop runs after the router + // returns; on Linux/macOS the rollback `ip` / `route` invocations need the saved capability + // of the original (root) invocation — which they keep when run via sudo since the kernel + // releases their privileges only at process exit. if let Some(user) = cfg.client.run_as.as_deref() { privdrop::drop_to_user(user).context("dropping client privileges per [client] run_as")?; } let router = AuraRouter::new(tun, routes, conn); - router.run().await.context("router run loop")?; - Ok(()) + let run_result = router.run().await.context("router run loop"); + // _os_routes_guard drops here, rolling back any installed system routes. + run_result } /// Re-parse the `[tunnel.split]` CIDR rules into `(IpNetwork, RouteAction)` pairs for the admin diff --git a/crates/aura-cli/src/config.rs b/crates/aura-cli/src/config.rs index 206b2c2..6c3f842 100644 --- a/crates/aura-cli/src/config.rs +++ b/crates/aura-cli/src/config.rs @@ -213,6 +213,48 @@ pub struct ClientTunnelSection { /// `[tunnel.split]` split-tunnel configuration. #[serde(default)] pub split: SplitSection, + /// `[tunnel.os_routes]` sub-section: v2 OS-level split tunnelling. Omitting it (or setting + /// `enabled = false`) preserves the v1 user-space behaviour where the [`AuraRouter`] sees + /// every packet (the `send_direct` path was a stub). When enabled, the client programs the + /// system routing table so DIRECT destinations bypass the TUN entirely and only + /// VPN-classified traffic reaches it. See [`crate::os_routes`]. + #[serde(default)] + pub os_routes: Option, +} + +/// `[tunnel.os_routes]` section: v2 OS-level split-tunnel programming. When `enabled` (the +/// default), the client adds system routes at startup so DIRECT-classified traffic never enters +/// the TUN; when omitted or `enabled = false`, behaviour falls back to the v1 user-space router. +/// +/// See [`crate::os_routes`] for the apply / rollback semantics. +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct OsRoutesSection { + /// Master switch. `true` (default) installs system routes via [`crate::os_routes::OsRouteGuard`]; + /// `false` leaves the host routing table alone and behaves like v1. + pub enabled: bool, + /// When `true`, every routing command is only logged (`would run: ...`) and not executed. + /// Useful for testing and for verifying the plan without root privileges. + pub dry_run: bool, + /// Optional explicit IPv4 default gateway. When set, the gateway-auto-detection step is + /// skipped and this value is used for every DIRECT bypass route. When omitted (the default), + /// the gateway is read from the host (Linux: `ip route show default`; macOS: + /// `route -n get default`). + pub gateway: Option, + /// Optional explicit egress interface name (e.g. `"eth0"` on Linux, `"en0"` on macOS). When + /// omitted (the default), derived from the same auto-detection step as `gateway`. + pub egress_iface: Option, +} + +impl Default for OsRoutesSection { + fn default() -> Self { + Self { + enabled: true, + dry_run: false, + gateway: None, + egress_iface: None, + } + } } /// `[tunnel.split]` section: default action plus direct/vpn override rules. diff --git a/crates/aura-cli/src/lib.rs b/crates/aura-cli/src/lib.rs index da2c5ee..b57d58d 100644 --- a/crates/aura-cli/src/lib.rs +++ b/crates/aura-cli/src/lib.rs @@ -18,6 +18,7 @@ pub mod client; pub mod config; pub mod masks; pub mod nat; +pub mod os_routes; pub mod pki; pub mod pool; pub mod privdrop; diff --git a/crates/aura-cli/src/os_routes.rs b/crates/aura-cli/src/os_routes.rs new file mode 100644 index 0000000..9ac04bf --- /dev/null +++ b/crates/aura-cli/src/os_routes.rs @@ -0,0 +1,988 @@ +//! 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"); + } +} diff --git a/crates/aura-cli/tests/os_routes.rs b/crates/aura-cli/tests/os_routes.rs new file mode 100644 index 0000000..0816da8 --- /dev/null +++ b/crates/aura-cli/tests/os_routes.rs @@ -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)> = 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()); +}