Files
AuraVPN/crates/aura-tunnel/src/tun.rs
T
xah30 f68a61f760 fix(tunnel,aura-gui): macOS TUN auto-assign + admin-access check
Two bugs found in the GUI's first end-to-end test:

## #41 was incomplete — `Some("")` is not the same as `None` for tun-rs

The agent's earlier #41 fix passed `""` to `Configuration::tun_name()`
expecting the tun crate to treat empty as "let the kernel auto-assign". It
doesn't. 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 `starts_with("utun")` so the create errors out before
the kernel is ever consulted. The auto-assign branch ONLY triggers when
`config.tun_name` is `None` — which requires us to skip the `.tun_name()`
call entirely, not pass a sentinel value.

Fix: split the builder chain so `.tun_name()` is only called when the
sanitized name is non-empty. The kernel now correctly auto-picks the next
free `utunN` for the standard provisioned `tun_name = "aura0"` config.

User-visible symptom this resolves: the GUI's Connect button consistently
died with `failed to create TUN device 'aura0'` followed by an InvalidName
chain, even though aura was running as root.

## check_admin_access tested the wrong command shape

`check_admin_access` ran `sudo -n <aura> --help` and inferred the sudoers
entry was installed iff that succeeded. But our sudoers entry is scoped to
`<aura> client *` — `<aura> --help` does NOT match, so even when the
entry was correctly installed and Connect was already working, the yellow
"One-time setup needed" banner stayed up forever.

Switched to `sudo -n -l <aura>` which lists matching sudoers entries for
the binary path itself. Returns 0 iff ANY entry covers it without a
password — works regardless of the per-command scope.

## Verification

- `cargo test -p aura-tunnel --lib tun` — all 3 sanitize / create tests pass
- Rebuilt `target/release/aura` and `/Applications/Aura.app` against the
  fixes
- Confirmed via `sudo -n -l /Users/xah30/AuraVPN/target/release/aura` that
  the installed sudoers entry is detectable by the new check

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 19:45:59 +03:00

410 lines
19 KiB
Rust

//! 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<Vec<u8>>;
/// 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<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,
/// 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<Self> {
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<Vec<u8>> {
// 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<Self> {
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<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,
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<Vec<u8>> {
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<Vec<u8>> {
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"
);
}
}