//! Cross-platform TUN device (project §8.1 / §8.2). //! //! [`AuraTun`] is a thin async wrapper over a layer-3 TUN interface: //! //! * **Unix (Linux + macOS)** via the [`tun`] crate (0.8): `tun::create_as_async(&Configuration)` //! yields an `AsyncDevice` whose `recv`/`send` move whole IP packets. On macOS the kernel //! `utun` driver requires interface names to match `^utun[0-9]+$`; any other requested name is //! rewritten to an empty string before creation, which makes the kernel auto-assign the next //! free `utunN`. The actual assigned name is captured via [`tun::AbstractDevice::tun_name`] and //! exposed via [`AuraTun::name`] so callers (e.g. the OS-routes installer) can program the real //! interface instead of the requested-but-ignored config string. //! * **Windows** via the [`wintun`] crate (0.5): `Adapter::create(..)` + `start_session(..)`. The //! adapter accepts arbitrary names (it's a display name, not a kernel interface name), so the //! requested `name` is used verbatim. `cfg(windows)`-gated and validated by inspection on the //! macOS development host. //! //! Creating a real TUN needs elevated privileges and cannot run in unit tests. The router talks to //! the device through the small [`PacketIo`] trait (defined here) so tests can substitute an //! in-memory fake; [`AuraTun`] is the production implementor. use async_trait::async_trait; /// The minimal read/write seam the router needs from a packet device. /// /// Implemented by the real [`AuraTun`] and, in tests, by an in-memory fake. This is the testability /// seam that lets [`crate::router::AuraRouter`] be driven without root or a real TUN. It is a tiny, /// crate-defined trait (deliberately not a general I/O abstraction); it is `pub` only so that the /// crate's integration tests (which live in an external test crate) can supply a fake implementor. #[async_trait] pub trait PacketIo: Send { /// Read one IP packet from the device. async fn read_packet(&mut self) -> std::io::Result>; /// Write one IP packet to the device. async fn write_packet(&mut self, packet: &[u8]) -> std::io::Result<()>; } /// A cross-platform layer-3 TUN device. pub struct AuraTun { #[cfg(not(windows))] inner: tun::AsyncDevice, #[cfg(not(windows))] mtu: u16, /// Active wintun session. `Session::Drop` ends the session via `WintunEndSession`. #[cfg(windows)] inner: std::sync::Arc, /// Keep the wintun adapter alive for the lifetime of the session. `wintun::Session` only /// holds an `Arc` (the DLL handle), NOT an `Arc` — if the adapter is /// dropped, its `WintunCloseAdapter` runs and the session's underlying handle is /// invalidated. Holding the `Arc` here is what guarantees the adapter outlives /// the session. #[cfg(windows)] _adapter: std::sync::Arc, #[cfg(windows)] mtu: u16, /// The actual kernel-assigned interface name. On Linux and Windows this matches the /// `name` argument passed to [`AuraTun::create`]; on macOS the kernel `utun` driver may /// assign a different `utunN` (see the module docs for why), in which case this field /// holds the assigned name and the requested config string is discarded. name: String, } impl AuraTun { /// Create and bring up a TUN interface named `name` with address `ip`/`prefix_len` and the /// given `mtu`. /// /// On macOS `name` is advisory: the kernel `utun` driver only accepts names matching /// `^utun[0-9]+$`, so a non-conforming requested name (e.g. `"aura0"`, the default the v1 /// config carries from Linux/Windows) would otherwise fail creation with `invalid device tun /// name`. We rewrite a non-conforming name to the empty string before calling into the /// `tun` crate, which makes the kernel auto-assign the next free `utunN`; the assigned name /// is captured into [`AuraTun::name`] so callers (e.g. the OS-routes installer) can program /// the *actual* interface, not the requested-but-ignored config string. /// /// On Linux the requested name is honoured verbatim and recorded as-is. /// /// Requires privileges, so this is never called from unit tests except for the macOS /// auto-rename verification gated on `target_os = "macos"`. #[cfg(not(windows))] pub async fn create( name: &str, ip: std::net::IpAddr, prefix_len: u8, mtu: u16, ) -> anyhow::Result { use anyhow::Context; // `tun_name()` (and the other accessors) live on the AbstractDevice trait. use tun::AbstractDevice; // Derive the dotted/colon netmask for the requested prefix length from ipnetwork, which // keeps the v4/v6 mask maths in one well-tested place. let netmask = ipnetwork::IpNetwork::new(ip, prefix_len) .with_context(|| format!("invalid TUN address {ip}/{prefix_len}"))? .mask(); // macOS: the kernel utun driver enforces `^utun[0-9]+$` and rejects anything else with // `invalid device tun name`. Earlier v3.4 attempt passed `""` to `.tun_name()` thinking // tun-rs would treat empty as "kernel auto-assign" — it does NOT. Looking at // tun-0.8.9/src/platform/macos/device.rs: // // if !tun_name.starts_with("utun") { return Err(Error::InvalidName); } // // An empty string fails the `starts_with` check and the create errors out. The fix is // to skip the `.tun_name()` call ENTIRELY for non-conforming names — that leaves // `Configuration::tun_name` as `None`, which the tun crate handles by passing id=0 to // the kernel (auto-assign next free utunN). #[cfg(target_os = "macos")] let requested_name = sanitize_macos_tun_name(name); #[cfg(not(target_os = "macos"))] let requested_name: &str = name; let mut config = tun::Configuration::default(); config .address(ip) .netmask(netmask) .mtu(mtu) .layer(tun::Layer::L3) .up(); // Only set tun_name when it's a value the kernel will accept. On macOS that means a // valid `utunN` string; otherwise we leave it unset (None) so the tun crate's auto- // assign branch kicks in. On Linux/Windows the requested name is always honoured. if !requested_name.is_empty() { config.tun_name(requested_name); } let inner = tun::create_as_async(&config) .with_context(|| format!("failed to create TUN device '{name}'"))?; // Capture the kernel-assigned name. On macOS this is the auto-picked `utunN`; on Linux // it matches `name`. If the accessor fails (shouldn't in practice), fall back to the // requested name so the rest of the system still has *something* to log/route against. let actual = inner.tun_name().unwrap_or_else(|_| name.to_string()); #[cfg(target_os = "macos")] if requested_name.is_empty() { tracing::info!( requested = name, actual = %actual, "macOS kernel utun driver rejects names not matching ^utun[0-9]+$; \ auto-assigned an interface — downstream OS-routes / logs use the actual name" ); } else if actual != name { // The user passed a `utunN` name explicitly but the kernel handed back a different // one (typically because the requested utunN was already in use). tracing::info!( requested = name, actual = %actual, "macOS kernel assigned a different utunN than requested (requested busy?)" ); } Ok(Self { inner, mtu, name: actual, }) } /// The actual kernel-assigned interface name. On Linux/Windows this matches the `name` /// passed to [`AuraTun::create`]. On macOS the kernel `utun` driver may auto-assign a /// `utunN` different from the requested name (and *must* do so when the requested name /// doesn't match `^utun[0-9]+$`); callers must use this method, not the original config /// string, when programming OS routes or logging the live device. #[must_use] pub fn name(&self) -> &str { &self.name } /// Read one IP packet from the TUN device. #[cfg(not(windows))] pub async fn read_packet(&mut self) -> anyhow::Result> { // Size the buffer to the MTU plus headroom so a full-size packet is never truncated. let mut buf = vec![0u8; self.mtu as usize + 4]; let n = self.inner.recv(&mut buf).await?; buf.truncate(n); Ok(buf) } /// Write one IP packet to the TUN device. #[cfg(not(windows))] pub async fn write_packet(&mut self, packet: &[u8]) -> anyhow::Result<()> { self.inner.send(packet).await?; Ok(()) } // ---- Windows (wintun 0.5) ------------------------------------------------------------------- // cfg(windows)-gated: not compiled on the macOS host, validated by inspection only. /// Create and bring up a wintun adapter named `name` with address `ip`/`prefix_len`. /// /// wintun ignores per-interface MTU (its ring is fixed at 65535), so `mtu` is retained only for /// read-buffer sizing. Only IPv4 addressing is wired here, matching wintun 0.5's /// `set_address`/`set_netmask` (which take `Ipv4Addr`); an IPv6 address yields an error. #[cfg(windows)] pub async fn create( name: &str, ip: std::net::IpAddr, prefix_len: u8, mtu: u16, ) -> anyhow::Result { use anyhow::Context; use std::net::IpAddr; let ipv4 = match ip { IpAddr::V4(v4) => v4, IpAddr::V6(_) => { anyhow::bail!("wintun backend currently supports only IPv4 TUN addresses") } }; let netmask = match ipnetwork::IpNetwork::new(ip, prefix_len) .with_context(|| format!("invalid TUN address {ip}/{prefix_len}"))? .mask() { IpAddr::V4(m) => m, IpAddr::V6(_) => unreachable!("v4 address yields a v4 mask"), }; // 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 .set_address(ipv4) .context("failed to set wintun adapter address")?; adapter .set_netmask(netmask) .context("failed to set wintun adapter netmask")?; let session = adapter .start_session(wintun::MAX_RING_CAPACITY) .context("failed to start wintun session")?; // Hold both the Arc 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, name: name.to_string(), }) } /// Read one IP packet from the wintun session. /// /// `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> { let session = self.inner.clone(); let packet = tokio::task::spawn_blocking(move || session.receive_blocking()).await??; 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() .map_err(|_| anyhow::anyhow!("packet too large for wintun ({} bytes)", packet.len()))?; let mut send = self .inner .allocate_send_packet(len) .map_err(|e| anyhow::anyhow!("wintun allocate_send_packet failed: {e}"))?; send.bytes_mut().copy_from_slice(packet); self.inner.send_packet(send); Ok(()) } } #[async_trait] impl PacketIo for AuraTun { async fn read_packet(&mut self) -> std::io::Result> { AuraTun::read_packet(self) .await .map_err(|e| std::io::Error::other(e.to_string())) } async fn write_packet(&mut self, packet: &[u8]) -> std::io::Result<()> { AuraTun::write_packet(self, packet) .await .map_err(|e| std::io::Error::other(e.to_string())) } } /// Rewrite a requested TUN name into a form acceptable to the macOS kernel `utun` driver. /// /// The driver only accepts names matching `^utun[0-9]+$`. Anything else (including the Linux /// default `"aura0"`) is mapped to the empty string, which `tun::create_as_async` interprets as /// "let the kernel pick the next free `utunN`". A name that already matches is passed through /// verbatim so the operator can still pin a specific `utunN` from config when they want to. /// /// Made `pub(crate)` (and unit-tested below) so the macOS create path is the only public surface /// that sees the rewrite; the function is platform-independent so we always compile it (avoids a /// `cfg`-gated helper that's only exercised on macOS CI). #[cfg_attr(not(target_os = "macos"), allow(dead_code))] pub(crate) fn sanitize_macos_tun_name(name: &str) -> &str { if is_valid_macos_utun_name(name) { name } else { "" } } /// Does `name` match `^utun[0-9]+$` — the only form the macOS kernel `utun` driver accepts? fn is_valid_macos_utun_name(name: &str) -> bool { let Some(digits) = name.strip_prefix("utun") else { return false; }; !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) } #[cfg(test)] mod tests { use super::*; /// `sanitize_macos_tun_name` accepts `utunN` verbatim for any non-empty all-digit suffix. #[test] fn sanitize_accepts_valid_utun_names() { assert_eq!(sanitize_macos_tun_name("utun0"), "utun0"); assert_eq!(sanitize_macos_tun_name("utun8"), "utun8"); assert_eq!(sanitize_macos_tun_name("utun42"), "utun42"); assert_eq!(sanitize_macos_tun_name("utun999"), "utun999"); } /// `sanitize_macos_tun_name` rewrites any non-conforming name (including the Linux default /// `"aura0"` and edge cases like `"utun"` with no digits or `"utunx"` with non-digits) to /// `""` so the kernel auto-assigns the next free `utunN`. #[test] fn sanitize_rewrites_invalid_names_to_empty() { assert_eq!(sanitize_macos_tun_name("aura0"), ""); assert_eq!(sanitize_macos_tun_name("aura-srv0"), ""); assert_eq!(sanitize_macos_tun_name(""), ""); // No digits after `utun` → invalid. assert_eq!(sanitize_macos_tun_name("utun"), ""); // Non-digit suffix → invalid. assert_eq!(sanitize_macos_tun_name("utunx"), ""); assert_eq!(sanitize_macos_tun_name("utun1a"), ""); // Wrong prefix. assert_eq!(sanitize_macos_tun_name("tun0"), ""); } /// On macOS, requesting a non-`utunN` name (like the Linux/Windows default `"aura0"`) must /// succeed and yield a kernel-assigned `utunN`. Requires root, so the test is gated on /// `AURA_TUN_TEST=1` to keep `cargo test` runnable as a regular user. When the env var is not /// set, the test logs a skip and returns. When it is set but creation fails for any reason /// (e.g. running unprivileged anyway), the test still fails so we don't silently lose /// coverage. #[cfg(target_os = "macos")] #[tokio::test] async fn macos_create_with_non_utun_name_auto_assigns() { if std::env::var_os("AURA_TUN_TEST").is_none() { eprintln!( "skipping macos_create_with_non_utun_name_auto_assigns: \ set AURA_TUN_TEST=1 and run as root to exercise this test" ); return; } let tun = AuraTun::create("aura0", "10.7.0.2".parse().unwrap(), 24, 1420) .await .expect("creation must succeed even with a non-utunN requested name"); let assigned = tun.name(); assert!( is_valid_macos_utun_name(assigned), "kernel-assigned name {:?} must match ^utun[0-9]+$", assigned ); assert_ne!( assigned, "aura0", "macOS must NOT honour the requested non-utunN name" ); } }