f68a61f760
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>
410 lines
19 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|