feat(transport,tunnel): implement Wave 3 — QUIC transport + split-tunnel router

aura-transport: quinn 0.11 endpoint with HTTP/3 mimicry (ALPN h3/h3-29,
Chrome-like transport params), outer-TLS accept-any (real auth is the inner
Aura handshake), packet padding to HTTPS sizes; AuraServer/AuraClient drive the
proto handshake over a QUIC bidi stream; AuraConnection impls
aura_proto::PacketConnection (full-duplex via Session::split + per-half mutex).
14 tests incl. a real-QUIC loopback end-to-end (crypto+pki+proto+transport).

aura-tunnel: RouteTable (longest-prefix split-tunnel classify), AuraDns
(hickory) host-route registration, AuraRouter over a PacketIo TUN seam +
Arc<dyn PacketConnection>, AuraTun (tun 0.8 unix; wintun cfg-gated Windows).
10 tests (route classify/priority, dst-IP parse, mock router). send_direct is a
v1 stub. Whole workspace: tests green, clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
xah30
2026-05-25 18:26:39 +03:00
parent 0a045c248d
commit c19a6c5586
14 changed files with 1887 additions and 4 deletions
+102
View File
@@ -0,0 +1,102 @@
//! DNS resolution that feeds the split-tunnel routing table (project §8.5).
//!
//! [`AuraDns`] wraps a hickory [`TokioAsyncResolver`] and a shared
//! `Arc<RwLock<RouteTable>>`. [`AuraDns::resolve_and_register`] resolves a domain to a set of IP
//! addresses, inserts each as a host route into the shared table with the requested
//! [`RouteAction`], and caches the result so repeated calls don't re-query.
//!
//! The actual *registration* logic ([`AuraDns::register_ips`]) is split out from the network query
//! so it can be unit-tested without touching the network.
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Arc;
use hickory_resolver::config::{ResolverConfig, ResolverOpts};
use hickory_resolver::TokioAsyncResolver;
use tokio::sync::RwLock;
use crate::routes::{RouteAction, RouteTable};
/// A DNS resolver that registers resolved addresses as host routes in a shared [`RouteTable`].
pub struct AuraDns {
resolver: TokioAsyncResolver,
/// Domain -> last resolved set of addresses.
cache: HashMap<String, Vec<IpAddr>>,
routes: Arc<RwLock<RouteTable>>,
}
impl AuraDns {
/// Build an `AuraDns` over the system default resolver configuration, registering routes into
/// the shared `routes` table.
///
/// hickory 0.24's `TokioAsyncResolver::tokio` is infallible, so this cannot fail; it is `async`
/// only to keep the door open for future config that needs the runtime, and to read naturally
/// at call sites.
pub async fn new(routes: Arc<RwLock<RouteTable>>) -> anyhow::Result<Self> {
let resolver =
TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default());
Ok(Self {
resolver,
cache: HashMap::new(),
routes,
})
}
/// Build an `AuraDns` with a caller-supplied resolver (e.g. a custom upstream / config).
pub fn with_resolver(resolver: TokioAsyncResolver, routes: Arc<RwLock<RouteTable>>) -> Self {
Self {
resolver,
cache: HashMap::new(),
routes,
}
}
/// The shared routing table this resolver registers into.
pub fn routes(&self) -> &Arc<RwLock<RouteTable>> {
&self.routes
}
/// Cached addresses for a previously resolved domain, if any.
pub fn cached(&self, domain: &str) -> Option<&[IpAddr]> {
self.cache.get(domain).map(|v| v.as_slice())
}
/// Resolve `domain`, register each resulting IP as a host route with `action`, cache, and
/// return the addresses.
///
/// This performs a live DNS query and so is **not** exercised by unit tests; the
/// registration/caching half is factored into [`AuraDns::register_ips`], which the tests call
/// directly.
pub async fn resolve_and_register(
&mut self,
domain: &str,
action: RouteAction,
) -> anyhow::Result<Vec<IpAddr>> {
let lookup = self.resolver.lookup_ip(domain).await?;
let ips: Vec<IpAddr> = lookup.iter().collect();
self.register_ips(domain, &ips, action).await;
Ok(ips)
}
/// Register an already-known set of `ips` for `domain`: insert each as a host route with
/// `action` into the shared table and cache the set.
///
/// Network-free, so unit tests use it to validate the routing-table side effects without a live
/// query.
pub async fn register_ips(&mut self, domain: &str, ips: &[IpAddr], action: RouteAction) {
{
let mut table = self.routes.write().await;
for &ip in ips {
table.add_host_route(ip, action);
}
}
self.cache.insert(domain.to_owned(), ips.to_vec());
tracing::debug!(
domain,
count = ips.len(),
?action,
"registered host routes for domain"
);
}
}
+86 -1
View File
@@ -1 +1,86 @@
//! aura-tunnel — TUN interface and split tunneling (skeleton; implemented in Wave 3).
//! aura-tunnel — the Aura VPN data-plane tunnel (project §8).
//!
//! This crate turns a host's IP traffic into something the encrypted transport can carry, and back
//! again. It has four pieces:
//!
//! * [`AuraTun`] — a cross-platform layer-3 TUN device (Linux/macOS via the `tun` crate; Windows via
//! `wintun`, `cfg`-gated). See [`tun`](mod@crate::tun).
//! * [`RouteTable`] / [`RouteAction`] — a longest-prefix-match split-tunnel routing table deciding
//! VPN-vs-direct per destination IP. See [`routes`](mod@crate::routes).
//! * [`AuraDns`] — a hickory-backed resolver that registers resolved domain addresses as host
//! routes in a shared [`RouteTable`]. See [`dns`](mod@crate::dns).
//! * [`AuraRouter`] — the run-loop bridging the TUN device and an
//! [`aura_proto::PacketConnection`]. See [`router`](mod@crate::router).
//!
//! ## Wiring it together (for the CLI)
//!
//! The router is generic over the [`PacketIo`] device seam and shares the routing table and the
//! packet connection by `Arc`:
//!
//! ```no_run
//! # async fn demo(conn: std::sync::Arc<dyn aura_proto::PacketConnection>) -> anyhow::Result<()> {
//! use std::sync::Arc;
//! use tokio::sync::RwLock;
//! use aura_tunnel::{AuraDns, AuraRouter, AuraTun, RouteAction, RouteTable};
//!
//! // 1. Build a shared routing table (default: everything through the VPN).
//! let routes = Arc::new(RwLock::new(RouteTable::new(RouteAction::Vpn)));
//! routes.write().await.add_cidr("192.168.0.0/16".parse()?, RouteAction::Direct);
//!
//! // 2. Optionally resolve domains into host routes.
//! let mut dns = AuraDns::new(Arc::clone(&routes)).await?;
//! dns.resolve_and_register("example.com", RouteAction::Direct).await?;
//!
//! // 3. Create the TUN device (needs privileges).
//! let tun = AuraTun::create("aura0", "10.7.0.2".parse()?, 24, 1420).await?;
//!
//! // 4. Build the router from the TUN, the table, and the connection, then run it.
//! let router = AuraRouter::new(tun, routes, conn);
//! router.run().await?;
//! # Ok(())
//! # }
//! ```
#![cfg_attr(not(windows), forbid(unsafe_code))]
#![warn(missing_docs)]
pub mod dns;
pub mod router;
pub mod routes;
pub mod tun;
pub use dns::AuraDns;
pub use router::{dst_ip, AuraRouter};
pub use routes::{RouteAction, RouteTable};
pub use tun::{AuraTun, PacketIo};
use thiserror::Error;
/// Errors produced by the tunnel data plane.
///
/// The router and DNS surfaces mostly return [`anyhow::Result`] (they compose I/O, the `tun`/`wintun`
/// backends, hickory, and the [`aura_proto::PacketConnection`] contract, all of which already carry
/// rich context). This enum names the tunnel-specific failure modes for callers that want to match
/// on them, and converts cleanly from the underlying I/O and resolver errors.
#[derive(Debug, Error)]
pub enum TunnelError {
/// Creating or configuring the TUN/wintun device failed.
#[error("TUN device error: {0}")]
Device(String),
/// An I/O error while reading from or writing to the TUN device.
#[error("TUN I/O error: {0}")]
Io(#[from] std::io::Error),
/// The requested TUN address/prefix was not a valid network.
#[error("invalid TUN address or prefix: {0}")]
InvalidAddress(#[from] ipnetwork::IpNetworkError),
/// DNS resolution failed.
#[error("DNS resolution error: {0}")]
Dns(#[from] hickory_resolver::error::ResolveError),
/// The underlying encrypted packet connection failed.
#[error("packet connection error: {0}")]
Connection(String),
}
+151
View File
@@ -0,0 +1,151 @@
//! The split-tunnel router (project §8.6): the bridge between the TUN device and the encrypted
//! [`PacketConnection`](aura_proto::PacketConnection).
//!
//! [`AuraRouter`] owns three things: a packet device (anything implementing the crate-internal
//! [`PacketIo`] trait — in production [`AuraTun`](crate::AuraTun), in tests an in-memory fake), a
//! shared `Arc<RwLock<RouteTable>>`, and an `Arc<dyn PacketConnection>`.
//!
//! [`AuraRouter::run`] drives two logical flows that share the one connection:
//!
//! * **Outbound** — read an IP packet from the TUN, parse its destination,
//! [`classify`](RouteTable::classify) it, and for [`RouteAction::Vpn`] `send_packet` it over the
//! connection. [`RouteAction::Direct`] packets are handed to `send_direct`, a documented **v1
//! stub** (real raw-socket egress is out of scope).
//! * **Inbound** — `recv_packet` decrypted IP packets from the connection and write them to the
//! TUN.
//!
//! Because [`PacketIo`] is `&mut self` for both directions, a single owner task multiplexes the TUN:
//! it `select!`s between "a packet arrived from the TUN" and "a packet is queued to be written to
//! the TUN" (queued by the inbound task over an mpsc channel). This keeps exclusive `&mut` access to
//! the device in one place while still running both directions concurrently.
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::sync::Arc;
use aura_proto::PacketConnection;
use tokio::sync::{mpsc, RwLock};
use crate::routes::{RouteAction, RouteTable};
use crate::tun::PacketIo;
/// Parse the destination IP address out of a raw IPv4 or IPv6 packet.
///
/// Returns `None` for packets too short to contain a destination, or whose version nibble is
/// neither 4 nor 6. (IPv4: dst at bytes 16..20; IPv6: dst at bytes 24..40.)
pub fn dst_ip(pkt: &[u8]) -> Option<IpAddr> {
match pkt.first()? >> 4 {
4 if pkt.len() >= 20 => Some(Ipv4Addr::new(pkt[16], pkt[17], pkt[18], pkt[19]).into()),
6 if pkt.len() >= 40 => {
let mut a = [0u8; 16];
a.copy_from_slice(&pkt[24..40]);
Some(Ipv6Addr::from(a).into())
}
_ => None,
}
}
/// Routes IP packets between a TUN device and an encrypted [`PacketConnection`].
pub struct AuraRouter<P: PacketIo> {
tun: P,
routes: Arc<RwLock<RouteTable>>,
conn: Arc<dyn PacketConnection>,
}
impl<P: PacketIo + 'static> AuraRouter<P> {
/// Construct a router from a packet device, a shared routing table, and a packet connection.
///
/// `tun` is any [`PacketIo`]; the CLI passes an [`AuraTun`](crate::AuraTun) (which implements
/// it). `conn` is shared (`Arc`) so both the outbound and inbound flows can use it.
pub fn new(tun: P, routes: Arc<RwLock<RouteTable>>, conn: Arc<dyn PacketConnection>) -> Self {
Self { tun, routes, conn }
}
/// Run the router until the connection or TUN errors out.
///
/// Spawns an inbound task (`conn.recv_packet` → queue for TUN write) and runs the TUN owner loop
/// inline (multiplexing TUN reads against queued writes). Returns when either direction hits an
/// unrecoverable error.
pub async fn run(mut self) -> anyhow::Result<()> {
// Inbound: decrypted packets from the peer, queued for writing to the TUN. Bounded so a
// slow TUN exerts backpressure on the receive task rather than growing unboundedly.
let (to_tun_tx, mut to_tun_rx) = mpsc::channel::<Vec<u8>>(1024);
let inbound_conn = Arc::clone(&self.conn);
let inbound = tokio::spawn(async move {
loop {
let pkt = inbound_conn.recv_packet().await?;
if to_tun_tx.send(pkt).await.is_err() {
// TUN owner loop has stopped; nothing more to do.
break;
}
}
Ok::<(), anyhow::Error>(())
});
// Outbound + TUN ownership: one place holds &mut self.tun.
let result = loop {
tokio::select! {
read = self.tun.read_packet() => {
match read {
Ok(pkt) => {
if let Err(e) = self.route_outbound(&pkt).await {
break Err(e);
}
}
Err(e) => break Err(anyhow::Error::new(e).context("TUN read failed")),
}
}
maybe_pkt = to_tun_rx.recv() => {
match maybe_pkt {
Some(pkt) => {
if let Err(e) = self.tun.write_packet(&pkt).await {
break Err(anyhow::Error::new(e).context("TUN write failed"));
}
}
// Inbound task ended (connection closed/errored).
None => break Ok(()),
}
}
}
};
inbound.abort();
result
}
/// Classify one outbound packet and dispatch it: VPN packets go over the connection, Direct
/// packets go to the v1 [`send_direct`](Self::send_direct) stub. Unparseable packets are dropped
/// with a trace.
async fn route_outbound(&self, pkt: &[u8]) -> anyhow::Result<()> {
let Some(dst) = dst_ip(pkt) else {
tracing::trace!(len = pkt.len(), "dropping unparseable outbound packet");
return Ok(());
};
let action = self.routes.read().await.classify(dst);
match action {
RouteAction::Vpn => {
self.conn.send_packet(pkt).await?;
}
RouteAction::Direct => {
self.send_direct(dst, pkt).await?;
}
}
Ok(())
}
/// **v1 stub** for direct (non-VPN) egress.
///
/// A production split tunnel would re-inject these packets onto the host's default route via a
/// raw socket (or hand them back to the OS networking stack). That raw-socket egress path is out
/// of scope for v1; here we log the destination and best-effort drop the packet so the router
/// stays functional end-to-end for the VPN path. The method is `async` and fallible so the real
/// implementation can slot in without changing call sites.
async fn send_direct(&self, dst: IpAddr, pkt: &[u8]) -> anyhow::Result<()> {
tracing::debug!(
%dst,
len = pkt.len(),
"send_direct: direct egress is a v1 stub; dropping packet (real raw-socket egress is out of scope)"
);
Ok(())
}
}
+108
View File
@@ -0,0 +1,108 @@
//! Split-tunnel routing table (project §8.4).
//!
//! [`RouteTable`] decides, for a given destination IP, whether a packet should travel through the
//! VPN ([`RouteAction::Vpn`]) or egress directly ([`RouteAction::Direct`]). Rules come in two
//! flavours:
//!
//! * **CIDR rules** — an [`ipnetwork::IpNetwork`] plus an action. [`RouteTable::classify`] performs
//! a *longest-prefix match*: among every CIDR rule whose network contains the destination, the
//! one with the most specific (largest) prefix wins. When several rules share the same prefix
//! length, the last-inserted one wins (it overwrites the earlier entry).
//! * **Domain rules** — a domain name plus an action. These do not match IPs directly; instead
//! [`AuraDns`](crate::AuraDns) resolves the domain and inserts each resulting address as a host
//! route (`/32` for IPv4, `/128` for IPv6) so it participates in the normal longest-prefix match.
//!
//! If no CIDR rule matches, [`RouteTable::classify`] returns the table's default action.
use std::collections::HashMap;
use std::net::IpAddr;
use ipnetwork::IpNetwork;
/// What to do with a packet bound for a particular destination.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum RouteAction {
/// Send the packet through the encrypted VPN tunnel.
Vpn,
/// Let the packet egress directly, bypassing the tunnel.
Direct,
}
/// A longest-prefix-match routing table mapping destination IPs to a [`RouteAction`].
///
/// Cheap to clone is *not* a goal here; the router shares a single table behind an
/// `Arc<RwLock<RouteTable>>` so that [`AuraDns`](crate::AuraDns) can register freshly resolved
/// host routes while the router keeps classifying.
#[derive(Clone, Debug)]
pub struct RouteTable {
/// CIDR rules keyed by their network, so re-adding the same network overwrites the prior action.
cidr_rules: HashMap<IpNetwork, RouteAction>,
/// Domain rules retained for introspection / re-resolution. Classification does not consult
/// these directly — resolved IPs are inserted as host routes in `cidr_rules`.
domain_rules: HashMap<String, RouteAction>,
/// Action returned when no CIDR rule matches a destination.
default: RouteAction,
}
impl RouteTable {
/// Create an empty table whose `classify` returns `default` until rules are added.
pub fn new(default: RouteAction) -> Self {
Self {
cidr_rules: HashMap::new(),
domain_rules: HashMap::new(),
default,
}
}
/// The default action returned when no CIDR rule matches.
pub fn default_action(&self) -> RouteAction {
self.default
}
/// Add (or overwrite) a CIDR rule. Re-adding the same network replaces its action.
pub fn add_cidr(&mut self, cidr: IpNetwork, action: RouteAction) {
self.cidr_rules.insert(cidr, action);
}
/// Add (or overwrite) a domain rule. The rule takes effect only once
/// [`AuraDns::resolve_and_register`](crate::AuraDns::resolve_and_register) has resolved the
/// domain and inserted host routes for its addresses.
pub fn add_domain(&mut self, domain: &str, action: RouteAction) {
self.domain_rules.insert(domain.to_owned(), action);
}
/// The action recorded for a domain rule, if any. Mainly for tests / introspection.
pub fn domain_action(&self, domain: &str) -> Option<RouteAction> {
self.domain_rules.get(domain).copied()
}
/// Insert a single resolved IP as a host route (`/32` v4, `/128` v6) with `action`.
///
/// This is how domain rules become matchable. It is also a convenient unit-testable seam that
/// [`AuraDns`](crate::AuraDns) calls for each resolved address.
pub fn add_host_route(&mut self, ip: IpAddr, action: RouteAction) {
let host = match ip {
IpAddr::V4(v4) => IpNetwork::new(IpAddr::V4(v4), 32),
IpAddr::V6(v6) => IpNetwork::new(IpAddr::V6(v6), 128),
};
// A /32 or /128 is always a valid prefix, so this never errors; fall back gracefully anyway.
if let Ok(net) = host {
self.cidr_rules.insert(net, action);
}
}
/// Classify a destination IP via longest-prefix match.
///
/// Among all CIDR rules whose network `.contains(dst_ip)`, the rule with the largest prefix
/// length wins. With no match, the table default is returned. IPv4 rules only match IPv4
/// destinations and IPv6 rules only match IPv6 destinations (this is the natural behaviour of
/// `IpNetwork::contains`).
pub fn classify(&self, dst_ip: IpAddr) -> RouteAction {
self.cidr_rules
.iter()
.filter(|(net, _)| net.contains(dst_ip))
.max_by_key(|(net, _)| net.prefix())
.map(|(_, action)| *action)
.unwrap_or(self.default)
}
}
+206
View File
@@ -0,0 +1,206 @@
//! 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 interface name
//! is system-assigned (`utunN`) and the requested name may be ignored — we do not treat a name
//! mismatch as an error.
//! * **Windows** via the [`wintun`] crate (0.5): `Adapter::create(..)` + `start_session(..)`. This
//! path is `cfg(windows)`-gated and is *not compiled* on the macOS development host; it is
//! validated by inspection only.
//!
//! 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,
#[cfg(windows)]
inner: std::sync::Arc<wintun::Session>,
#[cfg(windows)]
mtu: u16,
}
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 assigns `utunN`); a different resulting name is not
/// an error. Requires privileges, so this is never called from unit tests.
#[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();
let mut config = tun::Configuration::default();
config
.tun_name(name)
.address(ip)
.netmask(netmask)
.mtu(mtu)
.layer(tun::Layer::L3)
.up();
let inner = tun::create_as_async(&config)
.with_context(|| format!("failed to create TUN device '{name}'"))?;
// macOS hands back a system-assigned utunN; log the real name but don't fail on mismatch.
if let Ok(actual) = inner.tun_name() {
if actual != name {
tracing::info!(
requested = name,
actual = %actual,
"TUN interface name differs from requested (expected on macOS)"
);
}
}
Ok(Self { inner, mtu })
}
/// 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 via its documented entry point.
let wintun = unsafe { wintun::load() }.context("failed to load wintun.dll")?;
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")?;
Ok(Self {
inner: std::sync::Arc::new(session),
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.
#[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())
}
/// Write one IP packet to the wintun session.
#[cfg(windows)]
pub async fn write_packet(&mut self, packet: &[u8]) -> anyhow::Result<()> {
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()))
}
}