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:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user