feat(cli,tunnel,docs): full Windows support — OS routes + wintun audit

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>
This commit is contained in:
xah30
2026-05-27 21:14:23 +03:00
parent 1893e24174
commit 5ea643a9e5
4 changed files with 809 additions and 37 deletions
+50 -4
View File
@@ -37,8 +37,16 @@ pub struct AuraTun {
#[cfg(not(windows))]
mtu: u16,
/// Active wintun session. `Session::Drop` ends the session via `WintunEndSession`.
#[cfg(windows)]
inner: std::sync::Arc<wintun::Session>,
/// Keep the wintun adapter alive for the lifetime of the session. `wintun::Session` only
/// holds an `Arc<Wintun>` (the DLL handle), NOT an `Arc<Adapter>` — if the adapter is
/// dropped, its `WintunCloseAdapter` runs and the session's underlying handle is
/// invalidated. Holding the `Arc<Adapter>` here is what guarantees the adapter outlives
/// the session.
#[cfg(windows)]
_adapter: std::sync::Arc<wintun::Adapter>,
#[cfg(windows)]
mtu: u16,
}
@@ -141,8 +149,14 @@ impl AuraTun {
IpAddr::V6(_) => unreachable!("v4 address yields a v4 mask"),
};
// SAFETY: loads the bundled wintun.dll via its documented entry point.
// SAFETY: loads the bundled wintun.dll (expected next to aura.exe). The wintun crate
// documents this `load()` call as the entry point for in-process driver loading; failure
// here usually means wintun.dll is not on the PATH / app directory.
let wintun = unsafe { wintun::load() }.context("failed to load wintun.dll")?;
// Adapter name is the display name used by Windows (also what `netsh ... "Aura"`
// references in [`crate::os_routes::windows_apply_plan`]). "Aura" doubles as the
// tunnel-type string — wintun groups adapters by tunnel_type, so all aura sessions
// appear under one category in Device Manager.
let adapter = wintun::Adapter::create(&wintun, name, "Aura", None)
.with_context(|| format!("failed to create wintun adapter '{name}'"))?;
adapter
@@ -156,26 +170,58 @@ impl AuraTun {
.start_session(wintun::MAX_RING_CAPACITY)
.context("failed to start wintun session")?;
// Hold both the Arc<Adapter> and the Session: Session::Drop calls WintunEndSession, then
// Adapter::Drop calls WintunCloseAdapter — that ordering matches what the wintun crate
// docs prescribe (end the session before closing the adapter handle). Struct fields are
// dropped in declaration order, so `inner` (Session) drops first, then `_adapter`.
Ok(Self {
inner: std::sync::Arc::new(session),
_adapter: adapter,
mtu,
})
}
/// Read one IP packet from the wintun session.
///
/// `receive_blocking` is a blocking call, so it runs on a blocking thread to avoid stalling the
/// async runtime.
/// `receive_blocking` is a blocking call (it parks on the wintun ring's read event), so it
/// runs on a blocking thread to avoid stalling the tokio runtime. The returned `Packet` owns
/// a slice into the ring buffer; we copy it out to a `Vec` because the ring slot is freed on
/// `Packet::Drop` (the next read overwrites it). MTU is checked only as a sanity bound — the
/// wintun ring itself is fixed at 64 KiB, but receiving anything larger than the negotiated
/// MTU means the OS is doing something wrong upstream.
#[cfg(windows)]
pub async fn read_packet(&mut self) -> anyhow::Result<Vec<u8>> {
let session = self.inner.clone();
let packet = tokio::task::spawn_blocking(move || session.receive_blocking()).await??;
Ok(packet.bytes().to_vec())
let bytes = packet.bytes();
if bytes.len() > self.mtu as usize {
tracing::warn!(
target: "aura::tun",
len = bytes.len(),
mtu = self.mtu,
"wintun packet larger than configured MTU; forwarding anyway"
);
}
Ok(bytes.to_vec())
}
/// Write one IP packet to the wintun session.
///
/// `allocate_send_packet` reserves a slot in the send ring; we fill it with `bytes_mut()`
/// then `send_packet` hands the slot back to the driver for transmission. The size cast to
/// `u16` is the wintun-imposed per-packet limit (the API takes `u16`, mirroring an
/// ETHERNET-class frame). Packets larger than [`Self::mtu`] are rejected up front so the
/// allocation does not even happen — that matches the Unix `tun` crate's behaviour where
/// `write` rejects oversized frames at the syscall layer.
#[cfg(windows)]
pub async fn write_packet(&mut self, packet: &[u8]) -> anyhow::Result<()> {
if packet.len() > self.mtu as usize {
anyhow::bail!(
"outbound packet ({} bytes) exceeds wintun MTU ({})",
packet.len(),
self.mtu
);
}
let len: u16 = packet
.len()
.try_into()