5ea643a9e5
Windows is now first-class for client use: - aura-cli::os_routes Windows path is no longer a stub. Real install via `route ADD <net> MASK <mask> <gw> METRIC 1` for DIRECT bypass (rollback: `route DELETE ...`) and `netsh interface ipv4 add route <cidr> "Aura" <tun_local_ip> store=active` for VPN default/CIDR (rollback: `netsh ... delete route ...`). Default-gateway detection by parsing `route print 0` output via parse_windows_route_print_default; rejects `On-link` rows. Dry run works on every host. - aura-tunnel::tun wintun audit fixed a real bug: AuraTun was holding only Arc<Session> while Session does NOT keep Arc<Adapter> alive (only the Wintun DLL handle). On Drop the adapter was being closed under the session. Fixed by adding _adapter: Arc<wintun::Adapter> to AuraTun, with field order ensuring Session is dropped before Adapter so end-session precedes close-adapter. Also wired mtu into write_packet (hard limit) + read_packet (warn). - Cross-compile verified: cargo check --target x86_64-pc-windows-gnu --workspace and clippy on the windows target are both clean (added mingw-w64 + x86_64-pc-windows-gnu via rustup). - docs/deployment.md: §6 updated (Windows OS-routes now Done), new §8 «Windows как клиент» with download wintun.dll, Admin run, [tunnel.os_routes] enabled, known no-ops (run_as, [server.nat]). 9 new tests (7 parser/plan/undo unit + 1 windows dry-run integration + 1 existing). Workspace: 293 tests passed (+9), clippy -D warnings clean, fmt clean. macOS host + windows-gnu cross-target both green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
180 lines
5.8 KiB
Rust
180 lines
5.8 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());
|
|
}
|
|
|
|
/// v3.3: a Windows-style client.toml (with the operator's pre-detected gateway already pinned
|
|
/// in `[tunnel.os_routes]`) still parses and the dry-run install renders the windows plan in
|
|
/// the logs. We do not assert on the log contents here — that is covered by the inner
|
|
/// `windows_plan_default_vpn` unit test in `os_routes.rs` — but we *do* verify that the API
|
|
/// surface accepts the same hints on every host (no Windows-only fields).
|
|
#[test]
|
|
fn dry_run_install_windows_style_overrides_succeed_anywhere() {
|
|
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(),
|
|
};
|
|
// On Windows the "egress" hint is the upstream interface IP, not its display name.
|
|
// The dry-run path renders this verbatim into the windows plan.
|
|
let guard = OsRouteGuard::install(
|
|
"Aura",
|
|
&split,
|
|
Some("192.168.1.1"),
|
|
Some("192.168.1.42"),
|
|
/* dry_run */ true,
|
|
)
|
|
.expect("dry_run with Windows-style overrides must succeed on every host");
|
|
drop(guard);
|
|
}
|